Skip to content

Commit

Permalink
feat(core): InstantSearch hot remove/add widgets (#2384)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Maxime Janton authored and Alex S committed Nov 13, 2017
1 parent 673c339 commit cfc1710
Show file tree
Hide file tree
Showing 42 changed files with 977 additions and 64 deletions.
4 changes: 4 additions & 0 deletions dev/app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { registerDisposer, start } from 'dev-novel';
import initBuiltInWidgets from './builtin/init-stories';
import initJqueryWidgets from './jquery/init-stories';
import initVanillaWidgets from './vanilla/init-stories';
import initUnmountWidgets from './init-unmount-widgets.js';

import '../style.css';
import '../../src/css/instantsearch.scss';
Expand All @@ -21,6 +22,9 @@ switch (true) {
case q.includes('widgets=jquery'):
initJqueryWidgets();
break;
case q.includes('widgets=unmount'):
initUnmountWidgets();
break;
default:
initBuiltInWidgets();
}
Expand Down
319 changes: 319 additions & 0 deletions dev/app/init-unmount-widgets.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
/* eslint-disable import/default */
import { storiesOf } from 'dev-novel';
import instantsearch from '../../index.js';

import wrapWithHits from './utils/wrap-with-hits.js';

function wrapWithUnmount(getWidget, params) {
return wrapWithHits(container => {
container.innerHTML = `
<div>
<div id="widgetContainer"></div>
<div style="margin: 10px; border-top: solid 1px #E4E4E4;">
<button id="unmount" style="float: left; margin-right: 10px; margin-top: 10px">
Unmount widget
</div>
<button id="remount" style="float: left; margin-right: 10px;">
Remount widget
</div>
<button id="reload" style="float: left;">
Reload
</div>
</div>
</div>
`;

const widget = getWidget('#widgetContainer');

window.search.addWidget(widget);

function unmount() {
window.search.removeWidget(widget);
document
.getElementById('unmount')
.removeEventListener('click', unmount, false);
}

function remount() {
window.search.addWidget(widget);
document
.getElementById('remount')
.removeEventListener('click', remount, false);
}

function reload() {
window.location.reload();
document
.getElementById('reload')
.removeEventListener('click', reload, false);
}

document
.getElementById('unmount')
.addEventListener('click', unmount, false);

document
.getElementById('remount')
.addEventListener('click', remount, false);

document.getElementById('reload').addEventListener('click', reload, false);
}, params);
}

export default () => {
storiesOf('ClearAll').add(
'default',
wrapWithUnmount(
container => instantsearch.widgets.clearAll({ container }),
{
searchParameters: {
disjunctiveFacetsRefinements: { brand: ['Apple'] },
disjunctiveFacets: ['brand'],
},
}
)
);

storiesOf('CurrentRefinedValues').add(
'default',
wrapWithUnmount(
container => instantsearch.widgets.currentRefinedValues({ container }),
{
searchParameters: {
disjunctiveFacetsRefinements: { brand: ['Apple', 'Samsung'] },
disjunctiveFacets: ['brand'],
},
}
)
);

storiesOf('HierarchicalMenu').add(
'default',
wrapWithUnmount(
container =>
instantsearch.widgets.hierarchicalMenu({
container,
attributes: [
'hierarchicalCategories.lvl0',
'hierarchicalCategories.lvl1',
'hierarchicalCategories.lvl2',
],
rootPath: 'Cameras & Camcorders',
}),
{
searchParameters: {
hierarchicalFacetsRefinements: {
'hierarchicalCategories.lvl0': [
'Cameras & Camcorders > Digital Cameras',
],
},
},
}
)
);

storiesOf('Hits').add(
'default',
wrapWithUnmount(container => instantsearch.widgets.hits({ container }))
);

storiesOf('HitsPerPage').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.hitsPerPageSelector({
container,
items: [
{ value: 3, label: '3 per page' },
{ value: 5, label: '5 per page' },
{ value: 10, label: '10 per page' },
],
})
)
);

storiesOf('InfiniteHits').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.infiniteHits({
container,
showMoreLabel: 'Show more',
templates: {
item: '{{name}}',
},
})
)
);

storiesOf('Menu').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.menu({
container,
attributeName: 'categories',
})
)
);

storiesOf('NumericRefinementList').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.numericRefinementList({
container,
attributeName: 'price',
operator: 'or',
options: [
{ name: 'All' },
{ end: 4, name: 'less than 4' },
{ start: 4, end: 4, name: '4' },
{ start: 5, end: 10, name: 'between 5 and 10' },
{ start: 10, name: 'more than 10' },
],
cssClasses: {
header: 'facet-title',
link: 'facet-value',
count: 'facet-count pull-right',
active: 'facet-active',
},
templates: {
header: 'Numeric refinement list (price)',
},
})
)
);

storiesOf('NumericSelector').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.numericSelector({
container,
operator: '>=',
attributeName: 'popularity',
options: [
{ label: 'Default', value: 0 },
{ label: 'Top 10', value: 9991 },
{ label: 'Top 100', value: 9901 },
{ label: 'Top 500', value: 9501 },
],
})
)
);

storiesOf('Pagination').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.pagination({
container,
maxPages: 20,
})
)
);

storiesOf('PriceRanges').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.priceRanges({
container,
attributeName: 'price',
templates: {
header: 'Price ranges',
},
})
)
);

storiesOf('RefinementList').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.refinementList({
container,
attributeName: 'brand',
operator: 'or',
limit: 10,
templates: {
header: 'Brands',
},
})
)
);

storiesOf('SearchBox').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.searchBox({
container,
placeholder: 'Search for products',
poweredBy: true,
})
)
);

storiesOf('SortBySelector').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.sortBySelector({
container,
indices: [
{ name: 'instant_search', label: 'Most relevant' },
{ name: 'instant_search_price_asc', label: 'Lowest price' },
{ name: 'instant_search_price_desc', label: 'Highest price' },
],
})
)
);

storiesOf('StarRating').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.starRating({
container,
attributeName: 'rating',
max: 5,
labels: {
andUp: '& Up',
},
templates: {
header: 'Rating',
},
})
)
);

storiesOf('Stats').add(
'default',
wrapWithUnmount(container => instantsearch.widgets.stats({ container }))
);

storiesOf('Toggle').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.toggle({
container,
attributeName: 'free_shipping',
label: 'Free Shipping (toggle single value)',
templates: {
header: 'Shipping',
},
})
)
);

storiesOf('rangeSlider').add(
'default',
wrapWithUnmount(container =>
instantsearch.widgets.rangeSlider({
container,
attributeName: 'price',
templates: {
header: 'Price',
},
max: 500,
step: 10,
tooltips: {
format(rawValue) {
return `$${Math.round(rawValue).toLocaleString()}`;
},
},
})
)
);
};
18 changes: 14 additions & 4 deletions docgen/src/guides/custom-widget.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,24 @@ There's a simple example of a custom widget at the end of this guide.

## The widget lifecycle and API

InstantSearch.js defines the widget lifecycle of the widgets in 3 steps:
InstantSearch.js defines the widget lifecycle of the widgets in 4 steps:

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

Thoses steps translate directly into the widget API. Widgets are defined as plain
JS objects with 3 methods:
JS objects with 4 methods:

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

If we translate this to code, this looks like:

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

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

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

0 comments on commit cfc1710

Please sign in to comment.