From 4bec81e797d741f45b1715904140af014f1fc2c2 Mon Sep 17 00:00:00 2001 From: Maxime Janton <127086@supinfo.com> Date: Tue, 29 May 2018 18:59:50 +0200 Subject: [PATCH] feat(connectors): add connectAutocomplete (#2841) * feat(connectors): add connectAutocomplete * docs(dev-novel): add autcomplete example * test(connectors): connectAutocomplete * docs(autocomplete): story with action on selected item * docs(connectAutocomplete): JS Doc * refactor(connectAutocomplete): default `indices` to `[]` * fix(connectAutocomplete): use `label` as find key this allow the usage of more than once index, for instance you can imagines cases where the user pass the same index twice with different search parameters applied cf: https://github.com/algolia/instantsearch.js/pull/2841#discussion_r188383882 * fix(connectAutocomplete): call `.detach()` on derived indices * docs(devnovel): clearOptions on autocomplete before render * feat(connectAutocomplete): default `indices[x].hits` to an empty array * test(connectAutocomplete): use renderFn params * fix(connectAutocomplete): default hits to [] * docs(connectAutocomplete): story with multi-index * fix(connectAutocomplete): provide `currentRefinement` * docs(aucomplete): query when no resutls * fix(connectAutocomplete): check if `results` and `results.htis` are present * docs(multi-index): add search box * docs(connectAutocomplete): specify the fact you get the main index * feat(connectAutocomplete): remove `helper` from public indices Fix #2313 --- dev/app/jquery/init-stories.js | 2 + .../jquery/stories/autocomplete.stories.js | 208 ++++++++++++++++++ dev/template.html | 7 + .../__tests__/connectAutocomplete-test.js | 72 ++++++ .../autocomplete/connectAutocomplete.js | 163 ++++++++++++++ src/connectors/index.js | 3 + 6 files changed, 455 insertions(+) create mode 100644 dev/app/jquery/stories/autocomplete.stories.js create mode 100644 src/connectors/autocomplete/__tests__/connectAutocomplete-test.js create mode 100644 src/connectors/autocomplete/connectAutocomplete.js diff --git a/dev/app/jquery/init-stories.js b/dev/app/jquery/init-stories.js index 8b7329de46..617c243bad 100644 --- a/dev/app/jquery/init-stories.js +++ b/dev/app/jquery/init-stories.js @@ -15,6 +15,7 @@ import initSortBySelectorStories from './stories/sort-by-selector.stories'; import initStarRatingStories from './stories/star-rating.stories'; import initStatsStories from './stories/stats.stories'; import initToggleStories from './stories/toggle.stories'; +import initAutcompleteStories from './stories/autocomplete.stories'; export default () => { initClearAllStories(); @@ -34,4 +35,5 @@ export default () => { initStarRatingStories(); initStatsStories(); initToggleStories(); + initAutcompleteStories(); }; diff --git a/dev/app/jquery/stories/autocomplete.stories.js b/dev/app/jquery/stories/autocomplete.stories.js new file mode 100644 index 0000000000..ab610b1693 --- /dev/null +++ b/dev/app/jquery/stories/autocomplete.stories.js @@ -0,0 +1,208 @@ +/* eslint-disable import/default */ + +import { action, storiesOf } from 'dev-novel'; + +import instantsearch from '../../../../index.js'; +import { wrapWithHitsAndJquery } from '../../utils/wrap-with-hits.js'; + +const stories = storiesOf('Autocomplete'); + +// Widget to search into brands, select one and set it as query +const autocompleteBrands = instantsearch.connectors.connectAutocomplete( + ({ indices, refine, widgetParams: { containerNode } }, isFirstRendering) => { + if (isFirstRendering) { + containerNode.html(` + Search for a brand: + + `); + + containerNode.find('select').selectize({ + options: [], + + valueField: 'brand', + labelField: 'brand', + searchField: 'brand', + + highlight: false, + + onType: refine, + + onChange: refine, + }); + } + + if (!isFirstRendering && indices[0].results) { + const autocompleteInstance = containerNode.find('select')[0].selectize; + + indices[0].results.hits.forEach(h => autocompleteInstance.addOption(h)); + autocompleteInstance.refreshOptions(autocompleteInstance.isOpen); + } + } +); + +// widget to search into hits, select a choice open a new page (event example) +const autocompleteAndSelect = instantsearch.connectors.connectAutocomplete( + ({ indices, refine, widgetParams: { containerNode } }, isFirstRendering) => { + const onItemSelected = objectID => { + const item = indices.reduce((match, index) => { + if (match) return match; + return index.hits.find(obj => obj.objectID === objectID); + }, null); + + action('item:selected')(item); + }; + + if (isFirstRendering) { + containerNode.html(` + Search for anything: + + `); + + containerNode.find('select').selectize({ + options: [], + + valueField: 'objectID', + labelField: 'name', + searchField: ['name', 'brand', 'categories', 'description'], + + render: { + option: item => ` +
+
+ +
+ +
+
+ ${item._highlightResult.name.value} + ${item.price_formatted} + ${item.rating} stars +
+ +
+ ${item._highlightResult.type.value} +
+ +
+ ${item._highlightResult.description.value} +
+
+
+ `, + }, + + highlight: false, + onType: refine, + + onChange: onItemSelected, + }); + + // HACK: bind `autocompleteInstance.search` with an empty query so it returns + // all the hits sent by Algolia + const autocompleteInstance = containerNode.find('select')[0].selectize; + autocompleteInstance.search.bind(autocompleteInstance, ''); + } + + if (!isFirstRendering && indices[0].results) { + const autocompleteInstance = containerNode.find('select')[0].selectize; + + // first clear options + autocompleteInstance.clearOptions(); + // add new ones + indices[0].results.hits.forEach(h => autocompleteInstance.addOption(h)); + // refresh the view + autocompleteInstance.refreshOptions(autocompleteInstance.isOpen); + } + } +); + +const multiIndex = instantsearch.connectors.connectAutocomplete( + ( + { indices, currentRefinement, widgetParams: { containerNode } }, + isFirstRendering + ) => { + if (isFirstRendering) { + containerNode.append(` +
+
+
+ +
+
+ +
+
+ `); + } + + // display hits + indices.forEach(({ hits }, index) => { + const hitsHTML = + hits.length === 0 + ? `No results for query ${currentRefinement}` + : hits.map( + hit => ` +
+
+ +
+ +
+
+ ${hit._highlightResult.name.value} +
+ +
+ ${hit._highlightResult.type.value} +
+
+
+ ` + ); + + containerNode.find(`#hits${index}`).html(hitsHTML); + }); + } +); + +export default () => { + stories + .add( + 'default', + wrapWithHitsAndJquery(containerNode => { + window.search.addWidget(autocompleteBrands({ containerNode })); + }) + ) + .add( + 'Autcomplete into hits', + wrapWithHitsAndJquery(containerNode => + window.search.addWidget(autocompleteAndSelect({ containerNode })) + ) + ) + .add( + 'Multi index', + wrapWithHitsAndJquery(containerNode => { + containerNode.append(''); + window.search.addWidget( + instantsearch.widgets.searchBox({ + container: '#multi-index-search-box', + placeholder: 'Search into the two indices', + poweredBy: false, + autofocus: false, + }) + ); + window.search.addWidget( + multiIndex({ + containerNode, + indices: [{ label: 'ikea', value: 'ikea' }], + }) + ); + }) + ); +}; diff --git a/dev/template.html b/dev/template.html index 2b8a303852..f3f3edc377 100644 --- a/dev/template.html +++ b/dev/template.html @@ -5,7 +5,14 @@ Instant search demo built with instantsearch.js + + + + + + + diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.js b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.js new file mode 100644 index 0000000000..8013f0dc59 --- /dev/null +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.js @@ -0,0 +1,72 @@ +import jsHelper from 'algoliasearch-helper'; +import connectAutocomplete from '../connectAutocomplete.js'; + +const fakeClient = { addAlgoliaAgent: () => {} }; + +describe('connectAutocomplete', () => { + it('throws without `renderFn`', () => { + expect(() => connectAutocomplete()).toThrow(); + }); + + it('renders during init and render', () => { + const renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + const widget = makeWidget(); + + expect(renderFn).toHaveBeenCalledTimes(0); + + const helper = jsHelper(fakeClient, '', {}); + helper.search = jest.fn(); + + widget.init({ + helper, + instantSearchInstance: {}, + }); + + expect(renderFn).toHaveBeenCalledTimes(1); + expect(renderFn.mock.calls[0][1]).toBeTruthy(); + + widget.render({ + widgetParams: {}, + indices: widget.indices, + instantSearchInstance: widget.instantSearchInstance, + }); + + expect(renderFn).toHaveBeenCalledTimes(2); + expect(renderFn.mock.calls[1][1]).toBeFalsy(); + }); + + it('creates derived helper', () => { + const renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + const widget = makeWidget({ indices: [{ label: 'foo', value: 'foo' }] }); + + const helper = jsHelper(fakeClient, '', {}); + helper.search = jest.fn(); + + widget.init({ helper, instantSearchInstance: {} }); + expect(renderFn).toHaveBeenCalledTimes(1); + + // original helper + derived one + const renderOpts = renderFn.mock.calls[0][0]; + expect(renderOpts.indices).toHaveLength(2); + }); + + it('set a query and trigger search on `refine`', () => { + const renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + const widget = makeWidget(); + + const helper = jsHelper(fakeClient, '', {}); + helper.search = jest.fn(); + + widget.init({ helper, instantSearchInstance: {} }); + + const { refine } = renderFn.mock.calls[0][0]; + refine('foo'); + + expect(refine).toBe(widget._refine); + expect(helper.search).toHaveBeenCalledTimes(1); + expect(helper.getState().query).toBe('foo'); + }); +}); diff --git a/src/connectors/autocomplete/connectAutocomplete.js b/src/connectors/autocomplete/connectAutocomplete.js new file mode 100644 index 0000000000..3ab75c01c4 --- /dev/null +++ b/src/connectors/autocomplete/connectAutocomplete.js @@ -0,0 +1,163 @@ +import escapeHits, { tagConfig } from '../../lib/escape-highlight'; +import { checkRendering } from '../../lib/utils'; + +const usage = `Usage: +var customAutcomplete = connectAutocomplete(function render(params, isFirstRendering) { + // params = { + // indices, + // refine, + // currentRefinement + // } +}); +search.addWiget(customAutcomplete({ + [ indices ], + [ escapeHits = false ] +})); +Full documentation available at https://community.algolia.com/instantsearch.js/connectors/connectAutocomplete.html +`; + +/** + * @typedef {Object} Index + * @property {string} index Name of the index. + * @property {string} label Label of the index (for display purpose). + * @property {Object[]} hits The hits resolved from the index matching the query. + * @property {Object} results The full results object from Algolia API. + */ + +/** + * @typedef {Object} AutocompleteRenderingOptions + * @property {Index[]} indices The indices you provided with their hits and results and the main index as first position. + * @property {function(string)} refine Search into the indices with the query provided. + * @property {string} currentRefinement The actual value of the query. + * @property {Object} widgetParams All original widget options forwarded to the `renderFn`. + */ + +/** + * @typedef {Object} CustomAutocompleteWidgetOptions + * @property {string[]} [indices = []] Name of the others indices to search into. + * @property {boolean} [escapeHits = false] If true, escape HTML tags from `hits[i]._highlightResult`. + */ + +/** + * **Autocomplete** connector provides the logic to build a widget that will give the user the ability to search into multiple indices. + * + * This connector provides a `refine()` function to search for a query and a `currentRefinement` as the current query used to search. + * + * THere's a complete example available on how to write a custom **Autocomplete** widget: + * [autocomplete.js](https://github.com/algolia/instantsearch.js/blob/develop/dev/app/custom-widgets/jquery/autocomplete.js) + * @type {Connector} + * @param {function(AutocompleteRenderingOptions, boolean)} renderFn Rendering function for the custom **Autocomplete** widget. + * @param {function} unmountFn Unmount function called when the widget is disposed. + * @return {function(CustomAutocompleteWidgetOptions)} Re-usable widget factory for a custom **Autocomplete** widget. + */ +export default function connectAutocomplete(renderFn, unmountFn) { + checkRendering(renderFn, usage); + + return (widgetParams = {}) => { + const { indices = [] } = widgetParams; + + // user passed a wrong `indices` option type + if (!Array.isArray(indices)) { + throw new Error(usage); + } + + return { + getConfiguration() { + return widgetParams.escapeHits ? tagConfig : undefined; + }, + + init({ instantSearchInstance, helper }) { + this._refine = this.refine(helper); + + this.indices = [ + { + helper, + label: 'primary', + index: helper.getIndex(), + results: undefined, + hits: [], + }, + ]; + + // add additionnal indices into `this.indices` + indices.forEach(({ label, value }) => { + const derivedHelper = helper.derive(searchParameters => + searchParameters.setIndex(value) + ); + + this.indices.push({ + label, + index: value, + helper: derivedHelper, + results: undefined, + hits: [], + }); + + // update results then trigger render after a search from any helper + derivedHelper.on('result', results => + this.saveResults({ results, label }) + ); + }); + + this.instantSearchInstance = instantSearchInstance; + this.renderWithAllIndices({ isFirstRendering: true }); + }, + + saveResults({ results, label }) { + const derivedIndex = this.indices.find(i => i.label === label); + + if ( + widgetParams.escapeHits && + results.hits && + results.hits.length > 0 + ) { + results.hits = escapeHits(results.hits); + } + + derivedIndex.results = results; + derivedIndex.hits = + results && results.hits && Array.isArray(results.hits) + ? results.hits + : []; + + this.renderWithAllIndices(); + }, + + refine(helper) { + return query => helper.setQuery(query).search(); + }, + + render({ results }) { + this.saveResults({ results, label: this.indices[0].label }); + }, + + renderWithAllIndices({ isFirstRendering = false } = {}) { + const currentRefinement = this.indices[0].helper.state.query; + + renderFn( + { + widgetParams, + currentRefinement, + // we do not want to provide the `helper` to the end-user + indices: this.indices.map(({ index, label, hits, results }) => ({ + index, + label, + hits, + results, + })), + instantSearchInstance: this.instantSearchInstance, + refine: this._refine, + }, + isFirstRendering + ); + }, + + dispose() { + // detach every derived indices from the main helper instance + this.indices.slice(1).forEach(({ helper }) => helper.detach()); + + unmountFn(); + }, + }; + }; +} diff --git a/src/connectors/index.js b/src/connectors/index.js index 0d728e4aa5..f76ad9019f 100644 --- a/src/connectors/index.js +++ b/src/connectors/index.js @@ -46,3 +46,6 @@ export { } from './breadcrumb/connectBreadcrumb.js'; export { default as connectGeoSearch } from './geo-search/connectGeoSearch.js'; export { default as connectConfigure } from './configure/connectConfigure.js'; +export { + default as connectAutocomplete, +} from './autocomplete/connectAutocomplete.js';