Skip to content

Commit cfc1710

Browse files
Maxime JantonAlex S
authored andcommitted
feat(core): InstantSearch hot remove/add widgets (#2384)
* chore(devnovel): add unmount widgets example * feat(connectors): implement dispose() API * feat(widgets): pass `unmountFn` to connectors * feat(InstantSearch): add `removeWidget()` method * fix(connectHierarchicalMenu): dont mutate state directly * feat(dispose): better error message * refactor(dispose): provide helper state directly * refactor(widgets): inline disposer fn * feat(removeWidget): accept array of widgets * refactor(removeWidget): have two distinct methods * chore(dev): add `re-mount` button for testing purpose * feat(addWidget): allow mount widgets after started * feat(addWidgets): allow mount multiple widgets at once * test(InstantSearch): removeWidget * test(instantsearch): removeWidgets() * test(instantsearch): add widgets after start * fix(menuSelect): use preact instead of React (#2460) * fix(test): correctly reset the wired dependency (#2461) * fix(addWidgets): update helper state before call `init()` * feat(InstantSearch): add `dispose()` method to remove all widgets * doc(InstantSearch): add jsdoc for new methods * doc(custom-widget): add notes about dispose() * feat(url-sync): implement dispose strategy * fix(InstantSearch.dispose): dispose urlSyncWidget first
1 parent 673c339 commit cfc1710

File tree

42 files changed

+977
-64
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+977
-64
lines changed

dev/app/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { registerDisposer, start } from 'dev-novel';
22
import initBuiltInWidgets from './builtin/init-stories';
33
import initJqueryWidgets from './jquery/init-stories';
44
import initVanillaWidgets from './vanilla/init-stories';
5+
import initUnmountWidgets from './init-unmount-widgets.js';
56

67
import '../style.css';
78
import '../../src/css/instantsearch.scss';
@@ -21,6 +22,9 @@ switch (true) {
2122
case q.includes('widgets=jquery'):
2223
initJqueryWidgets();
2324
break;
25+
case q.includes('widgets=unmount'):
26+
initUnmountWidgets();
27+
break;
2428
default:
2529
initBuiltInWidgets();
2630
}

dev/app/init-unmount-widgets.js

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
/* eslint-disable import/default */
2+
import { storiesOf } from 'dev-novel';
3+
import instantsearch from '../../index.js';
4+
5+
import wrapWithHits from './utils/wrap-with-hits.js';
6+
7+
function wrapWithUnmount(getWidget, params) {
8+
return wrapWithHits(container => {
9+
container.innerHTML = `
10+
<div>
11+
<div id="widgetContainer"></div>
12+
<div style="margin: 10px; border-top: solid 1px #E4E4E4;">
13+
<button id="unmount" style="float: left; margin-right: 10px; margin-top: 10px">
14+
Unmount widget
15+
</div>
16+
<button id="remount" style="float: left; margin-right: 10px;">
17+
Remount widget
18+
</div>
19+
<button id="reload" style="float: left;">
20+
Reload
21+
</div>
22+
</div>
23+
</div>
24+
`;
25+
26+
const widget = getWidget('#widgetContainer');
27+
28+
window.search.addWidget(widget);
29+
30+
function unmount() {
31+
window.search.removeWidget(widget);
32+
document
33+
.getElementById('unmount')
34+
.removeEventListener('click', unmount, false);
35+
}
36+
37+
function remount() {
38+
window.search.addWidget(widget);
39+
document
40+
.getElementById('remount')
41+
.removeEventListener('click', remount, false);
42+
}
43+
44+
function reload() {
45+
window.location.reload();
46+
document
47+
.getElementById('reload')
48+
.removeEventListener('click', reload, false);
49+
}
50+
51+
document
52+
.getElementById('unmount')
53+
.addEventListener('click', unmount, false);
54+
55+
document
56+
.getElementById('remount')
57+
.addEventListener('click', remount, false);
58+
59+
document.getElementById('reload').addEventListener('click', reload, false);
60+
}, params);
61+
}
62+
63+
export default () => {
64+
storiesOf('ClearAll').add(
65+
'default',
66+
wrapWithUnmount(
67+
container => instantsearch.widgets.clearAll({ container }),
68+
{
69+
searchParameters: {
70+
disjunctiveFacetsRefinements: { brand: ['Apple'] },
71+
disjunctiveFacets: ['brand'],
72+
},
73+
}
74+
)
75+
);
76+
77+
storiesOf('CurrentRefinedValues').add(
78+
'default',
79+
wrapWithUnmount(
80+
container => instantsearch.widgets.currentRefinedValues({ container }),
81+
{
82+
searchParameters: {
83+
disjunctiveFacetsRefinements: { brand: ['Apple', 'Samsung'] },
84+
disjunctiveFacets: ['brand'],
85+
},
86+
}
87+
)
88+
);
89+
90+
storiesOf('HierarchicalMenu').add(
91+
'default',
92+
wrapWithUnmount(
93+
container =>
94+
instantsearch.widgets.hierarchicalMenu({
95+
container,
96+
attributes: [
97+
'hierarchicalCategories.lvl0',
98+
'hierarchicalCategories.lvl1',
99+
'hierarchicalCategories.lvl2',
100+
],
101+
rootPath: 'Cameras & Camcorders',
102+
}),
103+
{
104+
searchParameters: {
105+
hierarchicalFacetsRefinements: {
106+
'hierarchicalCategories.lvl0': [
107+
'Cameras & Camcorders > Digital Cameras',
108+
],
109+
},
110+
},
111+
}
112+
)
113+
);
114+
115+
storiesOf('Hits').add(
116+
'default',
117+
wrapWithUnmount(container => instantsearch.widgets.hits({ container }))
118+
);
119+
120+
storiesOf('HitsPerPage').add(
121+
'default',
122+
wrapWithUnmount(container =>
123+
instantsearch.widgets.hitsPerPageSelector({
124+
container,
125+
items: [
126+
{ value: 3, label: '3 per page' },
127+
{ value: 5, label: '5 per page' },
128+
{ value: 10, label: '10 per page' },
129+
],
130+
})
131+
)
132+
);
133+
134+
storiesOf('InfiniteHits').add(
135+
'default',
136+
wrapWithUnmount(container =>
137+
instantsearch.widgets.infiniteHits({
138+
container,
139+
showMoreLabel: 'Show more',
140+
templates: {
141+
item: '{{name}}',
142+
},
143+
})
144+
)
145+
);
146+
147+
storiesOf('Menu').add(
148+
'default',
149+
wrapWithUnmount(container =>
150+
instantsearch.widgets.menu({
151+
container,
152+
attributeName: 'categories',
153+
})
154+
)
155+
);
156+
157+
storiesOf('NumericRefinementList').add(
158+
'default',
159+
wrapWithUnmount(container =>
160+
instantsearch.widgets.numericRefinementList({
161+
container,
162+
attributeName: 'price',
163+
operator: 'or',
164+
options: [
165+
{ name: 'All' },
166+
{ end: 4, name: 'less than 4' },
167+
{ start: 4, end: 4, name: '4' },
168+
{ start: 5, end: 10, name: 'between 5 and 10' },
169+
{ start: 10, name: 'more than 10' },
170+
],
171+
cssClasses: {
172+
header: 'facet-title',
173+
link: 'facet-value',
174+
count: 'facet-count pull-right',
175+
active: 'facet-active',
176+
},
177+
templates: {
178+
header: 'Numeric refinement list (price)',
179+
},
180+
})
181+
)
182+
);
183+
184+
storiesOf('NumericSelector').add(
185+
'default',
186+
wrapWithUnmount(container =>
187+
instantsearch.widgets.numericSelector({
188+
container,
189+
operator: '>=',
190+
attributeName: 'popularity',
191+
options: [
192+
{ label: 'Default', value: 0 },
193+
{ label: 'Top 10', value: 9991 },
194+
{ label: 'Top 100', value: 9901 },
195+
{ label: 'Top 500', value: 9501 },
196+
],
197+
})
198+
)
199+
);
200+
201+
storiesOf('Pagination').add(
202+
'default',
203+
wrapWithUnmount(container =>
204+
instantsearch.widgets.pagination({
205+
container,
206+
maxPages: 20,
207+
})
208+
)
209+
);
210+
211+
storiesOf('PriceRanges').add(
212+
'default',
213+
wrapWithUnmount(container =>
214+
instantsearch.widgets.priceRanges({
215+
container,
216+
attributeName: 'price',
217+
templates: {
218+
header: 'Price ranges',
219+
},
220+
})
221+
)
222+
);
223+
224+
storiesOf('RefinementList').add(
225+
'default',
226+
wrapWithUnmount(container =>
227+
instantsearch.widgets.refinementList({
228+
container,
229+
attributeName: 'brand',
230+
operator: 'or',
231+
limit: 10,
232+
templates: {
233+
header: 'Brands',
234+
},
235+
})
236+
)
237+
);
238+
239+
storiesOf('SearchBox').add(
240+
'default',
241+
wrapWithUnmount(container =>
242+
instantsearch.widgets.searchBox({
243+
container,
244+
placeholder: 'Search for products',
245+
poweredBy: true,
246+
})
247+
)
248+
);
249+
250+
storiesOf('SortBySelector').add(
251+
'default',
252+
wrapWithUnmount(container =>
253+
instantsearch.widgets.sortBySelector({
254+
container,
255+
indices: [
256+
{ name: 'instant_search', label: 'Most relevant' },
257+
{ name: 'instant_search_price_asc', label: 'Lowest price' },
258+
{ name: 'instant_search_price_desc', label: 'Highest price' },
259+
],
260+
})
261+
)
262+
);
263+
264+
storiesOf('StarRating').add(
265+
'default',
266+
wrapWithUnmount(container =>
267+
instantsearch.widgets.starRating({
268+
container,
269+
attributeName: 'rating',
270+
max: 5,
271+
labels: {
272+
andUp: '& Up',
273+
},
274+
templates: {
275+
header: 'Rating',
276+
},
277+
})
278+
)
279+
);
280+
281+
storiesOf('Stats').add(
282+
'default',
283+
wrapWithUnmount(container => instantsearch.widgets.stats({ container }))
284+
);
285+
286+
storiesOf('Toggle').add(
287+
'default',
288+
wrapWithUnmount(container =>
289+
instantsearch.widgets.toggle({
290+
container,
291+
attributeName: 'free_shipping',
292+
label: 'Free Shipping (toggle single value)',
293+
templates: {
294+
header: 'Shipping',
295+
},
296+
})
297+
)
298+
);
299+
300+
storiesOf('rangeSlider').add(
301+
'default',
302+
wrapWithUnmount(container =>
303+
instantsearch.widgets.rangeSlider({
304+
container,
305+
attributeName: 'price',
306+
templates: {
307+
header: 'Price',
308+
},
309+
max: 500,
310+
step: 10,
311+
tooltips: {
312+
format(rawValue) {
313+
return `$${Math.round(rawValue).toLocaleString()}`;
314+
},
315+
},
316+
})
317+
)
318+
);
319+
};

docgen/src/guides/custom-widget.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,24 @@ There's a simple example of a custom widget at the end of this guide.
3232

3333
## The widget lifecycle and API
3434

35-
InstantSearch.js defines the widget lifecycle of the widgets in 3 steps:
35+
InstantSearch.js defines the widget lifecycle of the widgets in 4 steps:
3636

3737
- the configuration step, during which the initial search configuration is computed
3838
- the init step, which happens before the first search
3939
- the render step, which happens after each result from Algolia
40+
- the dispose step, which happens when you remove the widget or dispose the InstantSearch instance
4041

4142
Thoses steps translate directly into the widget API. Widgets are defined as plain
42-
JS objects with 3 methods:
43+
JS objects with 4 methods:
4344

4445
- `getConfiguration` (optional), returns the necessary subpart of the configuration, specific
4546
to this widget
4647
- `init`, optional, used to setup the widget (good place to first setup the initial DOM).
4748
Called before the first search.
4849
- `render`, optional, used to update the widget with the new information from the results.
4950
Called after each time results come back from Algolia
51+
- `dispose` optional, used to remove the specific configuration which was specified in the `getConfiguration` method.
52+
Called when removing the widget or when InstantSearch disposes itself.
5053

5154
If we translate this to code, this looks like:
5255

@@ -69,6 +72,13 @@ search.addWidget({
6972
// - helper: to modify the search state and propagate the user interaction
7073
// - state: the state at this point
7174
// - createURL: if the url sync is active, will make it possible to create new URLs
75+
},
76+
dispose: function(disposeOptions) {
77+
// disposeOptions contains one key:
78+
// - state: the state at this point to
79+
//
80+
// The dispose method should return the next state of the search,
81+
// if it has been modified.
7282
}
7383
});
7484
```
@@ -80,8 +90,8 @@ A widget is valid as long as it implements at least `render` or `init`.
8090
The previous custom widget API boilerplate is the reading part of the widgets. To be able to transform
8191
user interaction into search parameters we need to be able to modify the state.
8292

83-
The whole search state is held by an instance of the
84-
[JS Helper](https://community.algolia.com/algoliasearch-helper-js/) in InstantSearch.js.
93+
The whole search state is held by an instance of the
94+
[JS Helper](https://community.algolia.com/algoliasearch-helper-js/) in InstantSearch.js.
8595
This instance of the helper is accessible at the `init` and `render` phases.
8696

8797
The helper is used to change the parameters of the search. It provides methods

0 commit comments

Comments
 (0)