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: ` + + + + + + + + `, + 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: