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 => `
    1. ${instantsearch.highlight({ attribute: 'name', hit })}
    2. ` + ) + .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, + }), + ]), + ]), + ]); + }) +);