diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.js b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.js
deleted file mode 100644
index 86a1db1dde..0000000000
--- a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.js
+++ /dev/null
@@ -1,369 +0,0 @@
-import algoliasearchHelper, { SearchResults } from 'algoliasearch-helper';
-import connectAutocomplete from '../connectAutocomplete';
-import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight';
-
-const fakeClient = { addAlgoliaAgent: () => {} };
-
-describe('connectAutocomplete', () => {
- it('throws without render function', () => {
- expect(() => {
- connectAutocomplete();
- }).toThrowErrorMatchingInlineSnapshot(`
-"The render function is not valid (got type \\"undefined\\").
-
-See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplete/js/#connector"
-`);
- });
-
- it('is a widget', () => {
- const render = jest.fn();
- const unmount = jest.fn();
-
- const customAutocomplete = connectAutocomplete(render, unmount);
- const widget = customAutocomplete({});
-
- expect(widget).toEqual(
- expect.objectContaining({
- $$type: 'ais.autocomplete',
- init: expect.any(Function),
- render: expect.any(Function),
- dispose: expect.any(Function),
- getConfiguration: expect.any(Function),
- })
- );
- });
-
- it('renders during init and render', () => {
- const renderFn = jest.fn();
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget();
-
- expect(renderFn).toHaveBeenCalledTimes(0);
-
- const helper = algoliasearchHelper(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 DerivedHelper', () => {
- const renderFn = jest.fn();
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget({ indices: [{ label: 'foo', value: 'foo' }] });
-
- const helper = algoliasearchHelper(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 = algoliasearchHelper(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.state.query).toBe('foo');
- });
-
- it('with escapeHTML should escape the hits', () => {
- const renderFn = jest.fn();
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget({ escapeHTML: true });
-
- const helper = algoliasearchHelper(fakeClient, '', {});
- helper.search = jest.fn();
-
- const hits = [
- {
- _highlightResult: {
- foobar: {
- value: ``,
- },
- },
- },
- ];
-
- const escapedHits = [
- {
- _highlightResult: {
- foobar: {
- value: '<script>foobar</script>',
- },
- },
- },
- ];
-
- escapedHits.__escaped = true;
-
- widget.init({ helper, instantSearchInstance: {} });
- const results = new SearchResults(helper.state, [{ hits }]);
- widget.render({
- results,
- state: helper.state,
- helper,
- createURL: () => '#',
- });
-
- const rendering = renderFn.mock.calls[1][0];
-
- expect(rendering.indices[0].hits).toEqual(escapedHits);
- });
-
- it('without escapeHTML should not escape the hits', () => {
- const renderFn = jest.fn();
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget({ escapeHTML: false });
-
- const helper = algoliasearchHelper(fakeClient, '', {});
- helper.search = jest.fn();
-
- const hits = [
- {
- _highlightResult: {
- foobar: {
- value: ``,
- },
- },
- },
- ];
-
- widget.init({ helper, instantSearchInstance: {} });
- const results = new SearchResults(helper.state, [{ hits }]);
- widget.render({
- results,
- state: helper.state,
- helper,
- createURL: () => '#',
- });
-
- const rendering = renderFn.mock.calls[1][0];
-
- expect(rendering.indices[0].hits).toEqual(hits);
- });
-
- describe('getConfiguration', () => {
- it('adds a `query` to the `SearchParameters`', () => {
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget();
-
- const nextConfiguation = widget.getConfiguration();
-
- expect(nextConfiguation.query).toBe('');
- });
-
- it('adds the TAG_PLACEHOLDER to the `SearchParameters`', () => {
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget();
-
- const nextConfiguation = widget.getConfiguration();
-
- expect(nextConfiguation.highlightPreTag).toBe(
- TAG_PLACEHOLDER.highlightPreTag
- );
-
- expect(nextConfiguation.highlightPostTag).toBe(
- TAG_PLACEHOLDER.highlightPostTag
- );
- });
-
- it('does not add the TAG_PLACEHOLDER to the `SearchParameters` with `escapeHTML` disabled', () => {
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget({
- escapeHTML: false,
- });
-
- const nextConfiguation = widget.getConfiguration();
-
- expect(nextConfiguation.highlightPreTag).toBeUndefined();
- expect(nextConfiguation.highlightPostTag).toBeUndefined();
- });
- });
-
- describe('dispose', () => {
- it('calls the unmount function', () => {
- const helper = algoliasearchHelper(fakeClient, 'firstIndex');
-
- const renderFn = () => {};
- const unmountFn = jest.fn();
- const makeWidget = connectAutocomplete(renderFn, unmountFn);
- const widget = makeWidget();
-
- widget.init({ helper, instantSearchInstance: {} });
-
- expect(unmountFn).toHaveBeenCalledTimes(0);
-
- widget.dispose({ helper, state: helper.state });
-
- expect(unmountFn).toHaveBeenCalledTimes(1);
- });
-
- it('does not throw without the unmount function', () => {
- const helper = algoliasearchHelper(fakeClient, 'firstIndex');
-
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget();
-
- widget.init({ helper, instantSearchInstance: {} });
-
- expect(() =>
- widget.dispose({ helper, state: helper.state })
- ).not.toThrow();
- });
-
- it('removes the created DerivedHelper', () => {
- const detachDerivedHelper = jest.fn();
- const helper = algoliasearchHelper(fakeClient, 'firstIndex');
- helper.detachDerivedHelper = detachDerivedHelper;
-
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget({
- indices: [
- { label: 'Second', value: 'secondIndex' },
- { label: 'Third', value: 'thirdIndex' },
- ],
- });
-
- widget.init({ helper, instantSearchInstance: {} });
-
- expect(detachDerivedHelper).toHaveBeenCalledTimes(0);
-
- widget.dispose({ helper, state: helper.state });
-
- expect(detachDerivedHelper).toHaveBeenCalledTimes(2);
- });
-
- it('removes only the DerivedHelper created by autocomplete', () => {
- const detachDerivedHelper = jest.fn();
- const helper = algoliasearchHelper(fakeClient, 'firstIndex');
- helper.detachDerivedHelper = detachDerivedHelper;
-
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget({
- indices: [
- { label: 'Second', value: 'secondIndex' },
- { label: 'Third', value: 'thirdIndex' },
- ],
- });
-
- const derivedHelperOne = helper.derive(state => state);
- const derivedHelperTwo = helper.derive(state => state);
-
- widget.init({ helper, instantSearchInstance: {} });
-
- expect(detachDerivedHelper).toHaveBeenCalledTimes(0);
-
- widget.dispose({ helper, state: helper.state });
-
- expect(detachDerivedHelper).toHaveBeenCalledTimes(2);
- expect(helper.derivedHelpers).toEqual(
- expect.arrayContaining([derivedHelperOne, derivedHelperTwo])
- );
- });
-
- it('removes the `query` from the `SearchParameters`', () => {
- const helper = algoliasearchHelper(fakeClient, 'firstIndex', {
- query: 'Apple',
- });
-
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget();
-
- widget.init({ helper, instantSearchInstance: {} });
-
- expect(helper.state.query).toBe('Apple');
-
- const nextState = widget.dispose({ helper, state: helper.state });
-
- expect(nextState.query).toBeUndefined();
- });
-
- it('removes the TAG_PLACEHOLDER from the `SearchParameters`', () => {
- const helper = algoliasearchHelper(fakeClient, 'firstIndex', {
- ...TAG_PLACEHOLDER,
- });
-
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget();
-
- expect(helper.state.highlightPreTag).toBe(
- TAG_PLACEHOLDER.highlightPreTag
- );
-
- expect(helper.state.highlightPostTag).toBe(
- TAG_PLACEHOLDER.highlightPostTag
- );
-
- widget.init({ helper, instantSearchInstance: {} });
-
- const nextState = widget.dispose({ helper, state: helper.state });
-
- expect(nextState.highlightPreTag).toBeUndefined();
- expect(nextState.highlightPostTag).toBeUndefined();
- });
-
- it('does not remove the TAG_PLACEHOLDER from the `SearchParameters` with `escapeHTML` disabled', () => {
- const helper = algoliasearchHelper(fakeClient, 'firstIndex', {
- highlightPreTag: '',
- highlightPostTag: '',
- });
-
- const renderFn = () => {};
- const makeWidget = connectAutocomplete(renderFn);
- const widget = makeWidget({
- escapeHTML: false,
- });
-
- expect(helper.state.highlightPreTag).toBe('');
- expect(helper.state.highlightPostTag).toBe('');
-
- widget.init({ helper, instantSearchInstance: {} });
-
- const nextState = widget.dispose({ helper, state: helper.state });
-
- expect(nextState.highlightPreTag).toBe('');
- expect(nextState.highlightPostTag).toBe('');
- });
- });
-});
diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts
new file mode 100644
index 0000000000..02265ae872
--- /dev/null
+++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts
@@ -0,0 +1,455 @@
+import algoliasearchHelper, {
+ SearchResults,
+ SearchParameters,
+} from 'algoliasearch-helper';
+import {
+ createInitOptions,
+ createRenderOptions,
+ createDisposeOptions,
+} from '../../../../test/mock/createWidget';
+import { createSearchClient } from '../../../../test/mock/createSearchClient';
+import { createSingleSearchResponse } from '../../../../test/mock/createAPIResponse';
+import connectAutocomplete from '../connectAutocomplete';
+import { TAG_PLACEHOLDER } from '../../../lib/escape-highlight';
+
+describe('connectAutocomplete', () => {
+ it('throws without render function', () => {
+ expect(() => {
+ // @ts-ignore
+ connectAutocomplete();
+ }).toThrowErrorMatchingInlineSnapshot(`
+"The render function is not valid (got type \\"undefined\\").
+
+See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplete/js/#connector"
+`);
+ });
+
+ it('warns when using the outdated `indices` option', () => {
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+
+ const trigger = () => {
+ makeWidget({
+ // @ts-ignore outdated `indices` option
+ indices: [
+ { label: 'Products', value: 'products' },
+ { label: 'Services', value: 'services' },
+ ],
+ });
+ };
+
+ expect(trigger)
+ .toWarnDev(`[InstantSearch.js]: The option \`indices\` has been removed from the Autocomplete connector.
+
+The indices to target are now inferred from the widgets tree.
+
+An alternative would be:
+
+const autocomplete = connectAutocomplete(renderer);
+
+search.addWidgets([
+ index({ indexName: 'products' }),
+ index({ indexName: 'services' }),
+ autocomplete()
+]);`);
+ });
+
+ it('is a widget', () => {
+ const render = jest.fn();
+ const unmount = jest.fn();
+
+ const customAutocomplete = connectAutocomplete(render, unmount);
+ const widget = customAutocomplete({});
+
+ expect(widget).toEqual(
+ expect.objectContaining({
+ $$type: 'ais.autocomplete',
+ init: expect.any(Function),
+ render: expect.any(Function),
+ dispose: expect.any(Function),
+ getConfiguration: expect.any(Function),
+ })
+ );
+ });
+
+ it('renders during init and render', () => {
+ const searchClient = createSearchClient();
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({});
+
+ expect(render).toHaveBeenCalledTimes(0);
+
+ const helper = algoliasearchHelper(searchClient, '', {});
+ helper.search = jest.fn();
+
+ widget.init!(createInitOptions({ helper }));
+
+ expect(render).toHaveBeenCalledTimes(1);
+ expect(render).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ currentRefinement: '',
+ indices: [],
+ refine: expect.any(Function),
+ instantSearchInstance: expect.any(Object),
+ widgetParams: expect.any(Object),
+ }),
+ true
+ );
+
+ widget.render!(createRenderOptions());
+
+ expect(render).toHaveBeenCalledTimes(2);
+ expect(render).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ currentRefinement: '',
+ indices: expect.any(Array),
+ refine: expect.any(Function),
+ instantSearchInstance: expect.any(Object),
+ widgetParams: expect.any(Object),
+ }),
+ false
+ );
+ });
+
+ it('consumes the correct indices', () => {
+ const searchClient = createSearchClient();
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({ escapeHTML: false });
+
+ const helper = algoliasearchHelper(searchClient, '', {});
+ helper.search = jest.fn();
+
+ widget.init!(createInitOptions({ helper }));
+
+ expect(render).toHaveBeenCalledTimes(1);
+
+ const firstRenderOptions = render.mock.calls[0][0];
+
+ expect(firstRenderOptions.indices).toHaveLength(0);
+
+ const firstIndexHits = [{ name: 'Hit 1' }];
+ const secondIndexHits = [{ name: 'Hit 1' }, { name: 'Hit 2' }];
+ const scopedResults = [
+ {
+ indexId: 'index0',
+ results: new SearchResults(helper.state, [
+ createSingleSearchResponse({
+ index: 'index0',
+ hits: firstIndexHits,
+ }),
+ ]),
+ },
+ {
+ indexId: 'index1',
+ results: new SearchResults(helper.state, [
+ createSingleSearchResponse({
+ index: 'index1',
+ hits: secondIndexHits,
+ }),
+ ]),
+ },
+ ];
+
+ widget.render!(createRenderOptions({ helper, scopedResults }));
+
+ const secondRenderOptions = render.mock.calls[1][0];
+
+ expect(render).toHaveBeenCalledTimes(2);
+ expect(secondRenderOptions.indices).toHaveLength(2);
+ expect(secondRenderOptions.indices[0].indexName).toEqual('index0');
+ expect(secondRenderOptions.indices[0].hits).toEqual(firstIndexHits);
+ expect(secondRenderOptions.indices[0].results.index).toEqual('index0');
+ expect(secondRenderOptions.indices[0].results.hits).toEqual(firstIndexHits);
+ expect(secondRenderOptions.indices[1].indexName).toEqual('index1');
+ expect(secondRenderOptions.indices[1].hits).toEqual(secondIndexHits);
+ expect(secondRenderOptions.indices[1].results.index).toEqual('index1');
+ expect(secondRenderOptions.indices[1].results.hits).toEqual(
+ secondIndexHits
+ );
+ });
+
+ it('sets a query and triggers search on `refine`', () => {
+ const searchClient = createSearchClient();
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({});
+
+ const helper = algoliasearchHelper(searchClient, '', {});
+ helper.search = jest.fn();
+
+ widget.init!(createInitOptions({ helper }));
+
+ const { refine } = render.mock.calls[0][0];
+ refine('foo');
+
+ expect(helper.search).toHaveBeenCalledTimes(1);
+ expect(helper.state.query).toBe('foo');
+ });
+
+ it('with escapeHTML should escape the hits and the results', () => {
+ const searchClient = createSearchClient();
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({ escapeHTML: true });
+
+ const helper = algoliasearchHelper(searchClient, '', {});
+ helper.search = jest.fn();
+
+ const hits = [
+ {
+ _highlightResult: {
+ foobar: {
+ value: ``,
+ },
+ },
+ },
+ ];
+
+ const escapedHits = [
+ {
+ _highlightResult: {
+ foobar: {
+ value: '<script>foobar</script>',
+ },
+ },
+ },
+ ];
+
+ (escapedHits as any).__escaped = true;
+
+ widget.init!(createInitOptions({ helper }));
+
+ widget.render!(
+ createRenderOptions({
+ scopedResults: [
+ {
+ indexId: 'index0',
+ results: new SearchResults(helper.state, [
+ createSingleSearchResponse({ hits }),
+ ]),
+ },
+ ],
+ state: helper.state,
+ helper,
+ })
+ );
+
+ const rendering = render.mock.calls[1][0];
+
+ expect(rendering.indices[0].hits).toEqual(escapedHits);
+ expect(rendering.indices[0].results.hits).toEqual(escapedHits);
+ });
+
+ it('without escapeHTML should not escape the hits', () => {
+ const searchClient = createSearchClient();
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({ escapeHTML: false });
+
+ const helper = algoliasearchHelper(searchClient, '', {});
+ helper.search = jest.fn();
+
+ const hits = [
+ {
+ _highlightResult: {
+ foobar: {
+ value: ``,
+ },
+ },
+ },
+ ];
+
+ widget.init!(createInitOptions({ helper }));
+
+ widget.render!(
+ createRenderOptions({
+ scopedResults: [
+ {
+ indexId: 'index0',
+ results: new SearchResults(helper.state, [
+ createSingleSearchResponse({ hits }),
+ ]),
+ },
+ ],
+ state: helper.state,
+ helper,
+ })
+ );
+
+ const rendering = render.mock.calls[1][0];
+
+ expect(rendering.indices[0].hits).toEqual(hits);
+ expect(rendering.indices[0].results.hits).toEqual(hits);
+ });
+
+ describe('getConfiguration', () => {
+ it('takes the existing `query` from the `SearchParameters`', () => {
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({});
+
+ const nextConfiguation = widget.getConfiguration!(
+ new SearchParameters({
+ query: 'First query',
+ })
+ );
+
+ expect(nextConfiguation.query).toBe('First query');
+ });
+
+ it('adds a `query` to the `SearchParameters`', () => {
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({});
+
+ const nextConfiguation = widget.getConfiguration!(new SearchParameters());
+
+ expect(nextConfiguation.query).toBe('');
+ });
+
+ it('adds the TAG_PLACEHOLDER to the `SearchParameters`', () => {
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({});
+
+ const nextConfiguation = widget.getConfiguration!(new SearchParameters());
+
+ expect(nextConfiguation.highlightPreTag).toBe(
+ TAG_PLACEHOLDER.highlightPreTag
+ );
+
+ expect(nextConfiguation.highlightPostTag).toBe(
+ TAG_PLACEHOLDER.highlightPostTag
+ );
+ });
+
+ it('does not add the TAG_PLACEHOLDER to the `SearchParameters` with `escapeHTML` disabled', () => {
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({
+ escapeHTML: false,
+ });
+
+ const nextConfiguation = widget.getConfiguration!(new SearchParameters());
+
+ expect(nextConfiguation.highlightPreTag).toBeUndefined();
+ expect(nextConfiguation.highlightPostTag).toBeUndefined();
+ });
+ });
+
+ describe('dispose', () => {
+ it('calls the unmount function', () => {
+ const searchClient = createSearchClient();
+ const helper = algoliasearchHelper(searchClient, '');
+
+ const render = jest.fn();
+ const unmount = jest.fn();
+ const makeWidget = connectAutocomplete(render, unmount);
+ const widget = makeWidget({});
+
+ widget.init!(createInitOptions({ helper }));
+
+ expect(unmount).toHaveBeenCalledTimes(0);
+
+ widget.dispose!(createDisposeOptions({ helper, state: helper.state }));
+
+ expect(unmount).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not throw without the unmount function', () => {
+ const searchClient = createSearchClient();
+ const helper = algoliasearchHelper(searchClient, '');
+
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({});
+
+ widget.init!(createInitOptions({ helper }));
+
+ expect(() =>
+ widget.dispose!(createDisposeOptions({ helper, state: helper.state }))
+ ).not.toThrow();
+ });
+
+ it('removes the `query` from the `SearchParameters`', () => {
+ const searchClient = createSearchClient();
+ const helper = algoliasearchHelper(searchClient, '', {
+ query: 'Apple',
+ });
+
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({});
+
+ widget.init!(createInitOptions({ helper }));
+
+ expect(helper.state.query).toBe('Apple');
+
+ const nextState = widget.dispose!(
+ createDisposeOptions({ helper, state: helper.state })
+ ) as SearchParameters;
+
+ expect(nextState.query).toBeUndefined();
+ });
+
+ it('removes the TAG_PLACEHOLDER from the `SearchParameters`', () => {
+ const searchClient = createSearchClient();
+ const helper = algoliasearchHelper(searchClient, '', {
+ ...TAG_PLACEHOLDER,
+ });
+
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({});
+
+ expect(helper.state.highlightPreTag).toBe(
+ TAG_PLACEHOLDER.highlightPreTag
+ );
+
+ expect(helper.state.highlightPostTag).toBe(
+ TAG_PLACEHOLDER.highlightPostTag
+ );
+
+ widget.init!(createInitOptions({ helper }));
+
+ const nextState = widget.dispose!(
+ createDisposeOptions({ helper, state: helper.state })
+ ) as SearchParameters;
+
+ expect(nextState.highlightPreTag).toBeUndefined();
+ expect(nextState.highlightPostTag).toBeUndefined();
+ });
+
+ it('does not remove the TAG_PLACEHOLDER from the `SearchParameters` with `escapeHTML` disabled', () => {
+ const searchClient = createSearchClient();
+ const helper = algoliasearchHelper(searchClient, '', {
+ highlightPreTag: '',
+ highlightPostTag: '',
+ });
+
+ const render = jest.fn();
+ const makeWidget = connectAutocomplete(render);
+ const widget = makeWidget({
+ escapeHTML: false,
+ });
+
+ expect(helper.state.highlightPreTag).toBe('');
+ expect(helper.state.highlightPostTag).toBe('');
+
+ widget.init!(createInitOptions({ helper }));
+
+ const nextState = widget.dispose!(
+ createDisposeOptions({ helper, state: helper.state })
+ ) as SearchParameters;
+
+ expect(nextState.highlightPreTag).toBe('');
+ expect(nextState.highlightPostTag).toBe('');
+ });
+ });
+});
diff --git a/src/connectors/autocomplete/connectAutocomplete.js b/src/connectors/autocomplete/connectAutocomplete.js
deleted file mode 100644
index a61ebaefd4..0000000000
--- a/src/connectors/autocomplete/connectAutocomplete.js
+++ /dev/null
@@ -1,181 +0,0 @@
-import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight';
-import {
- checkRendering,
- createDocumentationMessageGenerator,
- find,
- noop,
-} from '../../lib/utils';
-
-const withUsage = createDocumentationMessageGenerator({
- name: 'autocomplete',
- connector: true,
-});
-
-/**
- * @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 {{value: string, label: string}[]} [indices = []] Name of the others indices to search into.
- * @property {boolean} [escapeHTML = true] 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.
- * @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 = noop) {
- checkRendering(renderFn, withUsage());
-
- return (widgetParams = {}) => {
- const { escapeHTML = true, indices = [] } = widgetParams;
-
- // user passed a wrong `indices` option type
- if (!Array.isArray(indices)) {
- throw new Error(
- withUsage('The `indices` option expects an array of objects.')
- );
- }
-
- return {
- $$type: 'ais.autocomplete',
-
- getConfiguration() {
- const parameters = {
- query: '',
- };
-
- if (!escapeHTML) {
- return parameters;
- }
-
- return {
- ...parameters,
- ...TAG_PLACEHOLDER,
- };
- },
-
- 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 = find(this.indices, i => i.label === label);
-
- if (escapeHTML && results && 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({ state }) {
- this.indices.slice(1).forEach(({ helper }) => helper.detach());
-
- unmountFn();
-
- const stateWithoutQuery = state.setQueryParameter('query', undefined);
-
- if (!escapeHTML) {
- return stateWithoutQuery;
- }
-
- return stateWithoutQuery.setQueryParameters(
- Object.keys(TAG_PLACEHOLDER).reduce(
- (acc, key) => ({
- ...acc,
- [key]: undefined,
- }),
- {}
- )
- );
- },
- };
- };
-}
diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts
new file mode 100644
index 0000000000..bb2dcf3737
--- /dev/null
+++ b/src/connectors/autocomplete/connectAutocomplete.ts
@@ -0,0 +1,188 @@
+import { SearchResults } from 'algoliasearch-helper';
+import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight';
+import {
+ checkRendering,
+ createDocumentationMessageGenerator,
+ noop,
+ warning,
+} from '../../lib/utils';
+import {
+ RendererOptions,
+ Renderer,
+ WidgetFactory,
+ Hits,
+ InstantSearch,
+ Unmounter,
+} from '../../types';
+
+const withUsage = createDocumentationMessageGenerator({
+ name: 'autocomplete',
+ connector: true,
+});
+
+interface AutocompleteIndex {
+ indexName: string;
+ hits: Hits;
+ results: SearchResults;
+}
+
+interface AutocompleteConnectorParams {
+ /**
+ * Escapes HTML entities from hits string values.
+ *
+ * @default `true`
+ */
+ escapeHTML?: boolean;
+}
+
+export interface AutocompleteRendererOptions
+ extends RendererOptions {
+ currentRefinement: string;
+ indices: AutocompleteIndex[];
+ instantSearchInstance: InstantSearch;
+ refine: (query: string) => void;
+}
+
+export type AutocompleteRenderer = Renderer<
+ AutocompleteRendererOptions<
+ AutocompleteConnectorParams & TAutocompleteWidgetParams
+ >
+>;
+
+export type AutocompleteWidgetFactory<
+ TAutocompleteWidgetParams
+> = WidgetFactory;
+
+export type AutocompleteConnector = (
+ render: AutocompleteRenderer,
+ unmount?: Unmounter
+) => AutocompleteWidgetFactory;
+
+const connectAutocomplete: AutocompleteConnector = (
+ renderFn,
+ unmountFn = noop
+) => {
+ checkRendering(renderFn, withUsage());
+
+ return widgetParams => {
+ const { escapeHTML = true } = widgetParams || {};
+
+ warning(
+ !(widgetParams as any).indices,
+ `
+The option \`indices\` has been removed from the Autocomplete connector.
+
+The indices to target are now inferred from the widgets tree.
+${
+ Array.isArray((widgetParams as any).indices)
+ ? `
+An alternative would be:
+
+const autocomplete = connectAutocomplete(renderer);
+
+search.addWidgets([
+ ${(widgetParams as any).indices
+ .map(({ value }: { value: string }) => `index({ indexName: '${value}' }),`)
+ .join('\n ')}
+ autocomplete()
+]);
+`
+ : ''
+}
+ `
+ );
+
+ type ConnectorState = {
+ instantSearchInstance?: InstantSearch;
+ refine?: (query: string) => void;
+ };
+
+ const connectorState: ConnectorState = {};
+
+ return {
+ $$type: 'ais.autocomplete',
+
+ getConfiguration(previousParameters) {
+ const parameters = {
+ query: previousParameters.query || '',
+ };
+
+ if (!escapeHTML) {
+ return previousParameters.setQueryParameters(parameters);
+ }
+
+ return previousParameters.setQueryParameters({
+ ...parameters,
+ ...TAG_PLACEHOLDER,
+ });
+ },
+
+ init({ instantSearchInstance, helper }) {
+ connectorState.instantSearchInstance = instantSearchInstance;
+ connectorState.refine = (query: string) => {
+ helper.setQuery(query).search();
+ };
+
+ renderFn(
+ {
+ widgetParams,
+ currentRefinement: helper.state.query || '',
+ indices: [],
+ refine: connectorState.refine,
+ instantSearchInstance: connectorState.instantSearchInstance,
+ },
+ true
+ );
+ },
+
+ render({ helper, scopedResults }) {
+ const indices = scopedResults.map(scopedResult => {
+ // We need to escape the hits because highlighting
+ // exposes HTML tags to the end-user.
+ scopedResult.results.hits = escapeHTML
+ ? escapeHits(scopedResult.results.hits)
+ : scopedResult.results.hits;
+
+ return {
+ indexName: scopedResult.results.index,
+ hits: scopedResult.results.hits,
+ results: scopedResult.results,
+ };
+ });
+
+ renderFn(
+ {
+ widgetParams,
+ currentRefinement: helper.state.query || '',
+ indices,
+ refine: connectorState.refine!,
+ instantSearchInstance: connectorState.instantSearchInstance!,
+ },
+ false
+ );
+ },
+
+ dispose({ state }) {
+ unmountFn();
+
+ const stateWithoutQuery = state.setQueryParameter('query', undefined);
+
+ if (!escapeHTML) {
+ return stateWithoutQuery;
+ }
+
+ return stateWithoutQuery.setQueryParameters(
+ Object.keys(TAG_PLACEHOLDER).reduce(
+ (acc, key) => ({
+ ...acc,
+ [key]: undefined,
+ }),
+ {}
+ )
+ );
+ },
+ };
+ };
+};
+
+export default connectAutocomplete;
diff --git a/stories/autocomplete.stories.ts b/stories/autocomplete.stories.ts
new file mode 100644
index 0000000000..5cea998744
--- /dev/null
+++ b/stories/autocomplete.stories.ts
@@ -0,0 +1,75 @@
+import { storiesOf } from '@storybook/html';
+import { withHits } from '../.storybook/decorators';
+
+storiesOf('Autocomplete', module).add(
+ 'default',
+ withHits(({ search, container, instantsearch }) => {
+ const instantSearchAutocomplete = document.createElement('div');
+
+ container.appendChild(instantSearchAutocomplete);
+
+ const customAutocomplete = instantsearch.connectors.connectAutocomplete(
+ (renderOptions, isFirstRender) => {
+ const {
+ indices,
+ currentRefinement,
+ refine,
+ widgetParams,
+ } = renderOptions;
+
+ if (isFirstRender) {
+ const input = document.createElement('input');
+ input.classList.add('ais-SearchBox-input');
+ const list = document.createElement('ul');
+
+ input.addEventListener('input', (event: any) => {
+ refine(event.currentTarget.value);
+ });
+
+ widgetParams.container.appendChild(input);
+ widgetParams.container.appendChild(list);
+ }
+
+ widgetParams.container.querySelector('input').value = currentRefinement;
+ widgetParams.container.querySelector('ul').innerHTML = indices
+ .map(
+ ({ indexName, hits }) => `
+
+ Index: ${indexName}
+
+ ${hits
+ .map(
+ hit => `- ${instantsearch.highlight({ attribute: 'name', hit })}
`
+ )
+ .join('')}
+
+
+`
+ )
+ .join('');
+ }
+ );
+
+ search.addWidgets([
+ instantsearch.widgets
+ .index({ indexName: 'instant_search_price_asc' })
+ .addWidgets([
+ instantsearch.widgets.configure({
+ hitsPerPage: 3,
+ }),
+
+ customAutocomplete({
+ container: instantSearchAutocomplete,
+ }),
+
+ instantsearch.widgets
+ .index({ indexName: 'instant_search_rating_asc' })
+ .addWidgets([
+ instantsearch.widgets.configure({
+ hitsPerPage: 2,
+ }),
+ ]),
+ ]),
+ ]);
+ })
+);