Skip to content

Commit 7d99ab9

Browse files
authoredSep 6, 2021
feat(dynamicWidgets): add fallbackWidget (#4847)
* feat(dynamicWidgets): add fallbackWidget * merge
1 parent d300d20 commit 7d99ab9

File tree

5 files changed

+196
-201
lines changed

5 files changed

+196
-201
lines changed
 

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

+41-73
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '../../../../test/mock/createAPIResponse';
1515
import connectHierarchicalMenu from '../../hierarchical-menu/connectHierarchicalMenu';
1616
import type { DynamicWidgetsConnectorParams } from '../connectDynamicWidgets';
17+
import connectRefinementList from '../../refinement-list/connectRefinementList';
1718

1819
expect.addSnapshotSerializer(widgetSnapshotSerializer);
1920

@@ -42,6 +43,14 @@ describe('connectDynamicWidgets', () => {
4243
See documentation: https://www.algolia.com/doc/api-reference/widgets/dynamic-widgets/js/#connector"
4344
`);
4445
});
46+
47+
it('does not fail when empty widgets are given', () => {
48+
expect(() =>
49+
EXPERIMENTAL_connectDynamicWidgets(() => {})({
50+
widgets: [],
51+
})
52+
).not.toThrow();
53+
});
4554
});
4655

4756
describe('init', () => {
@@ -80,7 +89,7 @@ describe('connectDynamicWidgets', () => {
8089
});
8190

8291
describe('widgets', () => {
83-
it('adds all widgets to the parent', () => {
92+
it('does not add widgets on init', () => {
8493
const dynamicWidgets = EXPERIMENTAL_connectDynamicWidgets(() => {})({
8594
transformItems() {
8695
return [];
@@ -108,12 +117,6 @@ describe('connectDynamicWidgets', () => {
108117
expect(parent.getWidgets()).toMatchInlineSnapshot(`
109118
[
110119
Widget(ais.dynamicWidgets),
111-
Widget(ais.menu) {
112-
attribute: test1
113-
},
114-
Widget(ais.hierarchicalMenu) {
115-
attribute: test2
116-
},
117120
]
118121
`);
119122
});
@@ -180,54 +183,6 @@ describe('connectDynamicWidgets', () => {
180183
});
181184

182185
describe('widgets', () => {
183-
it('removes all widgets if transformItems says so', async () => {
184-
const dynamicWidgets = EXPERIMENTAL_connectDynamicWidgets(() => {})({
185-
transformItems() {
186-
return [];
187-
},
188-
widgets: [
189-
connectMenu(() => {})({ attribute: 'test1' }),
190-
connectHierarchicalMenu(() => {})({
191-
attributes: ['test2', 'test3'],
192-
}),
193-
],
194-
});
195-
196-
const parent = index({ indexName: 'test' }).addWidgets([
197-
dynamicWidgets,
198-
]);
199-
200-
expect(parent.getWidgets()).toMatchInlineSnapshot(`
201-
[
202-
Widget(ais.dynamicWidgets),
203-
]
204-
`);
205-
206-
dynamicWidgets.init!(createInitOptions({ parent }));
207-
208-
expect(parent.getWidgets()).toMatchInlineSnapshot(`
209-
[
210-
Widget(ais.dynamicWidgets),
211-
Widget(ais.menu) {
212-
attribute: test1
213-
},
214-
Widget(ais.hierarchicalMenu) {
215-
attribute: test2
216-
},
217-
]
218-
`);
219-
220-
dynamicWidgets.render!(createRenderOptions({ parent }));
221-
222-
await wait(0);
223-
224-
expect(parent.getWidgets()).toMatchInlineSnapshot(`
225-
[
226-
Widget(ais.dynamicWidgets),
227-
]
228-
`);
229-
});
230-
231186
it('keeps static widgets returned in transformItems', async () => {
232187
const dynamicWidgets = EXPERIMENTAL_connectDynamicWidgets(() => {})({
233188
transformItems() {
@@ -256,12 +211,6 @@ describe('connectDynamicWidgets', () => {
256211
expect(parent.getWidgets()).toMatchInlineSnapshot(`
257212
[
258213
Widget(ais.dynamicWidgets),
259-
Widget(ais.menu) {
260-
attribute: test1
261-
},
262-
Widget(ais.hierarchicalMenu) {
263-
attribute: test2
264-
},
265214
]
266215
`);
267216

@@ -284,6 +233,8 @@ describe('connectDynamicWidgets', () => {
284233
transformItems(_items, { results }) {
285234
return results.userData[0].MOCK_facetOrder;
286235
},
236+
fallbackWidget: ({ attribute }) =>
237+
connectRefinementList(() => {})({ attribute }),
287238
widgets: [
288239
connectMenu(() => {})({ attribute: 'test1' }),
289240
connectHierarchicalMenu(() => {})({
@@ -307,12 +258,6 @@ describe('connectDynamicWidgets', () => {
307258
expect(parent.getWidgets()).toMatchInlineSnapshot(`
308259
[
309260
Widget(ais.dynamicWidgets),
310-
Widget(ais.menu) {
311-
attribute: test1
312-
},
313-
Widget(ais.hierarchicalMenu) {
314-
attribute: test2
315-
},
316261
]
317262
`);
318263

@@ -387,6 +332,35 @@ describe('connectDynamicWidgets', () => {
387332
},
388333
]
389334
`);
335+
336+
dynamicWidgets.render!(
337+
createRenderOptions({
338+
parent,
339+
results: new SearchResults(
340+
new SearchParameters(),
341+
createMultiSearchResponse({
342+
userData: [{ MOCK_facetOrder: ['test1', 'test4', 'test5'] }],
343+
}).results
344+
),
345+
})
346+
);
347+
348+
await wait(0);
349+
350+
expect(parent.getWidgets()).toMatchInlineSnapshot(`
351+
[
352+
Widget(ais.dynamicWidgets),
353+
Widget(ais.menu) {
354+
attribute: test1
355+
},
356+
Widget(ais.refinementList) {
357+
attribute: test4
358+
},
359+
Widget(ais.refinementList) {
360+
attribute: test5
361+
},
362+
]
363+
`);
390364
});
391365
});
392366
});
@@ -435,12 +409,6 @@ describe('connectDynamicWidgets', () => {
435409
expect(parent.getWidgets()).toMatchInlineSnapshot(`
436410
[
437411
Widget(ais.dynamicWidgets),
438-
Widget(ais.menu) {
439-
attribute: test1
440-
},
441-
Widget(ais.hierarchicalMenu) {
442-
attribute: test2
443-
},
444412
]
445413
`);
446414

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

+21-16
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type DynamicWidgetsRenderState = {
1818

1919
export type DynamicWidgetsConnectorParams = {
2020
widgets: Widget[];
21+
fallbackWidget?(args: { attribute: string }): Widget;
2122
transformItems?(
2223
items: string[],
2324
metadata: { results: SearchResults }
@@ -42,22 +43,18 @@ const connectDynamicWidgets: DynamicWidgetsConnector =
4243
checkRendering(renderFn, withUsage());
4344

4445
return (widgetParams) => {
45-
const { widgets, transformItems = (items) => items } = widgetParams;
46+
const {
47+
widgets,
48+
transformItems = (items) => items,
49+
fallbackWidget,
50+
} = widgetParams;
4651

4752
if (
48-
!widgets ||
49-
!Array.isArray(widgets) ||
50-
widgets.some((widget) => typeof widget !== 'object')
51-
) {
52-
throw new Error(
53-
withUsage('The `widgets` option expects an array of widgets.')
54-
);
55-
}
56-
57-
if (
58-
!widgets ||
59-
!Array.isArray(widgets) ||
60-
widgets.some((widget) => typeof widget !== 'object')
53+
!(
54+
widgets &&
55+
Array.isArray(widgets) &&
56+
widgets.every((widget) => typeof widget === 'object')
57+
)
6158
) {
6259
throw new Error(
6360
withUsage('The `widgets` option expects an array of widgets.')
@@ -72,9 +69,8 @@ const connectDynamicWidgets: DynamicWidgetsConnector =
7269
init(initOptions) {
7370
widgets.forEach((widget) => {
7471
const attribute = getWidgetAttribute(widget, initOptions);
75-
localWidgets.set(attribute, { widget, isMounted: true });
72+
localWidgets.set(attribute, { widget, isMounted: false });
7673
});
77-
initOptions.parent!.addWidgets(widgets);
7874

7975
renderFn(
8076
{
@@ -91,6 +87,15 @@ const connectDynamicWidgets: DynamicWidgetsConnector =
9187
const widgetsToUnmount: Widget[] = [];
9288
const widgetsToMount: Widget[] = [];
9389

90+
if (fallbackWidget) {
91+
renderState.attributesToRender.forEach((attribute) => {
92+
if (!localWidgets.has(attribute)) {
93+
const widget = fallbackWidget({ attribute });
94+
localWidgets.set(attribute, { widget, isMounted: false });
95+
}
96+
});
97+
}
98+
9499
localWidgets.forEach(({ widget, isMounted }, attribute) => {
95100
const shouldMount =
96101
renderState.attributesToRender.indexOf(attribute) > -1;

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

+93-96
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { SearchParameters, SearchResults } from 'algoliasearch-helper';
88
import { createMultiSearchResponse } from '../../../../test/mock/createAPIResponse';
99
import { wait } from '../../../../test/utils/wait';
1010
import { widgetSnapshotSerializer } from '../../../../test/utils/widgetSnapshotSerializer';
11+
import refinementList from '../../refinement-list/refinement-list';
12+
import { createSearchClient } from '../../../../test/mock/createSearchClient';
13+
import instantsearch from '../../..';
1114

1215
expect.addSnapshotSerializer(widgetSnapshotSerializer);
1316

@@ -37,6 +40,15 @@ describe('dynamicWidgets()', () => {
3740
`);
3841
});
3942

43+
test('widgets can be empty', () => {
44+
expect(() =>
45+
EXPERIMENTAL_dynamicWidgets({
46+
container: document.createElement('div'),
47+
widgets: [],
48+
})
49+
).not.toThrowError();
50+
});
51+
4052
test('widgets is required to be callbacks', () => {
4153
expect(() =>
4254
EXPERIMENTAL_dynamicWidgets({
@@ -199,10 +211,13 @@ describe('dynamicWidgets()', () => {
199211
});
200212

201213
it('renders the widgets returned by transformItems', async () => {
202-
const instantSearchInstance = createInstantSearch();
214+
const instantSearchInstance = instantsearch({
215+
indexName: '',
216+
searchClient: createSearchClient(),
217+
});
203218
const rootContainer = document.createElement('div');
204219

205-
const indexWidget = index({ indexName: 'test' }).addWidgets([
220+
instantSearchInstance.addWidgets([
206221
EXPERIMENTAL_dynamicWidgets({
207222
container: rootContainer,
208223
transformItems() {
@@ -237,18 +252,7 @@ describe('dynamicWidgets()', () => {
237252
}),
238253
]);
239254

240-
indexWidget.init(createInitOptions({ instantSearchInstance }));
241-
242-
// set results to the relevant index, so it renders all children
243-
instantSearchInstance.mainHelper!.derivedHelpers[0].lastResults =
244-
new SearchResults(
245-
indexWidget.getWidgetSearchParameters(new SearchParameters(), {
246-
uiState: {},
247-
}),
248-
createMultiSearchResponse({}).results
249-
);
250-
251-
indexWidget.render(createRenderOptions({ instantSearchInstance }));
255+
instantSearchInstance.start();
252256

253257
await wait(0);
254258

@@ -279,14 +283,20 @@ describe('dynamicWidgets()', () => {
279283
});
280284

281285
it('updates the position of widgets returned by transformItems', async () => {
282-
const instantSearchInstance = createInstantSearch();
286+
const instantSearchInstance = instantsearch({
287+
indexName: '',
288+
searchClient: createSearchClient(),
289+
});
290+
instantSearchInstance.start();
283291
const rootContainer = document.createElement('div');
284292

285-
const indexWidget = index({ indexName: 'test' }).addWidgets([
293+
let ordering = ['test1', 'test4'];
294+
295+
instantSearchInstance.addWidgets([
286296
EXPERIMENTAL_dynamicWidgets({
287297
container: rootContainer,
288-
transformItems(_items, { results }) {
289-
return results.userData[0].MOCK_facetOrder;
298+
transformItems() {
299+
return ordering;
290300
},
291301
widgets: [
292302
(container) =>
@@ -317,21 +327,6 @@ describe('dynamicWidgets()', () => {
317327
}),
318328
]);
319329

320-
indexWidget.init(createInitOptions({ instantSearchInstance }));
321-
322-
// set results to the relevant index, so it renders all children
323-
instantSearchInstance.mainHelper!.derivedHelpers[0].lastResults =
324-
new SearchResults(
325-
indexWidget.getWidgetSearchParameters(new SearchParameters(), {
326-
uiState: {},
327-
}),
328-
createMultiSearchResponse({
329-
userData: [{ MOCK_facetOrder: ['test1', 'test4'] }],
330-
}).results
331-
);
332-
333-
indexWidget.render(createRenderOptions({ instantSearchInstance }));
334-
335330
await wait(0);
336331

337332
expect(rootContainer).toMatchInlineSnapshot(`
@@ -363,18 +358,10 @@ describe('dynamicWidgets()', () => {
363358
</div>
364359
`);
365360

366-
// set results to the relevant index, so it renders all children
367-
instantSearchInstance.mainHelper!.derivedHelpers[0].lastResults =
368-
new SearchResults(
369-
indexWidget.getWidgetSearchParameters(new SearchParameters(), {
370-
uiState: {},
371-
}),
372-
createMultiSearchResponse({
373-
userData: [{ MOCK_facetOrder: ['test4', 'test1'] }],
374-
}).results
375-
);
361+
ordering = ['test4', 'test1'];
376362

377-
indexWidget.render(createRenderOptions({ instantSearchInstance }));
363+
instantSearchInstance.scheduleRender();
364+
await wait(0);
378365

379366
expect(rootContainer).toMatchInlineSnapshot(`
380367
<div>
@@ -416,6 +403,12 @@ describe('dynamicWidgets()', () => {
416403
transformItems(_items, { results }) {
417404
return results.userData[0].MOCK_facetOrder;
418405
},
406+
fallbackWidget: ({ container, attribute }) =>
407+
refinementList({
408+
attribute,
409+
container,
410+
cssClasses: { root: attribute },
411+
}),
419412
widgets: [
420413
(container) =>
421414
menu({
@@ -454,7 +447,7 @@ describe('dynamicWidgets()', () => {
454447
uiState: {},
455448
}),
456449
createMultiSearchResponse({
457-
userData: [{ MOCK_facetOrder: ['test1', 'test4'] }],
450+
userData: [{ MOCK_facetOrder: ['test1', 'test4', 'test5'] }],
458451
}).results
459452
);
460453

@@ -475,6 +468,10 @@ describe('dynamicWidgets()', () => {
475468
$$widgetType: ais.menu
476469
attribute: test4
477470
},
471+
Widget(ais.refinementList) {
472+
$$widgetType: ais.refinementList
473+
attribute: test5
474+
},
478475
]
479476
`);
480477

@@ -486,58 +483,53 @@ describe('dynamicWidgets()', () => {
486483
});
487484

488485
it('removes dom on dispose', async () => {
489-
const instantSearchInstance = createInstantSearch();
486+
const instantSearchInstance = instantsearch({
487+
indexName: '',
488+
searchClient: createSearchClient(),
489+
});
490+
instantSearchInstance.start();
490491
const rootContainer = document.createElement('div');
491492

492-
const indexWidget = index({ indexName: 'test' }).addWidgets([
493-
EXPERIMENTAL_dynamicWidgets({
494-
container: rootContainer,
495-
transformItems(_items, { results }) {
496-
return results.userData[0].MOCK_facetOrder;
497-
},
498-
widgets: [
499-
(container) =>
500-
menu({
501-
attribute: 'test1',
502-
container,
503-
cssClasses: { root: 'test1' },
504-
}),
505-
(container) =>
506-
menu({
507-
attribute: 'test2',
508-
container,
509-
cssClasses: { root: 'test2' },
510-
}),
511-
(container) =>
512-
menu({
513-
attribute: 'test3',
514-
container,
515-
cssClasses: { root: 'test3' },
516-
}),
517-
(container) =>
518-
menu({
519-
attribute: 'test4',
520-
container,
521-
cssClasses: { root: 'test4' },
522-
}),
523-
],
524-
}),
525-
]);
526-
527-
indexWidget.init(createInitOptions({ instantSearchInstance }));
528-
529-
// set results to the relevant index, so it renders all children
530-
instantSearchInstance.mainHelper!.derivedHelpers[0].lastResults =
531-
new SearchResults(
532-
indexWidget.getWidgetSearchParameters(new SearchParameters(), {
533-
uiState: {},
493+
const dynamicWidget = EXPERIMENTAL_dynamicWidgets({
494+
container: rootContainer,
495+
transformItems() {
496+
return ['test1', 'test5', 'test4'];
497+
},
498+
fallbackWidget: ({ container, attribute }) =>
499+
refinementList({
500+
attribute,
501+
container,
502+
cssClasses: { root: attribute },
534503
}),
535-
createMultiSearchResponse({
536-
userData: [{ MOCK_facetOrder: ['test1', 'test4'] }],
537-
}).results
538-
);
504+
widgets: [
505+
(container) =>
506+
menu({
507+
attribute: 'test1',
508+
container,
509+
cssClasses: { root: 'test1' },
510+
}),
511+
(container) =>
512+
menu({
513+
attribute: 'test2',
514+
container,
515+
cssClasses: { root: 'test2' },
516+
}),
517+
(container) =>
518+
menu({
519+
attribute: 'test3',
520+
container,
521+
cssClasses: { root: 'test3' },
522+
}),
523+
(container) =>
524+
menu({
525+
attribute: 'test4',
526+
container,
527+
cssClasses: { root: 'test4' },
528+
}),
529+
],
530+
});
539531

540-
indexWidget.render(createRenderOptions({ instantSearchInstance }));
532+
instantSearchInstance.addWidgets([dynamicWidget]);
541533

542534
await wait(0);
543535

@@ -559,6 +551,13 @@ describe('dynamicWidgets()', () => {
559551
class="ais-Menu test1 ais-Menu--noRefinement"
560552
/>
561553
</div>
554+
<div
555+
class="ais-DynamicWidgets-widget"
556+
>
557+
<div
558+
class="ais-RefinementList test5 ais-RefinementList--noRefinement"
559+
/>
560+
</div>
562561
<div
563562
class="ais-DynamicWidgets-widget"
564563
>
@@ -570,9 +569,7 @@ describe('dynamicWidgets()', () => {
570569
</div>
571570
`);
572571

573-
const dynamicWidget = indexWidget.getWidgets()[0];
574-
575-
indexWidget.removeWidgets([dynamicWidget]);
572+
instantSearchInstance.removeWidgets([dynamicWidget]);
576573

577574
expect(rootContainer).toMatchInlineSnapshot(`<div />`);
578575
});

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

+26-7
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,44 @@ const suit = component('DynamicWidgets');
1919
export type DynamicWidgetsWidgetParams = {
2020
container: HTMLElement | string;
2121
widgets: Array<(container: HTMLElement) => Widget>;
22+
fallbackWidget?(args: { attribute: string; container: HTMLElement }): Widget;
2223
};
2324

2425
export type DynamicWidgetsWidget = WidgetFactory<
2526
DynamicWidgetsWidgetDescription & { $$widgetType: 'ais.dynamicWidgets' },
26-
Omit<DynamicWidgetsConnectorParams, 'widgets'>,
27+
Omit<DynamicWidgetsConnectorParams, 'widgets' | 'fallbackWidget'>,
2728
DynamicWidgetsWidgetParams
2829
>;
2930

31+
function createContainer(rootContainer: HTMLElement) {
32+
const container = document.createElement('div');
33+
container.className = suit({ descendantName: 'widget' });
34+
35+
rootContainer.appendChild(container);
36+
37+
return container;
38+
}
39+
3040
const dynamicWidgets: DynamicWidgetsWidget = function dynamicWidgets(
3141
widgetParams
3242
) {
3343
const {
3444
container: containerSelector,
3545
transformItems,
3646
widgets,
47+
fallbackWidget,
3748
} = widgetParams || {};
3849

3950
if (!containerSelector) {
4051
throw new Error(withUsage('The `container` option is required.'));
4152
}
4253

4354
if (
44-
!widgets ||
45-
!Array.isArray(widgets) ||
46-
widgets.some((widget) => typeof widget !== 'function')
55+
!(
56+
widgets &&
57+
Array.isArray(widgets) &&
58+
widgets.every((widget) => typeof widget === 'function')
59+
)
4760
) {
4861
throw new Error(
4962
withUsage('The `widgets` option expects an array of callbacks.')
@@ -79,15 +92,21 @@ const dynamicWidgets: DynamicWidgetsWidget = function dynamicWidgets(
7992
const widget = makeWidget({
8093
transformItems,
8194
widgets: connectorWidgets,
95+
fallbackWidget:
96+
typeof fallbackWidget === 'function'
97+
? ({ attribute }) => {
98+
const container = createContainer(rootContainer);
99+
containers.set(attribute, container);
100+
return fallbackWidget({ attribute, container });
101+
}
102+
: undefined,
82103
});
83104

84105
return {
85106
...widget,
86107
init(initOptions) {
87108
widgets.forEach((cb) => {
88-
const container = document.createElement('div');
89-
container.className = suit({ descendantName: 'widget' });
90-
rootContainer.appendChild(container);
109+
const container = createContainer(rootContainer);
91110

92111
const childWidget = cb(container);
93112
const attribute = getWidgetAttribute(childWidget, initOptions);

‎stories/dynamic-widgets.stories.ts

+15-9
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,26 @@ storiesOf('Basics/DynamicWidgets', module).add(
1414
search.addWidgets([
1515
instantsearch.widgets.EXPERIMENTAL_dynamicWidgets({
1616
container: dynamicWidgetsContainer,
17+
fallbackWidget: ({ attribute, container }) =>
18+
instantsearch.widgets.panel<
19+
typeof instantsearch.widgets.refinementList
20+
>({
21+
templates: {
22+
header(stuff) {
23+
return stuff.widgetParams.attribute;
24+
},
25+
},
26+
})(instantsearch.widgets.refinementList)({ attribute, container }),
1727
widgets: [
1828
(container) =>
19-
instantsearch.widgets.menu({ container, attribute: 'categories' }),
20-
(container) =>
21-
instantsearch.widgets.panel({ templates: { header: 'brand' } })(
22-
instantsearch.widgets.refinementList
23-
)({
29+
instantsearch.widgets.menu({
2430
container,
25-
attribute: 'brand',
31+
attribute: 'categories',
2632
}),
2733
(container) =>
28-
instantsearch.widgets.panel({ templates: { header: 'hierarchy' } })(
29-
instantsearch.widgets.hierarchicalMenu
30-
)({
34+
instantsearch.widgets.panel({
35+
templates: { header: 'hierarchy' },
36+
})(instantsearch.widgets.hierarchicalMenu)({
3137
container,
3238
attributes: [
3339
'hierarchicalCategories.lvl0',

0 commit comments

Comments
 (0)
Please sign in to comment.