Skip to content
This repository has been archived by the owner on Dec 30, 2022. It is now read-only.

feat(ais-dynamic-widgets): add implementation #922

Merged
merged 14 commits into from
Jul 9, 2021
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
},
"dependencies": {
"algoliasearch-helper": "^3.1.0",
"instantsearch.js": "^4.20.0"
"instantsearch.js": "^4.22.0"
},
"peerDependencies": {
"algoliasearch": ">= 3.32.0 < 5",
Expand Down Expand Up @@ -114,11 +114,11 @@
"bundlesize": [
{
"path": "./dist/vue-instantsearch.js",
"maxSize": "53.00 kB"
"maxSize": "53.50 kB"
},
{
"path": "./dist/vue-instantsearch.common.js",
"maxSize": "16.50 kB"
"maxSize": "16.75 kB"
}
],
"resolutions": {
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);
}
Expand Down
86 changes: 86 additions & 0 deletions src/components/DynamicWidgets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { createWidgetMixin } from '../mixins/widget';
import { EXPERIMENTAL_connectDynamicWidgets } from 'instantsearch.js/es/connectors';
import { createSuitMixin } from '../mixins/suit';

function getVueAttribute(vnode) {
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
if (vnode.componentOptions && vnode.componentOptions.propsData) {
if (vnode.componentOptions.propsData.attribute) {
return vnode.componentOptions.propsData.attribute;
}
if (Array.isArray(vnode.componentOptions.propsData.attributes)) {
return vnode.componentOptions.propsData.attributes[0];
}
}
Haroenv marked this conversation as resolved.
Show resolved Hide resolved

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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this behavior result in an unexpected UI flash were some widget will appear and then get removed because computed based on state.attributesToRender?

What if we don't render anything when we don't have a state?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note that the widgets we render initially are hidden, so this won't cause a flash, but possibly slightly delayed visuals compared to server side rendering (which solves this, as the widget information is available on that server + initial frontend render)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I think I rather meant: won't this results in an unnecessary search parameters computation because it mounts widgets that will likely get removed?

Why don't we render nothing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we render nothing, the parameters of the URL can't be applied unfortunately, this was the best workaround I found

if (!this.state) {
const allComponents = [];
components.forEach(vnode => allComponents.push(vnode));
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
Haroenv marked this conversation as resolved.
Show resolved Hide resolved

return createElement(
'div',
{ attrs: { hidden: true }, class: [this.suit()] },
allComponents
);
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
}

return createElement(
'div',
{ class: [this.suit()] },
this.state.attributesToRender.map(attribute => components.get(attribute))
);
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
},
computed: {
widgetParams() {
return {
transformItems: this.transformItems,
// we do not pass "widgets" to the connector, since Vue is in charge of rendering
widgets: [],
};
},
},
};
178 changes: 178 additions & 0 deletions src/components/__tests__/DynamicWidgets.js
Original file line number Diff line number Diff line change
@@ -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: `
<ais-refinement-list attribute="test1"/>
<ais-refinement-list attribute="test2"/>
<ais-panel>
<ais-hierarchical-menu :attributes="['test3', 'test3']" />
</ais-panel>
`,
},
});

// the inner widgets don't render anything because state is falsy, but they are mounted
expect(wrapper.html()).toMatchInlineSnapshot(`

<div hidden="hidden"
class="ais-DynamicWidgets"
>
<div class="ais-DynamicWidgets-widget">
</div>
<div class="ais-DynamicWidgets-widget">
</div>
<div class="ais-DynamicWidgets-widget">
<div class="ais-Panel">
<div class="ais-Panel-body">
</div>
</div>
</div>
</div>

`);
});

it('renders nothing without children', () => {
__setState({
attributesToRender: [],
});
Haroenv marked this conversation as resolved.
Show resolved Hide resolved

const wrapper = mount(DynamicWidgets, {
propsData: {
transformItems: items => items,
},
});
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
expect(wrapper.html()).toMatchInlineSnapshot(`

<div class="ais-DynamicWidgets">
</div>

`);
});

it('renders nothing with empty items', () => {
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
const localVue = createLocalVue();

localVue.use(plugin);

__setState({
attributesToRender: [],
items: [],
});

const wrapper = mount(DynamicWidgets, {
localVue,
propsData: {
transformItems: items => items,
},
slots: {
default: `<ais-refinement-list attribute="test1"/>`,
},
});

expect(wrapper.html()).toMatchInlineSnapshot(`

<div class="ais-DynamicWidgets">
</div>

`);
});

it('renders attributesToRender 1', () => {
Haroenv marked this conversation as resolved.
Show resolved Hide resolved
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: `
<test-component attribute="test1" />
<ais-refinement-list attribute="test2" />
`,
},
});

expect(wrapper.html()).toMatchInlineSnapshot(`

<div class="ais-DynamicWidgets">
<div class="ais-DynamicWidgets-widget">
<div>
test1
</div>
</div>
</div>

`);
});

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: `
<test-component attribute="test1" />
<ais-refinement-list attribute="test2" />
`,
},
});

expect(wrapper.html()).toMatchInlineSnapshot(`

<div class="ais-DynamicWidgets">
<div class="ais-DynamicWidgets-widget">
</div>
</div>

`);
});
1 change: 1 addition & 0 deletions src/widgets.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
36 changes: 36 additions & 0 deletions stories/DynamicWidgets.stories.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { previewWrapper } from './utils';
import { storiesOf } from '@storybook/vue';

storiesOf('ais-dynamic-widgets', module)
.addDecorator(previewWrapper())
.add('simple usage', () => ({
template: `
<ais-experimental-dynamic-widgets :transform-items="transformItems">
<ais-refinement-list attribute="brand"></ais-refinement-list>
<ais-menu attribute="categories"></ais-menu>
<ais-panel>
<template slot="header">hierarchy</template>
<ais-hierarchical-menu :attributes="hierarchicalCategories"></ais-hierarchical-menu>
</ais-panel>
</ais-experimental-dynamic-widgets>`,
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'];
},
},
}));
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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.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.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"
Expand Down