Skip to content

Commit 2e7ccc9

Browse files
authoredMay 3, 2021
feat(dynamicWidgets): implementation (#4687)
* feat(dynamicWidgets): implementation * return render state * make transformItems optional * chore: update test * chore: update test
1 parent 1f3b6ff commit 2e7ccc9

File tree

16 files changed

+1764
-29
lines changed

16 files changed

+1764
-29
lines changed
 

‎.storybook/MemoryRouter.ts

-27
This file was deleted.

‎.storybook/decorators/withHits.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import algoliasearch from 'algoliasearch/lite';
33
import instantsearch from '../../src/index';
44
import defaultPlayground from '../playgrounds/default';
55
import { configure } from '../../src/widgets';
6+
import { InstantSearch } from '../../src/types';
67

78
export const withHits = (
89
storyFn: ({
@@ -12,7 +13,7 @@ export const withHits = (
1213
}: {
1314
container: HTMLElement;
1415
instantsearch: any;
15-
search: any;
16+
search: InstantSearch;
1617
}) => void,
1718
searchOptions?: any
1819
) => () => {

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@
149149
"bundlesize": [
150150
{
151151
"path": "./dist/instantsearch.production.min.js",
152-
"maxSize": "68.25 kB"
152+
"maxSize": "69 kB"
153153
},
154154
{
155155
"path": "./dist/instantsearch.development.js",

‎src/connectors/dynamic-widgets/__tests__/connectDynamicWidgets-test.ts

+636
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { SearchResults } from 'algoliasearch-helper';
2+
import {
3+
checkRendering,
4+
createDocumentationMessageGenerator,
5+
getWidgetAttribute,
6+
noop,
7+
} from '../../lib/utils';
8+
import { Connector, Widget } from '../../types';
9+
10+
const withUsage = createDocumentationMessageGenerator({
11+
name: 'dynamic-widgets',
12+
connector: true,
13+
});
14+
15+
export type DynamicWidgetsRenderState = {
16+
attributesToRender: string[];
17+
};
18+
19+
export type DynamicWidgetsConnectorParams = {
20+
widgets: Widget[];
21+
transformItems(
22+
items: string[],
23+
metadata: { results: SearchResults }
24+
): string[];
25+
};
26+
27+
export type DynamicWidgetsWidgetDescription = {
28+
$$type: 'ais.dynamicWidgets';
29+
renderState: DynamicWidgetsRenderState;
30+
indexRenderState: {
31+
dynamicWidgets: DynamicWidgetsRenderState;
32+
};
33+
};
34+
35+
export type DynamicWidgetsConnector = Connector<
36+
DynamicWidgetsWidgetDescription,
37+
DynamicWidgetsConnectorParams
38+
>;
39+
40+
const connectDynamicWidgets: DynamicWidgetsConnector = function connectDynamicWidgets(
41+
renderFn,
42+
unmountFn = noop
43+
) {
44+
checkRendering(renderFn, withUsage());
45+
46+
return widgetParams => {
47+
const { widgets, transformItems } = widgetParams;
48+
49+
if (
50+
!widgets ||
51+
!Array.isArray(widgets) ||
52+
widgets.some(widget => typeof widget !== 'object')
53+
) {
54+
throw new Error(
55+
withUsage('The `widgets` option expects an array of widgets.')
56+
);
57+
}
58+
59+
// @TODO once the attributes are computed from the results, make this optional
60+
if (typeof transformItems !== 'function') {
61+
throw new Error(
62+
withUsage('the `transformItems` option is required to be a function.')
63+
);
64+
}
65+
66+
if (
67+
!widgets ||
68+
!Array.isArray(widgets) ||
69+
widgets.some(widget => typeof widget !== 'object')
70+
) {
71+
throw new Error(
72+
withUsage('The `widgets` option expects an array of widgets.')
73+
);
74+
}
75+
76+
const localWidgets: Map<
77+
string,
78+
{ widget: Widget; isMounted: boolean }
79+
> = new Map();
80+
81+
return {
82+
$$type: 'ais.dynamicWidgets',
83+
init(initOptions) {
84+
widgets.forEach(widget => {
85+
const attribute = getWidgetAttribute(widget, initOptions);
86+
localWidgets.set(attribute, { widget, isMounted: true });
87+
});
88+
initOptions.parent!.addWidgets(widgets);
89+
90+
renderFn(
91+
{
92+
...this.getWidgetRenderState(initOptions),
93+
instantSearchInstance: initOptions.instantSearchInstance,
94+
},
95+
true
96+
);
97+
},
98+
render(renderOptions) {
99+
const { parent } = renderOptions;
100+
const renderState = this.getWidgetRenderState(renderOptions);
101+
102+
const widgetsToUnmount: Widget[] = [];
103+
const widgetsToMount: Widget[] = [];
104+
105+
localWidgets.forEach(({ widget, isMounted }, attribute) => {
106+
const shouldMount =
107+
renderState.attributesToRender.indexOf(attribute) > -1;
108+
109+
if (!isMounted && shouldMount) {
110+
widgetsToMount.push(widget);
111+
localWidgets.set(attribute, {
112+
widget,
113+
isMounted: true,
114+
});
115+
} else if (isMounted && !shouldMount) {
116+
widgetsToUnmount.push(widget);
117+
localWidgets.set(attribute, {
118+
widget,
119+
isMounted: false,
120+
});
121+
}
122+
});
123+
124+
parent!.addWidgets(widgetsToMount);
125+
// make sure this only happens after the regular render, otherwise it
126+
// happens too quick, since render is "deferred" for the next microtask,
127+
// so this needs to be a whole task later
128+
setTimeout(() => parent!.removeWidgets(widgetsToUnmount), 0);
129+
130+
renderFn(
131+
{
132+
...renderState,
133+
instantSearchInstance: renderOptions.instantSearchInstance,
134+
},
135+
false
136+
);
137+
},
138+
dispose({ parent }) {
139+
const toRemove: Widget[] = [];
140+
localWidgets.forEach(({ widget, isMounted }) => {
141+
if (isMounted) {
142+
toRemove.push(widget);
143+
}
144+
});
145+
parent!.removeWidgets(toRemove);
146+
147+
unmountFn();
148+
},
149+
getRenderState(renderState, renderOptions) {
150+
return {
151+
...renderState,
152+
dynamicWidgets: this.getWidgetRenderState(renderOptions),
153+
};
154+
},
155+
getWidgetRenderState({ results }) {
156+
if (!results) {
157+
return { attributesToRender: [], widgetParams };
158+
}
159+
160+
// @TODO: retrieve the facet order out of the results:
161+
// results.renderContext.facetOrder.map(facet => facet.attribute)
162+
const attributesToRender = transformItems([], { results });
163+
164+
return { attributesToRender, widgetParams };
165+
},
166+
};
167+
};
168+
};
169+
170+
export default connectDynamicWidgets;

‎src/connectors/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ export { default as connectQueryRules } from './query-rules/connectQueryRules';
2626
export { default as connectVoiceSearch } from './voice-search/connectVoiceSearch';
2727
export { default as EXPERIMENTAL_connectAnswers } from './answers/connectAnswers';
2828
export { default as connectRelevantSort } from './relevant-sort/connectRelevantSort';
29+
export { default as EXPERIMENTAL_connectDynamicWidgets } from './dynamic-widgets/connectDynamicWidgets';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { getWidgetAttribute } from '../';
2+
import { createInitOptions } from '../../../../test/mock/createWidget';
3+
import { connectRefinementList } from '../../../connectors';
4+
import {
5+
hierarchicalMenu,
6+
hits,
7+
panel,
8+
refinementList,
9+
} from '../../../widgets';
10+
11+
describe('getWidgetAttribute', () => {
12+
it('reads the attribute from a refinementList', () => {
13+
expect(
14+
getWidgetAttribute(
15+
refinementList({
16+
container: document.createElement('div'),
17+
attribute: 'test',
18+
}),
19+
createInitOptions()
20+
)
21+
).toBe('test');
22+
});
23+
24+
it('reads the attribute from a connectRefinementList', () => {
25+
expect(
26+
getWidgetAttribute(
27+
connectRefinementList(() => {})({ attribute: 'test' }),
28+
createInitOptions()
29+
)
30+
).toBe('test');
31+
});
32+
33+
it('reads the attribute from a hierarchicalMenu', () => {
34+
expect(
35+
getWidgetAttribute(
36+
hierarchicalMenu({
37+
container: document.createElement('div'),
38+
attributes: ['test1', 'test2'],
39+
}),
40+
createInitOptions()
41+
)
42+
).toBe('test1');
43+
});
44+
45+
it('reads the attribute from a panel', () => {
46+
expect(
47+
getWidgetAttribute(
48+
panel()(refinementList)({
49+
container: document.createElement('div'),
50+
attribute: 'test',
51+
}),
52+
createInitOptions()
53+
)
54+
).toBe('test');
55+
});
56+
57+
it('reads the attribute from a custom widget', () => {
58+
expect(
59+
getWidgetAttribute(
60+
{
61+
$$type: 'mock.widget',
62+
getWidgetRenderState() {
63+
return { widgetParams: { attribute: 'test' } };
64+
},
65+
},
66+
createInitOptions()
67+
)
68+
).toBe('test');
69+
});
70+
71+
it('does not read the attribute from hits', () => {
72+
expect(() =>
73+
getWidgetAttribute(
74+
hits({ container: document.createElement('div') }),
75+
createInitOptions()
76+
)
77+
).toThrowError(`Could not find the attribute of the widget:
78+
79+
{"$$type":"ais.hits","$$widgetType":"ais.hits"}
80+
81+
Please check whether the widget's getWidgetRenderState returns widgetParams.attribute correctly.`);
82+
});
83+
84+
it('does not read the attribute from a custom widget without getWidgetRenderState', () => {
85+
expect(() =>
86+
getWidgetAttribute(
87+
// @ts-expect-error testing invalid input
88+
{},
89+
createInitOptions()
90+
)
91+
).toThrowError(`Could not find the attribute of the widget:
92+
93+
{}
94+
95+
Please check whether the widget's getWidgetRenderState returns widgetParams.attribute correctly.`);
96+
});
97+
98+
it('does not read the attribute from a custom widget without widgetParams in getWidgetRenderState', () => {
99+
expect(() =>
100+
getWidgetAttribute(
101+
{
102+
// @ts-expect-error testing invalid input
103+
getWidgetRenderState() {
104+
return { yo: true };
105+
},
106+
},
107+
createInitOptions()
108+
)
109+
).toThrowError(`Could not find the attribute of the widget:
110+
111+
{}
112+
113+
Please check whether the widget's getWidgetRenderState returns widgetParams.attribute correctly.`);
114+
});
115+
116+
it('does not read the attribute from a custom widget with nothing in getWidgetRenderState', () => {
117+
expect(() =>
118+
getWidgetAttribute(
119+
{
120+
// @ts-expect-error testing invalid input
121+
getWidgetRenderState() {},
122+
},
123+
createInitOptions()
124+
)
125+
).toThrowError(`Could not find the attribute of the widget:
126+
127+
{}
128+
129+
Please check whether the widget's getWidgetRenderState returns widgetParams.attribute correctly.`);
130+
});
131+
});

‎src/lib/utils/getWidgetAttribute.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { InitOptions, Widget } from '../../types';
2+
import { IndexWidget } from '../../widgets/index/index';
3+
4+
export function getWidgetAttribute(
5+
widget: Widget | IndexWidget,
6+
initOptions: InitOptions
7+
): string {
8+
try {
9+
// assume the type to be the correct one, but throw a nice error if it isn't the case
10+
type WidgetWithAttribute = Widget<{
11+
$$type: string;
12+
renderState: Record<string, unknown>;
13+
indexRenderState: Record<string, unknown>;
14+
widgetParams: { attribute: string } | { attributes: string[] };
15+
}>;
16+
17+
const {
18+
widgetParams,
19+
} = (widget as WidgetWithAttribute).getWidgetRenderState(initOptions);
20+
21+
const attribute =
22+
'attribute' in widgetParams
23+
? widgetParams.attribute
24+
: widgetParams.attributes[0];
25+
26+
if (typeof attribute !== 'string') throw new Error();
27+
28+
return attribute;
29+
} catch (e) {
30+
throw new Error(
31+
`Could not find the attribute of the widget:
32+
33+
${JSON.stringify(widget)}
34+
35+
Please check whether the widget's getWidgetRenderState returns widgetParams.attribute correctly.`
36+
);
37+
}
38+
}

‎src/lib/utils/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,4 @@ export { convertNumericRefinementsToFilters } from './convertNumericRefinementsT
5555
export { createConcurrentSafePromise } from './createConcurrentSafePromise';
5656
export { debounce } from './debounce';
5757
export { serializePayload, deserializePayload } from './serializer';
58+
export { getWidgetAttribute } from './getWidgetAttribute';

‎src/types/widget.ts

+2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ export type BuiltinTypes =
5353
| 'ais.configure'
5454
| 'ais.configureRelatedItems'
5555
| 'ais.currentRefinements'
56+
| 'ais.dynamicWidgets'
5657
| 'ais.geoSearch'
5758
| 'ais.hierarchicalMenu'
5859
| 'ais.hits'
@@ -87,6 +88,7 @@ export type BuiltinWidgetTypes =
8788
| 'ais.configure'
8889
| 'ais.configureRelatedItems'
8990
| 'ais.currentRefinements'
91+
| 'ais.dynamicWidgets'
9092
| 'ais.geoSearch'
9193
| 'ais.hierarchicalMenu'
9294
| 'ais.hits'

‎src/widgets/__tests__/index.test.ts

+10
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,16 @@ function initiateAllWidgets(): Array<[WidgetNames, Widget]> {
121121
attributes: ['attr1', 'attr2'],
122122
});
123123
}
124+
case 'EXPERIMENTAL_dynamicWidgets': {
125+
const EXPERIMENTAL_dynamicWidgets = widget as Widgets['EXPERIMENTAL_dynamicWidgets'];
126+
return EXPERIMENTAL_dynamicWidgets({
127+
transformItems(items) {
128+
return items;
129+
},
130+
container,
131+
widgets: [],
132+
});
133+
}
124134
case 'EXPERIMENTAL_answers': {
125135
const EXPERIMENTAL_answers = widget as Widgets['EXPERIMENTAL_answers'];
126136
return EXPERIMENTAL_answers({ container, queryLanguages: ['en'] });

‎src/widgets/dynamic-widgets/__tests__/dynamic-widgets-test.ts

+588
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import connectDynamicWidgets, {
2+
DynamicWidgetsConnectorParams,
3+
DynamicWidgetsWidgetDescription,
4+
} from '../../connectors/dynamic-widgets/connectDynamicWidgets';
5+
import { component } from '../../lib/suit';
6+
import {
7+
createDocumentationMessageGenerator,
8+
getContainerNode,
9+
getWidgetAttribute,
10+
} from '../../lib/utils';
11+
import { Widget, WidgetFactory } from '../../types';
12+
13+
const withUsage = createDocumentationMessageGenerator({
14+
name: 'dynamic-widgets',
15+
});
16+
const suit = component('DynamicWidgets');
17+
18+
export type DynamicWidgetsWidgetParams = {
19+
container: HTMLElement | string;
20+
widgets: Array<(container: HTMLElement) => Widget>;
21+
};
22+
23+
export type DynamicWidgets = WidgetFactory<
24+
DynamicWidgetsWidgetDescription & { $$widgetType: 'ais.dynamicWidgets' },
25+
Omit<DynamicWidgetsConnectorParams, 'widgets'>,
26+
DynamicWidgetsWidgetParams
27+
>;
28+
29+
const dynamicWidgets: DynamicWidgets = function dynamicWidgets(widgetParams) {
30+
const { container: containerSelector, transformItems, widgets } =
31+
widgetParams || {};
32+
33+
if (!containerSelector) {
34+
throw new Error(withUsage('The `container` option is required.'));
35+
}
36+
37+
if (
38+
!widgets ||
39+
!Array.isArray(widgets) ||
40+
widgets.some(widget => typeof widget !== 'function')
41+
) {
42+
throw new Error(
43+
withUsage('The `widgets` option expects an array of callbacks.')
44+
);
45+
}
46+
47+
const userContainer = getContainerNode(containerSelector);
48+
const rootContainer = document.createElement('div');
49+
rootContainer.className = suit();
50+
51+
const containers = new Map<string, HTMLElement>();
52+
const connectorWidgets: Widget[] = [];
53+
54+
const makeWidget = connectDynamicWidgets(
55+
({ attributesToRender }, isFirstRender) => {
56+
if (isFirstRender) {
57+
userContainer.appendChild(rootContainer);
58+
}
59+
60+
attributesToRender.forEach(attribute => {
61+
if (!containers.has(attribute)) {
62+
return;
63+
}
64+
const container = containers.get(attribute)!;
65+
rootContainer.appendChild(container);
66+
});
67+
},
68+
() => {
69+
userContainer.removeChild(rootContainer);
70+
}
71+
);
72+
73+
const widget = makeWidget({
74+
transformItems,
75+
widgets: connectorWidgets,
76+
});
77+
78+
return {
79+
...widget,
80+
init(initOptions) {
81+
widgets.forEach(cb => {
82+
const container = document.createElement('div');
83+
container.className = suit({ descendantName: 'widget' });
84+
rootContainer.appendChild(container);
85+
86+
const childWidget = cb(container);
87+
const attribute = getWidgetAttribute(childWidget, initOptions);
88+
89+
containers.set(attribute, container);
90+
connectorWidgets.push(childWidget);
91+
});
92+
93+
widget.init!(initOptions);
94+
},
95+
$$widgetType: 'ais.dynamicWidgets',
96+
};
97+
};
98+
export default dynamicWidgets;

‎src/widgets/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,4 @@ export { default as index } from './index/index';
3030
export { default as places } from './places/places';
3131
export { default as EXPERIMENTAL_answers } from './answers/answers';
3232
export { default as relevantSort } from './relevant-sort/relevant-sort';
33+
export { default as EXPERIMENTAL_dynamicWidgets } from './dynamic-widgets/dynamic-widgets';

‎stories/dynamic-widgets.stories.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { storiesOf } from '@storybook/html';
2+
import { withHits } from '../.storybook/decorators';
3+
import {
4+
refinementList,
5+
menu,
6+
panel,
7+
hierarchicalMenu,
8+
EXPERIMENTAL_dynamicWidgets,
9+
} from '../src/widgets';
10+
11+
storiesOf('Basics/DynamicWidgets', module).add(
12+
'default',
13+
withHits(({ search, container: rootContainer }) => {
14+
search.addWidgets([
15+
EXPERIMENTAL_dynamicWidgets({
16+
transformItems(_attributes, { results }) {
17+
if (results._state.query === 'dog') {
18+
return ['categories'];
19+
}
20+
if (results._state.query === 'lego') {
21+
return ['categories', 'brand'];
22+
}
23+
return ['brand', 'hierarchicalCategories.lvl0', 'categories'];
24+
},
25+
container: rootContainer,
26+
widgets: [
27+
container => menu({ container, attribute: 'categories' }),
28+
container =>
29+
panel({ templates: { header: 'brand' } })(refinementList)({
30+
container,
31+
attribute: 'brand',
32+
}),
33+
container =>
34+
panel({ templates: { header: 'hierarchy' } })(hierarchicalMenu)({
35+
container,
36+
attributes: [
37+
'hierarchicalCategories.lvl0',
38+
'hierarchicalCategories.lvl1',
39+
'hierarchicalCategories.lvl2',
40+
],
41+
}),
42+
],
43+
}),
44+
]);
45+
})
46+
);
+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Widget } from '../../src/types';
2+
import { IndexWidget } from '../../src/widgets/index/index';
3+
import { getWidgetAttribute } from '../../src/lib/utils';
4+
import { createInitOptions } from '../mock/createWidget';
5+
6+
function getAttribute(widget) {
7+
try {
8+
return getWidgetAttribute(widget, createInitOptions());
9+
} catch {
10+
return undefined;
11+
}
12+
}
13+
14+
export const widgetSnapshotSerializer: jest.SnapshotSerializerPlugin = {
15+
serialize(widget: Widget | IndexWidget, { indent }, indentation) {
16+
const keys = {
17+
$$widgetType: widget.$$widgetType,
18+
attribute: getAttribute(widget),
19+
};
20+
21+
const widgetName = `Widget(${widget.$$type || 'unknown'})`;
22+
23+
const content = Object.entries(keys)
24+
.filter(([_key, value]) => value)
25+
.map(([key, value]) => `${indentation}${indent}${key}: ${value}`)
26+
.join('\n');
27+
28+
if (content) {
29+
return `${widgetName} {
30+
${content}
31+
${indentation}}`;
32+
}
33+
34+
return widgetName;
35+
},
36+
test(value) {
37+
return Boolean(value?.$$type);
38+
},
39+
};

0 commit comments

Comments
 (0)
Please sign in to comment.