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';