diff --git a/package.json b/package.json
index b9f879bba..14a2b9025 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,7 @@
},
"dependencies": {
"algoliasearch-helper": "^3.1.0",
- "instantsearch.js": "^4.20.0"
+ "instantsearch.js": "^4.25.0"
},
"peerDependencies": {
"algoliasearch": ">= 3.32.0 < 5",
@@ -114,11 +114,11 @@
"bundlesize": [
{
"path": "./dist/vue-instantsearch.js",
- "maxSize": "53.00 kB"
+ "maxSize": "54 kB"
},
{
"path": "./dist/vue-instantsearch.common.js",
- "maxSize": "16.50 kB"
+ "maxSize": "16.75 kB"
}
],
"resolutions": {
diff --git a/src/__tests__/index.js b/src/__tests__/index.js
index 7917651e8..9b2aa4652 100644
--- a/src/__tests__/index.js
+++ b/src/__tests__/index.js
@@ -34,6 +34,8 @@ it('should have `name` the same as the suit class name everywhere', () => {
expect(installedName).toBe(name);
if (name === 'AisInstantSearchSsr') {
expect(suitClass).toBe(`ais-InstantSearch`);
+ } else if (name === 'AisExperimentalDynamicWidgets') {
+ expect(suitClass).toBe(`ais-DynamicWidgets`);
} else {
expect(suitClass).toBe(`ais-${name.substr(3)}`);
}
diff --git a/src/components/DynamicWidgets.js b/src/components/DynamicWidgets.js
new file mode 100644
index 000000000..e58e89385
--- /dev/null
+++ b/src/components/DynamicWidgets.js
@@ -0,0 +1,87 @@
+import { createWidgetMixin } from '../mixins/widget';
+import { EXPERIMENTAL_connectDynamicWidgets } from 'instantsearch.js/es/connectors';
+import { createSuitMixin } from '../mixins/suit';
+
+function getWidgetAttribute(vnode) {
+ const props = vnode.componentOptions && vnode.componentOptions.propsData;
+ if (props) {
+ if (props.attribute) {
+ return props.attribute;
+ }
+ if (Array.isArray(props.attributes)) {
+ return props.attributes[0];
+ }
+ }
+
+ const children =
+ vnode.componentOptions && vnode.componentOptions.children
+ ? vnode.componentOptions.children
+ : vnode.children;
+
+ if (Array.isArray(children)) {
+ // return first child with a truthy attribute
+ return children.reduce(
+ (acc, curr) => acc || getWidgetAttribute(curr),
+ undefined
+ );
+ }
+
+ return undefined;
+}
+
+export default {
+ name: 'AisExperimentalDynamicWidgets',
+ mixins: [
+ createWidgetMixin({ connector: EXPERIMENTAL_connectDynamicWidgets }),
+ createSuitMixin({ name: 'DynamicWidgets' }),
+ ],
+ props: {
+ transformItems: {
+ type: Function,
+ default: undefined,
+ },
+ },
+ render(createElement) {
+ const components = new Map();
+ (this.$slots.default || []).forEach(vnode => {
+ const attribute = getWidgetAttribute(vnode);
+ if (attribute) {
+ components.set(
+ attribute,
+ createElement(
+ 'div',
+ { key: attribute, class: [this.suit('widget')] },
+ [vnode]
+ )
+ );
+ }
+ });
+
+ // by default, render everything, but hidden so that the routing doesn't disappear
+ if (!this.state) {
+ const allComponents = [];
+ components.forEach(component => allComponents.push(component));
+
+ return createElement(
+ 'div',
+ { attrs: { hidden: true }, class: [this.suit()] },
+ allComponents
+ );
+ }
+
+ return createElement(
+ 'div',
+ { class: [this.suit()] },
+ this.state.attributesToRender.map(attribute => components.get(attribute))
+ );
+ },
+ computed: {
+ widgetParams() {
+ return {
+ transformItems: this.transformItems,
+ // we do not pass "widgets" to the connector, since Vue is in charge of rendering
+ widgets: [],
+ };
+ },
+ },
+};
diff --git a/src/components/__tests__/DynamicWidgets.js b/src/components/__tests__/DynamicWidgets.js
new file mode 100644
index 000000000..0e22b41de
--- /dev/null
+++ b/src/components/__tests__/DynamicWidgets.js
@@ -0,0 +1,419 @@
+import { createLocalVue, mount } from '@vue/test-utils';
+import DynamicWidgets from '../DynamicWidgets';
+import { __setState } from '../../mixins/widget';
+import { plugin } from '../../plugin';
+jest.mock('../../mixins/widget');
+
+const MockRefinementList = {
+ props: { attribute: { type: String } },
+ render(h) {
+ return h('div', {
+ attrs: {
+ 'widget-name': 'ais-refinement-list',
+ attribute: this.attribute,
+ },
+ });
+ },
+};
+
+const MockMenu = {
+ props: { attribute: { type: String } },
+ render(h) {
+ return h('div', {
+ attrs: { 'widget-name': 'ais-menu', attribute: this.attribute },
+ });
+ },
+};
+
+const MockHierarchicalMenu = {
+ props: { attributes: { type: Array } },
+ render(h) {
+ return h('div', {
+ attrs: {
+ 'widget-name': 'ais-hierarchical-menu',
+ attributes: this.attributes,
+ },
+ });
+ },
+};
+
+it('renders all children without state', () => {
+ const localVue = createLocalVue();
+
+ localVue.use(plugin);
+
+ __setState(null);
+
+ const wrapper = mount(DynamicWidgets, {
+ localVue,
+ propsData: {
+ transformItems: items => items,
+ },
+ slots: {
+ default: `
+
+
+
+
+
+ `,
+ },
+ stubs: {
+ 'ais-refinement-list': MockRefinementList,
+ 'ais-menu': MockMenu,
+ 'ais-hierarchical-menu': MockHierarchicalMenu,
+ },
+ });
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+`);
+});
+
+it('renders nothing without children', () => {
+ __setState({
+ attributesToRender: ['something-that-does-not-show'],
+ });
+
+ const wrapper = mount(DynamicWidgets, {
+ propsData: {
+ transformItems: items => items,
+ },
+ });
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+
+`);
+});
+
+it('renders nothing with empty attributesToRender', () => {
+ const localVue = createLocalVue();
+
+ localVue.use(plugin);
+
+ __setState({
+ attributesToRender: [],
+ });
+
+ const wrapper = mount(DynamicWidgets, {
+ localVue,
+ propsData: {
+ transformItems: items => items,
+ },
+ slots: {
+ default: ``,
+ },
+ stubs: {
+ 'ais-refinement-list': MockRefinementList,
+ },
+ });
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+
+`);
+});
+
+it('renders attributesToRender (menu)', () => {
+ const localVue = createLocalVue();
+
+ localVue.use(plugin);
+
+ __setState({
+ attributesToRender: ['test1'],
+ });
+
+ const wrapper = mount(DynamicWidgets, {
+ localVue,
+ propsData: {
+ transformItems: items => items,
+ },
+ slots: {
+ default: `
+
+
+ `,
+ },
+ stubs: {
+ 'ais-refinement-list': MockRefinementList,
+ 'ais-menu': MockMenu,
+ },
+ });
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+`);
+});
+
+it('renders attributesToRender (refinement list)', () => {
+ const localVue = createLocalVue();
+
+ localVue.use(plugin);
+
+ __setState({
+ attributesToRender: ['test2'],
+ });
+
+ const wrapper = mount(DynamicWidgets, {
+ localVue,
+ propsData: {
+ transformItems: items => items,
+ },
+ slots: {
+ default: `
+
+
+ `,
+ },
+ stubs: {
+ 'ais-refinement-list': MockRefinementList,
+ 'ais-menu': MockMenu,
+ },
+ });
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+`);
+});
+
+it('renders attributesToRender (panel)', () => {
+ const localVue = createLocalVue();
+
+ localVue.use(plugin);
+
+ __setState({
+ attributesToRender: ['test2'],
+ });
+
+ const wrapper = mount(DynamicWidgets, {
+ localVue,
+ propsData: {
+ transformItems: items => items,
+ },
+ slots: {
+ default: `
+
+
+
+
+ `,
+ },
+ stubs: {
+ 'ais-refinement-list': MockRefinementList,
+ 'ais-menu': MockMenu,
+ },
+ });
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+`);
+});
+
+it('renders attributesToRender (hierarchical menu)', () => {
+ const localVue = createLocalVue();
+
+ localVue.use(plugin);
+
+ __setState({
+ attributesToRender: ['test1'],
+ });
+
+ const wrapper = mount(DynamicWidgets, {
+ localVue,
+ propsData: {
+ transformItems: items => items,
+ },
+ slots: {
+ default: `
+
+
+
+
+
+ `,
+ },
+ stubs: {
+ 'ais-refinement-list': MockRefinementList,
+ 'ais-menu': MockMenu,
+ 'ais-hierarchical-menu': MockHierarchicalMenu,
+ },
+ });
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+`);
+});
+
+it('updates DOM when attributesToRender changes', () => {
+ const localVue = createLocalVue();
+
+ localVue.use(plugin);
+
+ let attributesToRender = ['test1'];
+
+ __setState({
+ get attributesToRender() {
+ return attributesToRender;
+ },
+ });
+
+ const wrapper = mount(DynamicWidgets, {
+ localVue,
+ propsData: {
+ transformItems: items => items,
+ },
+ slots: {
+ default: `
+
+
+
+
+
+ `,
+ },
+ stubs: {
+ 'ais-refinement-list': MockRefinementList,
+ 'ais-menu': MockMenu,
+ 'ais-hierarchical-menu': MockHierarchicalMenu,
+ },
+ });
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+`);
+
+ attributesToRender = ['test3'];
+
+ wrapper.vm.$forceUpdate();
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+`);
+
+ attributesToRender = ['test1', 'test4'];
+
+ wrapper.vm.$forceUpdate();
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+`);
+
+ attributesToRender = [];
+
+ wrapper.vm.$forceUpdate();
+
+ expect(wrapper.html()).toMatchInlineSnapshot(`
+
+
+
+
+`);
+});
diff --git a/src/widgets.js b/src/widgets.js
index 5b966dbbb..4ea249cf7 100644
--- a/src/widgets.js
+++ b/src/widgets.js
@@ -43,3 +43,4 @@ export {
} from './components/ToggleRefinement.vue';
export { default as AisVoiceSearch } from './components/VoiceSearch.vue';
export { default as AisRelevantSort } from './components/RelevantSort.vue';
+export { default as AisDynamicWidgets } from './components/DynamicWidgets';
diff --git a/stories/DynamicWidgets.stories.js b/stories/DynamicWidgets.stories.js
new file mode 100644
index 000000000..75390c320
--- /dev/null
+++ b/stories/DynamicWidgets.stories.js
@@ -0,0 +1,36 @@
+import { previewWrapper } from './utils';
+import { storiesOf } from '@storybook/vue';
+
+storiesOf('ais-dynamic-widgets', module)
+ .addDecorator(previewWrapper())
+ .add('simple usage', () => ({
+ template: `
+
+
+
+
+ hierarchy
+
+
+ `,
+ data() {
+ return {
+ hierarchicalCategories: [
+ 'hierarchicalCategories.lvl0',
+ 'hierarchicalCategories.lvl1',
+ 'hierarchicalCategories.lvl2',
+ ],
+ };
+ },
+ methods: {
+ transformItems(_attributes, { results }) {
+ if (results._state.query === 'dog') {
+ return ['categories'];
+ }
+ if (results._state.query === 'lego') {
+ return ['categories', 'brand'];
+ }
+ return ['brand', 'hierarchicalCategories.lvl0', 'categories'];
+ },
+ },
+ }));
diff --git a/yarn.lock b/yarn.lock
index 77b81ad05..c6603c491 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -698,10 +698,15 @@
"@types/minimatch" "*"
"@types/node" "*"
-"@types/googlemaps@^3.39.6":
- version "3.39.7"
- resolved "https://registry.npmjs.org/@types/googlemaps/-/googlemaps-3.39.7.tgz#70c1af22839976cfd462858c882b54f3006f0b08"
- integrity sha512-U8ZjnjfGiZXzun8jIDOZ/5Gt5Ky07B9pBhFbgzten1S364bnuueFsCt5e5V7Wn55o26NkLWgc6becL+j401bRw==
+"@types/google.maps@^3.45.3":
+ version "3.45.6"
+ resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.45.6.tgz#441a7bc76424243b307596fc8d282a435a979ebd"
+ integrity sha512-BzGzxs8UXFxeP8uN/0nRgGbsbpYQxSCKsv/7S8OitU7wwhfFcqQSm5aAcL1nbwueMiJ/VVmIZKPq69s0kX5W+Q==
+
+"@types/hogan.js@^3.0.0":
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/@types/hogan.js/-/hogan.js-3.0.0.tgz#bf26560f39a38224ab6d0491b06f72c8fbe0953d"
+ integrity sha512-djkvb/AN43c3lIGCojNQ1FBS9VqqKhcTns5RQnHw4xBT/csy0jAssAsOiJ8NfaaioZaeKYE7XkVRxE5NeSZcaA==
"@types/http-cache-semantics@*":
version "4.0.0"
@@ -1156,10 +1161,10 @@ algoliasearch-helper@^3.1.0:
dependencies:
events "^1.1.1"
-algoliasearch-helper@^3.4.4:
- version "3.4.4"
- resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.4.4.tgz#f2eb46bc4d2f6fed82c7201b8ac4ce0a1988ae67"
- integrity sha512-OjyVLjykaYKCMxxRMZNiwLp8CS310E0qAeIY2NaublcmLAh8/SL19+zYHp7XCLtMem2ZXwl3ywMiA32O9jszuw==
+algoliasearch-helper@^3.5.4:
+ version "3.5.4"
+ resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.5.4.tgz#21b20ab8a258daa9dde9aef2daa5e8994cd66077"
+ integrity sha512-t+FLhXYnPZiwjYe5ExyN962HQY8mi3KwRju3Lyf6OBgtRdx30d6mqvtClXf5NeBihH45Xzj6t4Y5YyvAI432XA==
dependencies:
events "^1.1.1"
@@ -7892,18 +7897,18 @@ instantsearch.css@7.3.1:
resolved "https://registry.yarnpkg.com/instantsearch.css/-/instantsearch.css-7.3.1.tgz#7ab74a8f355091ae040947a9cf5438f379026622"
integrity sha512-/kaMDna5D+Q9mImNBHEhb9HgHATDOFKYii7N1Iwvrj+lmD9gBJLqVhUw67gftq2O0QI330pFza+CRscIwB1wQQ==
-instantsearch.js@^4.20.0:
- version "4.20.0"
- resolved "https://registry.yarnpkg.com/instantsearch.js/-/instantsearch.js-4.20.0.tgz#e7ed763a8380384ff59a88f3b56a01c5dedf0fca"
- integrity sha512-fO3oqt/WEsUvdx8LB9Fm/MH9pLbl3gHV3ALnFOvbwdIpg+HRe5zZv3C/bE8E0SuTbZo2C6Fxg6rC0MEMTxNVGA==
+instantsearch.js@^4.25.0:
+ version "4.25.0"
+ resolved "https://registry.yarnpkg.com/instantsearch.js/-/instantsearch.js-4.25.0.tgz#0c631479afa0826af0adee120dc2bbb68e1caeb3"
+ integrity sha512-146u/zlu+lHjMgykKnvYr5yvM4xB/CHN8jmf5872gYNfjoZlmQ6GAoYwrgRjQrbLAbIZ6hhpXkDhwmoptmvUgQ==
dependencies:
- "@types/googlemaps" "^3.39.6"
- algoliasearch-helper "^3.4.4"
+ "@types/google.maps" "^3.45.3"
+ "@types/hogan.js" "^3.0.0"
+ algoliasearch-helper "^3.5.4"
classnames "^2.2.5"
events "^1.1.0"
hogan.js "^3.0.2"
preact "^10.0.0"
- prop-types "^15.5.10"
qs "^6.5.1"
interpret@^1.0.0: