From 539b816ec64f05a678aaecaea87a13cab95dd2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 18 Jul 2019 16:09:08 +0200 Subject: [PATCH 01/20] feat(index): provide scoped results to render hook --- src/types/widget.ts | 6 + src/widgets/index/__tests__/index-test.ts | 132 ++++++++++++++++++++++ src/widgets/index/index.ts | 42 +++++++ 3 files changed, 180 insertions(+) diff --git a/src/types/widget.ts b/src/types/widget.ts index b52cb8fd0f..9d4d70cf7d 100644 --- a/src/types/widget.ts +++ b/src/types/widget.ts @@ -15,10 +15,16 @@ export interface InitOptions { createURL(state: SearchParameters): string; } +export interface ScopedResult { + indexId: string; + results: SearchResults; +} + export interface RenderOptions { instantSearchInstance: InstantSearch; templatesConfig: object; results: SearchResults; + scopedResults: ScopedResult[]; state: SearchParameters; helper: Helper; searchMetadata: { diff --git a/src/widgets/index/__tests__/index-test.ts b/src/widgets/index/__tests__/index-test.ts index 576728597b..ab3209c9c6 100644 --- a/src/widgets/index/__tests__/index-test.ts +++ b/src/widgets/index/__tests__/index-test.ts @@ -1257,6 +1257,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" expect(widget.render).toHaveBeenCalledWith({ instantSearchInstance, results: expect.any(algoliasearchHelper.SearchResults), + scopedResults: [ + { + indexId: 'index_name', + results: (widget.render as jest.Mock).mock.calls[0][0].results, + }, + ], state: expect.any(algoliasearchHelper.SearchParameters), helper: instance.getHelper(), templatesConfig: instantSearchInstance.templatesConfig, @@ -1301,6 +1307,132 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" expect(widget.render).toHaveBeenCalledTimes(0); }); }); + + it('calls `render` with `scopedResults` coming from siblings and children', async () => { + /* eslint-disable @typescript-eslint/camelcase */ + const level0 = index({ indexName: 'level0_index_name' }); + const level1 = index({ indexName: 'level1_index_name' }); + const level2 = index({ indexName: 'level2_index_name' }); + const level2_1 = index({ indexName: 'level2_1_index_name' }); + const level2_2 = index({ indexName: 'level2_2_index_name' }); + const level2_2_1 = index({ indexName: 'level2_2_1_index_name' }); + const level3 = index({ indexName: 'level3_index_name' }); + const instantSearchInstance = createInstantSearch(); + const level0SearchBox = createSearchBox(); + const level1SearchBox = createSearchBox(); + + level0.addWidgets([ + level0SearchBox, + level1.addWidgets([level1SearchBox]), + level2.addWidgets([ + createSearchBox(), + level2_1.addWidgets([createSearchBox()]), + level2_2.addWidgets([ + createSearchBox(), + level2_2_1.addWidgets([createSearchBox()]), + ]), + ]), + level3.addWidgets([createSearchBox()]), + ]); + + level0.init( + createInitOptions({ + instantSearchInstance, + }) + ); + + // Simulate a call to search from a widget - this step is required otherwise + // the DerivedHelper does not contain the results. The `lastResults` attribute + // is set once the `result` event is emitted. + level0.getHelper()!.search(); + + await runAllMicroTasks(); + + level0.render( + createRenderOptions({ + instantSearchInstance, + }) + ); + + // First-level index + expect(level1SearchBox.render).toHaveBeenCalledTimes(1); + expect(level1SearchBox.render).toHaveBeenCalledWith( + expect.objectContaining({ + scopedResults: [ + // Current index + { + indexId: 'level1_index_name', + results: (level1SearchBox.render as jest.Mock).mock.calls[0][0] + .results, + }, + // Siblings and children + { + indexId: 'level2_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level2_1_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level2_2_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level2_2_1_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level3_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + ], + }) + ); + + // Top-level index + expect(level0SearchBox.render).toHaveBeenCalledTimes(1); + expect(level0SearchBox.render).toHaveBeenCalledWith( + expect.objectContaining({ + scopedResults: [ + // Current index + { + indexId: 'level0_index_name', + results: (level0SearchBox.render as jest.Mock).mock.calls[0][0] + .results, + }, + // Siblings and children + { + indexId: 'level1_index_name', + results: (level1SearchBox.render as jest.Mock).mock.calls[0][0] + .results, + }, + { + indexId: 'level2_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level2_1_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level2_2_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level2_2_1_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level3_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + ], + }) + ); + + /* eslint-enable @typescript-eslint/camelcase */ + }); }); describe('dispose', () => { diff --git a/src/widgets/index/index.ts b/src/widgets/index/index.ts index 1a6a139d96..fc27e4540d 100644 --- a/src/widgets/index/index.ts +++ b/src/widgets/index/index.ts @@ -10,6 +10,7 @@ import { RenderOptions, DisposeOptions, Client, + ScopedResult, } from '../../types'; import { createDocumentationMessageGenerator, @@ -27,7 +28,9 @@ type IndexProps = { }; export type Index = Widget & { + getIndexId(): string; getHelper(): Helper | null; + getDerivedHelper(): DerivedHelper | null; getParent(): Index | null; getWidgets(): Widget[]; addWidgets(widgets: Widget[]): Index; @@ -60,6 +63,36 @@ function resetPageFromWidgets(widgets: Widget[]): void { }); } +function resolveScopedResultsFromWidgets(widgets: Widget[]): ScopedResult[] { + const indexWidgets = widgets.filter(isIndexWidget); + + if (indexWidgets.length === 0) { + return []; + } + + return indexWidgets.reduce((scopedResults, current) => { + const currentDerivedHelper = current.getDerivedHelper()!; + + scopedResults.push({ + indexId: current.getIndexId(), + results: currentDerivedHelper.lastResults!, + }); + + return [ + ...scopedResults, + ...resolveScopedResultsFromWidgets(current.getWidgets()), + ]; + }, []); +} + +function resolveScopedResultsFromIndex(widget: Index): ScopedResult[] { + const widgetParent = widget.getParent(); + // If the widget is the root, we consider itself as the only sibling. + const widgetSiblings = widgetParent ? widgetParent.getWidgets() : [widget]; + + return resolveScopedResultsFromWidgets(widgetSiblings); +} + const index = (props: IndexProps): Index => { const { indexName = null } = props || {}; @@ -76,10 +109,18 @@ const index = (props: IndexProps): Index => { return { $$type: 'ais.index', + getIndexId() { + return indexName; + }, + getHelper() { return helper; }, + getDerivedHelper() { + return derivedHelper; + }, + getParent() { return localParent; }, @@ -280,6 +321,7 @@ const index = (props: IndexProps): Index => { helper: helper!, instantSearchInstance, results: derivedHelper!.lastResults, + scopedResults: resolveScopedResultsFromIndex(this), state: derivedHelper!.lastResults._state, templatesConfig: instantSearchInstance.templatesConfig, createURL: instantSearchInstance._createAbsoluteURL, From e6e38b568d59414015f179efa315a976c6ed1a8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 18 Jul 2019 17:21:38 +0200 Subject: [PATCH 02/20] fix(tests): add `scopedResults` in RenderOptions mock --- test/mock/createWidget.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/mock/createWidget.ts b/test/mock/createWidget.ts index f5b96694a8..94472460cd 100644 --- a/test/mock/createWidget.ts +++ b/test/mock/createWidget.ts @@ -39,6 +39,15 @@ export const createRenderOptions = ( instantSearchInstance.helper!.state, response.results ), + scopedResults: [ + { + indexId: instantSearchInstance.helper!.state.index, + results: new algolisearchHelper.SearchResults( + instantSearchInstance.helper!.state, + response.results + ), + }, + ], searchMetadata: { isSearchStalled: false, }, From 3073d50ba24d295e9b8698041f97a808513aa3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 18 Jul 2019 17:24:09 +0200 Subject: [PATCH 03/20] refactor(tests): use the same variable in `results` and `scopedResults` --- test/mock/createWidget.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/test/mock/createWidget.ts b/test/mock/createWidget.ts index 94472460cd..e9fbd81588 100644 --- a/test/mock/createWidget.ts +++ b/test/mock/createWidget.ts @@ -29,23 +29,21 @@ export const createRenderOptions = ( ): RenderOptions => { const instantSearchInstance = createInstantSearch(); const response = createMultiSearchResponse(); + const results = new algolisearchHelper.SearchResults( + instantSearchInstance.helper!.state, + response.results + ); return { instantSearchInstance, templatesConfig: instantSearchInstance.templatesConfig, helper: instantSearchInstance.helper!, state: instantSearchInstance.helper!.state, - results: new algolisearchHelper.SearchResults( - instantSearchInstance.helper!.state, - response.results - ), + results, scopedResults: [ { indexId: instantSearchInstance.helper!.state.index, - results: new algolisearchHelper.SearchResults( - instantSearchInstance.helper!.state, - response.results - ), + results, }, ], searchMetadata: { From 745384cbb93b363f630f91f7c07c0a52dea8e7d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Thu, 18 Jul 2019 17:42:50 +0200 Subject: [PATCH 04/20] refactor(index): use a single push call --- src/widgets/index/index.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/widgets/index/index.ts b/src/widgets/index/index.ts index fc27e4540d..b29a2281b6 100644 --- a/src/widgets/index/index.ts +++ b/src/widgets/index/index.ts @@ -73,15 +73,15 @@ function resolveScopedResultsFromWidgets(widgets: Widget[]): ScopedResult[] { return indexWidgets.reduce((scopedResults, current) => { const currentDerivedHelper = current.getDerivedHelper()!; - scopedResults.push({ - indexId: current.getIndexId(), - results: currentDerivedHelper.lastResults!, - }); - - return [ - ...scopedResults, - ...resolveScopedResultsFromWidgets(current.getWidgets()), - ]; + scopedResults.push( + { + indexId: current.getIndexId(), + results: currentDerivedHelper.lastResults!, + }, + ...resolveScopedResultsFromWidgets(current.getWidgets()) + ); + + return scopedResults; }, []); } From b669adcffa78cc43aa510db6c2d94228eeabfada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 22 Jul 2019 14:34:06 +0200 Subject: [PATCH 05/20] fix(index): remove stop condition from recursion --- src/widgets/index/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/widgets/index/index.ts b/src/widgets/index/index.ts index b29a2281b6..2103e7f6a5 100644 --- a/src/widgets/index/index.ts +++ b/src/widgets/index/index.ts @@ -66,10 +66,6 @@ function resetPageFromWidgets(widgets: Widget[]): void { function resolveScopedResultsFromWidgets(widgets: Widget[]): ScopedResult[] { const indexWidgets = widgets.filter(isIndexWidget); - if (indexWidgets.length === 0) { - return []; - } - return indexWidgets.reduce((scopedResults, current) => { const currentDerivedHelper = current.getDerivedHelper()!; From 6436fc035807dbf0278e6f41dcb8237e3e50c660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 22 Jul 2019 14:35:53 +0200 Subject: [PATCH 06/20] fix(index): replace `push` by `concat` --- src/widgets/index/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/widgets/index/index.ts b/src/widgets/index/index.ts index 2103e7f6a5..56ae910e7c 100644 --- a/src/widgets/index/index.ts +++ b/src/widgets/index/index.ts @@ -67,17 +67,13 @@ function resolveScopedResultsFromWidgets(widgets: Widget[]): ScopedResult[] { const indexWidgets = widgets.filter(isIndexWidget); return indexWidgets.reduce((scopedResults, current) => { - const currentDerivedHelper = current.getDerivedHelper()!; - - scopedResults.push( + return scopedResults.concat( { indexId: current.getIndexId(), - results: currentDerivedHelper.lastResults!, + results: current.getDerivedHelper()!.lastResults!, }, ...resolveScopedResultsFromWidgets(current.getWidgets()) ); - - return scopedResults; }, []); } From da1d45d3045d77b256f521525d94c59d20f05d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 22 Jul 2019 14:50:44 +0200 Subject: [PATCH 07/20] test(index): add assertion for siblings --- src/widgets/index/__tests__/index-test.ts | 55 ++++++++++++++++------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/widgets/index/__tests__/index-test.ts b/src/widgets/index/__tests__/index-test.ts index ab3209c9c6..3d2b070bc9 100644 --- a/src/widgets/index/__tests__/index-test.ts +++ b/src/widgets/index/__tests__/index-test.ts @@ -1318,15 +1318,16 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" const level2_2_1 = index({ indexName: 'level2_2_1_index_name' }); const level3 = index({ indexName: 'level3_index_name' }); const instantSearchInstance = createInstantSearch(); - const level0SearchBox = createSearchBox(); - const level1SearchBox = createSearchBox(); + const searchBoxLevel0 = createSearchBox(); + const searchBoxLevel1 = createSearchBox(); + const seachBoxLevel2_1 = createSearchBox(); level0.addWidgets([ - level0SearchBox, - level1.addWidgets([level1SearchBox]), + searchBoxLevel0, + level1.addWidgets([searchBoxLevel1]), level2.addWidgets([ createSearchBox(), - level2_1.addWidgets([createSearchBox()]), + level2_1.addWidgets([seachBoxLevel2_1]), level2_2.addWidgets([ createSearchBox(), level2_2_1.addWidgets([createSearchBox()]), @@ -1354,15 +1355,15 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" }) ); - // First-level index - expect(level1SearchBox.render).toHaveBeenCalledTimes(1); - expect(level1SearchBox.render).toHaveBeenCalledWith( + // First-level child index + expect(searchBoxLevel1.render).toHaveBeenCalledTimes(1); + expect(searchBoxLevel1.render).toHaveBeenCalledWith( expect.objectContaining({ scopedResults: [ - // Current index + // Root index { indexId: 'level1_index_name', - results: (level1SearchBox.render as jest.Mock).mock.calls[0][0] + results: (searchBoxLevel1.render as jest.Mock).mock.calls[0][0] .results, }, // Siblings and children @@ -1390,21 +1391,45 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" }) ); + // Sibling index + expect(seachBoxLevel2_1.render).toHaveBeenCalledTimes(1); + expect(seachBoxLevel2_1.render).toHaveBeenCalledWith( + expect.objectContaining({ + scopedResults: [ + // Root index + { + indexId: 'level2_1_index_name', + results: (seachBoxLevel2_1.render as jest.Mock).mock.calls[0][0] + .results, + }, + // Siblings and children + { + indexId: 'level2_2_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + { + indexId: 'level2_2_1_index_name', + results: expect.any(algoliasearchHelper.SearchResults), + }, + ], + }) + ); + // Top-level index - expect(level0SearchBox.render).toHaveBeenCalledTimes(1); - expect(level0SearchBox.render).toHaveBeenCalledWith( + expect(searchBoxLevel0.render).toHaveBeenCalledTimes(1); + expect(searchBoxLevel0.render).toHaveBeenCalledWith( expect.objectContaining({ scopedResults: [ - // Current index + // Root index { indexId: 'level0_index_name', - results: (level0SearchBox.render as jest.Mock).mock.calls[0][0] + results: (searchBoxLevel0.render as jest.Mock).mock.calls[0][0] .results, }, // Siblings and children { indexId: 'level1_index_name', - results: (level1SearchBox.render as jest.Mock).mock.calls[0][0] + results: (searchBoxLevel1.render as jest.Mock).mock.calls[0][0] .results, }, { From 347ebbb807a0783836e34fd855452cfc13530111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 22 Jul 2019 14:52:59 +0200 Subject: [PATCH 08/20] fix(index): expose `getResults` instead of `getDerivedHelper` --- src/widgets/index/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/widgets/index/index.ts b/src/widgets/index/index.ts index 56ae910e7c..5355d956e6 100644 --- a/src/widgets/index/index.ts +++ b/src/widgets/index/index.ts @@ -2,6 +2,7 @@ import algoliasearchHelper, { AlgoliaSearchHelper as Helper, DerivedHelper, PlainSearchParameters, + SearchResults, } from 'algoliasearch-helper'; import { InstantSearch, @@ -30,7 +31,7 @@ type IndexProps = { export type Index = Widget & { getIndexId(): string; getHelper(): Helper | null; - getDerivedHelper(): DerivedHelper | null; + getResults(): SearchResults | null; getParent(): Index | null; getWidgets(): Widget[]; addWidgets(widgets: Widget[]): Index; @@ -70,7 +71,7 @@ function resolveScopedResultsFromWidgets(widgets: Widget[]): ScopedResult[] { return scopedResults.concat( { indexId: current.getIndexId(), - results: current.getDerivedHelper()!.lastResults!, + results: current.getResults()!, }, ...resolveScopedResultsFromWidgets(current.getWidgets()) ); @@ -109,8 +110,8 @@ const index = (props: IndexProps): Index => { return helper; }, - getDerivedHelper() { - return derivedHelper; + getResults() { + return derivedHelper && derivedHelper.lastResults; }, getParent() { From d1826e7c0bee88ce19a20239e36921ec4a553ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 22 Jul 2019 15:11:23 +0200 Subject: [PATCH 09/20] test(index): return search parameters in `getConfiguration` --- src/widgets/index/__tests__/index-test.ts | 100 +++++++++++----------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/src/widgets/index/__tests__/index-test.ts b/src/widgets/index/__tests__/index-test.ts index 3d2b070bc9..1179b42548 100644 --- a/src/widgets/index/__tests__/index-test.ts +++ b/src/widgets/index/__tests__/index-test.ts @@ -824,76 +824,76 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" level0.addWidgets([ createWidget({ getConfiguration() { - return { + return new SearchParameters({ hitsPerPage: 5, - }; + }); }, }), createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 1, - }; + }); }, }), level1.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 2, - }; + }); }, }), level2.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone XS', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 3, - }; + }); }, }), level3.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone XS Red', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 4, - }; + }); }, }), ]), @@ -965,76 +965,76 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" level0.addWidgets([ createWidget({ getConfiguration() { - return { + return new SearchParameters({ hitsPerPage: 5, - }; + }); }, }), createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 1, - }; + }); }, }), level1.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 2, - }; + }); }, }), level2.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone XS', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 3, - }; + }); }, }), level3.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone XS Red', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 4, - }; + }); }, }), ]), @@ -1105,60 +1105,60 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index/js/" level0.addWidgets([ createWidget({ getConfiguration() { - return { + return new SearchParameters({ hitsPerPage: 5, - }; + }); }, }), createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 1, - }; + }); }, }), level1.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone', - }; + }); }, }), createPagination({ getConfiguration() { - return { + return new SearchParameters({ page: 2, - }; + }); }, }), level2.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone XS', - }; + }); }, }), level3.addWidgets([ createSearchBox({ getConfiguration() { - return { + return new SearchParameters({ query: 'Apple iPhone XS Red', - }; + }); }, }), ]), From 898ebecaeaa73ad77b9c5bcb55ce30b4e7cf9e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Fri, 19 Jul 2019 10:48:20 +0200 Subject: [PATCH 10/20] feat(autocomplete): leverage scoped results --- .../__tests__/connectAutocomplete-test.js | 369 --------------- .../__tests__/connectAutocomplete-test.ts | 421 ++++++++++++++++++ .../autocomplete/connectAutocomplete.js | 181 -------- .../autocomplete/connectAutocomplete.ts | 171 +++++++ stories/autocomplete.stories.ts | 63 +++ 5 files changed, 655 insertions(+), 550 deletions(-) delete mode 100644 src/connectors/autocomplete/__tests__/connectAutocomplete-test.js create mode 100644 src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts delete mode 100644 src/connectors/autocomplete/connectAutocomplete.js create mode 100644 src/connectors/autocomplete/connectAutocomplete.ts create mode 100644 stories/autocomplete.stories.ts 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..dc5684c981 --- /dev/null +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts @@ -0,0 +1,421 @@ +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 renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + + const trigger = () => { + // @ts-ignore outdated `indices` option + makeWidget({ indices: [{ label: 'foo', value: 'foo' }] }); + }; + + expect(trigger).toWarnDev( + '[InstantSearch.js]: Since InstantSearch.js 4, `connectAutocomplete` infers the indices from the tree of widgets.\nThe `indices` option is ignored.' + ); + }); + + 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 renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + const widget = makeWidget({}); + + expect(renderFn).toHaveBeenCalledTimes(0); + + const helper = algoliasearchHelper(searchClient, '', {}); + helper.search = jest.fn(); + + widget.init!(createInitOptions({ helper })); + + expect(renderFn).toHaveBeenCalledTimes(1); + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + currentRefinement: '', + indices: [], + refine: expect.any(Function), + }), + true + ); + + widget.render!(createRenderOptions()); + + expect(renderFn).toHaveBeenCalledTimes(2); + expect(renderFn).toHaveBeenLastCalledWith( + expect.objectContaining({ + currentRefinement: '', + indices: expect.any(Array), + refine: expect.any(Function), + }), + false + ); + }); + + it('consumes the correct indices', () => { + const searchClient = createSearchClient(); + const renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + const widget = makeWidget({ escapeHTML: false }); + + const helper = algoliasearchHelper(searchClient, '', {}); + helper.search = jest.fn(); + + widget.init!(createInitOptions({ helper })); + + expect(renderFn).toHaveBeenCalledTimes(1); + + const firstRenderOptions = renderFn.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 = renderFn.mock.calls[1][0]; + + expect(renderFn).toHaveBeenCalledTimes(2); + expect(secondRenderOptions.indices).toHaveLength(2); + expect(secondRenderOptions.indices[0].index).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].index).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 renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + const widget = makeWidget({}); + + const helper = algoliasearchHelper(searchClient, '', {}); + helper.search = jest.fn(); + + widget.init!(createInitOptions({ helper })); + + const { refine } = renderFn.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 renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + 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 = renderFn.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 renderFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn); + 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 = renderFn.mock.calls[1][0]; + + expect(rendering.indices[0].hits).toEqual(hits); + expect(rendering.indices[0].results.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 searchClient = createSearchClient(); + const helper = algoliasearchHelper(searchClient, 'firstIndex'); + + const renderFn = () => {}; + const unmountFn = jest.fn(); + const makeWidget = connectAutocomplete(renderFn, unmountFn); + const widget = makeWidget({}); + + widget.init!(createInitOptions({ helper })); + + expect(unmountFn).toHaveBeenCalledTimes(0); + + widget.dispose!(createDisposeOptions({ helper, state: helper.state })); + + expect(unmountFn).toHaveBeenCalledTimes(1); + }); + + it('does not throw without the unmount function', () => { + const searchClient = createSearchClient(); + const helper = algoliasearchHelper(searchClient, 'firstIndex'); + + const renderFn = () => {}; + const makeWidget = connectAutocomplete(renderFn); + 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, 'firstIndex', { + query: 'Apple', + }); + + const renderFn = () => {}; + const makeWidget = connectAutocomplete(renderFn); + 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, '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!(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, '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!(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..7a8e49b8aa --- /dev/null +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -0,0 +1,171 @@ +import { + AlgoliaSearchHelper as Helper, + 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 { + index: string; + hits: Hits; + results: SearchResults | undefined; +} + +interface AutocompleteConnectorParams { + /** + * Escapes HTML entities from hits string values. + * + * @default `true` + */ + escapeHTML?: boolean; +} + +type RefineFunction = (query: string) => Helper; + +export interface AutocompleteRendererOptions + extends RendererOptions { + currentRefinement: string; + indices: AutocompleteIndex[]; + instantSearchInstance: InstantSearch; + refine: RefineFunction; +} + +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, + `Since InstantSearch.js 4, \`connectAutocomplete\` infers the indices from the tree of widgets. +The \`indices\` option is ignored.` + ); + + type ConnectorState = { + instantSearchInstance?: InstantSearch; + refine?: RefineFunction; + }; + + const connectorState: ConnectorState = {}; + + return { + $$type: 'ais.autocomplete', + + getConfiguration() { + const parameters = { + query: '', + }; + + if (!escapeHTML) { + return parameters; + } + + return { + ...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 => { + scopedResult.results.hits = escapeHTML + ? escapeHits(scopedResult.results.hits) + : scopedResult.results.hits; + + return { + index: 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..40e23c3e14 --- /dev/null +++ b/stories/autocomplete.stories.ts @@ -0,0 +1,63 @@ +import { storiesOf } from '@storybook/html'; +import { withHits } from '../.storybook/decorators'; + +storiesOf('Autocomplete', module).add( + 'default', + withHits(({ search, container, instantsearch }) => { + const instantSearchRatingAscSearchBox = document.createElement('div'); + const instantSearchAutocomplete = document.createElement('div'); + + container.appendChild(instantSearchRatingAscSearchBox); + container.appendChild(instantSearchAutocomplete); + + const customAutocomplete = instantsearch.connectors.connectAutocomplete( + renderOptions => { + const { indices, widgetParams } = renderOptions; + + widgetParams.container.innerHTML = indices + .map( + ({ index, hits }) => ` +
  • + Index: ${index} +
      + ${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, + }), + + instantsearch.widgets.searchBox({ + container: instantSearchRatingAscSearchBox, + placeholder: 'Search in the autocomplete list', + }), + + customAutocomplete({ + container: instantSearchAutocomplete, + }), + + instantsearch.widgets + .index({ indexName: 'instant_search_rating_asc' }) + .addWidgets([ + instantsearch.widgets.configure({ + hitsPerPage: 2, + }), + ]), + ]), + ]); + }) +); From dd3509251347d4cd6af44a72d1c5616be21ab68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 22 Jul 2019 15:35:24 +0200 Subject: [PATCH 11/20] fix(autocomplete): return search parameters in `getConfiguration` --- .../autocomplete/__tests__/connectAutocomplete-test.ts | 6 +++--- src/connectors/autocomplete/connectAutocomplete.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts index dc5684c981..17861cd6e9 100644 --- a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts @@ -274,7 +274,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet const makeWidget = connectAutocomplete(renderFn); const widget = makeWidget({}); - const nextConfiguation = widget.getConfiguration!(); + const nextConfiguation = widget.getConfiguration!(new SearchParameters()); expect(nextConfiguation.query).toBe(''); }); @@ -284,7 +284,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet const makeWidget = connectAutocomplete(renderFn); const widget = makeWidget({}); - const nextConfiguation = widget.getConfiguration!(); + const nextConfiguation = widget.getConfiguration!(new SearchParameters()); expect(nextConfiguation.highlightPreTag).toBe( TAG_PLACEHOLDER.highlightPreTag @@ -302,7 +302,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet escapeHTML: false, }); - const nextConfiguation = widget.getConfiguration!(); + const nextConfiguation = widget.getConfiguration!(new SearchParameters()); expect(nextConfiguation.highlightPreTag).toBeUndefined(); expect(nextConfiguation.highlightPostTag).toBeUndefined(); diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index 7a8e49b8aa..6ad5584934 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -88,19 +88,19 @@ The \`indices\` option is ignored.` return { $$type: 'ais.autocomplete', - getConfiguration() { + getConfiguration(previousParameters) { const parameters = { query: '', }; if (!escapeHTML) { - return parameters; + return previousParameters.setQueryParameters(parameters); } - return { + return previousParameters.setQueryParameters({ ...parameters, ...TAG_PLACEHOLDER, - }; + }); }, init({ instantSearchInstance, helper }) { From 4255b78709febaadd0d22d31c96bb598bb4aba4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 23 Jul 2019 13:55:21 +0200 Subject: [PATCH 12/20] refactor(autocomplete): update refine function type --- src/connectors/autocomplete/connectAutocomplete.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index 6ad5584934..54339d6bf4 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -38,14 +38,12 @@ interface AutocompleteConnectorParams { escapeHTML?: boolean; } -type RefineFunction = (query: string) => Helper; - export interface AutocompleteRendererOptions extends RendererOptions { currentRefinement: string; indices: AutocompleteIndex[]; instantSearchInstance: InstantSearch; - refine: RefineFunction; + refine: (query: string) => void; } export type AutocompleteRenderer = Renderer< @@ -80,7 +78,7 @@ The \`indices\` option is ignored.` type ConnectorState = { instantSearchInstance?: InstantSearch; - refine?: RefineFunction; + refine?: (query: string) => void; }; const connectorState: ConnectorState = {}; @@ -105,8 +103,9 @@ The \`indices\` option is ignored.` init({ instantSearchInstance, helper }) { connectorState.instantSearchInstance = instantSearchInstance; - connectorState.refine = (query: string) => + connectorState.refine = (query: string) => { helper.setQuery(query).search(); + }; renderFn( { From 2eb8667c206c090f2334a0290344e55262749f39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 23 Jul 2019 13:58:18 +0200 Subject: [PATCH 13/20] fix(autocomplete): support initial query configuration --- .../__tests__/connectAutocomplete-test.ts | 14 ++++++++++++++ src/connectors/autocomplete/connectAutocomplete.ts | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts index 17861cd6e9..867aa6537d 100644 --- a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts @@ -269,6 +269,20 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet }); describe('getConfiguration', () => { + it('takes the existing `query` from the `SearchParameters`', () => { + const renderFn = () => {}; + const makeWidget = connectAutocomplete(renderFn); + 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 renderFn = () => {}; const makeWidget = connectAutocomplete(renderFn); diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index 54339d6bf4..abf8a6d642 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -88,7 +88,7 @@ The \`indices\` option is ignored.` getConfiguration(previousParameters) { const parameters = { - query: '', + query: previousParameters.query || '', }; if (!escapeHTML) { From 29b58641aa176f155e3d9aecfb9062d692b156b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 23 Jul 2019 13:59:41 +0200 Subject: [PATCH 14/20] refactor(autocomplete): don't type results as undefined --- src/connectors/autocomplete/connectAutocomplete.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index abf8a6d642..4260c13b44 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -1,7 +1,4 @@ -import { - AlgoliaSearchHelper as Helper, - SearchResults, -} from 'algoliasearch-helper'; +import { SearchResults } from 'algoliasearch-helper'; import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight'; import { checkRendering, @@ -26,7 +23,7 @@ const withUsage = createDocumentationMessageGenerator({ interface AutocompleteIndex { index: string; hits: Hits; - results: SearchResults | undefined; + results: SearchResults; } interface AutocompleteConnectorParams { From 6a3c5da4fa18fd440c7c7e343dbf236274a9a2a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 23 Jul 2019 14:01:45 +0200 Subject: [PATCH 15/20] refactor: change `index` to `indexName` --- .../autocomplete/__tests__/connectAutocomplete-test.ts | 4 ++-- src/connectors/autocomplete/connectAutocomplete.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts index 867aa6537d..65911909e8 100644 --- a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts @@ -138,11 +138,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet expect(renderFn).toHaveBeenCalledTimes(2); expect(secondRenderOptions.indices).toHaveLength(2); - expect(secondRenderOptions.indices[0].index).toEqual('index0'); + 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].index).toEqual('index1'); + 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( diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index 4260c13b44..7434d2cb0c 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -21,7 +21,7 @@ const withUsage = createDocumentationMessageGenerator({ }); interface AutocompleteIndex { - index: string; + indexName: string; hits: Hits; results: SearchResults; } @@ -123,7 +123,7 @@ The \`indices\` option is ignored.` : scopedResult.results.hits; return { - index: scopedResult.results.index, + indexName: scopedResult.results.index, hits: scopedResult.results.hits, results: scopedResult.results, }; From a2f89dc588f3d3757fcf110b662dd16d13c050ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 23 Jul 2019 14:19:18 +0200 Subject: [PATCH 16/20] test: refactor tests --- .../__tests__/connectAutocomplete-test.ts | 104 +++++++++--------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts index 65911909e8..4bb2077315 100644 --- a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts @@ -25,8 +25,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet }); it('warns when using the outdated `indices` option', () => { - const renderFn = jest.fn(); - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const trigger = () => { // @ts-ignore outdated `indices` option @@ -58,35 +58,39 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet it('renders during init and render', () => { const searchClient = createSearchClient(); - const renderFn = jest.fn(); - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({}); - expect(renderFn).toHaveBeenCalledTimes(0); + expect(render).toHaveBeenCalledTimes(0); const helper = algoliasearchHelper(searchClient, '', {}); helper.search = jest.fn(); widget.init!(createInitOptions({ helper })); - expect(renderFn).toHaveBeenCalledTimes(1); - expect(renderFn).toHaveBeenLastCalledWith( + 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(renderFn).toHaveBeenCalledTimes(2); - expect(renderFn).toHaveBeenLastCalledWith( + 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 ); @@ -94,8 +98,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet it('consumes the correct indices', () => { const searchClient = createSearchClient(); - const renderFn = jest.fn(); - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({ escapeHTML: false }); const helper = algoliasearchHelper(searchClient, '', {}); @@ -103,9 +107,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet widget.init!(createInitOptions({ helper })); - expect(renderFn).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(1); - const firstRenderOptions = renderFn.mock.calls[0][0]; + const firstRenderOptions = render.mock.calls[0][0]; expect(firstRenderOptions.indices).toHaveLength(0); @@ -134,9 +138,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet widget.render!(createRenderOptions({ helper, scopedResults })); - const secondRenderOptions = renderFn.mock.calls[1][0]; + const secondRenderOptions = render.mock.calls[1][0]; - expect(renderFn).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(2); expect(secondRenderOptions.indices).toHaveLength(2); expect(secondRenderOptions.indices[0].indexName).toEqual('index0'); expect(secondRenderOptions.indices[0].hits).toEqual(firstIndexHits); @@ -152,8 +156,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet it('sets a query and triggers search on `refine`', () => { const searchClient = createSearchClient(); - const renderFn = jest.fn(); - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({}); const helper = algoliasearchHelper(searchClient, '', {}); @@ -161,7 +165,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet widget.init!(createInitOptions({ helper })); - const { refine } = renderFn.mock.calls[0][0]; + const { refine } = render.mock.calls[0][0]; refine('foo'); expect(helper.search).toHaveBeenCalledTimes(1); @@ -170,8 +174,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet it('with escapeHTML should escape the hits and the results', () => { const searchClient = createSearchClient(); - const renderFn = jest.fn(); - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({ escapeHTML: true }); const helper = algoliasearchHelper(searchClient, '', {}); @@ -218,7 +222,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet }) ); - const rendering = renderFn.mock.calls[1][0]; + const rendering = render.mock.calls[1][0]; expect(rendering.indices[0].hits).toEqual(escapedHits); expect(rendering.indices[0].results.hits).toEqual(escapedHits); @@ -226,8 +230,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet it('without escapeHTML should not escape the hits', () => { const searchClient = createSearchClient(); - const renderFn = jest.fn(); - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({ escapeHTML: false }); const helper = algoliasearchHelper(searchClient, '', {}); @@ -262,7 +266,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet }) ); - const rendering = renderFn.mock.calls[1][0]; + const rendering = render.mock.calls[1][0]; expect(rendering.indices[0].hits).toEqual(hits); expect(rendering.indices[0].results.hits).toEqual(hits); @@ -270,8 +274,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet describe('getConfiguration', () => { it('takes the existing `query` from the `SearchParameters`', () => { - const renderFn = () => {}; - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({}); const nextConfiguation = widget.getConfiguration!( @@ -284,8 +288,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet }); it('adds a `query` to the `SearchParameters`', () => { - const renderFn = () => {}; - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({}); const nextConfiguation = widget.getConfiguration!(new SearchParameters()); @@ -294,8 +298,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet }); it('adds the TAG_PLACEHOLDER to the `SearchParameters`', () => { - const renderFn = () => {}; - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({}); const nextConfiguation = widget.getConfiguration!(new SearchParameters()); @@ -310,8 +314,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet }); it('does not add the TAG_PLACEHOLDER to the `SearchParameters` with `escapeHTML` disabled', () => { - const renderFn = () => {}; - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({ escapeHTML: false, }); @@ -326,28 +330,28 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet describe('dispose', () => { it('calls the unmount function', () => { const searchClient = createSearchClient(); - const helper = algoliasearchHelper(searchClient, 'firstIndex'); + const helper = algoliasearchHelper(searchClient, ''); - const renderFn = () => {}; - const unmountFn = jest.fn(); - const makeWidget = connectAutocomplete(renderFn, unmountFn); + const render = jest.fn(); + const unmount = jest.fn(); + const makeWidget = connectAutocomplete(render, unmount); const widget = makeWidget({}); widget.init!(createInitOptions({ helper })); - expect(unmountFn).toHaveBeenCalledTimes(0); + expect(unmount).toHaveBeenCalledTimes(0); widget.dispose!(createDisposeOptions({ helper, state: helper.state })); - expect(unmountFn).toHaveBeenCalledTimes(1); + expect(unmount).toHaveBeenCalledTimes(1); }); it('does not throw without the unmount function', () => { const searchClient = createSearchClient(); - const helper = algoliasearchHelper(searchClient, 'firstIndex'); + const helper = algoliasearchHelper(searchClient, ''); - const renderFn = () => {}; - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({}); widget.init!(createInitOptions({ helper })); @@ -359,12 +363,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet it('removes the `query` from the `SearchParameters`', () => { const searchClient = createSearchClient(); - const helper = algoliasearchHelper(searchClient, 'firstIndex', { + const helper = algoliasearchHelper(searchClient, '', { query: 'Apple', }); - const renderFn = () => {}; - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({}); widget.init!(createInitOptions({ helper })); @@ -380,12 +384,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet it('removes the TAG_PLACEHOLDER from the `SearchParameters`', () => { const searchClient = createSearchClient(); - const helper = algoliasearchHelper(searchClient, 'firstIndex', { + const helper = algoliasearchHelper(searchClient, '', { ...TAG_PLACEHOLDER, }); - const renderFn = () => {}; - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({}); expect(helper.state.highlightPreTag).toBe( @@ -408,13 +412,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet it('does not remove the TAG_PLACEHOLDER from the `SearchParameters` with `escapeHTML` disabled', () => { const searchClient = createSearchClient(); - const helper = algoliasearchHelper(searchClient, 'firstIndex', { + const helper = algoliasearchHelper(searchClient, '', { highlightPreTag: '', highlightPostTag: '', }); - const renderFn = () => {}; - const makeWidget = connectAutocomplete(renderFn); + const render = jest.fn(); + const makeWidget = connectAutocomplete(render); const widget = makeWidget({ escapeHTML: false, }); From 0640afd48049aea11e75c20aaf43543f9dad1942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 23 Jul 2019 16:53:19 +0200 Subject: [PATCH 17/20] feat: add alternative usage warning --- .../__tests__/connectAutocomplete-test.ts | 26 +++++++++++++++---- .../autocomplete/connectAutocomplete.ts | 23 ++++++++++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts index 4bb2077315..0c56e10c18 100644 --- a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts @@ -29,13 +29,29 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/autocomplet const makeWidget = connectAutocomplete(render); const trigger = () => { - // @ts-ignore outdated `indices` option - makeWidget({ indices: [{ label: 'foo', value: 'foo' }] }); + makeWidget({ + // @ts-ignore outdated `indices` option + indices: [ + { label: 'Products', value: 'products' }, + { label: 'Services', value: 'services' }, + ], + }); }; - expect(trigger).toWarnDev( - '[InstantSearch.js]: Since InstantSearch.js 4, `connectAutocomplete` infers the indices from the tree of widgets.\nThe `indices` option is ignored.' - ); + 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', () => { diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index 7434d2cb0c..9d9428fa16 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -69,8 +69,27 @@ const connectAutocomplete: AutocompleteConnector = ( warning( !(widgetParams as any).indices, - `Since InstantSearch.js 4, \`connectAutocomplete\` infers the indices from the tree of widgets. -The \`indices\` option is ignored.` + ` +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 = { From c22a5bd53d1d69929c67f5dcbdfb0f18bb275e3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 23 Jul 2019 17:00:06 +0200 Subject: [PATCH 18/20] docs: add comment about escaping results --- src/connectors/autocomplete/connectAutocomplete.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index 9d9428fa16..d9ea0e3abc 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -137,6 +137,8 @@ search.addWidgets([ 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; From c9e3677687c38a86e739f58a93ffac152d50f4ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Tue, 23 Jul 2019 17:40:27 +0200 Subject: [PATCH 19/20] refactor: use connectAutocomplete connector in story --- stories/autocomplete.stories.ts | 36 ++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/stories/autocomplete.stories.ts b/stories/autocomplete.stories.ts index 40e23c3e14..5cea998744 100644 --- a/stories/autocomplete.stories.ts +++ b/stories/autocomplete.stories.ts @@ -4,21 +4,38 @@ import { withHits } from '../.storybook/decorators'; storiesOf('Autocomplete', module).add( 'default', withHits(({ search, container, instantsearch }) => { - const instantSearchRatingAscSearchBox = document.createElement('div'); const instantSearchAutocomplete = document.createElement('div'); - container.appendChild(instantSearchRatingAscSearchBox); container.appendChild(instantSearchAutocomplete); const customAutocomplete = instantsearch.connectors.connectAutocomplete( - renderOptions => { - const { indices, widgetParams } = renderOptions; + (renderOptions, isFirstRender) => { + const { + indices, + currentRefinement, + refine, + widgetParams, + } = renderOptions; - widgetParams.container.innerHTML = indices + 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( - ({ index, hits }) => ` + ({ indexName, hits }) => `
  • - Index: ${index} + Index: ${indexName}
      ${hits .map( @@ -41,11 +58,6 @@ storiesOf('Autocomplete', module).add( hitsPerPage: 3, }), - instantsearch.widgets.searchBox({ - container: instantSearchRatingAscSearchBox, - placeholder: 'Search in the autocomplete list', - }), - customAutocomplete({ container: instantSearchAutocomplete, }), From 3d92a59a499c43e9ad099fb6d5e43ff584c48ca0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Wed, 24 Jul 2019 11:59:12 +0200 Subject: [PATCH 20/20] fix(autocomplete): add space before closing bracket in warning --- .../autocomplete/__tests__/connectAutocomplete-test.ts | 4 ++-- src/connectors/autocomplete/connectAutocomplete.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts index 0c56e10c18..02265ae872 100644 --- a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts @@ -48,8 +48,8 @@ An alternative would be: const autocomplete = connectAutocomplete(renderer); search.addWidgets([ - index({ indexName: 'products'}), - index({ indexName: 'services'}), + index({ indexName: 'products' }), + index({ indexName: 'services' }), autocomplete() ]);`); }); diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index d9ea0e3abc..bb2dcf3737 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -82,7 +82,7 @@ const autocomplete = connectAutocomplete(renderer); search.addWidgets([ ${(widgetParams as any).indices - .map(({ value }: { value: string }) => `index({ indexName: '${value}'}),`) + .map(({ value }: { value: string }) => `index({ indexName: '${value}' }),`) .join('\n ')} autocomplete() ]);