From 27ecb1f2dc66e9519ae369e01e9dcca0644fe38b Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Thu, 4 Mar 2021 18:05:48 +0100 Subject: [PATCH] feat(ais-dynamic-widgets): add implementation Adds a new widget _ais-experimental-dynamic-widgets_ that can be used to conditionally render other widgets. This condition is based on the search results. At the moment there isn't yet a default way to enforce facet ordering in the Algolia engine, thus the data available to `transformItems` is an empty array. This will change once the Algolia engine adds support for facet ordering and this widget will move out of experimental mode. ```vue ``` See also: https://github.com/algolia/instantsearch.js/pull/4687 --- package.json | 2 +- src/__tests__/index.js | 2 + src/components/DynamicWidgets.js | 89 +++++++++++ src/components/__tests__/DynamicWidgets.js | 178 +++++++++++++++++++++ src/widgets.js | 1 + stories/DynamicWidgets.stories.js | 36 +++++ yarn.lock | 8 +- 7 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 src/components/DynamicWidgets.js create mode 100644 src/components/__tests__/DynamicWidgets.js create mode 100644 stories/DynamicWidgets.stories.js diff --git a/package.json b/package.json index e164ef427..ef7023a81 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ }, "dependencies": { "algoliasearch-helper": "^3.1.0", - "instantsearch.js": "^4.16.1" + "instantsearch.js": "^4.22.0" }, "peerDependencies": { "algoliasearch": ">= 3.32.0 < 5", 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..42007c714 --- /dev/null +++ b/src/components/DynamicWidgets.js @@ -0,0 +1,89 @@ +import { createWidgetMixin } from '../mixins/widget'; +import { EXPERIMENTAL_connectDynamicWidgets } from 'instantsearch.js/es/connectors'; +import { createSuitMixin } from '../mixins/suit'; + +function getVueAttribute(vnode) { + if (vnode.componentOptions && vnode.componentOptions.propsData) { + if (vnode.componentOptions.propsData.attribute) { + return vnode.componentOptions.propsData.attribute; + } + if ( + vnode.componentOptions && + Array.isArray(vnode.componentOptions.propsData.attributes) + ) { + return vnode.componentOptions.propsData.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 || getVueAttribute(curr), + undefined + ); + } + + return undefined; +} + +export default { + name: 'AisExperimentalDynamicWidgets', + mixins: [ + createWidgetMixin({ connector: EXPERIMENTAL_connectDynamicWidgets }), + createSuitMixin({ name: 'DynamicWidgets' }), + ], + props: { + transformItems: { + type: Function, + required: true, + }, + }, + render(createElement) { + const components = new Map(); + (this.$slots.default || []).forEach(vnode => { + const attribute = getVueAttribute(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(vnode => allComponents.push(vnode)); + + 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..bd23d0853 --- /dev/null +++ b/src/components/__tests__/DynamicWidgets.js @@ -0,0 +1,178 @@ +import { createLocalVue, mount } from '@vue/test-utils'; +import DynamicWidgets from '../DynamicWidgets'; +import { __setState } from '../../mixins/widget'; +import { plugin } from '../../plugin'; +jest.mock('../../mixins/widget'); + +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: ` + + + + + + `, + }, + }); + + // the inner widgets don't render anything because state is falsy, but they are mounted + expect(wrapper.html()).toMatchInlineSnapshot(` + + + +`); +}); + +it('renders nothing without children', () => { + __setState({ + attributesToRender: [], + }); + + const wrapper = mount(DynamicWidgets, { + propsData: { + transformItems: items => items, + }, + }); + expect(wrapper.html()).toMatchInlineSnapshot(` + +
+
+ +`); +}); + +it('renders nothing with empty items', () => { + const localVue = createLocalVue(); + + localVue.use(plugin); + + __setState({ + attributesToRender: [], + items: [], + }); + + const wrapper = mount(DynamicWidgets, { + localVue, + propsData: { + transformItems: items => items, + }, + slots: { + default: ``, + }, + }); + + expect(wrapper.html()).toMatchInlineSnapshot(` + +
+
+ +`); +}); + +it('renders attributesToRender 1', () => { + const localVue = createLocalVue(); + + localVue.use(plugin); + + __setState({ + attributesToRender: ['test1'], + items: [], + }); + + localVue.component('test-component', { + props: { attribute: { type: String } }, + render(h) { + return h('div', {}, this.attribute); + }, + }); + + const wrapper = mount(DynamicWidgets, { + localVue, + propsData: { + transformItems: items => items, + }, + slots: { + default: ` + + + `, + }, + }); + + expect(wrapper.html()).toMatchInlineSnapshot(` + +
+
+
+ test1 +
+
+
+ +`); +}); + +it('renders attributesToRender 2', () => { + const localVue = createLocalVue(); + + localVue.use(plugin); + + __setState({ + attributesToRender: ['test2'], + items: [], + }); + + localVue.component('test-component', { + props: { attribute: { type: String } }, + render(h) { + return h('div', {}, this.attribute); + }, + }); + + const wrapper = mount(DynamicWidgets, { + localVue, + propsData: { + transformItems: items => items, + }, + slots: { + default: ` + + + `, + }, + }); + + 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 bba0fc143..53854be82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7892,10 +7892,10 @@ 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.16.1: - version "4.16.1" - resolved "https://registry.yarnpkg.com/instantsearch.js/-/instantsearch.js-4.16.1.tgz#78e00d2256c89693a94f58ebfc5f0864953b7ec8" - integrity sha512-NfwNOb+Ftj7Y+h6lW7iCd5SXWKIHQZ981ldSddEHbgTexbdJEyxgWhaF8c3HajCySp8wZPD2gj9KpNQy/BsUgQ== +instantsearch.js@^4.22.0: + version "4.22.0" + resolved "https://registry.yarnpkg.com/instantsearch.js/-/instantsearch.js-4.22.0.tgz#d28c7a2be6b399736f8f2edd6f59d474a0f661a4" + integrity sha512-IPjg/EwhDqFpvCJC1g3DF+i2cAr3SQZ9JPFhuKWC2apnGNfiFkPIQKvHx1pFTQm7FlZe262Xcpqi1ag1KVPGpA== dependencies: "@types/googlemaps" "^3.39.6" algoliasearch-helper "^3.4.4"