From 6ee7a40cd3f07019f87eb5b956fa68bc43db34ce Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Wed, 2 Sep 2020 14:05:34 +0200 Subject: [PATCH] feat(insights): implement sendEvent in all the connectors (3/4) (#4463) * update infiniteHits * modify createSendEventForHits to accept index instead of helper * add sendEvent to connectAutocomplete * add sendEvent to connectGeoSearch * add sendEvent to connectHierarchicalMenu * add sendEvent to connectBreadcrumb * add sendEvent to connectMenu * add sendEvent to connectNumericMenu * extract getRefinedState in connectRange * add sendEvent to connectRange * add sendEvent to connectRatingMenu * add sendEvent to connectToggleRefinement * fix test error * fix export * fix type error * use $$type instead of hard-code * remove sendEvent from connectBreadcrumb * moved createSendEvent to the top level in the files * feat(insights): add tests (4/4) (#4464) * add tests for userToken * fix import paths * fix test cases to accept null instead of false as insightsClient * update bundlesize * fix lint errors * test sendEventToInsights * use $$type instead of hard-code * remove sendEvent from connectBreadcrumb * moved createSendEvent to the top level in the files * log insights event from storybook * add test for connectors * add tests for createSendEvent helpers * add integration tests for hits and infinite-hits widgets with bindEvent in templates * update bundlesize * Update src/connectors/hits/__tests__/connectHits-test.ts Co-authored-by: Haroen Viaene * use factory function instead of globally exposed variables * clean up * update comment * use runAllMicroTasks instead of nextTick * fix: type errors * fix wrong import paths * fix: pass insightsClient to onEvent as the second parameter * update titles of describe blocks Co-authored-by: Haroen Viaene Co-authored-by: Haroen Viaene --- .storybook/playgrounds/default.ts | 9 + package.json | 4 +- src/components/InfiniteHits/InfiniteHits.tsx | 5 + .../__tests__/InfiniteHits-test.tsx | 15 + .../__tests__/connectAutocomplete-test.ts | 150 ++++++++ .../autocomplete/connectAutocomplete.ts | 16 + .../__tests__/connectGeoSearch-test.js | 246 +++++++++++-- src/connectors/geo-search/connectGeoSearch.js | 17 +- .../__tests__/connectHierarchicalMenu-test.js | 47 +++ .../connectHierarchicalMenu.js | 12 + .../hits/__tests__/connectHits-test.ts | 181 +++++++++ src/connectors/hits/connectHits.ts | 4 +- .../__tests__/connectInfiniteHits-test.ts | 181 +++++++++ .../infinite-hits/connectInfiniteHits.ts | 19 + .../menu/__tests__/connectMenu-test.js | 65 ++++ src/connectors/menu/connectMenu.js | 13 + .../__tests__/connectNumericMenu-test.ts | 58 +++ .../numeric-menu/connectNumericMenu.ts | 65 +++- .../range/__tests__/connectRange-test.js | 199 ++++++++-- src/connectors/range/connectRange.js | 232 ++++++++---- .../__tests__/connectRatingMenu-test.js | 61 ++- .../rating-menu/connectRatingMenu.js | 45 ++- .../__tests__/connectRefinementList-test.js | 70 ++++ .../__tests__/connectToggleRefinement-test.js | 83 ++++- .../connectToggleRefinement.js | 42 ++- src/lib/insights/listener.tsx | 2 +- .../__tests__/createSendEventForFacet-test.ts | 114 ++++++ .../__tests__/createSendEventForHits-test.ts | 229 ++++++++++++ .../convertNumericRefinementsToFilters.ts | 30 ++ src/lib/utils/createSendEventForFacet.ts | 22 +- src/lib/utils/createSendEventForHits.ts | 25 +- src/lib/utils/index.ts | 10 +- src/lib/utils/isFacetRefined.ts | 4 +- .../__tests__/createInsightsMiddleware.ts | 348 ++++++++++++++++++ src/middlewares/createInsightsMiddleware.ts | 20 +- src/types/widget.ts | 5 + .../geo-search/__tests__/geo-search-test.js | 4 +- .../__tests__/hierarchical-menu-test.js | 16 +- .../__tests__/__snapshots__/hits-test.ts.snap | 6 + .../hits/__tests__/hits-integration-test.ts | 168 +++++++++ src/widgets/hits/__tests__/hits-test.ts | 26 +- src/widgets/hits/hits.tsx | 5 +- .../__snapshots__/infinite-hits-test.ts.snap | 20 +- .../infinite-hits-integration-test.ts | 321 +++++++++++----- .../__tests__/infinite-hits-test.ts | 21 +- src/widgets/infinite-hits/infinite-hits.tsx | 5 +- .../__tests__/range-slider-test.js | 21 +- .../rating-menu/__tests__/rating-menu-test.js | 5 +- .../__tests__/toggle-refinement-test.js | 29 +- test/mock/createInsightsClient.ts | 69 ++++ test/mock/createInstantSearch.ts | 7 +- 51 files changed, 3030 insertions(+), 341 deletions(-) create mode 100644 src/lib/utils/__tests__/createSendEventForFacet-test.ts create mode 100644 src/lib/utils/__tests__/createSendEventForHits-test.ts create mode 100644 src/lib/utils/convertNumericRefinementsToFilters.ts create mode 100644 src/middlewares/__tests__/createInsightsMiddleware.ts create mode 100644 src/widgets/hits/__tests__/hits-integration-test.ts create mode 100644 test/mock/createInsightsClient.ts diff --git a/.storybook/playgrounds/default.ts b/.storybook/playgrounds/default.ts index a3c88565ac..8ecf9a7938 100644 --- a/.storybook/playgrounds/default.ts +++ b/.storybook/playgrounds/default.ts @@ -1,5 +1,6 @@ import instantsearch from '../../src/index'; import { panel, numericMenu, hits } from '../../src/widgets'; +import { createInsightsMiddleware } from '../../src/middlewares'; export const hitsItemTemplate = `
{ + console.log('insights onEvent', props); + }, + }); + search.EXPERIMENTAL_use(insights); } export default instantSearchPlayground; diff --git a/package.json b/package.json index 3cd34f964f..b32cb13699 100644 --- a/package.json +++ b/package.json @@ -142,11 +142,11 @@ "bundlesize": [ { "path": "./dist/instantsearch.production.min.js", - "maxSize": "64 kB" + "maxSize": "64.50 kB" }, { "path": "./dist/instantsearch.development.js", - "maxSize": "160 kB" + "maxSize": "150.40 kB" } ] } diff --git a/src/components/InfiniteHits/InfiniteHits.tsx b/src/components/InfiniteHits/InfiniteHits.tsx index cbf2d3f402..e270ec1cc0 100644 --- a/src/components/InfiniteHits/InfiniteHits.tsx +++ b/src/components/InfiniteHits/InfiniteHits.tsx @@ -6,6 +6,7 @@ import Template from '../Template/Template'; import { SearchResults } from 'algoliasearch-helper'; import { Hits } from '../../types'; import { InfiniteHitsTemplates } from '../../widgets/infinite-hits/infinite-hits'; +import { SendEventForHits, BindEventForHits } from '../../lib/utils'; type InfiniteHitsCSSClasses = { root: string; @@ -31,11 +32,14 @@ type InfiniteHitsProps = { }; isFirstPage: boolean; isLastPage: boolean; + sendEvent: SendEventForHits; + bindEvent: BindEventForHits; }; const InfiniteHits = ({ results, hits, + bindEvent, hasShowPrevious, showPrevious, showMore, @@ -86,6 +90,7 @@ const InfiniteHits = ({ ...hit, __hitIndex: position, }} + bindEvent={bindEvent} /> ))} diff --git a/src/components/InfiniteHits/__tests__/InfiniteHits-test.tsx b/src/components/InfiniteHits/__tests__/InfiniteHits-test.tsx index 550fafceab..4c0486c567 100644 --- a/src/components/InfiniteHits/__tests__/InfiniteHits-test.tsx +++ b/src/components/InfiniteHits/__tests__/InfiniteHits-test.tsx @@ -18,6 +18,9 @@ describe('InfiniteHits', () => { disabledLoadMore: 'disabledLoadMore', }; + const sendEvent = () => {}; + const bindEvent = () => ''; + describe('markup', () => { it('should render on first page', () => { const hits: Hits = [ @@ -50,6 +53,8 @@ describe('InfiniteHits', () => { }, }, cssClasses, + sendEvent, + bindEvent, }; const { container } = render(); @@ -88,6 +93,8 @@ describe('InfiniteHits', () => { }, }, cssClasses, + sendEvent, + bindEvent, }; const { container } = render(); @@ -115,6 +122,8 @@ describe('InfiniteHits', () => { }, }, cssClasses, + sendEvent, + bindEvent, }; const { container } = render(); @@ -142,6 +151,8 @@ describe('InfiniteHits', () => { }, }, cssClasses, + sendEvent, + bindEvent, }; const { container } = render(); @@ -180,6 +191,8 @@ describe('InfiniteHits', () => { }, }, cssClasses, + sendEvent, + bindEvent, }; const { container } = render(); @@ -223,6 +236,8 @@ describe('InfiniteHits', () => { }, }, cssClasses, + sendEvent, + bindEvent, }; const { container } = render(); diff --git a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts index 0d9ff1cfa0..90c815af08 100644 --- a/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts +++ b/src/connectors/autocomplete/__tests__/connectAutocomplete-test.ts @@ -566,4 +566,154 @@ search.addWidgets([ ); }); }); + + describe('insights', () => { + const createRenderedWidget = () => { + 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 initOptions = createInitOptions({ helper }); + const instantSearchInstance = initOptions.instantSearchInstance; + widget.init!(initOptions); + + const firstIndexHits = [ + { + name: 'Hit 1-1', + objectID: '1-1', + __queryID: 'test-query-id', + __position: 0, + }, + ]; + const secondIndexHits = [ + { + name: 'Hit 2-1', + objectID: '2-1', + __queryID: 'test-query-id', + __position: 0, + }, + { + name: 'Hit 2-2', + objectID: '2-2', + __queryID: 'test-query-id', + __position: 1, + }, + ]; + + const scopedResults = [ + { + indexId: 'indexId0', + results: new SearchResults(helper.state, [ + createSingleSearchResponse({ + index: 'indexName0', + hits: firstIndexHits, + }), + ]), + helper, + }, + { + indexId: 'indexId1', + results: new SearchResults(helper.state, [ + createSingleSearchResponse({ + index: 'indexName1', + hits: secondIndexHits, + }), + ]), + helper, + }, + ]; + + widget.render!( + createRenderOptions({ instantSearchInstance, helper, scopedResults }) + ); + + const sendEventToInsights = instantSearchInstance.sendEventToInsights as jest.Mock; + + return { + instantSearchInstance, + sendEventToInsights, + render, + firstIndexHits, + secondIndexHits, + }; + }; + + it('sends view event when hits are rendered', () => { + const { sendEventToInsights } = createRenderedWidget(); + expect(sendEventToInsights).toHaveBeenCalledTimes(2); + expect(sendEventToInsights.mock.calls[0][0]).toEqual({ + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: 'indexName0', + objectIDs: ['1-1'], + }, + widgetType: 'ais.autocomplete', + }); + expect(sendEventToInsights.mock.calls[1][0]).toEqual({ + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: 'indexName1', + objectIDs: ['2-1', '2-2'], + }, + widgetType: 'ais.autocomplete', + }); + }); + + it('sends click event', () => { + const { + sendEventToInsights, + render, + secondIndexHits, + } = createRenderedWidget(); + expect(sendEventToInsights).toHaveBeenCalledTimes(2); // two view events for each index by render + + const { indices } = render.mock.calls[render.mock.calls.length - 1][0]; + indices[1].sendEvent('click', secondIndexHits[0], 'Product Added'); + expect(sendEventToInsights).toHaveBeenCalledTimes(3); + expect(sendEventToInsights.mock.calls[2][0]).toEqual({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Product Added', + index: 'indexName1', + objectIDs: ['2-1'], + positions: [0], + queryID: 'test-query-id', + }, + widgetType: 'ais.autocomplete', + }); + }); + + it('sends conversion event', () => { + const { + sendEventToInsights, + render, + firstIndexHits, + } = createRenderedWidget(); + expect(sendEventToInsights).toHaveBeenCalledTimes(2); // two view events for each index by render + + const { indices } = render.mock.calls[render.mock.calls.length - 1][0]; + indices[0].sendEvent('conversion', firstIndexHits[0], 'Product Ordered'); + expect(sendEventToInsights).toHaveBeenCalledTimes(3); + expect(sendEventToInsights.mock.calls[2][0]).toEqual({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: 'indexName0', + objectIDs: ['1-1'], + queryID: 'test-query-id', + }, + widgetType: 'ais.autocomplete', + }); + }); + }); }); diff --git a/src/connectors/autocomplete/connectAutocomplete.ts b/src/connectors/autocomplete/connectAutocomplete.ts index ff8813726b..bcb898f8a2 100644 --- a/src/connectors/autocomplete/connectAutocomplete.ts +++ b/src/connectors/autocomplete/connectAutocomplete.ts @@ -3,6 +3,8 @@ import escapeHits, { TAG_PLACEHOLDER } from '../../lib/escape-highlight'; import { checkRendering, createDocumentationMessageGenerator, + createSendEventForHits, + SendEventForHits, noop, warning, } from '../../lib/utils'; @@ -46,6 +48,11 @@ export type AutocompleteRendererOptions = { * The full results object from the Algolia API. */ results: SearchResults; + + /** + * Send event to insights middleware + */ + sendEvent: SendEventForHits; }>; /** @@ -127,11 +134,20 @@ search.addWidgets([ ? escapeHits(scopedResult.results.hits) : scopedResult.results.hits; + const sendEvent = createSendEventForHits({ + instantSearchInstance, + index: scopedResult.results.index, + widgetType: this.$$type!, + }); + + sendEvent('view', scopedResult.results.hits); + return { indexId: scopedResult.indexId, indexName: scopedResult.results.index, hits: scopedResult.results.hits, results: scopedResult.results, + sendEvent, }; }); diff --git a/src/connectors/geo-search/__tests__/connectGeoSearch-test.js b/src/connectors/geo-search/__tests__/connectGeoSearch-test.js index cc1b019df3..4a4e977c95 100644 --- a/src/connectors/geo-search/__tests__/connectGeoSearch-test.js +++ b/src/connectors/geo-search/__tests__/connectGeoSearch-test.js @@ -3,6 +3,7 @@ import algoliasearchHelper, { SearchResults, } from 'algoliasearch-helper'; import connectGeoSearch from '../connectGeoSearch'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; describe('connectGeoSearch', () => { const createFakeHelper = client => { @@ -71,9 +72,8 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ const customGeoSearch = connectGeoSearch(render, unmount); const widget = customGeoSearch(); - const client = {}; - const helper = createFakeHelper(client); - const instantSearchInstance = { client, helper }; + const instantSearchInstance = createInstantSearch(); + const { mainHelper: helper } = instantSearchInstance; widget.init({ state: helper.state, @@ -87,6 +87,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ items: [], position: undefined, refine: expect.any(Function), + sendEvent: expect.any(Function), clearMapRefinement: expect.any(Function), isRefinedWithMap: expect.any(Function), toggleRefineOnMapMove: expect.any(Function), @@ -155,6 +156,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ widget.init({ state: helper.state, helper, + instantSearchInstance: createInstantSearch(), }); expect(render).toHaveBeenCalledTimes(1); @@ -181,7 +183,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ const widget = customGeoSearch(); const helper = createFakeHelper({}); - + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); widget.render({ results: new SearchResults(helper.state, [ { @@ -223,6 +229,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ }); const helper = createFakeHelper({}); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); widget.render({ results: new SearchResults(helper.state, [ @@ -350,6 +361,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper.setQueryParameter('aroundLatLng', '10,12'); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results, helper, @@ -362,13 +379,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); lastRenderArgs(render).setMapMoveSinceLastRefine(); - expect(render).toHaveBeenCalledTimes(3); + expect(render).toHaveBeenCalledTimes(4); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); @@ -379,7 +396,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(4); + expect(render).toHaveBeenCalledTimes(5); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); }); @@ -415,6 +432,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper.setQueryParameter('aroundLatLng', '10,12'); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results, helper, @@ -427,13 +450,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); lastRenderArgs(render).setMapMoveSinceLastRefine(); - expect(render).toHaveBeenCalledTimes(3); + expect(render).toHaveBeenCalledTimes(4); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); @@ -444,7 +467,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(4); + expect(render).toHaveBeenCalledTimes(5); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); }); @@ -480,6 +503,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper.setQueryParameter('insideBoundingBox', '10,12,14,16'); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results, helper, @@ -492,13 +521,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); lastRenderArgs(render).setMapMoveSinceLastRefine(); - expect(render).toHaveBeenCalledTimes(3); + expect(render).toHaveBeenCalledTimes(4); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); @@ -509,7 +538,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(4); + expect(render).toHaveBeenCalledTimes(5); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(false); }); @@ -545,6 +574,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper.setQueryParameter('insideBoundingBox', '10,12,14,16'); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results, helper, @@ -557,13 +592,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); lastRenderArgs(render).setMapMoveSinceLastRefine(); - expect(render).toHaveBeenCalledTimes(3); + expect(render).toHaveBeenCalledTimes(4); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); @@ -574,7 +609,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(4); + expect(render).toHaveBeenCalledTimes(5); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); }); @@ -743,6 +778,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ widget.init({ state: helper.state, helper, + instantSearchInstance: createInstantSearch(), }); expect(render).toHaveBeenCalledTimes(1); @@ -790,12 +826,18 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ }, ]); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results, helper, }); - expect(render).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(2); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(false); expect(helper.state.insideBoundingBox).toBe(undefined); @@ -808,7 +850,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); expect(helper.state.insideBoundingBox).toEqual('12,10,40,42'); @@ -845,6 +887,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ widget.init({ state: helper.state, helper, + instantSearchInstance: createInstantSearch(), }); expect(render).toHaveBeenCalledTimes(1); @@ -905,12 +948,18 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ }, ]); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results, helper, }); - expect(render).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(2); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(false); expect(helper.state.insideBoundingBox).toBe(undefined); @@ -923,7 +972,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(true); expect(helper.state.insideBoundingBox).toEqual('12,10,40,42'); @@ -936,7 +985,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(3); + expect(render).toHaveBeenCalledTimes(4); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); expect(lastRenderArgs(render).isRefinedWithMap()).toBe(false); expect(helper.state.insideBoundingBox).toBe(undefined); @@ -957,6 +1006,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ widget.init({ state: helper.state, helper, + instantSearchInstance: createInstantSearch(), }); expect(render).toHaveBeenCalledTimes(1); @@ -992,6 +1042,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ const helper = createFakeHelper({}); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results: new SearchResults(helper.state, [ { @@ -1001,12 +1057,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ helper, }); - expect(render).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(2); expect(lastRenderArgs(render).isRefineOnMapMove()).toBe(true); lastRenderArgs(render).toggleRefineOnMapMove(); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).isRefineOnMapMove()).toBe(false); expect(firstRenderArgs(render).toggleRefineOnMapMove).toBe( lastRenderArgs(render).toggleRefineOnMapMove @@ -1027,6 +1083,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ widget.init({ state: helper.state, helper, + instantSearchInstance: createInstantSearch(), }); expect(render).toHaveBeenCalledTimes(1); @@ -1068,17 +1125,23 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ }, ]); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results, helper, }); - expect(render).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(2); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); lastRenderArgs(render).setMapMoveSinceLastRefine(); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); expect(firstRenderArgs(render).setMapMoveSinceLastRefine).toBe( lastRenderArgs(render).setMapMoveSinceLastRefine @@ -1100,22 +1163,28 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ }, ]); + widget.init({ + state: helper.state, + helper, + instantSearchInstance: createInstantSearch(), + }); + widget.render({ results, helper, }); - expect(render).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalledTimes(2); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(false); lastRenderArgs(render).setMapMoveSinceLastRefine(); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); lastRenderArgs(render).setMapMoveSinceLastRefine(); - expect(render).toHaveBeenCalledTimes(2); + expect(render).toHaveBeenCalledTimes(3); expect(lastRenderArgs(render).hasMapMoveSinceLastRefine()).toBe(true); expect(firstRenderArgs(render).setMapMoveSinceLastRefine).toBe( lastRenderArgs(render).setMapMoveSinceLastRefine @@ -1278,4 +1347,125 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/geo-search/ expect(searchParametersAfter.insideBoundingBox).toBeUndefined(); }); }); + + describe('insights', () => { + const createRenderedWidget = () => { + const hits = [ + { + objectID: 123, + _geoloc: { lat: 10, lng: 12 }, + __position: 0, + __queryID: 'test-query-id', + }, + { + objectID: 456, + _geoloc: { lat: 12, lng: 14 }, + __position: 1, + __queryID: 'test-query-id', + }, + { + objectID: 789, + _geoloc: { lat: 14, lng: 16 }, + __position: 2, + __queryID: 'test-query-id', + }, + ]; + const render = jest.fn(); + const unmount = jest.fn(); + + const customGeoSearch = connectGeoSearch(render, unmount); + const widget = customGeoSearch(); + + const instantSearchInstance = createInstantSearch(); + const { mainHelper: helper } = instantSearchInstance; + + widget.init({ + state: helper.state, + instantSearchInstance, + helper, + }); + + widget.render({ + results: new SearchResults(helper.state, [ + { + hits, + }, + ]), + helper, + instantSearchInstance, + }); + + return { + render, + instantSearchInstance, + hits, + }; + }; + + it('sends view event when hits are rendered', () => { + const { instantSearchInstance } = createRenderedWidget(); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: 'indexName', + objectIDs: [123, 456, 789], + }, + widgetType: 'ais.geoSearch', + }); + }); + + it('sends click event', () => { + const { instantSearchInstance, render, hits } = createRenderedWidget(); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); // view event by render + + const { sendEvent } = render.mock.calls[render.mock.calls.length - 1][0]; + sendEvent('click', hits[0], 'Location Added'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 2 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Location Added', + index: 'indexName', + objectIDs: [123], + positions: [0], + queryID: 'test-query-id', + }, + widgetType: 'ais.geoSearch', + }); + }); + + it('sends conversion event', () => { + const { instantSearchInstance, render, hits } = createRenderedWidget(); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); // view event by render + + const { sendEvent } = render.mock.calls[render.mock.calls.length - 1][0]; + sendEvent('conversion', hits[0], 'Location Saved'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 2 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Location Saved', + index: 'indexName', + objectIDs: [123], + queryID: 'test-query-id', + }, + widgetType: 'ais.geoSearch', + }); + }); + }); }); diff --git a/src/connectors/geo-search/connectGeoSearch.js b/src/connectors/geo-search/connectGeoSearch.js index 4ef57089b5..d75f9bf36b 100644 --- a/src/connectors/geo-search/connectGeoSearch.js +++ b/src/connectors/geo-search/connectGeoSearch.js @@ -3,6 +3,7 @@ import { aroundLatLngToPosition, insideBoundingBoxToBoundingBox, createDocumentationMessageGenerator, + createSendEventForHits, noop, } from '../../lib/utils'; @@ -11,6 +12,8 @@ const withUsage = createDocumentationMessageGenerator({ connector: true, }); +const $$type = 'ais.geoSearch'; + /** * @typedef {Object} LatLng * @property {number} lat The latitude in degrees. @@ -167,10 +170,18 @@ const connectGeoSearch = (renderFn, unmountFn = noop) => { const hasMapMoveSinceLastRefine = () => widgetState.hasMapMoveSinceLastRefine; + let sendEvent; + const init = initArgs => { const { state, helper, instantSearchInstance } = initArgs; const isFirstRendering = true; + sendEvent = createSendEventForHits({ + instantSearchInstance, + index: helper.getIndex(), + widgetType: $$type, + }); + widgetState.internalToggleRefineOnMapMove = createInternalToggleRefinementOnMapMove( noop, initArgs @@ -187,6 +198,7 @@ const connectGeoSearch = (renderFn, unmountFn = noop) => { position: getPositionFromState(state), currentRefinement: getCurrentRefinementFromState(state), refine: refine(helper), + sendEvent, clearMapRefinement: clearMapRefinement(helper), isRefinedWithMap: isRefinedWithMap(state), toggleRefineOnMapMove, @@ -236,12 +248,15 @@ const connectGeoSearch = (renderFn, unmountFn = noop) => { const items = transformItems(results.hits.filter(hit => hit._geoloc)); + sendEvent('view', items); + renderFn( { items, position: getPositionFromState(state), currentRefinement: getCurrentRefinementFromState(state), refine: refine(helper), + sendEvent, clearMapRefinement: clearMapRefinement(helper), isRefinedWithMap: isRefinedWithMap(state), toggleRefineOnMapMove, @@ -256,7 +271,7 @@ const connectGeoSearch = (renderFn, unmountFn = noop) => { }; return { - $$type: 'ais.geoSearch', + $$type, init, diff --git a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js index a7675d2b54..c0a1026709 100644 --- a/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js +++ b/src/connectors/hierarchical-menu/__tests__/connectHierarchicalMenu-test.js @@ -4,6 +4,7 @@ import jsHelper, { } from 'algoliasearch-helper'; import { warning } from '../../../lib/utils'; import connectHierarchicalMenu from '../connectHierarchicalMenu'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; describe('connectHierarchicalMenu', () => { describe('Usage', () => { @@ -157,6 +158,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchica helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); const firstRenderingOptions = rendering.mock.calls[0][0]; @@ -409,6 +411,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchica helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); const firstRenderingOptions = rendering.mock.calls[0][0]; @@ -1125,4 +1128,48 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchica ); }); }); + + describe('insights', () => { + it('sends event when a facet is added', () => { + const rendering = jest.fn(); + const instantSearchInstance = createInstantSearch(); + const makeWidget = connectHierarchicalMenu(rendering); + const widget = makeWidget({ + attributes: ['category', 'sub_category'], + }); + + const helper = jsHelper( + {}, + '', + widget.getWidgetSearchParameters(new SearchParameters(), { + uiState: {}, + }) + ); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + instantSearchInstance, + }); + + const firstRenderingOptions = rendering.mock.calls[0][0]; + const { refine } = firstRenderingOptions; + refine('value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['category:"value"'], + index: '', + }, + widgetType: 'ais.hierarchicalMenu', + }); + }); + }); }); diff --git a/src/connectors/hierarchical-menu/connectHierarchicalMenu.js b/src/connectors/hierarchical-menu/connectHierarchicalMenu.js index 9d29661506..91f564cf66 100644 --- a/src/connectors/hierarchical-menu/connectHierarchicalMenu.js +++ b/src/connectors/hierarchical-menu/connectHierarchicalMenu.js @@ -2,6 +2,7 @@ import { checkRendering, warning, createDocumentationMessageGenerator, + createSendEventForFacet, isEqual, noop, } from '../../lib/utils'; @@ -89,6 +90,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) { // so that we can always map $hierarchicalFacetName => real attributes // we use the first attribute name const [hierarchicalFacetName] = attributes; + let sendEvent; return { $$type: 'ais.hierarchicalMenu', @@ -114,8 +116,16 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) { }, init({ helper, createURL, instantSearchInstance }) { + sendEvent = createSendEventForFacet({ + instantSearchInstance, + helper, + attribute: hierarchicalFacetName, + widgetType: this.$$type, + }); + this.cachedToggleShowMore = this.cachedToggleShowMore.bind(this); this._refine = function(facetValue) { + sendEvent('click', facetValue); helper.toggleRefinement(hierarchicalFacetName, facetValue).search(); }; @@ -131,6 +141,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) { items: [], createURL: _createURL, refine: this._refine, + sendEvent, instantSearchInstance, widgetParams, isShowingMore: false, @@ -193,6 +204,7 @@ export default function connectHierarchicalMenu(renderFn, unmountFn = noop) { { items, refine: this._refine, + sendEvent, createURL: _createURL, instantSearchInstance, widgetParams, diff --git a/src/connectors/hits/__tests__/connectHits-test.ts b/src/connectors/hits/__tests__/connectHits-test.ts index 155dcf2510..fd911b920c 100644 --- a/src/connectors/hits/__tests__/connectHits-test.ts +++ b/src/connectors/hits/__tests__/connectHits-test.ts @@ -550,4 +550,185 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hits/js/#co expect(nextState.highlightPostTag).toBe(''); }); }); + + describe('insights', () => { + const createRenderedWidget = () => { + const renderFn = jest.fn(); + const makeWidget = connectHits(renderFn); + const widget = makeWidget({}); + + const helper = algoliasearchHelper(createSearchClient(), '', {}); + helper.search = jest.fn(); + + const initOptions = createInitOptions({ + helper, + state: helper.state, + }); + const instantSearchInstance = initOptions.instantSearchInstance; + widget.init!(initOptions); + + const hits = [ + { + objectID: '1', + fake: 'data', + __queryID: 'test-query-id', + __position: 0, + }, + { + objectID: '2', + sample: 'infos', + __queryID: 'test-query-id', + __position: 1, + }, + ]; + + const results = new SearchResults(helper.state, [ + createSingleSearchResponse({ hits }), + ]); + widget.render!( + createRenderOptions({ + results, + state: helper.state, + helper, + }) + ); + + return { instantSearchInstance, renderFn, hits }; + }; + + describe('insights', () => { + describe('sendEvent', () => { + it('sends view event when hits are rendered', () => { + const { instantSearchInstance } = createRenderedWidget(); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(1); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledWith({ + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: '', + objectIDs: ['1', '2'], + }, + widgetType: 'ais.hits', + }); + }); + + it('sends click event', () => { + const { + instantSearchInstance, + renderFn, + hits, + } = createRenderedWidget(); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(1); // view event by render + + const { sendEvent } = renderFn.mock.calls[ + renderFn.mock.calls.length - 1 + ][0]; + sendEvent('click', hits[0], 'Product Added'); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(2); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Product Added', + index: '', + objectIDs: ['1'], + positions: [0], + queryID: 'test-query-id', + }, + widgetType: 'ais.hits', + }); + }); + + it('sends conversion event', () => { + const { + instantSearchInstance, + renderFn, + hits, + } = createRenderedWidget(); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(1); // view event by render + + const { sendEvent } = renderFn.mock.calls[ + renderFn.mock.calls.length - 1 + ][0]; + sendEvent('conversion', hits[1], 'Product Ordered'); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(2); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledWith({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: '', + objectIDs: ['2'], + queryID: 'test-query-id', + }, + widgetType: 'ais.hits', + }); + }); + }); + + describe('bindEvent', () => { + it('returns a payload for click event', () => { + const { renderFn, hits } = createRenderedWidget(); + const { bindEvent } = renderFn.mock.calls[ + renderFn.mock.calls.length - 1 + ][0]; + const payload = bindEvent('click', hits[0], 'Product Added'); + expect(payload.startsWith('data-insights-event=')).toBe(true); + expect( + JSON.parse(atob(payload.substr('data-insights-event='.length))) + ).toEqual({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Product Added', + index: '', + objectIDs: ['1'], + positions: [0], + queryID: 'test-query-id', + }, + widgetType: 'ais.hits', + }); + }); + + it('returns a payload for conversion event', () => { + const { renderFn, hits } = createRenderedWidget(); + const { bindEvent } = renderFn.mock.calls[ + renderFn.mock.calls.length - 1 + ][0]; + const payload = bindEvent('conversion', hits[1], 'Product Ordered'); + expect(payload.startsWith('data-insights-event=')).toBe(true); + expect( + JSON.parse(atob(payload.substr('data-insights-event='.length))) + ).toEqual({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: '', + objectIDs: ['2'], + queryID: 'test-query-id', + }, + widgetType: 'ais.hits', + }); + }); + }); + }); + }); }); diff --git a/src/connectors/hits/connectHits.ts b/src/connectors/hits/connectHits.ts index c222d84725..5d8ad77b85 100644 --- a/src/connectors/hits/connectHits.ts +++ b/src/connectors/hits/connectHits.ts @@ -74,11 +74,11 @@ const connectHits: HitsConnector = function connectHits( init({ instantSearchInstance, helper }) { sendEvent = createSendEventForHits({ instantSearchInstance, - helper, + index: helper.getIndex(), widgetType: this.$$type!, }); bindEvent = createBindEventForHits({ - helper, + index: helper.getIndex(), widgetType: this.$$type!, }); diff --git a/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts b/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts index 0644e8f5a5..4dab82d433 100644 --- a/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts +++ b/src/connectors/infinite-hits/__tests__/connectInfiniteHits-test.ts @@ -978,4 +978,185 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/infinite-hi expect(actual.page).toEqual(2); }); }); + + describe('insights', () => { + const createRenderedWidget = () => { + const renderFn = jest.fn(); + const makeWidget = connectInfiniteHits(renderFn); + const widget = makeWidget({}); + + const helper = algoliasearchHelper(createSearchClient(), '', {}); + helper.search = jest.fn(); + + const initOptions = createInitOptions({ + helper, + state: helper.state, + }); + const instantSearchInstance = initOptions.instantSearchInstance; + widget.init!(initOptions); + + const hits = [ + { + objectID: '1', + fake: 'data', + __queryID: 'test-query-id', + __position: 0, + }, + { + objectID: '2', + sample: 'infos', + __queryID: 'test-query-id', + __position: 1, + }, + ]; + + const results = new SearchResults(helper.state, [ + createSingleSearchResponse({ hits }), + ]); + widget.render!( + createRenderOptions({ + results, + state: helper.state, + helper, + }) + ); + + return { instantSearchInstance, renderFn, hits }; + }; + + describe('insights', () => { + describe('sendEvent', () => { + it('sends view event when hits are rendered', () => { + const { instantSearchInstance } = createRenderedWidget(); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(1); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledWith({ + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: '', + objectIDs: ['1', '2'], + }, + widgetType: 'ais.infiniteHits', + }); + }); + + it('sends click event', () => { + const { + instantSearchInstance, + renderFn, + hits, + } = createRenderedWidget(); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(1); // view event by render + + const { sendEvent } = renderFn.mock.calls[ + renderFn.mock.calls.length - 1 + ][0]; + sendEvent('click', hits[0], 'Product Added'); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(2); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Product Added', + index: '', + objectIDs: ['1'], + positions: [0], + queryID: 'test-query-id', + }, + widgetType: 'ais.infiniteHits', + }); + }); + + it('sends conversion event', () => { + const { + instantSearchInstance, + renderFn, + hits, + } = createRenderedWidget(); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(1); // view event by render + + const { sendEvent } = renderFn.mock.calls[ + renderFn.mock.calls.length - 1 + ][0]; + sendEvent('conversion', hits[1], 'Product Ordered'); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledTimes(2); + expect( + instantSearchInstance.sendEventToInsights + ).toHaveBeenCalledWith({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: '', + objectIDs: ['2'], + queryID: 'test-query-id', + }, + widgetType: 'ais.infiniteHits', + }); + }); + }); + + describe('bindEvent', () => { + it('returns a payload for click event', () => { + const { renderFn, hits } = createRenderedWidget(); + const { bindEvent } = renderFn.mock.calls[ + renderFn.mock.calls.length - 1 + ][0]; + const payload = bindEvent('click', hits[0], 'Product Added'); + expect(payload.startsWith('data-insights-event=')).toBe(true); + expect( + JSON.parse(atob(payload.substr('data-insights-event='.length))) + ).toEqual({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Product Added', + index: '', + objectIDs: ['1'], + positions: [0], + queryID: 'test-query-id', + }, + widgetType: 'ais.infiniteHits', + }); + }); + + it('returns a payload for conversion event', () => { + const { renderFn, hits } = createRenderedWidget(); + const { bindEvent } = renderFn.mock.calls[ + renderFn.mock.calls.length - 1 + ][0]; + const payload = bindEvent('conversion', hits[1], 'Product Ordered'); + expect(payload.startsWith('data-insights-event=')).toBe(true); + expect( + JSON.parse(atob(payload.substr('data-insights-event='.length))) + ).toEqual({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: '', + objectIDs: ['2'], + queryID: 'test-query-id', + }, + widgetType: 'ais.infiniteHits', + }); + }); + }); + }); + }); }); diff --git a/src/connectors/infinite-hits/connectInfiniteHits.ts b/src/connectors/infinite-hits/connectInfiniteHits.ts index 9182fb1f62..922a10a921 100644 --- a/src/connectors/infinite-hits/connectInfiniteHits.ts +++ b/src/connectors/infinite-hits/connectInfiniteHits.ts @@ -11,7 +11,9 @@ import { addAbsolutePosition, addQueryID, noop, + createSendEventForHits, SendEventForHits, + createBindEventForHits, BindEventForHits, } from '../../lib/utils'; @@ -155,6 +157,8 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( let prevState: Partial; let showPrevious: () => void; let showMore: () => void; + let sendEvent; + let bindEvent; const getFirstReceivedPage = () => Math.min(...Object.keys(cachedHits || {}).map(Number)); @@ -193,6 +197,15 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( init({ instantSearchInstance, helper }) { showPrevious = getShowPrevious(helper); showMore = getShowMore(helper); + sendEvent = createSendEventForHits({ + instantSearchInstance, + index: helper.getIndex(), + widgetType: this.$$type!, + }); + bindEvent = createBindEventForHits({ + index: helper.getIndex(), + widgetType: this.$$type!, + }); renderFn( { @@ -200,6 +213,8 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( cache.read({ state: helper.state }) || {} ), results: undefined, + sendEvent, + bindEvent, showPrevious, showMore, isFirstPage: @@ -278,10 +293,14 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( const isFirstPage = getFirstReceivedPage() === 0; const isLastPage = results.nbPages <= results.page + 1; + sendEvent('view', cachedHits[page]); + renderFn( { hits: extractHitsFromCachedHits(cachedHits!), results, + sendEvent, + bindEvent, showPrevious, showMore, isFirstPage, diff --git a/src/connectors/menu/__tests__/connectMenu-test.js b/src/connectors/menu/__tests__/connectMenu-test.js index 65867507b9..10d51d0185 100644 --- a/src/connectors/menu/__tests__/connectMenu-test.js +++ b/src/connectors/menu/__tests__/connectMenu-test.js @@ -3,6 +3,7 @@ import jsHelper, { SearchParameters, } from 'algoliasearch-helper'; import { createSingleSearchResponse } from '../../../../test/mock/createAPIResponse'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; import connectMenu from '../connectMenu'; describe('connectMenu', () => { @@ -220,6 +221,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/menu/js/#co helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); const firstRenderingOptions = rendering.mock.calls[0][0]; @@ -975,6 +977,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/menu/js/#co helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); widget.render({ @@ -1084,4 +1087,66 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/menu/js/#co expect(newState).toEqual(new SearchParameters()); }); }); + + describe('insights', () => { + const createInitializedWidget = () => { + const widget = makeWidget({ + attribute: 'category', + }); + const instantSearchInstance = createInstantSearch(); + const helper = jsHelper( + {}, + '', + widget.getWidgetSearchParameters(new SearchParameters(), { + uiState: {}, + }) + ); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + instantSearchInstance, + }); + + return { instantSearchInstance, helper }; + }; + + it('sends event when a facet is refined', () => { + const { instantSearchInstance } = createInitializedWidget(); + const firstRenderingOptions = rendering.mock.calls[0][0]; + const { refine } = firstRenderingOptions; + refine('value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['category:"value"'], + index: '', + }, + widgetType: 'ais.menu', + }); + }); + + it('does not send event when a facet is removed', () => { + const { instantSearchInstance, helper } = createInitializedWidget(); + const firstRenderingOptions = rendering.mock.calls[0][0]; + const { refine } = firstRenderingOptions; + refine('value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(helper.hasRefinements('category')).toBe(true); + + refine('value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); // still the same + }); + }); }); diff --git a/src/connectors/menu/connectMenu.js b/src/connectors/menu/connectMenu.js index 56ac3b74b7..bfb129dfec 100644 --- a/src/connectors/menu/connectMenu.js +++ b/src/connectors/menu/connectMenu.js @@ -1,6 +1,7 @@ import { checkRendering, createDocumentationMessageGenerator, + createSendEventForFacet, noop, } from '../../lib/utils'; @@ -114,6 +115,8 @@ export default function connectMenu(renderFn, unmountFn = noop) { ); } + let sendEvent; + return { $$type: 'ais.menu', @@ -142,6 +145,7 @@ export default function connectMenu(renderFn, unmountFn = noop) { const [refinedItem] = helper.getHierarchicalFacetBreadcrumb( attribute ); + sendEvent('click', facetValue ? facetValue : refinedItem); helper .toggleRefinement(attribute, facetValue ? facetValue : refinedItem) .search(); @@ -149,6 +153,13 @@ export default function connectMenu(renderFn, unmountFn = noop) { }, init({ helper, createURL, instantSearchInstance }) { + sendEvent = createSendEventForFacet({ + instantSearchInstance, + helper, + attribute, + widgetType: this.$$type, + }); + this.cachedToggleShowMore = this.cachedToggleShowMore.bind(this); this._createURL = facetValue => @@ -161,6 +172,7 @@ export default function connectMenu(renderFn, unmountFn = noop) { items: [], createURL: this._createURL, refine: this._refine, + sendEvent, instantSearchInstance, canRefine: false, widgetParams, @@ -197,6 +209,7 @@ export default function connectMenu(renderFn, unmountFn = noop) { items, createURL: this._createURL, refine: this._refine, + sendEvent, instantSearchInstance, canRefine: items.length > 0, widgetParams, diff --git a/src/connectors/numeric-menu/__tests__/connectNumericMenu-test.ts b/src/connectors/numeric-menu/__tests__/connectNumericMenu-test.ts index 8e6737bf0e..79b8c63cdf 100644 --- a/src/connectors/numeric-menu/__tests__/connectNumericMenu-test.ts +++ b/src/connectors/numeric-menu/__tests__/connectNumericMenu-test.ts @@ -1017,4 +1017,62 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/numeric-men ); }); }); + + describe('insights', () => { + it('sends event when a facet is added', () => { + const rendering = jest.fn(); + const makeWidget = connectNumericMenu(rendering); + const widget = makeWidget({ + attribute: 'numerics', + items: [ + { label: 'below 10', end: 10 }, + { label: '10 - 20', start: 10, end: 20 }, + { label: 'more than 20', start: 20 }, + { label: '42', start: 42, end: 42 }, + { label: 'void' }, + ], + }); + + const helper = jsHelper(createSearchClient(), ''); + helper.search = jest.fn(); + const initOptions = createInitOptions({ + helper, + state: helper.state, + }); + const { instantSearchInstance } = initOptions; + widget.init!(initOptions); + + const firstRenderingOptions = rendering.mock.calls[0][0]; + const { refine, items } = firstRenderingOptions; + refine(items[0].value); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['numerics<=10'], + index: '', + }, + widgetType: 'ais.numericMenu', + }); + + refine(items[1].value); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 2 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['numerics<=20', 'numerics>=10'], + index: '', + }, + widgetType: 'ais.numericMenu', + }); + }); + }); }); diff --git a/src/connectors/numeric-menu/connectNumericMenu.ts b/src/connectors/numeric-menu/connectNumericMenu.ts index e69c32b2f2..f31ce528f6 100644 --- a/src/connectors/numeric-menu/connectNumericMenu.ts +++ b/src/connectors/numeric-menu/connectNumericMenu.ts @@ -2,6 +2,8 @@ import { checkRendering, createDocumentationMessageGenerator, isFiniteNumber, + SendEventForFacet, + convertNumericRefinementsToFilters, noop, } from '../../lib/utils'; import { Connector, CreateURL, TransformItems } from '../../types'; @@ -86,6 +88,11 @@ export type NumericMenuRendererOptions = { * Sets the selected value and trigger a new search */ refine: (facetValue: string) => void; + + /** + * Send event to insights middleware + */ + sendEvent: SendEventForFacet; }; export type NumericMenuConnector = Connector< @@ -93,6 +100,42 @@ export type NumericMenuConnector = Connector< NumericMenuConnectorParams >; +const $$type = 'ais.numericMenu'; + +const createSendEvent = ({ instantSearchInstance, helper, attribute }) => ( + ...args +) => { + if (args.length === 1) { + instantSearchInstance.sendEventToInsights(args[0]); + return; + } + + const [eventType, facetValue, eventName = 'Filter Applied'] = args; + if (eventType !== 'click') { + return; + } + // facetValue === "%7B%22start%22:5,%22end%22:10%7D" + const filters = convertNumericRefinementsToFilters( + getRefinedState(helper.state, attribute, facetValue), + attribute + ); + if (filters && filters.length > 0) { + /* + filters === ["price<=10", "price>=5"] + */ + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'clickedFilters', + widgetType: $$type, + eventType, + payload: { + eventName, + index: helper.getIndex(), + filters, + }, + }); + } +}; + const connectNumericMenu: NumericMenuConnector = function connectNumericMenu( renderFn, unmountFn = noop @@ -116,6 +159,7 @@ const connectNumericMenu: NumericMenuConnector = function connectNumericMenu( type ConnectorState = { refine?: (facetValue: string) => void; createURL?: (state: SearchParameters) => (facetValue: string) => string; + sendEvent?: SendEventForFacet; }; const prepareItems = (state: SearchParameters) => @@ -128,16 +172,27 @@ const connectNumericMenu: NumericMenuConnector = function connectNumericMenu( const connectorState: ConnectorState = {}; return { - $$type: 'ais.numericMenu', + $$type, init({ helper, createURL, instantSearchInstance }) { + connectorState.sendEvent = createSendEvent({ + instantSearchInstance, + helper, + attribute, + }); + connectorState.refine = facetValue => { - const refinedState = refine(helper.state, attribute, facetValue); + const refinedState = getRefinedState( + helper.state, + attribute, + facetValue + ); + connectorState.sendEvent!('click', facetValue); helper.setState(refinedState).search(); }; connectorState.createURL = state => facetValue => - createURL(refine(state, attribute, facetValue)); + createURL(getRefinedState(state, attribute, facetValue)); renderFn( { @@ -145,6 +200,7 @@ const connectNumericMenu: NumericMenuConnector = function connectNumericMenu( items: transformItems(prepareItems(helper.state)), hasNoResults: true, refine: connectorState.refine, + sendEvent: connectorState.sendEvent!, instantSearchInstance, widgetParams, }, @@ -159,6 +215,7 @@ const connectNumericMenu: NumericMenuConnector = function connectNumericMenu( items: transformItems(prepareItems(state)), hasNoResults: results.nbHits === 0, refine: connectorState.refine!, + sendEvent: connectorState.sendEvent!, instantSearchInstance, widgetParams, }, @@ -275,7 +332,7 @@ function isRefined( return false; } -function refine( +function getRefinedState( state: SearchParameters, attribute: string, facetValue: string diff --git a/src/connectors/range/__tests__/connectRange-test.js b/src/connectors/range/__tests__/connectRange-test.js index 46fd0f1f77..8b6c79eb34 100644 --- a/src/connectors/range/__tests__/connectRange-test.js +++ b/src/connectors/range/__tests__/connectRange-test.js @@ -3,6 +3,7 @@ import jsHelper, { SearchParameters, } from 'algoliasearch-helper'; import connectRange from '../connectRange'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; describe('connectRange', () => { describe('Usage', () => { @@ -211,6 +212,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input attribute, }); + const instantSearchInstance = createInstantSearch(); const helper = jsHelper( {}, '', @@ -222,6 +224,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper, state: helper.state, createURL: () => '#', + instantSearchInstance, }); { @@ -260,6 +263,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input state: helper.state, helper, createURL: () => '#', + instantSearchInstance, }); { @@ -294,6 +298,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); { @@ -336,6 +341,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); { @@ -376,6 +382,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); { @@ -546,7 +553,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input const createHelper = () => { const helper = jsHelper({}); helper.search = jest.fn(); - jest.spyOn(helper, 'removeNumericRefinement'); + jest.spyOn(helper.state, 'removeNumericRefinement'); return helper; }; @@ -558,11 +565,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input attribute, }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalledWith(attribute); + expect(helper.state.removeNumericRefinement).toHaveBeenCalledWith( + attribute + ); expect(helper.search).toHaveBeenCalled(); }); @@ -572,11 +581,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input const helper = createHelper(); const widget = connectRange(rendering)({ attribute }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalledWith(attribute); + expect(helper.state.removeNumericRefinement).toHaveBeenCalledWith( + attribute + ); expect(helper.search).toHaveBeenCalled(); }); @@ -586,11 +597,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input const helper = createHelper(); const widget = connectRange(rendering)({ attribute }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).toHaveBeenCalled(); expect(helper.search).toHaveBeenCalled(); }); @@ -600,11 +611,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input const helper = createHelper(); const widget = connectRange(rendering)({ attribute }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([11]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([491]); - expect(helper.removeNumericRefinement).toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).toHaveBeenCalled(); expect(helper.search).toHaveBeenCalled(); }); @@ -617,11 +628,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input min: 10, }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).toHaveBeenCalled(); expect(helper.search).toHaveBeenCalled(); }); @@ -634,11 +645,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input max: 490, }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).toHaveBeenCalled(); expect(helper.search).toHaveBeenCalled(); }); @@ -651,11 +662,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '>=', 10); helper.addNumericRefinement(attribute, '<=', 490); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalledWith(attribute); + expect(helper.state.removeNumericRefinement).toHaveBeenCalledWith( + attribute + ); expect(helper.search).toHaveBeenCalled(); }); @@ -668,11 +681,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '>=', 10); helper.addNumericRefinement(attribute, '<=', 490); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([]); - expect(helper.removeNumericRefinement).toHaveBeenCalledWith(attribute); + expect(helper.state.removeNumericRefinement).toHaveBeenCalledWith( + attribute + ); expect(helper.search).toHaveBeenCalled(); }); @@ -685,11 +700,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '>=', 10); helper.addNumericRefinement(attribute, '<=', 490); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalledWith(attribute); + expect(helper.state.removeNumericRefinement).toHaveBeenCalledWith( + attribute + ); expect(helper.search).toHaveBeenCalled(); }); @@ -702,11 +719,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '>=', 10); helper.addNumericRefinement(attribute, '<=', 490); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([]); - expect(helper.removeNumericRefinement).toHaveBeenCalledWith(attribute); + expect(helper.state.removeNumericRefinement).toHaveBeenCalledWith( + attribute + ); expect(helper.search).toHaveBeenCalled(); }); @@ -718,11 +737,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '>=', 10); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalledWith(attribute); + expect(helper.state.removeNumericRefinement).toHaveBeenCalledWith( + attribute + ); expect(helper.search).toHaveBeenCalled(); }); @@ -734,11 +755,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '<=', 490); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([]); - expect(helper.removeNumericRefinement).toHaveBeenCalledWith(attribute); + expect(helper.state.removeNumericRefinement).toHaveBeenCalledWith( + attribute + ); expect(helper.search).toHaveBeenCalled(); }); @@ -753,11 +776,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '>=', 20); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).toHaveBeenCalled(); expect(helper.search).toHaveBeenCalled(); }); @@ -772,11 +795,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '>=', 240); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([250]); - expect(helper.removeNumericRefinement).toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).toHaveBeenCalled(); expect(helper.search).toHaveBeenCalled(); }); @@ -786,11 +809,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input const helper = createHelper(); const widget = connectRange(rendering)({ attribute }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual(undefined); expect(helper.getNumericRefinement(attribute, '<=')).toEqual(undefined); - expect(helper.removeNumericRefinement).not.toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).not.toHaveBeenCalled(); expect(helper.search).not.toHaveBeenCalled(); }); @@ -800,11 +823,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input const helper = createHelper(); const widget = connectRange(rendering)({ attribute }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual(undefined); expect(helper.getNumericRefinement(attribute, '<=')).toEqual(undefined); - expect(helper.removeNumericRefinement).not.toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).not.toHaveBeenCalled(); expect(helper.search).not.toHaveBeenCalled(); }); @@ -814,11 +837,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input const helper = createHelper(); const widget = connectRange(rendering)({ attribute }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual(undefined); expect(helper.getNumericRefinement(attribute, '<=')).toEqual(undefined); - expect(helper.removeNumericRefinement).not.toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).not.toHaveBeenCalled(); expect(helper.search).not.toHaveBeenCalled(); }); @@ -831,11 +854,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper.addNumericRefinement(attribute, '>=', 10); helper.addNumericRefinement(attribute, '<=', 490); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual([10]); expect(helper.getNumericRefinement(attribute, '<=')).toEqual([490]); - expect(helper.removeNumericRefinement).not.toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).not.toHaveBeenCalled(); expect(helper.search).not.toHaveBeenCalled(); }); @@ -845,11 +868,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input const helper = createHelper(); const widget = connectRange(rendering)({ attribute }); - widget._refine(helper, range)(values); + widget._refine(createInstantSearch(), helper, range)(values); expect(helper.getNumericRefinement(attribute, '>=')).toEqual(undefined); expect(helper.getNumericRefinement(attribute, '<=')).toEqual(undefined); - expect(helper.removeNumericRefinement).not.toHaveBeenCalled(); + expect(helper.state.removeNumericRefinement).not.toHaveBeenCalled(); expect(helper.search).not.toHaveBeenCalled(); }); }); @@ -910,6 +933,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-input helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); const renderOptions = rendering.mock.calls[0][0]; @@ -1391,7 +1415,7 @@ describe('getWidgetSearchParameters', () => { }); const attribute = 'price'; - const rendering = () => {}; + let rendering = () => {}; it('expect to return default configuration', () => { const widget = connectRange(rendering)({ @@ -1508,4 +1532,99 @@ describe('getWidgetSearchParameters', () => { expect(actual).toEqual(expectation); }); + + describe('insights', () => { + it('sends event when a facet is added at each step', () => { + rendering = jest.fn(); + const makeWidget = connectRange(rendering); + const widget = makeWidget({ + attribute, + }); + + const instantSearchInstance = createInstantSearch(); + const helper = jsHelper( + {}, + '', + widget.getWidgetSearchParameters(new SearchParameters(), { + uiState: {}, + }) + ); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + instantSearchInstance, + }); + + { + // first rendering + const renderOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; + const { refine } = renderOptions; + refine([10, 30]); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['price>=10', 'price<=30'], + index: '', + }, + widgetType: 'ais.range', + }); + } + + widget.render({ + results: new SearchResults(helper.state, [ + { + hits: [{ test: 'oneTime' }], + facets: { price: { 10: 1, 20: 1, 30: 1 } }, + // eslint-disable-next-line @typescript-eslint/camelcase + facets_stats: { + price: { + avg: 20, + max: 30, + min: 10, + sum: 60, + }, + }, + nbHits: 1, + nbPages: 1, + page: 0, + }, + {}, + ]), + state: helper.state, + helper, + createURL: () => '#', + instantSearchInstance, + }); + + { + // Second rendering + const renderOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; + const { refine } = renderOptions; + refine([23, 27]); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 2 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['price>=23', 'price<=27'], + index: '', + }, + widgetType: 'ais.range', + }); + } + }); + }); }); diff --git a/src/connectors/range/connectRange.js b/src/connectors/range/connectRange.js index 3be5aa1240..5cca726679 100644 --- a/src/connectors/range/connectRange.js +++ b/src/connectors/range/connectRange.js @@ -1,6 +1,7 @@ import { checkRendering, createDocumentationMessageGenerator, + convertNumericRefinementsToFilters, isFiniteNumber, find, noop, @@ -11,6 +12,8 @@ const withUsage = createDocumentationMessageGenerator( { name: 'range-slider', connector: true } ); +const $$type = 'ais.range'; + /** * @typedef {Object} CustomRangeWidgetOptions * @property {string} attribute Name of the attribute for faceting. @@ -71,8 +74,138 @@ export default function connectRange(renderFn, unmountFn = noop) { to: v => formatToNumber(v).toLocaleString(), }; + // eslint-disable-next-line complexity + const getRefinedState = (helper, currentRange, nextMin, nextMax) => { + let resolvedState = helper.state; + const { min: currentRangeMin, max: currentRangeMax } = currentRange; + + const [min] = resolvedState.getNumericRefinement(attribute, '>=') || []; + const [max] = resolvedState.getNumericRefinement(attribute, '<=') || []; + + const isResetMin = nextMin === undefined || nextMin === ''; + const isResetMax = nextMax === undefined || nextMax === ''; + + const nextMinAsNumber = !isResetMin ? parseFloat(nextMin) : undefined; + const nextMaxAsNumber = !isResetMax ? parseFloat(nextMax) : undefined; + + let newNextMin; + if (!hasMinBound && currentRangeMin === nextMinAsNumber) { + newNextMin = undefined; + } else if (hasMinBound && isResetMin) { + newNextMin = minBound; + } else { + newNextMin = nextMinAsNumber; + } + + let newNextMax; + if (!hasMaxBound && currentRangeMax === nextMaxAsNumber) { + newNextMax = undefined; + } else if (hasMaxBound && isResetMax) { + newNextMax = maxBound; + } else { + newNextMax = nextMaxAsNumber; + } + + const isResetNewNextMin = newNextMin === undefined; + const isValidNewNextMin = isFiniteNumber(newNextMin); + const isValidMinCurrentRange = isFiniteNumber(currentRangeMin); + const isGreaterThanCurrentRange = + isValidMinCurrentRange && currentRangeMin <= newNextMin; + const isMinValid = + isResetNewNextMin || + (isValidNewNextMin && + (!isValidMinCurrentRange || isGreaterThanCurrentRange)); + + const isResetNewNextMax = newNextMax === undefined; + const isValidNewNextMax = isFiniteNumber(newNextMax); + const isValidMaxCurrentRange = isFiniteNumber(currentRangeMax); + const isLowerThanRange = + isValidMaxCurrentRange && currentRangeMax >= newNextMax; + const isMaxValid = + isResetNewNextMax || + (isValidNewNextMax && (!isValidMaxCurrentRange || isLowerThanRange)); + + const hasMinChange = min !== newNextMin; + const hasMaxChange = max !== newNextMax; + + if ((hasMinChange || hasMaxChange) && isMinValid && isMaxValid) { + resolvedState = resolvedState.removeNumericRefinement(attribute); + + if (isValidNewNextMin) { + resolvedState = resolvedState.addNumericRefinement( + attribute, + '>=', + formatToNumber(newNextMin) + ); + } + + if (isValidNewNextMax) { + resolvedState = resolvedState.addNumericRefinement( + attribute, + '<=', + formatToNumber(newNextMax) + ); + } + + return resolvedState; + } + + return null; + }; + + const sendEventWithRefinedState = ( + refinedState, + instantSearchInstance, + helper, + eventName = 'Filter Applied' + ) => { + const filters = convertNumericRefinementsToFilters( + refinedState, + attribute + ); + if (filters && filters.length > 0) { + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'clickedFilters', + widgetType: $$type, + eventType: 'click', + payload: { + eventName, + index: helper.getIndex(), + filters, + }, + }); + } + }; + + const createSendEvent = (instantSearchInstance, helper, currentRange) => ( + ...args + ) => { + if (args.length === 1) { + instantSearchInstance.sendEventToInsights(args[0]); + return; + } + + const [eventType, facetValue, eventName] = args; + if (eventType !== 'click') { + return; + } + const [nextMin, nextMax] = facetValue; + const refinedState = getRefinedState( + helper, + currentRange, + nextMin, + nextMax + ); + sendEventWithRefinedState( + refinedState, + instantSearchInstance, + helper, + eventName + ); + }; + return { - $$type: 'ais.range', + $$type, _getCurrentRange(stats) { const pow = Math.pow(10, precision); @@ -112,81 +245,22 @@ export default function connectRange(renderFn, unmountFn = noop) { return [min, max]; }, - _refine(helper, currentRange) { + _refine(instantSearchInstance, helper, currentRange) { // eslint-disable-next-line complexity return ([nextMin, nextMax] = []) => { - const { min: currentRangeMin, max: currentRangeMax } = currentRange; - - const [min] = helper.getNumericRefinement(attribute, '>=') || []; - const [max] = helper.getNumericRefinement(attribute, '<=') || []; - - const isResetMin = nextMin === undefined || nextMin === ''; - const isResetMax = nextMax === undefined || nextMax === ''; - - const nextMinAsNumber = !isResetMin ? parseFloat(nextMin) : undefined; - const nextMaxAsNumber = !isResetMax ? parseFloat(nextMax) : undefined; - - let newNextMin; - if (!hasMinBound && currentRangeMin === nextMinAsNumber) { - newNextMin = undefined; - } else if (hasMinBound && isResetMin) { - newNextMin = minBound; - } else { - newNextMin = nextMinAsNumber; - } - - let newNextMax; - if (!hasMaxBound && currentRangeMax === nextMaxAsNumber) { - newNextMax = undefined; - } else if (hasMaxBound && isResetMax) { - newNextMax = maxBound; - } else { - newNextMax = nextMaxAsNumber; - } - - const isResetNewNextMin = newNextMin === undefined; - const isValidNewNextMin = isFiniteNumber(newNextMin); - const isValidMinCurrentRange = isFiniteNumber(currentRangeMin); - const isGreaterThanCurrentRange = - isValidMinCurrentRange && currentRangeMin <= newNextMin; - const isMinValid = - isResetNewNextMin || - (isValidNewNextMin && - (!isValidMinCurrentRange || isGreaterThanCurrentRange)); - - const isResetNewNextMax = newNextMax === undefined; - const isValidNewNextMax = isFiniteNumber(newNextMax); - const isValidMaxCurrentRange = isFiniteNumber(currentRangeMax); - const isLowerThanRange = - isValidMaxCurrentRange && currentRangeMax >= newNextMax; - const isMaxValid = - isResetNewNextMax || - (isValidNewNextMax && - (!isValidMaxCurrentRange || isLowerThanRange)); - - const hasMinChange = min !== newNextMin; - const hasMaxChange = max !== newNextMax; - - if ((hasMinChange || hasMaxChange) && isMinValid && isMaxValid) { - helper.removeNumericRefinement(attribute); - - if (isValidNewNextMin) { - helper.addNumericRefinement( - attribute, - '>=', - formatToNumber(newNextMin) - ); - } - - if (isValidNewNextMax) { - helper.addNumericRefinement( - attribute, - '<=', - formatToNumber(newNextMax) - ); - } - - helper.search(); + const refinedState = getRefinedState( + helper, + currentRange, + nextMin, + nextMax + ); + if (refinedState) { + sendEventWithRefinedState( + refinedState, + instantSearchInstance, + helper + ); + helper.setState(refinedState).search(); } }; }, @@ -201,7 +275,8 @@ export default function connectRange(renderFn, unmountFn = noop) { // On first render pass an empty range // to be able to bypass the validation // related to it - refine: this._refine(helper, {}), + refine: this._refine(instantSearchInstance, helper, {}), + sendEvent: createSendEvent(instantSearchInstance, helper, {}), format: rangeFormatter, range: currentRange, widgetParams: { @@ -228,7 +303,12 @@ export default function connectRange(renderFn, unmountFn = noop) { renderFn( { - refine: this._refine(helper, currentRange), + refine: this._refine(instantSearchInstance, helper, currentRange), + sendEvent: createSendEvent( + instantSearchInstance, + helper, + currentRange + ), format: rangeFormatter, range: currentRange, widgetParams: { diff --git a/src/connectors/rating-menu/__tests__/connectRatingMenu-test.js b/src/connectors/rating-menu/__tests__/connectRatingMenu-test.js index 985faedde1..ed9c979cb3 100644 --- a/src/connectors/rating-menu/__tests__/connectRatingMenu-test.js +++ b/src/connectors/rating-menu/__tests__/connectRatingMenu-test.js @@ -3,11 +3,13 @@ import jsHelper, { SearchParameters, } from 'algoliasearch-helper'; import connectRatingMenu from '../connectRatingMenu'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; describe('connectRatingMenu', () => { const getInitializedWidget = (config = {}, unmount) => { const rendering = jest.fn(); const makeWidget = connectRatingMenu(rendering, unmount); + const instantSearchInstance = createInstantSearch(); const attribute = 'grade'; const widget = makeWidget({ @@ -26,11 +28,12 @@ describe('connectRatingMenu', () => { helper, state: helper.state, createURL: () => '#', + instantSearchInstance, }); const { refine } = rendering.mock.calls[0][0]; - return { widget, helper, refine, rendering }; + return { widget, helper, refine, rendering, instantSearchInstance }; }; describe('Usage', () => { @@ -531,4 +534,60 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/rating-menu ); }); }); + + describe('insights', () => { + it('sends event when a facet is added', () => { + const attribute = 'swag'; + const { refine, instantSearchInstance } = getInitializedWidget({ + attribute, + }); + + refine('3'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['swag>=3'], + index: '', + }, + widgetType: 'ais.ratingMenu', + }); + }); + + it('does not send event when a facet is removed', () => { + const attribute = 'swag'; + const { refine, instantSearchInstance } = getInitializedWidget({ + attribute, + }); + + refine('3'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + + refine('3'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); // still the same + + refine('4'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 2 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['swag>=4'], + index: '', + }, + widgetType: 'ais.ratingMenu', + }); + }); + }); }); diff --git a/src/connectors/rating-menu/connectRatingMenu.js b/src/connectors/rating-menu/connectRatingMenu.js index 222ca694c5..96e66be139 100644 --- a/src/connectors/rating-menu/connectRatingMenu.js +++ b/src/connectors/rating-menu/connectRatingMenu.js @@ -10,6 +10,37 @@ const withUsage = createDocumentationMessageGenerator({ connector: true, }); +const $$type = 'ais.ratingMenu'; + +const createSendEvent = ({ + instantSearchInstance, + helper, + getRefinedStar, + attribute, +}) => (...args) => { + if (args.length === 1) { + instantSearchInstance.sendEventToInsights(args[0]); + return; + } + const [eventType, facetValue, eventName = 'Filter Applied'] = args; + if (eventType !== 'click') { + return; + } + const isRefined = getRefinedStar() === Number(facetValue); + if (!isRefined) { + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'clickedFilters', + widgetType: $$type, + eventType, + payload: { + eventName, + index: helper.getIndex(), + filters: [`${attribute}>=${facetValue}`], + }, + }); + } +}; + /** * @typedef {Object} StarRatingItems * @property {string} name Name corresponding to the number of stars. @@ -104,20 +135,30 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { throw new Error(withUsage('The `attribute` option is required.')); } + let sendEvent; + return { - $$type: 'ais.ratingMenu', + $$type, init({ helper, createURL, instantSearchInstance }) { this._toggleRefinement = this._toggleRefinement.bind(this, helper); this._createURL = state => facetValue => createURL(state.toggleRefinement(attribute, facetValue)); + sendEvent = createSendEvent({ + instantSearchInstance, + helper, + getRefinedStar: () => this._getRefinedStar(helper.state), + attribute, + }); + renderFn( { instantSearchInstance, items: [], hasNoResults: true, refine: this._toggleRefinement, + sendEvent, createURL: this._createURL(helper.state), widgetParams, }, @@ -167,6 +208,7 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { items: facetValues, hasNoResults: results.nbHits === 0, refine: this._toggleRefinement, + sendEvent, createURL: this._createURL(state), widgetParams, }, @@ -221,6 +263,7 @@ export default function connectRatingMenu(renderFn, unmountFn = noop) { }, _toggleRefinement(helper, facetValue) { + sendEvent('click', facetValue); const isRefined = this._getRefinedStar(helper.state) === Number(facetValue); helper.removeDisjunctiveFacetRefinement(attribute); diff --git a/src/connectors/refinement-list/__tests__/connectRefinementList-test.js b/src/connectors/refinement-list/__tests__/connectRefinementList-test.js index f09d1c2fff..989623b29f 100644 --- a/src/connectors/refinement-list/__tests__/connectRefinementList-test.js +++ b/src/connectors/refinement-list/__tests__/connectRefinementList-test.js @@ -2400,4 +2400,74 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/refinement- }); }); }); + + describe('insights', () => { + const createInitializedWidget = () => { + const factoryResult = createWidgetFactory(); + const makeWidget = factoryResult.makeWidget; + const rendering = factoryResult.rendering; + const instantSearchInstance = createInstantSearch(); + const widget = makeWidget({ + attribute: 'category', + }); + + const helper = jsHelper( + {}, + '', + widget.getWidgetSearchParameters(new SearchParameters({}), { + uiState: {}, + }) + ); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + instantSearchInstance, + }); + + return { + rendering, + instantSearchInstance, + }; + }; + + it('sends event when a facet is added', () => { + const { rendering, instantSearchInstance } = createInitializedWidget(); + const firstRenderingOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; + const { refine } = firstRenderingOptions; + refine('value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['category:"value"'], + index: '', + }, + widgetType: 'ais.refinementList', + }); + }); + + it('does not send event when a facet is removed', () => { + const { rendering, instantSearchInstance } = createInitializedWidget(); + const firstRenderingOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; + const { refine } = firstRenderingOptions; + refine('value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + + refine('value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); // still the same + }); + }); }); diff --git a/src/connectors/toggle-refinement/__tests__/connectToggleRefinement-test.js b/src/connectors/toggle-refinement/__tests__/connectToggleRefinement-test.js index dea722b65e..2b5f9fdf9f 100644 --- a/src/connectors/toggle-refinement/__tests__/connectToggleRefinement-test.js +++ b/src/connectors/toggle-refinement/__tests__/connectToggleRefinement-test.js @@ -3,6 +3,7 @@ import jsHelper, { SearchParameters, } from 'algoliasearch-helper'; import connectToggleRefinement from '../connectToggleRefinement'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; describe('connectToggleRefinement', () => { describe('Usage', () => { @@ -299,6 +300,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/toggle-refi helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); { @@ -445,6 +447,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/toggle-refi helper, state: helper.state, createURL: () => '#', + instantSearchInstance: createInstantSearch(), }); { @@ -673,7 +676,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/toggle-refi ); helper.search = jest.fn(); - widget.init({ helper, state: helper.state }); + widget.init({ + helper, + state: helper.state, + instantSearchInstance: createInstantSearch(), + }); expect(helper.state.disjunctiveFacetsRefinements).toEqual({ whatever: [], @@ -724,7 +731,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/toggle-refi ); helper.search = jest.fn(); - widget.init({ helper, state: helper.state }); + widget.init({ + helper, + state: helper.state, + instantSearchInstance: createInstantSearch(), + }); expect(helper.state.disjunctiveFacetsRefinements).toEqual({ whatever: [], @@ -1080,4 +1091,72 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/toggle-refi }); }); }); + + describe('insights', () => { + const createInitializedWidget = () => { + const rendering = jest.fn(); + const instantSearchInstance = createInstantSearch(); + const makeWidget = connectToggleRefinement(rendering); + + const attribute = 'isShippingFree'; + const widget = makeWidget({ + attribute, + }); + + const helper = jsHelper( + {}, + '', + widget.getWidgetSearchParameters(new SearchParameters({}), { + uiState: {}, + }) + ); + helper.search = jest.fn(); + + widget.init({ + helper, + state: helper.state, + createURL: () => '#', + instantSearchInstance, + }); + + return { rendering, helper, instantSearchInstance, widget }; + }; + + it('sends event when a facet is added', () => { + const { rendering, instantSearchInstance } = createInitializedWidget(); + const renderOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; + const { refine } = renderOptions; + refine({ isRefined: false }); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['isShippingFree:true'], + index: '', + }, + widgetType: 'ais.toggleRefinement', + }); + }); + + it('does not send event when a facet is removed', () => { + const { rendering, instantSearchInstance } = createInitializedWidget(); + const renderOptions = + rendering.mock.calls[rendering.mock.calls.length - 1][0]; + const { refine } = renderOptions; + refine({ isRefined: false }); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + + refine({ isRefined: true }); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); // still the same + }); + }); }); diff --git a/src/connectors/toggle-refinement/connectToggleRefinement.js b/src/connectors/toggle-refinement/connectToggleRefinement.js index 057780fe98..6bafdc3e5b 100644 --- a/src/connectors/toggle-refinement/connectToggleRefinement.js +++ b/src/connectors/toggle-refinement/connectToggleRefinement.js @@ -13,6 +13,34 @@ const withUsage = createDocumentationMessageGenerator({ connector: true, }); +const $$type = 'ais.toggleRefinement'; + +const createSendEvent = ({ instantSearchInstance, attribute, on, helper }) => ( + ...args +) => { + if (args.length === 1) { + instantSearchInstance.sendEventToInsights(args[0]); + return; + } + const [eventType, isRefined, eventName = 'Filter Applied'] = args; + if (eventType !== 'click' || on === undefined) { + return; + } + // Checking + if (!isRefined) { + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'clickedFilters', + widgetType: $$type, + eventType, + payload: { + eventName, + index: helper.getIndex(), + filters: on.map(value => `${attribute}:${JSON.stringify(value)}`), + }, + }); + } +}; + /** * @typedef {Object} ToggleValue * @property {boolean} isRefined `true` if the toggle is on. @@ -105,12 +133,15 @@ export default function connectToggleRefinement(renderFn, unmountFn = noop) { ? toArray(userOff).map(escapeRefinement) : undefined; + let sendEvent; + return { - $$type: 'ais.toggleRefinement', + $$type, _toggleRefinement(helper, { isRefined } = {}) { // Checking if (!isRefined) { + sendEvent('click', isRefined); if (hasAnOffValue) { off.forEach(v => helper.removeDisjunctiveFacetRefinement(attribute, v) @@ -135,6 +166,13 @@ export default function connectToggleRefinement(renderFn, unmountFn = noop) { }, init({ state, helper, createURL, instantSearchInstance }) { + sendEvent = createSendEvent({ + instantSearchInstance, + attribute, + on, + helper, + }); + this._createURL = isCurrentlyRefined => () => { const valuesToRemove = isCurrentlyRefined ? on : off; if (valuesToRemove) { @@ -198,6 +236,7 @@ export default function connectToggleRefinement(renderFn, unmountFn = noop) { value, createURL: this._createURL(value.isRefined), refine: this.toggleRefinement, + sendEvent, instantSearchInstance, widgetParams, }, @@ -265,6 +304,7 @@ export default function connectToggleRefinement(renderFn, unmountFn = noop) { state, createURL: this._createURL(value.isRefined), refine: this.toggleRefinement, + sendEvent, helper, instantSearchInstance, widgetParams, diff --git a/src/lib/insights/listener.tsx b/src/lib/insights/listener.tsx index 6a1361eb20..58c2ea7d7b 100644 --- a/src/lib/insights/listener.tsx +++ b/src/lib/insights/listener.tsx @@ -3,7 +3,7 @@ import { h } from 'preact'; import { readDataAttributes, hasDataAttributes } from '../../helpers/insights'; import { InsightsClientWrapper } from '../../types'; -import { InsightsEvent } from '../../middleware/insights'; +import { InsightsEvent } from '../../middlewares/createInsightsMiddleware'; type WithInsightsListenerProps = { [key: string]: unknown; diff --git a/src/lib/utils/__tests__/createSendEventForFacet-test.ts b/src/lib/utils/__tests__/createSendEventForFacet-test.ts new file mode 100644 index 0000000000..6d4e2d2428 --- /dev/null +++ b/src/lib/utils/__tests__/createSendEventForFacet-test.ts @@ -0,0 +1,114 @@ +import algoliasearchHelper from 'algoliasearch-helper'; +import { createSendEventForFacet } from '../createSendEventForFacet'; +import { SearchClient } from '../../../types'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; + +jest.mock('../isFacetRefined', () => jest.fn()); + +import isFacetRefined from '../isFacetRefined'; + +const createTestEnvironment = () => { + const instantSearchInstance = createInstantSearch(); + const helper = algoliasearchHelper({} as SearchClient, '', {}); + (isFacetRefined as jest.Mock).mockImplementation(() => false); + const sendEvent = createSendEventForFacet({ + instantSearchInstance, + helper, + attribute: 'category', + widgetType: 'ais.customWidget', + }); + + return { + instantSearchInstance, + helper, + sendEvent, + }; +}; + +describe('createSendEventForFacet', () => { + describe('Usage', () => { + it('throws when facetValue is missing', () => { + const { sendEvent } = createTestEnvironment(); + expect(() => { + sendEvent('click'); + }).toThrowErrorMatchingInlineSnapshot(` +"You need to pass two arguments like: + sendEvent('click', facetValue); + +If you want to send a custom payload, you can pass one object: sendEvent(customPayload); +" +`); + }); + + it('throws with unknown eventType', () => { + const { sendEvent } = createTestEnvironment(); + expect(() => { + sendEvent('my custom event type'); + }).toThrowErrorMatchingInlineSnapshot(` +"You need to pass two arguments like: + sendEvent('click', facetValue); + +If you want to send a custom payload, you can pass one object: sendEvent(customPayload); +" +`); + }); + + it('throws when eventType is not click', () => { + const { sendEvent } = createTestEnvironment(); + expect(() => { + sendEvent('custom event type'); + }).toThrowErrorMatchingInlineSnapshot(` +"You need to pass two arguments like: + sendEvent('click', facetValue); + +If you want to send a custom payload, you can pass one object: sendEvent(customPayload); +" +`); + }); + + it('does not send event when a facet is removed', () => { + const { sendEvent, instantSearchInstance } = createTestEnvironment(); + (isFacetRefined as jest.Mock).mockImplementation(() => true); + sendEvent('click', 'value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 0 + ); + }); + + it('sends with default eventName', () => { + const { sendEvent, instantSearchInstance } = createTestEnvironment(); + sendEvent('click', 'value'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Filter Applied', + filters: ['category:"value"'], + index: '', + }, + widgetType: 'ais.customWidget', + }); + }); + + it('sends with custom eventName', () => { + const { sendEvent, instantSearchInstance } = createTestEnvironment(); + sendEvent('click', 'value', 'Category Clicked'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes( + 1 + ); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedFilters', + payload: { + eventName: 'Category Clicked', + filters: ['category:"value"'], + index: '', + }, + widgetType: 'ais.customWidget', + }); + }); + }); +}); diff --git a/src/lib/utils/__tests__/createSendEventForHits-test.ts b/src/lib/utils/__tests__/createSendEventForHits-test.ts new file mode 100644 index 0000000000..a793b1de84 --- /dev/null +++ b/src/lib/utils/__tests__/createSendEventForHits-test.ts @@ -0,0 +1,229 @@ +import { + createSendEventForHits, + createBindEventForHits, +} from '../createSendEventForHits'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; + +const createTestEnvironment = () => { + const instantSearchInstance = createInstantSearch(); + const index = 'testIndex'; + const widgetType = 'ais.testWidget'; + const hits = [ + { + objectID: 'obj0', + __position: 0, + __queryID: 'test-query-id', + }, + { + objectID: 'obj1', + __position: 1, + __queryID: 'test-query-id', + }, + ]; + const sendEvent = createSendEventForHits({ + instantSearchInstance, + index, + widgetType, + }); + const bindEvent = createBindEventForHits({ + index, + widgetType, + }); + return { + instantSearchInstance, + index, + widgetType, + hits, + sendEvent, + bindEvent, + }; +}; + +describe('createSendEventForHits', () => { + describe('Usage', () => { + it('throws when hit is missing', () => { + const { sendEvent } = createTestEnvironment(); + expect(() => { + sendEvent('click'); + }).toThrowErrorMatchingInlineSnapshot(` +"You need to pass hit or hits as the second argument like: + sendEvent(eventType, hit); + " +`); + }); + + it('throws with unknown eventType', () => { + const { sendEvent } = createTestEnvironment(); + expect(() => { + sendEvent('my custom event type'); + }).toThrowErrorMatchingInlineSnapshot(` +"You need to pass hit or hits as the second argument like: + sendEvent(eventType, hit); + " +`); + }); + + it('throw when eventName is missing for click or conversion event', () => { + const { sendEvent } = createTestEnvironment(); + expect(() => { + sendEvent('click', {} as any); + }).toThrowErrorMatchingInlineSnapshot(` +"You need to pass eventName as the third argument for 'click' or 'conversion' events like: + sendEvent('click', hit, 'Product Purchased'); + + To learn more about event naming: https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/in-depth/clicks-conversions-best-practices/ + " +`); + + expect(() => { + sendEvent('conversion', {} as any); + }).toThrowErrorMatchingInlineSnapshot(` +"You need to pass eventName as the third argument for 'click' or 'conversion' events like: + sendEvent('click', hit, 'Product Purchased'); + + To learn more about event naming: https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/in-depth/clicks-conversions-best-practices/ + " +`); + }); + }); + + it('sends view event with default eventName', () => { + const { sendEvent, instantSearchInstance, hits } = createTestEnvironment(); + sendEvent('view', hits[0]); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes(1); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: 'testIndex', + objectIDs: ['obj0'], + }, + widgetType: 'ais.testWidget', + }); + }); + + it('sends view event with custom eventName', () => { + const { sendEvent, instantSearchInstance, hits } = createTestEnvironment(); + sendEvent('view', hits[0], 'Products Displayed'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes(1); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Products Displayed', + index: 'testIndex', + objectIDs: ['obj0'], + }, + widgetType: 'ais.testWidget', + }); + }); + + it('sends view event with multiple hits', () => { + const { sendEvent, instantSearchInstance, hits } = createTestEnvironment(); + sendEvent('view', hits); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes(1); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: 'testIndex', + objectIDs: ['obj0', 'obj1'], + }, + widgetType: 'ais.testWidget', + }); + }); + + it('sends click event', () => { + const { sendEvent, instantSearchInstance, hits } = createTestEnvironment(); + sendEvent('click', hits[0], 'Product Clicked'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes(1); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Product Clicked', + index: 'testIndex', + objectIDs: ['obj0'], + positions: [0], + queryID: 'test-query-id', + }, + widgetType: 'ais.testWidget', + }); + }); + + it('sends conversion event', () => { + const { sendEvent, instantSearchInstance, hits } = createTestEnvironment(); + sendEvent('conversion', hits[0], 'Product Ordered'); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes(1); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: 'testIndex', + objectIDs: ['obj0'], + queryID: 'test-query-id', + }, + widgetType: 'ais.testWidget', + }); + }); + + it('sends custom event', () => { + const { sendEvent, instantSearchInstance } = createTestEnvironment(); + sendEvent({ + hello: 'world', + custom: 'event', + }); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledTimes(1); + expect(instantSearchInstance.sendEventToInsights).toHaveBeenCalledWith({ + hello: 'world', + custom: 'event', + }); + }); +}); + +describe('createBindEventForHits', () => { + function parsePayload(payload) { + expect(payload.startsWith('data-insights-event=')).toBe(true); + return JSON.parse(atob(payload.substr('data-insights-event='.length))); + } + + it('returns a payload for click event', () => { + const { bindEvent, hits } = createTestEnvironment(); + const parsedPayload = parsePayload( + bindEvent('click', hits[0], 'Product Clicked') + ); + expect(parsedPayload).toEqual({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Product Clicked', + index: 'testIndex', + objectIDs: ['obj0'], + positions: [0], + queryID: 'test-query-id', + }, + widgetType: 'ais.testWidget', + }); + }); + + it('returns a payload for conversion event', () => { + const { bindEvent, hits } = createTestEnvironment(); + const parsedPayload = parsePayload( + bindEvent('conversion', hits[0], 'Product Ordered') + ); + expect(parsedPayload).toEqual({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: 'testIndex', + objectIDs: ['obj0'], + queryID: 'test-query-id', + }, + widgetType: 'ais.testWidget', + }); + }); +}); diff --git a/src/lib/utils/convertNumericRefinementsToFilters.ts b/src/lib/utils/convertNumericRefinementsToFilters.ts new file mode 100644 index 0000000000..9487174fbd --- /dev/null +++ b/src/lib/utils/convertNumericRefinementsToFilters.ts @@ -0,0 +1,30 @@ +import { SearchParameters } from 'algoliasearch-helper'; + +export function convertNumericRefinementsToFilters( + state: SearchParameters | null, + attribute: string +) { + if (!state) { + return null; + } + const filtersObj = state.numericRefinements[attribute]; + /* + filtersObj === { + "<=": [10], + "=": [], + ">=": [5] + } + */ + const filters: string[] = []; + Object.keys(filtersObj) + .filter( + operator => + Array.isArray(filtersObj[operator]) && filtersObj[operator].length > 0 + ) + .forEach(operator => { + filtersObj[operator].forEach(value => { + filters.push(`${attribute}${operator}${value}`); + }); + }); + return filters; +} diff --git a/src/lib/utils/createSendEventForFacet.ts b/src/lib/utils/createSendEventForFacet.ts index ac9b8d5f1f..57d60b0836 100644 --- a/src/lib/utils/createSendEventForFacet.ts +++ b/src/lib/utils/createSendEventForFacet.ts @@ -2,12 +2,17 @@ import { AlgoliaSearchHelper } from 'algoliasearch-helper'; import { InstantSearch } from '../../types'; import isFacetRefined from './isFacetRefined'; -type BuiltInSendEventForFacet = (eventType: string, facetValue: string) => void; +type BuiltInSendEventForFacet = ( + eventType: string, + facetValue: string, + eventName?: string +) => void; type CustomSendEventForFacet = (customPayload: any) => void; -type SendEventForFacet = BuiltInSendEventForFacet & CustomSendEventForFacet; +export type SendEventForFacet = BuiltInSendEventForFacet & + CustomSendEventForFacet; -export default function createSendEventForFacet({ +export function createSendEventForFacet({ instantSearchInstance, helper, attribute, @@ -20,7 +25,12 @@ export default function createSendEventForFacet({ }): SendEventForFacet { const sendEventForFacet: SendEventForFacet = (...args) => { const [eventType, facetValue, eventName = 'Filter Applied'] = args; - if (eventType === 'click' && (args.length === 2 || args.length === 3)) { + if (args.length === 1 && typeof args[0] === 'object') { + instantSearchInstance.sendEventToInsights(args[0]); + } else if ( + eventType === 'click' && + (args.length === 2 || args.length === 3) + ) { if (!isFacetRefined(helper, attribute, facetValue)) { // send event only when the facet is being checked "ON" instantSearchInstance.sendEventToInsights({ @@ -34,12 +44,10 @@ export default function createSendEventForFacet({ }, }); } - } else if (args.length === 1) { - instantSearchInstance.sendEventToInsights(args[0]); } else if (__DEV__) { throw new Error( `You need to pass two arguments like: -sendEvent('click', facetValue); + sendEvent('click', facetValue); If you want to send a custom payload, you can pass one object: sendEvent(customPayload); ` diff --git a/src/lib/utils/createSendEventForHits.ts b/src/lib/utils/createSendEventForHits.ts index 95cc1293ce..2d654d0718 100644 --- a/src/lib/utils/createSendEventForHits.ts +++ b/src/lib/utils/createSendEventForHits.ts @@ -1,6 +1,5 @@ -import { AlgoliaSearchHelper } from 'algoliasearch-helper'; import { InstantSearch, Hit } from '../../types'; -import { InsightsEvent } from '../../middleware/insights'; +import { InsightsEvent } from '../../middlewares/createInsightsMiddleware'; type BuiltInSendEventForHits = ( eventType: string, @@ -20,18 +19,18 @@ export type BindEventForHits = BuiltInBindEventForHits & CustomBindEventForHits; type BuildPayload = (options: { widgetType: string; - helper: AlgoliaSearchHelper; + index: string; methodName: 'sendEvent' | 'bindEvent'; args: any[]; }) => InsightsEvent | null; const buildPayload: BuildPayload = ({ - helper, + index, widgetType, methodName, args, }) => { - if (args.length === 1) { + if (args.length === 1 && typeof args[0] === 'object') { return args[0]; } const eventType: string = args[0]; @@ -62,10 +61,12 @@ const buildPayload: BuildPayload = ({ } } const hitsArray = Array.isArray(hits) ? hits : [hits]; + if (hitsArray.length === 0) { + return null; + } const queryID = hitsArray[0].__queryID; const objectIDs = hitsArray.map(hit => hit.objectID); const positions = hitsArray.map(hit => hit.__position); - const index = helper.getIndex(); if (eventType === 'view') { return { @@ -114,17 +115,17 @@ const buildPayload: BuildPayload = ({ export function createSendEventForHits({ instantSearchInstance, - helper, + index, widgetType, }: { instantSearchInstance: InstantSearch; - helper: AlgoliaSearchHelper; + index: string; widgetType: string; }): SendEventForHits { const sendEventForHits: SendEventForHits = (...args) => { const payload = buildPayload({ widgetType, - helper, + index, methodName: 'sendEvent', args, }); @@ -136,16 +137,16 @@ export function createSendEventForHits({ } export function createBindEventForHits({ - helper, + index, widgetType, }: { - helper: AlgoliaSearchHelper; + index: string; widgetType: string; }): BindEventForHits { const bindEventForHits: BindEventForHits = (...args) => { const payload = buildPayload({ widgetType, - helper, + index, methodName: 'bindEvent', args, }); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 207eb3e227..1751cb6e90 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -37,11 +37,7 @@ export { export { addAbsolutePosition } from './hits-absolute-position'; export { addQueryID } from './hits-query-id'; export { default as isFacetRefined } from './isFacetRefined'; -export { default as createSendEventForFacet } from './createSendEventForFacet'; -export { - createSendEventForHits, - createBindEventForHits, - SendEventForHits, - BindEventForHits, -} from './createSendEventForHits'; +export * from './createSendEventForFacet'; +export * from './createSendEventForHits'; export { getAppIdAndApiKey } from './getAppIdAndApiKey'; +export { convertNumericRefinementsToFilters } from './convertNumericRefinementsToFilters'; diff --git a/src/lib/utils/isFacetRefined.ts b/src/lib/utils/isFacetRefined.ts index fbea8eef9f..6947a844a5 100644 --- a/src/lib/utils/isFacetRefined.ts +++ b/src/lib/utils/isFacetRefined.ts @@ -5,7 +5,9 @@ export default function isFacetRefined( facet: string, value: string ) { - if (helper.state.isConjunctiveFacet(facet)) { + if (helper.state.isHierarchicalFacet(facet)) { + return helper.state.isHierarchicalFacetRefined(facet, value); + } else if (helper.state.isConjunctiveFacet(facet)) { return helper.state.isFacetRefined(facet, value); } else { return helper.state.isDisjunctiveFacetRefined(facet, value); diff --git a/src/middlewares/__tests__/createInsightsMiddleware.ts b/src/middlewares/__tests__/createInsightsMiddleware.ts new file mode 100644 index 0000000000..1cd291fa49 --- /dev/null +++ b/src/middlewares/__tests__/createInsightsMiddleware.ts @@ -0,0 +1,348 @@ +import algoliasearch from 'algoliasearch'; +import algoliasearchHelper from 'algoliasearch-helper'; +import { createInsightsMiddleware } from '../createInsightsMiddleware'; +import { createInstantSearch } from '../../../test/mock/createInstantSearch'; +import { + createAlgoliaAnalytics, + createInsightsClient, + createInsightsUmdVersion, + ANONYMOUS_TOKEN, +} from '../../../test/mock/createInsightsClient'; +import { warning } from '../../lib/utils'; +import { SearchClient } from '../../types'; +import { Index } from '../../widgets/index/index'; + +describe('insights', () => { + const createTestEnvironment = () => { + const analytics = createAlgoliaAnalytics(); + const insightsClient = jest.fn(createInsightsClient(analytics)); + const instantSearchInstance = createInstantSearch({ + client: algoliasearch('myAppId', 'myApiKey'), + }); + const helper = algoliasearchHelper({} as SearchClient, ''); + const getUserToken = () => { + return (helper.state as any).userToken; + }; + instantSearchInstance.mainIndex = { + getHelper: () => helper, + } as Index; + + return { + analytics, + insightsClient, + instantSearchInstance, + helper, + getUserToken, + }; + }; + + beforeEach(() => { + warning.cache = {}; + }); + + describe('usage', () => { + it('throws when insightsClient is not given', () => { + expect(() => + // @ts-ignore:next-line + createInsightsMiddleware() + ).toThrowErrorMatchingInlineSnapshot( + `"The \`insightsClient\` option is required if you want userToken to be automatically set in search calls. If you don't want this behaviour, set it to \`null\`."` + ); + }); + + it('passes with insightsClient: null', () => { + expect(() => + createInsightsMiddleware({ + insightsClient: null, + }) + ).not.toThrow(); + }); + }); + + describe('initialize', () => { + it('initialize insightsClient', () => { + const { insightsClient, instantSearchInstance } = createTestEnvironment(); + expect.assertions(3); + + insightsClient('setUserToken', 'abc'); + createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + insightsClient('_get', '_hasCredentials', hasCredentials => { + expect(hasCredentials).toBe(true); + }); + insightsClient('_get', '_appId', appId => { + expect(appId).toBe('myAppId'); + }); + insightsClient('_get', '_apiKey', apiKey => { + expect(apiKey).toBe('myApiKey'); + }); + }); + + it('warns dev if userToken is set before creating the middleware', () => { + const { insightsClient, instantSearchInstance } = createTestEnvironment(); + insightsClient('setUserToken', 'abc'); + expect(() => { + createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + }) + .toWarnDev(`[InstantSearch.js]: You set userToken before \`createInsightsMiddleware()\` and it is ignored. +Please set the token after the \`createInsightsMiddleware()\` call. + +createInsightsMiddleware({ /* ... */ }); + +insightsClient('setUserToken', 'your-user-token'); +// or +aa('setUserToken', 'your-user-token');`); + }); + + it('applies clickAnalytics', () => { + const { + insightsClient, + instantSearchInstance, + helper, + } = createTestEnvironment(); + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + expect(helper.state.clickAnalytics).toBe(true); + }); + }); + + describe('userToken', () => { + it('applies userToken which was set before subscribe()', () => { + const { + insightsClient, + instantSearchInstance, + getUserToken, + } = createTestEnvironment(); + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + insightsClient('setUserToken', 'abc'); + middleware.subscribe(); + expect(getUserToken()).toEqual('abc'); + }); + + it('applies userToken which was set after subscribe()', () => { + const { + insightsClient, + instantSearchInstance, + getUserToken, + } = createTestEnvironment(); + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + insightsClient('setUserToken', 'def'); + expect(getUserToken()).toEqual('def'); + }); + + it('applies userToken from cookie when nothing given', () => { + const { + insightsClient, + instantSearchInstance, + getUserToken, + } = createTestEnvironment(); + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + expect(getUserToken()).toEqual(ANONYMOUS_TOKEN); + }); + + it('ignores userToken set before init', () => { + const { + insightsClient, + instantSearchInstance, + getUserToken, + } = createTestEnvironment(); + + insightsClient('setUserToken', 'token-from-queue-before-init'); + + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + expect(getUserToken()).toEqual(ANONYMOUS_TOKEN); + }); + + describe('umd', () => { + const createUmdTestEnvironment = () => { + const { + insightsClient, + libraryLoadedAndProcessQueue, + } = createInsightsUmdVersion(); + const instantSearchInstance = createInstantSearch({ + client: algoliasearch('myAppId', 'myApiKey'), + }); + const helper = algoliasearchHelper({} as SearchClient, ''); + const getUserToken = () => { + return (helper.state as any).userToken; + }; + instantSearchInstance.mainIndex = { + getHelper: () => helper, + } as Index; + return { + insightsClient, + libraryLoadedAndProcessQueue, + instantSearchInstance, + helper, + getUserToken, + }; + }; + it('applies userToken from queue if exists', () => { + const { + insightsClient, + libraryLoadedAndProcessQueue, + instantSearchInstance, + getUserToken, + } = createUmdTestEnvironment(); + + // call init and setUserToken even before the library is loaded. + insightsClient('init', { appId: 'myAppId', apiKey: 'myApiKey' }); + insightsClient('setUserToken', 'token-from-queue'); + libraryLoadedAndProcessQueue(); + + insightsClient('_get', '_userToken', userToken => { + expect(userToken).toEqual('token-from-queue'); + }); + + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + expect(getUserToken()).toEqual('token-from-queue'); + }); + + it('applies userToken from queue even though the queue is not processed', () => { + const { + insightsClient, + instantSearchInstance, + getUserToken, + } = createUmdTestEnvironment(); + + // call init and setUserToken even before the library is loaded. + insightsClient('init', { appId: 'myAppId', apiKey: 'myApiKey' }); + insightsClient('setUserToken', 'token-from-queue'); + + insightsClient('_get', '_userToken', userToken => { + expect(userToken).toEqual('token-from-queue'); + }); + + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + expect(getUserToken()).toEqual('token-from-queue'); + }); + + it('ignores userToken set before init', () => { + const { + insightsClient, + instantSearchInstance, + libraryLoadedAndProcessQueue, + getUserToken, + } = createUmdTestEnvironment(); + + insightsClient('setUserToken', 'token-from-queue-before-init'); + insightsClient('init', { appId: 'myAppId', apiKey: 'myApiKey' }); + libraryLoadedAndProcessQueue(); + + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + expect(getUserToken()).toEqual(ANONYMOUS_TOKEN); + }); + }); + }); + + describe('sendEventToInsights', () => { + it('sends events', () => { + const { + insightsClient, + instantSearchInstance, + analytics, + } = createTestEnvironment(); + + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'viewedObjectIDs', + widgetType: 'ais.customWidget', + eventType: 'click', + payload: { + hello: 'world', + }, + }); + expect(analytics.viewedObjectIDs).toHaveBeenCalledTimes(1); + expect(analytics.viewedObjectIDs).toHaveBeenCalledWith({ + hello: 'world', + }); + }); + + it('calls onEvent when given', () => { + const { + insightsClient, + instantSearchInstance, + analytics, + } = createTestEnvironment(); + + const onEvent = jest.fn(); + const middleware = createInsightsMiddleware({ + insightsClient, + onEvent, + })({ instantSearchInstance }); + middleware.subscribe(); + + instantSearchInstance.sendEventToInsights({ + insightsMethod: 'viewedObjectIDs', + widgetType: 'ais.customWidget', + eventType: 'click', + payload: { + hello: 'world', + }, + }); + expect(analytics.viewedObjectIDs).toHaveBeenCalledTimes(0); + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith( + { + insightsMethod: 'viewedObjectIDs', + widgetType: 'ais.customWidget', + eventType: 'click', + payload: { + hello: 'world', + }, + }, + insightsClient + ); + }); + + it('warns dev when neither insightsMethod nor onEvent is given', () => { + const { insightsClient, instantSearchInstance } = createTestEnvironment(); + + const middleware = createInsightsMiddleware({ + insightsClient, + })({ instantSearchInstance }); + middleware.subscribe(); + + const numberOfCalls = insightsClient.mock.calls.length; + expect(() => { + instantSearchInstance.sendEventToInsights({ + widgetType: 'ais.customWidget', + eventType: 'click', + payload: { + hello: 'world', + }, + }); + }).toWarnDev(); + expect(insightsClient).toHaveBeenCalledTimes(numberOfCalls); // still the same + }); + }); +}); diff --git a/src/middlewares/createInsightsMiddleware.ts b/src/middlewares/createInsightsMiddleware.ts index 10ba220062..077affc4ba 100644 --- a/src/middlewares/createInsightsMiddleware.ts +++ b/src/middlewares/createInsightsMiddleware.ts @@ -11,13 +11,16 @@ export type InsightsEvent = { export type InsightsProps = { insightsClient: null | InsightsClient; - onEvent?: (event: InsightsEvent) => void; + onEvent?: ( + event: InsightsEvent, + insightsClient: null | InsightsClient + ) => void; }; export type CreateInsightsMiddleware = (props: InsightsProps) => Middleware; export const createInsightsMiddleware: CreateInsightsMiddleware = props => { - const { insightsClient: _insightsClient, onEvent } = props; + const { insightsClient: _insightsClient, onEvent } = props || {}; if (_insightsClient !== null && !_insightsClient) { if (__DEV__) { throw new Error( @@ -83,10 +86,13 @@ aa('setUserToken', 'your-user-token'); // Context: The umd build of search-insights is asynchronously loaded by the snippet. // // When user calls `aa('setUserToken', 'my-user-token')` before `search-insights` is loaded, - // it is stored in `aa.queue` and we are reading it to set userToken to search call. - // This queue is meant to be consumed whenever `search-insights` is loaded and when it runs `processQueue()`. - // But the reason why we handle it here is to prevent the first search API from being triggered - // without userToken because search-insights is not loaded yet. + // ['setUserToken', 'my-user-token'] gets stored in `aa.queue`. + // Whenever `search-insights` is finally loaded, it will process the queue. + // + // But here's the reason why we handle it here: + // At this point, even though `search-insights` is not loaded yet, + // we still want to read the token from the queue. + // Otherwise, the first search call will be fired without the token. (insightsClient as any).queue.forEach(([method, firstArgument]) => { if (method === 'setUserToken') { setUserTokenToSearch(firstArgument); @@ -101,7 +107,7 @@ aa('setUserToken', 'your-user-token'); instantSearchInstance.sendEventToInsights = (event: InsightsEvent) => { if (onEvent) { - onEvent(event); + onEvent(event, _insightsClient); } else if (event.insightsMethod) { insightsClient(event.insightsMethod, event.payload); } else { diff --git a/src/types/widget.ts b/src/types/widget.ts index b7ff1ba02d..164b75e05c 100644 --- a/src/types/widget.ts +++ b/src/types/widget.ts @@ -6,6 +6,7 @@ import { PlainSearchParameters, } from 'algoliasearch-helper'; import { InstantSearch } from './instantsearch'; +import { BindEventForHits } from '../lib/utils'; export type InitOptions = { instantSearchInstance: InstantSearch; @@ -205,3 +206,7 @@ export type WidgetFactory = ( export type Template = | string | ((data: TTemplateData) => string); + +export type TemplateWithBindEvent = + | string + | ((data: TTemplateData, bindEvent: BindEventForHits) => string); diff --git a/src/widgets/geo-search/__tests__/geo-search-test.js b/src/widgets/geo-search/__tests__/geo-search-test.js index 3d4d9a03aa..4e41226f4a 100644 --- a/src/widgets/geo-search/__tests__/geo-search-test.js +++ b/src/widgets/geo-search/__tests__/geo-search-test.js @@ -3,6 +3,7 @@ import algoliasearchHelper from 'algoliasearch-helper'; import createHTMLMarker from '../createHTMLMarker'; import renderer from '../GeoSearchRenderer'; import geoSearch from '../geo-search'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; jest.mock('preact', () => { const module = require.requireActual('preact'); @@ -102,7 +103,8 @@ describe('GeoSearch', () => { }); const createContainer = () => document.createElement('div'); - const createFakeInstantSearch = () => ({ templatesConfig: undefined }); + const createFakeInstantSearch = () => + createInstantSearch({ templatesConfig: undefined }); const createFakeHelper = () => algoliasearchHelper( { diff --git a/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js b/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js index ed73f66ded..f5e3319455 100644 --- a/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js +++ b/src/widgets/hierarchical-menu/__tests__/hierarchical-menu-test.js @@ -1,6 +1,7 @@ import { render } from 'preact'; -import { SearchParameters } from 'algoliasearch-helper'; +import algoliasearchHelper, { SearchParameters } from 'algoliasearch-helper'; import hierarchicalMenu from '../hierarchical-menu'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; jest.mock('preact', () => { const module = require.requireActual('preact'); @@ -46,10 +47,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchica beforeEach(() => { data = { data: [{ name: 'foo' }, { name: 'bar' }] }; results = { getFacetValues: jest.fn(() => data) }; - helper = { - toggleRefinement: jest.fn().mockReturnThis(), - search: jest.fn(), - }; + helper = algoliasearchHelper({}, ''); + helper.toggleRefinement = jest.fn().mockReturnThis(); + helper.search = jest.fn(); state = new SearchParameters(); state.toggleRefinement = jest.fn(); options = { container, attributes }; @@ -165,7 +165,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hierarchica it('has a toggleRefinement method', () => { widget = hierarchicalMenu(options); - widget.init({ helper, createURL, instantSearchInstance: {} }); + widget.init({ + helper, + createURL, + instantSearchInstance: createInstantSearch(), + }); widget.render({ results, state }); const [firstRender] = render.mock.calls; diff --git a/src/widgets/hits/__tests__/__snapshots__/hits-test.ts.snap b/src/widgets/hits/__tests__/__snapshots__/hits-test.ts.snap index 98ce18f2be..d73ac03e88 100644 --- a/src/widgets/hits/__tests__/__snapshots__/hits-test.ts.snap +++ b/src/widgets/hits/__tests__/__snapshots__/hits-test.ts.snap @@ -2,6 +2,7 @@ exports[`hits() calls twice render(, container) 1`] = ` Object { + "bindEvent": [Function], "cssClasses": Object { "emptyRoot": "ais-Hits--empty", "item": "ais-Hits-item", @@ -27,6 +28,7 @@ Object { "hitsPerPage": 4, "page": 2, }, + "sendEvent": [Function], "templateProps": Object { "templates": Object { "empty": "No results", @@ -43,6 +45,7 @@ Object { exports[`hits() calls twice render(, container) 2`] = ` Object { + "bindEvent": [Function], "cssClasses": Object { "emptyRoot": "ais-Hits--empty", "item": "ais-Hits-item", @@ -68,6 +71,7 @@ Object { "hitsPerPage": 4, "page": 2, }, + "sendEvent": [Function], "templateProps": Object { "templates": Object { "empty": "No results", @@ -84,6 +88,7 @@ Object { exports[`hits() renders transformed items 1`] = ` Object { + "bindEvent": [Function], "cssClasses": Object { "emptyRoot": "ais-Hits--empty", "item": "ais-Hits-item", @@ -111,6 +116,7 @@ Object { "hitsPerPage": 4, "page": 2, }, + "sendEvent": [Function], "templateProps": Object { "templates": Object { "empty": "No results", diff --git a/src/widgets/hits/__tests__/hits-integration-test.ts b/src/widgets/hits/__tests__/hits-integration-test.ts new file mode 100644 index 0000000000..cdee99a4c8 --- /dev/null +++ b/src/widgets/hits/__tests__/hits-integration-test.ts @@ -0,0 +1,168 @@ +import { getByText, fireEvent } from '@testing-library/dom'; + +import instantsearch from '../../../index.es'; +import { hits, configure } from '../../'; +import { createInsightsMiddleware } from '../../../middlewares'; +import { createSingleSearchResponse } from '../../../../test/mock/createAPIResponse'; +import { runAllMicroTasks } from '../../../../test/utils/runAllMicroTasks'; + +const createInstantSearch = ({ hitsPerPage = 2 } = {}) => { + const page = 0; + + const searchClient: any = { + search: jest.fn(requests => + Promise.resolve({ + results: requests.map(() => + createSingleSearchResponse({ + hits: Array(hitsPerPage) + .fill(undefined) + .map((_, index) => ({ + title: `title ${page * hitsPerPage + index + 1}`, + objectID: `object-id${index}`, + })), + }) + ), + }) + ), + }; + const search = instantsearch({ + indexName: 'instant_search', + searchClient, + }); + + search.addWidgets([ + configure({ + hitsPerPage, + }), + ]); + + return { + search, + }; +}; + +describe('hits', () => { + let container; + + beforeEach(() => { + container = document.createElement('div'); + }); + + describe('insights', () => { + const createInsightsMiddlewareWithOnEvent = () => { + const onEvent = jest.fn(); + const insights = createInsightsMiddleware({ + insightsClient: null, + onEvent, + }); + return { + onEvent, + insights, + }; + }; + + it('sends view event when hits are rendered', async () => { + const { search } = createInstantSearch(); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + search.EXPERIMENTAL_use(insights); + + search.addWidgets([ + hits({ + container, + }), + ]); + search.start(); + await runAllMicroTasks(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith( + { + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: 'instant_search', + objectIDs: ['object-id0', 'object-id1'], + }, + widgetType: 'ais.hits', + }, + null + ); + }); + + it('sends click event', async () => { + const { search } = createInstantSearch(); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + search.EXPERIMENTAL_use(insights); + + search.addWidgets([ + hits({ + container, + templates: { + item: (item, bindEvent) => ` + + `, + }, + }), + ]); + search.start(); + await runAllMicroTasks(); + + expect(onEvent).toHaveBeenCalledTimes(1); // view event by render + fireEvent.click(getByText(container, 'title 1')); + expect(onEvent).toHaveBeenCalledTimes(2); + expect(onEvent.mock.calls[onEvent.mock.calls.length - 1][0]).toEqual({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Item Clicked', + index: 'instant_search', + objectIDs: ['object-id0'], + positions: [1], + }, + widgetType: 'ais.hits', + }); + }); + + it('sends conversion event', async () => { + const { search } = createInstantSearch(); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + search.EXPERIMENTAL_use(insights); + + search.addWidgets([ + hits({ + container, + templates: { + item: (item, bindEvent) => ` + + `, + }, + }), + ]); + search.start(); + await runAllMicroTasks(); + + expect(onEvent).toHaveBeenCalledTimes(1); // view event by render + fireEvent.click(getByText(container, 'title 2')); + expect(onEvent).toHaveBeenCalledTimes(2); + expect(onEvent.mock.calls[onEvent.mock.calls.length - 1][0]).toEqual({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: 'instant_search', + objectIDs: ['object-id1'], + }, + widgetType: 'ais.hits', + }); + }); + }); +}); diff --git a/src/widgets/hits/__tests__/hits-test.ts b/src/widgets/hits/__tests__/hits-test.ts index 2139826005..835b789412 100644 --- a/src/widgets/hits/__tests__/hits-test.ts +++ b/src/widgets/hits/__tests__/hits-test.ts @@ -1,7 +1,9 @@ import { render as preactRender } from 'preact'; -import defaultTemplates from '../defaultTemplates'; +import algoliasearchHelper from 'algoliasearch-helper'; +import { SearchClient } from '../../../types'; import hits from '../hits'; import { castToJestMock } from '../../../../test/utils/castToJestMock'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; const render = castToJestMock(preactRender); @@ -28,21 +30,22 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/hits/js/" describe('hits()', () => { let container; - let templateProps; let widget; let results; + let helper; beforeEach(() => { render.mockClear(); + helper = algoliasearchHelper({} as SearchClient, '', {}); container = document.createElement('div'); - templateProps = { - templatesConfig: undefined, - templates: defaultTemplates, - useCustomCompileOptions: { item: false, empty: false }, - }; widget = hits({ container, cssClasses: { root: ['root', 'cx'] } }); - widget.init({ instantSearchInstance: { templateProps } }); + widget.init({ + helper, + instantSearchInstance: createInstantSearch({ + templatesConfig: undefined, + }), + }); results = { hits: [{ first: 'hit', second: 'hit' }], hitsPerPage: 4, @@ -70,7 +73,12 @@ describe('hits()', () => { items.map(item => ({ ...item, transformed: true })), }); - widget.init({ instantSearchInstance: {} }); + widget.init({ + helper, + instantSearchInstance: createInstantSearch({ + templatesConfig: undefined, + }), + }); widget.render({ results }); const [firstRender] = render.mock.calls; diff --git a/src/widgets/hits/hits.tsx b/src/widgets/hits/hits.tsx index 3847b79156..ac6c2c71b7 100644 --- a/src/widgets/hits/hits.tsx +++ b/src/widgets/hits/hits.tsx @@ -17,12 +17,13 @@ import { component } from '../../lib/suit'; import { withInsights, withInsightsListener } from '../../lib/insights'; import { Template, + TemplateWithBindEvent, Hit, WidgetFactory, Renderer, InsightsClientWrapper, } from '../../types'; -import { InsightsEvent } from '../../middleware/insights'; +import { InsightsEvent } from '../../middlewares/createInsightsMiddleware'; const withUsage = createDocumentationMessageGenerator({ name: 'hits' }); const suit = component('Hits'); @@ -98,7 +99,7 @@ export type HitsTemplates = { * * @default '' */ - item?: Template< + item?: TemplateWithBindEvent< Hit & { __hitIndex: number; } diff --git a/src/widgets/infinite-hits/__tests__/__snapshots__/infinite-hits-test.ts.snap b/src/widgets/infinite-hits/__tests__/__snapshots__/infinite-hits-test.ts.snap index 8a6f6f5d75..a11d33dd4b 100644 --- a/src/widgets/infinite-hits/__tests__/__snapshots__/infinite-hits-test.ts.snap +++ b/src/widgets/infinite-hits/__tests__/__snapshots__/infinite-hits-test.ts.snap @@ -2,6 +2,7 @@ exports[`infiniteHits() calls twice render(, container) 1`] = ` Object { + "bindEvent": [Function], "cssClasses": Object { "disabledLoadMore": "ais-InfiniteHits-loadMore--disabled", "disabledLoadPrevious": "ais-InfiniteHits-loadPrevious--disabled", @@ -34,6 +35,7 @@ Object { "hitsPerPage": 2, "page": 1, }, + "sendEvent": [Function], "showMore": [Function], "showPrevious": [Function], "templateProps": Object { @@ -43,7 +45,7 @@ Object { "showMoreText": "Show more results", "showPreviousText": "Show previous results", }, - "templatesConfig": undefined, + "templatesConfig": Object {}, "useCustomCompileOptions": Object { "empty": false, "item": false, @@ -56,6 +58,7 @@ Object { exports[`infiniteHits() calls twice render(, container) 2`] = ` Object { + "bindEvent": [Function], "cssClasses": Object { "disabledLoadMore": "ais-InfiniteHits-loadMore--disabled", "disabledLoadPrevious": "ais-InfiniteHits-loadPrevious--disabled", @@ -88,6 +91,7 @@ Object { "hitsPerPage": 2, "page": 1, }, + "sendEvent": [Function], "showMore": [Function], "showPrevious": [Function], "templateProps": Object { @@ -97,7 +101,7 @@ Object { "showMoreText": "Show more results", "showPreviousText": "Show previous results", }, - "templatesConfig": undefined, + "templatesConfig": Object {}, "useCustomCompileOptions": Object { "empty": false, "item": false, @@ -110,6 +114,7 @@ Object { exports[`infiniteHits() if it is the last page, then the props should contain isLastPage true 1`] = ` Object { + "bindEvent": [Function], "cssClasses": Object { "disabledLoadMore": "ais-InfiniteHits-loadMore--disabled", "disabledLoadPrevious": "ais-InfiniteHits-loadPrevious--disabled", @@ -143,6 +148,7 @@ Object { "nbPages": 2, "page": 0, }, + "sendEvent": [Function], "showMore": [Function], "showPrevious": [Function], "templateProps": Object { @@ -152,7 +158,7 @@ Object { "showMoreText": "Show more results", "showPreviousText": "Show previous results", }, - "templatesConfig": undefined, + "templatesConfig": Object {}, "useCustomCompileOptions": Object { "empty": false, "item": false, @@ -165,6 +171,7 @@ Object { exports[`infiniteHits() if it is the last page, then the props should contain isLastPage true 2`] = ` Object { + "bindEvent": [Function], "cssClasses": Object { "disabledLoadMore": "ais-InfiniteHits-loadMore--disabled", "disabledLoadPrevious": "ais-InfiniteHits-loadPrevious--disabled", @@ -198,6 +205,7 @@ Object { "nbPages": 2, "page": 1, }, + "sendEvent": [Function], "showMore": [Function], "showPrevious": [Function], "templateProps": Object { @@ -207,7 +215,7 @@ Object { "showMoreText": "Show more results", "showPreviousText": "Show previous results", }, - "templatesConfig": undefined, + "templatesConfig": Object {}, "useCustomCompileOptions": Object { "empty": false, "item": false, @@ -220,6 +228,7 @@ Object { exports[`infiniteHits() renders transformed items 1`] = ` Object { + "bindEvent": [Function], "cssClasses": Object { "disabledLoadMore": "ais-InfiniteHits-loadMore--disabled", "disabledLoadPrevious": "ais-InfiniteHits-loadPrevious--disabled", @@ -254,6 +263,7 @@ Object { "hitsPerPage": 2, "page": 1, }, + "sendEvent": [Function], "showMore": [Function], "showPrevious": [Function], "templateProps": Object { @@ -263,7 +273,7 @@ Object { "showMoreText": "Show more results", "showPreviousText": "Show previous results", }, - "templatesConfig": undefined, + "templatesConfig": Object {}, "useCustomCompileOptions": Object { "empty": false, "item": false, diff --git a/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts b/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts index 39bd3ff7ab..50639be096 100644 --- a/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts +++ b/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts @@ -2,6 +2,8 @@ import { getByText, waitFor, fireEvent } from '@testing-library/dom'; import instantsearch from '../../../index.es'; import { infiniteHits, configure } from '../../'; +import { createInsightsMiddleware } from '../../../middlewares'; +import { runAllMicroTasks } from '../../../../test/utils/runAllMicroTasks'; function createSingleSearchResponse({ params: { hitsPerPage, page } }) { return { @@ -9,6 +11,7 @@ function createSingleSearchResponse({ params: { hitsPerPage, page } }) { .fill(undefined) .map((_, index) => ({ title: `title ${page * hitsPerPage + index + 1}`, + objectID: `object-id${index}`, })), page, hitsPerPage, @@ -16,124 +19,256 @@ function createSingleSearchResponse({ params: { hitsPerPage, page } }) { } describe('infiniteHits', () => { - let search; - let searchClient; - let container; - - let cachedState: any; - let cachedHits: any; - let customCache; - - beforeEach(() => { - searchClient = { + const createInstantSearch = ({ hitsPerPage = 2 } = {}) => { + const searchClient: any = { search: jest.fn(requests => Promise.resolve({ results: requests.map(request => createSingleSearchResponse(request)), }) ), }; - search = instantsearch({ + const search = instantsearch({ indexName: 'instant_search', searchClient, }); - - container = document.createElement('div'); - - const getStateWithoutPage = state => { - const { page, ...rest } = state || {}; - return rest; - }; - const isEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b); - cachedState = undefined; - cachedHits = undefined; - customCache = { - read: jest.fn(({ state }) => { - return isEqual(cachedState, getStateWithoutPage(state)) - ? cachedHits - : null; - }), - write: jest.fn(({ state, hits }) => { - cachedState = getStateWithoutPage(state); - cachedHits = hits; - }), - }; - }); - - it('calls read & write methods of custom cache', async () => { search.addWidgets([ configure({ - hitsPerPage: 2, - }), - infiniteHits({ - container, - cache: customCache, + hitsPerPage, }), ]); - search.start(); - await waitFor(() => { - const numberOfHits = container.querySelectorAll('.ais-InfiniteHits-item') - .length; - expect(numberOfHits).toEqual(2); + return { search, searchClient }; + }; + + let container; + + beforeEach(() => { + container = document.createElement('div'); + }); + + describe('cache', () => { + let cachedState: any; + let cachedHits: any; + let customCache; + + beforeEach(() => { + const getStateWithoutPage = state => { + const { page, ...rest } = state || {}; + return rest; + }; + const isEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b); + cachedState = undefined; + cachedHits = undefined; + customCache = { + read: jest.fn(({ state }) => { + return isEqual(cachedState, getStateWithoutPage(state)) + ? cachedHits + : null; + }), + write: jest.fn(({ state, hits }) => { + cachedState = getStateWithoutPage(state); + cachedHits = hits; + }), + }; }); - fireEvent.click(getByText(container, 'Show more results')); - await waitFor(() => { - const numberOfHits = container.querySelectorAll('.ais-InfiniteHits-item') - .length; - expect(numberOfHits).toEqual(4); + it('calls read & write methods of custom cache', async () => { + const { search } = createInstantSearch(); + + search.addWidgets([ + infiniteHits({ + container, + cache: customCache, + }), + ]); + search.start(); + + await waitFor(() => { + const numberOfHits = container.querySelectorAll( + '.ais-InfiniteHits-item' + ).length; + expect(numberOfHits).toEqual(2); + }); + + fireEvent.click(getByText(container, 'Show more results')); + await waitFor(() => { + const numberOfHits = container.querySelectorAll( + '.ais-InfiniteHits-item' + ).length; + expect(numberOfHits).toEqual(4); + }); + + expect(customCache.read).toHaveBeenCalledTimes(2); // init & render + expect(customCache.write).toHaveBeenCalledTimes(2); // page #0, page #1 }); - expect(customCache.read).toHaveBeenCalledTimes(2); // init & render - expect(customCache.write).toHaveBeenCalledTimes(2); // page #0, page #1 - }); + it('displays all the hits from cache', async () => { + const { search, searchClient } = createInstantSearch(); - it('displays all the hits from cache', async () => { - // flow #1 - load page #0 & #1 to fill the cache - search.addWidgets([ - configure({ - hitsPerPage: 2, - }), - infiniteHits({ - container, - cache: customCache, - }), - ]); - search.start(); + // flow #1 - load page #0 & #1 to fill the cache + search.addWidgets([ + infiniteHits({ + container, + cache: customCache, + }), + ]); + search.start(); - await waitFor(() => { - const numberOfHits = container.querySelectorAll('.ais-InfiniteHits-item') - .length; - expect(numberOfHits).toEqual(2); + await waitFor(() => { + const numberOfHits = container.querySelectorAll( + '.ais-InfiniteHits-item' + ).length; + expect(numberOfHits).toEqual(2); + }); + + fireEvent.click(getByText(container, 'Show more results')); + await waitFor(() => { + const numberOfHits = container.querySelectorAll( + '.ais-InfiniteHits-item' + ).length; + expect(numberOfHits).toEqual(4); + }); + + // flow #2 - new InstantSearch instance to leverage the cache + const search2 = instantsearch({ + indexName: 'instant_search', + searchClient, + }); + const container2 = document.createElement('div'); + search2.addWidgets([ + configure({ + hitsPerPage: 2, + }), + infiniteHits({ + container: container2, + cache: customCache, // same cache object + }), + ]); + search2.start(); + await waitFor(() => { + const numberOfHits = container.querySelectorAll( + '.ais-InfiniteHits-item' + ).length; + expect(numberOfHits).toEqual(4); // it loads two pages initially + }); }); + }); + + describe('insights', () => { + const createInsightsMiddlewareWithOnEvent = () => { + const onEvent = jest.fn(); + const insights = createInsightsMiddleware({ + insightsClient: null, + onEvent, + }); + return { + onEvent, + insights, + }; + }; + + it('sends view event when hits are rendered', async () => { + const { search } = createInstantSearch(); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + search.EXPERIMENTAL_use(insights); - fireEvent.click(getByText(container, 'Show more results')); - await waitFor(() => { - const numberOfHits = container.querySelectorAll('.ais-InfiniteHits-item') - .length; - expect(numberOfHits).toEqual(4); + search.addWidgets([ + infiniteHits({ + container, + }), + ]); + search.start(); + await runAllMicroTasks(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith( + { + eventType: 'view', + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: 'instant_search', + objectIDs: ['object-id0', 'object-id1'], + }, + widgetType: 'ais.infiniteHits', + }, + null + ); }); - // flow #2 - new InstantSearch instance to leverage the cache - const search2 = instantsearch({ - indexName: 'instant_search', - searchClient, + it('sends click event', async () => { + const { search } = createInstantSearch(); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + search.EXPERIMENTAL_use(insights); + + search.addWidgets([ + infiniteHits({ + container, + templates: { + item: (item, bindEvent) => ` + + `, + }, + }), + ]); + search.start(); + await runAllMicroTasks(); + + expect(onEvent).toHaveBeenCalledTimes(1); // view event by render + fireEvent.click(getByText(container, 'title 1')); + expect(onEvent).toHaveBeenCalledTimes(2); + expect(onEvent.mock.calls[onEvent.mock.calls.length - 1][0]).toEqual({ + eventType: 'click', + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Item Clicked', + index: 'instant_search', + objectIDs: ['object-id0'], + positions: [1], + }, + widgetType: 'ais.infiniteHits', + }); }); - const container2 = document.createElement('div'); - search2.addWidgets([ - configure({ - hitsPerPage: 2, - }), - infiniteHits({ - container: container2, - cache: customCache, // same cache object - }), - ]); - search2.start(); - await waitFor(() => { - const numberOfHits = container.querySelectorAll('.ais-InfiniteHits-item') - .length; - expect(numberOfHits).toEqual(4); // it loads two pages initially + + it('sends conversion event', async () => { + const { search } = createInstantSearch(); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + search.EXPERIMENTAL_use(insights); + + search.addWidgets([ + infiniteHits({ + container, + templates: { + item: (item, bindEvent) => ` + + `, + }, + }), + ]); + search.start(); + await runAllMicroTasks(); + + expect(onEvent).toHaveBeenCalledTimes(1); // view event by render + fireEvent.click(getByText(container, 'title 2')); + expect(onEvent).toHaveBeenCalledTimes(2); + expect(onEvent.mock.calls[onEvent.mock.calls.length - 1][0]).toEqual({ + eventType: 'conversion', + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: 'instant_search', + objectIDs: ['object-id1'], + }, + widgetType: 'ais.infiniteHits', + }); }); }); }); diff --git a/src/widgets/infinite-hits/__tests__/infinite-hits-test.ts b/src/widgets/infinite-hits/__tests__/infinite-hits-test.ts index 82ad008dba..a7d3c4033c 100644 --- a/src/widgets/infinite-hits/__tests__/infinite-hits-test.ts +++ b/src/widgets/infinite-hits/__tests__/infinite-hits-test.ts @@ -3,6 +3,7 @@ import algoliasearchHelper from 'algoliasearch-helper'; import { SearchClient } from '../../../types'; import infiniteHits from '../infinite-hits'; import { castToJestMock } from '../../../../test/utils/castToJestMock'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; const render = castToJestMock(preactRender); @@ -57,6 +58,8 @@ describe('infiniteHits()', () => { it('calls twice render(, container)', () => { const state = { page: 0 }; + const instantSearchInstance = createInstantSearch(); + widget.init({ helper, instantSearchInstance }); widget.render({ results, state }); widget.render({ results, state }); @@ -81,7 +84,7 @@ describe('infiniteHits()', () => { showPrevious: false, }); - widget.init({ helper, instantSearchInstance: {} }); + widget.init({ helper, instantSearchInstance: createInstantSearch() }); widget.render({ results, @@ -96,6 +99,8 @@ describe('infiniteHits()', () => { it('if it is the last page, then the props should contain isLastPage true', () => { const state = { page: 0 }; + const instantSearchInstance = createInstantSearch(); + widget.init({ helper, instantSearchInstance }); widget.render({ results: { ...results, page: 0, nbPages: 2 }, state, @@ -118,6 +123,8 @@ describe('infiniteHits()', () => { expect(helper.state.page).toBeUndefined(); const state = { page: 0 }; + const instantSearchInstance = createInstantSearch(); + widget.init({ helper, instantSearchInstance }); widget.render({ results, state }); @@ -134,7 +141,8 @@ describe('infiniteHits()', () => { it('should add __position key with absolute position', () => { results = { ...results, page: 4, hitsPerPage: 10 }; const state = { page: results.page }; - + const instantSearchInstance = createInstantSearch(); + widget.init({ helper, instantSearchInstance }); widget.render({ results, state }); expect(results.hits[0].__position).toEqual(41); @@ -142,7 +150,8 @@ describe('infiniteHits()', () => { it('if it is the first page, then the props should contain isFirstPage true', () => { const state = { page: 0 }; - + const instantSearchInstance = createInstantSearch(); + widget.init({ helper, instantSearchInstance }); widget.render({ results: { ...results, page: 0, nbPages: 2 }, state, @@ -165,7 +174,7 @@ describe('infiniteHits()', () => { it('if it is not the first page, then the props should contain isFirstPage false', () => { helper.setPage(1); - widget.init({ helper, instantSearchInstance: {} }); + widget.init({ helper, instantSearchInstance: createInstantSearch() }); const state = { page: 1 }; widget.render({ @@ -215,7 +224,7 @@ describe('infiniteHits()', () => { showPrevious: false, cache: customCache, }); - widget.init({ helper, instantSearchInstance: {} }); + widget.init({ helper, instantSearchInstance: createInstantSearch() }); expect(cachedState).toMatchInlineSnapshot(`undefined`); expect(cachedHits).toMatchInlineSnapshot(`undefined`); widget.render({ @@ -283,7 +292,7 @@ describe('infiniteHits()', () => { showPrevious: false, cache: customCache, }); - widget.init({ helper, instantSearchInstance: {} }); + widget.init({ helper, instantSearchInstance: createInstantSearch() }); widget.render({ results: { page: 0, diff --git a/src/widgets/infinite-hits/infinite-hits.tsx b/src/widgets/infinite-hits/infinite-hits.tsx index 7c1f678f29..0496598643 100644 --- a/src/widgets/infinite-hits/infinite-hits.tsx +++ b/src/widgets/infinite-hits/infinite-hits.tsx @@ -19,12 +19,13 @@ import { withInsights, withInsightsListener } from '../../lib/insights'; import { WidgetFactory, Template, + TemplateWithBindEvent, Hit, InsightsClientWrapper, Renderer, } from '../../types'; import defaultTemplates from './defaultTemplates'; -import { InsightsEvent } from '../../middleware/insights'; +import { InsightsEvent } from '../../middlewares/createInsightsMiddleware'; const withUsage = createDocumentationMessageGenerator({ name: 'infinite-hits', @@ -93,7 +94,7 @@ export type InfiniteHitsTemplates = { /** * The template to use for each result. */ - item?: Template; + item?: TemplateWithBindEvent; }; export type InfiniteHitsWidgetParams = { diff --git a/src/widgets/range-slider/__tests__/range-slider-test.js b/src/widgets/range-slider/__tests__/range-slider-test.js index 8fea0fed4d..df57056a60 100644 --- a/src/widgets/range-slider/__tests__/range-slider-test.js +++ b/src/widgets/range-slider/__tests__/range-slider-test.js @@ -1,6 +1,7 @@ import { render } from 'preact'; import AlgoliasearchHelper, { SearchParameters } from 'algoliasearch-helper'; import rangeSlider from '../range-slider'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; jest.mock('preact', () => { const module = require.requireActual('preact'); @@ -10,8 +11,6 @@ jest.mock('preact', () => { return module; }); -const instantSearchInstance = { templatesConfig: undefined }; - describe('rangeSlider', () => { describe('Usage', () => { it('throws without container', () => { @@ -41,6 +40,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-slide let container; let helper; let widget; + let instantSearchInstance; beforeEach(() => { render.mockClear(); @@ -55,6 +55,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-slide 'indexName', { disjunctiveFacets: ['aNumAttr'] } ); + instantSearchInstance = createInstantSearch({ + templatesConfig: undefined, + }); }); it('should render without results', () => { @@ -309,7 +312,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-slide const targetValue = stats.min + 1; const state0 = helper.state; - widget._refine(helper, stats)([targetValue, stats.max]); + widget._refine( + instantSearchInstance, + helper, + stats + )([targetValue, stats.max]); const state1 = helper.state; expect(helper.search).toHaveBeenCalledTimes(1); @@ -321,7 +328,11 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-slide const targetValue = stats.max - 1; const state0 = helper.state; - widget._refine(helper, stats)([stats.min, targetValue]); + widget._refine( + instantSearchInstance, + helper, + stats + )([stats.min, targetValue]); const state1 = helper.state; expect(helper.search).toHaveBeenCalledTimes(1); @@ -335,7 +346,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/range-slide const targetValue = [stats.min + 1, stats.max - 1]; const state0 = helper.state; - widget._refine(helper, stats)(targetValue); + widget._refine(instantSearchInstance, helper, stats)(targetValue); const state1 = helper.state; expect(helper.search).toHaveBeenCalledTimes(1); diff --git a/src/widgets/rating-menu/__tests__/rating-menu-test.js b/src/widgets/rating-menu/__tests__/rating-menu-test.js index ca0d56c172..78fb207737 100644 --- a/src/widgets/rating-menu/__tests__/rating-menu-test.js +++ b/src/widgets/rating-menu/__tests__/rating-menu-test.js @@ -4,6 +4,7 @@ import jsHelper, { SearchParameters, } from 'algoliasearch-helper'; import ratingMenu from '../rating-menu'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; jest.mock('preact', () => { const module = require.requireActual('preact'); @@ -66,7 +67,9 @@ describe('ratingMenu()', () => { createURL = () => '#'; widget.init({ helper, - instantSearchInstance: { templatesConfig: undefined }, + instantSearchInstance: createInstantSearch({ + templatesConfig: undefined, + }), }); }); diff --git a/src/widgets/toggle-refinement/__tests__/toggle-refinement-test.js b/src/widgets/toggle-refinement/__tests__/toggle-refinement-test.js index 711e249f3a..1eaa7f8f53 100644 --- a/src/widgets/toggle-refinement/__tests__/toggle-refinement-test.js +++ b/src/widgets/toggle-refinement/__tests__/toggle-refinement-test.js @@ -2,6 +2,7 @@ import { render } from 'preact'; import jsHelper, { SearchParameters } from 'algoliasearch-helper'; import toggleRefinement from '../toggle-refinement'; import RefinementList from '../../../components/RefinementList/RefinementList'; +import { createInstantSearch } from '../../../../test/mock/createInstantSearch'; jest.mock('preact', () => { const module = require.requireActual('preact'); @@ -39,7 +40,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/toggle-refi container: containerNode, attribute, }); - instantSearchInstance = { templatesConfig: undefined }; + instantSearchInstance = createInstantSearch({ + templatesConfig: undefined, + }); }); describe('render', () => { @@ -49,14 +52,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/toggle-refi let createURL; beforeEach(() => { - helper = { - state: { - isDisjunctiveFacetRefined: jest.fn().mockReturnValue(false), - }, - removeDisjunctiveFacetRefinement: jest.fn(), - addDisjunctiveFacetRefinement: jest.fn(), - search: jest.fn(), - }; + helper = jsHelper({}, ''); + helper.state.isDisjunctiveFacetRefined = jest + .fn() + .mockReturnValue(false); + helper.removeDisjunctiveFacetRefinement = jest.fn(); + helper.addDisjunctiveFacetRefinement = jest.fn(); + helper.search = jest.fn(); state = { removeDisjunctiveFacetRefinement: jest.fn(), addDisjunctiveFacetRefinement: jest.fn(), @@ -307,11 +309,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/toggle-refi } beforeEach(() => { - helper = { - removeDisjunctiveFacetRefinement: jest.fn(), - addDisjunctiveFacetRefinement: jest.fn(), - search: jest.fn(), - }; + helper = jsHelper({}, ''); + helper.removeDisjunctiveFacetRefinement = jest.fn(); + helper.addDisjunctiveFacetRefinement = jest.fn(); + helper.search = jest.fn(); }); describe('default values', () => { diff --git a/test/mock/createInsightsClient.ts b/test/mock/createInsightsClient.ts new file mode 100644 index 0000000000..b4cff2c4d5 --- /dev/null +++ b/test/mock/createInsightsClient.ts @@ -0,0 +1,69 @@ +export const ANONYMOUS_TOKEN = 'anonymous-user-id-1'; + +export function createAlgoliaAnalytics() { + let values: any = {}; + const setValues = obj => { + values = { + ...values, + ...obj, + }; + }; + let userTokenCallback; + const setUserToken = userToken => { + setValues({ _userToken: userToken }); + if (userTokenCallback) { + userTokenCallback(userToken); + } + }; + const init = ({ appId, apiKey }) => { + setValues({ _hasCredentials: true, _appId: appId, _apiKey: apiKey }); + setUserToken(ANONYMOUS_TOKEN); + }; + const _get = (key, callback) => callback(values[key]); + const onUserTokenChange = (callback, { immediate = false } = {}) => { + userTokenCallback = callback; + if (immediate) { + callback(values._userToken); + } + }; + + return { + setUserToken, + init, + _get, + onUserTokenChange, + viewedObjectIDs: jest.fn(), + }; +} + +export function createInsightsClient(instance = createAlgoliaAnalytics()) { + return (methodName, ...args) => { + if (!instance[methodName]) { + throw new Error(`${methodName} doesn't exist in this mocked instance`); + } + instance[methodName](...args); + }; +} + +export function createInsightsUmdVersion() { + const globalObject: any = {}; + globalObject.aa = (...args) => { + globalObject.aa.queue = globalObject.aa.queue || []; + globalObject.aa.queue.push(args); + }; + + return { + insightsClient: globalObject.aa, + libraryLoadedAndProcessQueue: () => { + const instance = createAlgoliaAnalytics(); + const _aa = createInsightsClient(instance); + const queue = globalObject.aa.queue; + queue.forEach(([methodName, ...args]) => { + _aa(methodName, ...args); + }); + queue.push = ([methodName, ...args]) => { + _aa(methodName, ...args); + }; + }, + }; +} diff --git a/test/mock/createInstantSearch.ts b/test/mock/createInstantSearch.ts index 581dda1b64..652bd8966f 100644 --- a/test/mock/createInstantSearch.ts +++ b/test/mock/createInstantSearch.ts @@ -7,16 +7,15 @@ import defer from '../../src/lib/utils/defer'; export const createInstantSearch = ( args: Partial = {} ): InstantSearch => { - const searchClient = createSearchClient(); - const { indexName = 'indexName' } = args; - const mainHelper = algoliasearchHelper(searchClient, indexName, {}); + const { indexName = 'indexName', client = createSearchClient() } = args; + const mainHelper = algoliasearchHelper(client, indexName, {}); const mainIndex = index({ indexName }); return { indexName, mainIndex, mainHelper, - client: searchClient, + client, started: false, start() { this.started = true;