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