diff --git a/bundlesize.config.json b/bundlesize.config.json index 087734a0907..a4ba3d1cac6 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,11 +10,11 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "122 kB" + "maxSize": "123 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "253.4 kB" + "maxSize": "260 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", diff --git a/packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteIndex.tsx b/packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteIndex.tsx index 730645b0638..8c38353fbe6 100644 --- a/packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteIndex.tsx +++ b/packages/instantsearch-ui-components/src/components/autocomplete/AutocompleteIndex.tsx @@ -2,7 +2,7 @@ import { cx } from '../../lib/cx'; -import type { ComponentProps, Renderer } from '../../types'; +import type { ComponentProps, Renderer, SendEventForHits } from '../../types'; export type AutocompleteIndexProps< T = { objectID: string; __indexName: string } & Record @@ -22,6 +22,7 @@ export type AutocompleteIndexProps< onSelect: () => void; onApply: () => void; }; + sendEvent?: SendEventForHits; classNames?: Partial; }; @@ -56,6 +57,7 @@ export function createAutocompleteIndexComponent({ createElement }: Renderer) { ItemComponent, NoResultsComponent, getItemProps, + sendEvent, classNames = {}, } = userProps; @@ -93,6 +95,20 @@ export function createAutocompleteIndexComponent({ createElement }: Renderer) { classNames.item, className )} + onClick={() => { + sendEvent?.( + 'click:internal', + item as Record, + 'Hit Clicked' + ); + }} + onAuxClick={() => { + sendEvent?.( + 'click:internal', + item as Record, + 'Hit Clicked' + ); + }} > foobar</script>', + matchLevel: 'full', + matchedWords: ['foobar'], + }, + }, + objectID: '1', + __position: 1, + }, + ]; + widget.init!(createInitOptions({ helper })); widget.render!( @@ -290,7 +309,7 @@ search.addWidgets([ const rendering = render.mock.calls[1][0]; - expect(rendering.indices[0].hits).toEqual(escapedHits); + expect(rendering.indices[0].hits).toEqual(enrichedEscapedHits); expect(rendering.indices[0].results.hits).toEqual(escapedHits); }); @@ -337,7 +356,12 @@ search.addWidgets([ const rendering = render.mock.calls[1][0]; - expect(rendering.indices[0].hits).toEqual(hits); + expect(rendering.indices[0].hits).toEqual( + hits.map((hit, index) => ({ + ...hit, + __position: index + 1, + })) + ); expect(rendering.indices[0].results.hits).toEqual(hits); }); @@ -728,22 +752,16 @@ search.addWidgets([ { 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, }, ]; @@ -754,6 +772,7 @@ search.addWidgets([ createSingleSearchResponse({ index: 'indexName0', hits: firstIndexHits, + queryID: 'test-query-id', }), ]), helper: algoliasearchHelper(searchClient, 'indexName0'), @@ -764,6 +783,7 @@ search.addWidgets([ createSingleSearchResponse({ index: 'indexName1', hits: secondIndexHits, + queryID: 'test-query-id', }), ]), helper: algoliasearchHelper(searchClient, 'indexName1'), @@ -794,7 +814,7 @@ search.addWidgets([ eventModifier: 'internal', hits: [ { - __position: 0, + __position: 1, __queryID: 'test-query-id', name: 'Hit 1-1', objectID: '1-1', @@ -813,13 +833,13 @@ search.addWidgets([ eventModifier: 'internal', hits: [ { - __position: 0, + __position: 1, __queryID: 'test-query-id', name: 'Hit 2-1', objectID: '2-1', }, { - __position: 1, + __position: 2, __queryID: 'test-query-id', name: 'Hit 2-2', objectID: '2-2', @@ -894,18 +914,17 @@ search.addWidgets([ }); it('sends click event', () => { - const { sendEventToInsights, render, secondIndexHits } = - createRenderedWidget(); + const { sendEventToInsights, render } = 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'); + indices[1].sendEvent('click', indices[1].hits[0], 'Product Added'); expect(sendEventToInsights).toHaveBeenCalledTimes(3); expect(sendEventToInsights.mock.calls[2][0]).toEqual({ eventType: 'click', hits: [ { - __position: 0, + __position: 1, __queryID: 'test-query-id', name: 'Hit 2-1', objectID: '2-1', @@ -916,7 +935,7 @@ search.addWidgets([ eventName: 'Product Added', index: 'indexName1', objectIDs: ['2-1'], - positions: [0], + positions: [1], queryID: 'test-query-id', }, widgetType: 'ais.autocomplete', @@ -924,18 +943,17 @@ search.addWidgets([ }); it('sends conversion event', () => { - const { sendEventToInsights, render, firstIndexHits } = - createRenderedWidget(); + const { sendEventToInsights, render } = 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'); + indices[0].sendEvent('conversion', indices[0].hits[0], 'Product Ordered'); expect(sendEventToInsights).toHaveBeenCalledTimes(3); expect(sendEventToInsights.mock.calls[2][0]).toEqual({ eventType: 'conversion', hits: [ { - __position: 0, + __position: 1, __queryID: 'test-query-id', name: 'Hit 1-1', objectID: '1-1', diff --git a/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts b/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts index 89a698c5bda..5af906bc766 100644 --- a/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts +++ b/packages/instantsearch.js/src/connectors/autocomplete/connectAutocomplete.ts @@ -1,4 +1,6 @@ import { + addAbsolutePosition, + addQueryID, escapeHits, TAG_PLACEHOLDER, checkRendering, @@ -228,10 +230,21 @@ search.addWidgets([ widgetType: this.$$type, }); + const hits = scopedResult.results + ? addQueryID( + addAbsolutePosition( + scopedResult.results.hits, + scopedResult.results.page, + scopedResult.results.hitsPerPage + ), + scopedResult.results.queryID + ) + : []; + return { indexId: scopedResult.indexId, indexName: scopedResult.results?.index || '', - hits: scopedResult.results?.hits || [], + hits, results: scopedResult.results || ({} as unknown as SearchResults), }; }); diff --git a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx index ec4fc36c58f..0798d270776 100644 --- a/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx +++ b/packages/instantsearch.js/src/widgets/autocomplete/autocomplete.tsx @@ -795,6 +795,7 @@ function AutocompleteWrapper({ __indexName: indexId, }))} getItemProps={getItemProps} + sendEvent={find(indices, (idx) => idx.indexId === indexId)?.sendEvent} classNames={currentIndexConfig.cssClasses} /> ); diff --git a/packages/react-instantsearch/src/widgets/Autocomplete.tsx b/packages/react-instantsearch/src/widgets/Autocomplete.tsx index ed165f8baf3..aded0600e38 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -869,6 +869,7 @@ function InnerAutocomplete({ __indexName: indexId, }))} getItemProps={getItemProps} + sendEvent={indices.find((idx) => idx.indexId === indexId)?.sendEvent} classNames={currentIndexConfig.classNames} /> ); diff --git a/tests/common/widgets/autocomplete/index.ts b/tests/common/widgets/autocomplete/index.ts index c287b1a1b43..b131fb75688 100644 --- a/tests/common/widgets/autocomplete/index.ts +++ b/tests/common/widgets/autocomplete/index.ts @@ -1,5 +1,6 @@ import { fakeAct, skippableDescribe } from '../../common'; +import { createInsightsTests } from './insights'; import { createOptionsTests } from './options'; import { createTemplatesTests } from './templates'; @@ -59,6 +60,7 @@ export function createAutocompleteWidgetTests( skippableDescribe('Autocomplete widget common tests', skippedTests, () => { createOptionsTests(setup, { act, skippedTests, flavor }); createTemplatesTests(setup, { act, skippedTests, flavor }); + createInsightsTests(setup, { act, skippedTests, flavor }); }); } createAutocompleteWidgetTests.flavored = true; diff --git a/tests/common/widgets/autocomplete/insights.tsx b/tests/common/widgets/autocomplete/insights.tsx new file mode 100644 index 00000000000..68e171fbd17 --- /dev/null +++ b/tests/common/widgets/autocomplete/insights.tsx @@ -0,0 +1,273 @@ +import { + createMultiSearchResponse, + createSearchClient, + createSingleSearchResponse, +} from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils'; +import { fireEvent } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import type { AutocompleteWidgetSetup } from '.'; +import type { TestOptions } from '../../common'; +import type { MockSearchClient } from '@instantsearch/mocks'; +import type { SearchClient } from 'instantsearch.js'; + +declare const window: Window & + typeof globalThis & { + aa: jest.Mock; + }; + +function createMockedSearchClient({ + hitsPerPage = 2, + delay = 100, +}: { hitsPerPage?: number; delay?: number } = {}) { + return createSearchClient({ + search: jest.fn(async (requests) => { + await wait(delay); + return createMultiSearchResponse( + ...requests.map( + ({ indexName }: Parameters[0][number]) => + createSingleSearchResponse({ + index: indexName, + hits: Array.from({ length: hitsPerPage }).map((_, index) => ({ + objectID: `${indexName}-${index}`, + name: `Item ${index}`, + })), + }) + ) + ); + }) as MockSearchClient['search'], + }); +} + +export function createInsightsTests( + setup: AutocompleteWidgetSetup, + { act }: Required +) { + describe('insights', () => { + test('sends a default click event when clicking an item', async () => { + const delay = 100; + const margin = 10; + window.aa = Object.assign(jest.fn(), { version: '2.17.2' }); + const searchClient = createMockedSearchClient({ delay }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + insights: true, + }, + widgetParams: { + javascript: { + indices: [ + { + indexName: 'indexName', + templates: { + item: (props) => props.item.name, + }, + }, + ], + }, + react: { + indices: [ + { + indexName: 'indexName', + itemComponent: (props) => <>{props.item.name}, + }, + ], + }, + vue: {}, + }, + }); + + // Wait for initial results + await act(async () => { + await wait(margin + delay); + await wait(0); + }); + + window.aa.mockClear(); + + const items = document.querySelectorAll('.ais-AutocompleteIndexItem'); + expect(items.length).toBeGreaterThanOrEqual(1); + + userEvent.click(items[0]); + + expect(window.aa).toHaveBeenCalledTimes(1); + expect(window.aa).toHaveBeenCalledWith( + 'clickedObjectIDsAfterSearch', + { + eventName: 'Hit Clicked', + algoliaSource: ['instantsearch', 'instantsearch-internal'], + index: 'indexName', + objectIDs: ['indexName-0'], + positions: [1], + }, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Algolia-API-Key': 'apiKey', + 'X-Algolia-Application-Id': 'appId', + }), + }) + ); + }); + + test('sends a default click event on auxclick (middle mouse button)', async () => { + const delay = 100; + const margin = 10; + window.aa = Object.assign(jest.fn(), { version: '2.17.2' }); + const searchClient = createMockedSearchClient({ delay }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + insights: true, + }, + widgetParams: { + javascript: { + indices: [ + { + indexName: 'indexName', + templates: { + item: (props) => props.item.name, + }, + }, + ], + }, + react: { + indices: [ + { + indexName: 'indexName', + itemComponent: (props) => <>{props.item.name}, + }, + ], + }, + vue: {}, + }, + }); + + // Wait for initial results + await act(async () => { + await wait(margin + delay); + await wait(0); + }); + + window.aa.mockClear(); + + const items = document.querySelectorAll('.ais-AutocompleteIndexItem'); + expect(items.length).toBeGreaterThanOrEqual(1); + + fireEvent( + items[0], + new MouseEvent('auxclick', { + bubbles: true, + cancelable: true, + button: 1, + }) + ); + + expect(window.aa).toHaveBeenCalledTimes(1); + expect(window.aa).toHaveBeenCalledWith( + 'clickedObjectIDsAfterSearch', + { + eventName: 'Hit Clicked', + algoliaSource: ['instantsearch', 'instantsearch-internal'], + index: 'indexName', + objectIDs: ['indexName-0'], + positions: [1], + }, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Algolia-API-Key': 'apiKey', + 'X-Algolia-Application-Id': 'appId', + }), + }) + ); + }); + + test('sends a click event for the correct index', async () => { + const delay = 100; + const margin = 10; + window.aa = Object.assign(jest.fn(), { version: '2.17.2' }); + const searchClient = createMockedSearchClient({ delay }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + insights: true, + }, + widgetParams: { + javascript: { + indices: [ + { + indexName: 'indexName', + templates: { + item: (props) => props.item.name, + }, + }, + { + indexName: 'indexName2', + templates: { + item: (props) => props.item.name, + }, + }, + ], + }, + react: { + indices: [ + { + indexName: 'indexName', + itemComponent: (props) => <>{props.item.name}, + }, + { + indexName: 'indexName2', + itemComponent: (props) => <>{props.item.name}, + }, + ], + }, + vue: {}, + }, + }); + + // Wait for initial results + await act(async () => { + await wait(margin + delay); + await wait(0); + }); + + window.aa.mockClear(); + + const allIndices = document.querySelectorAll('.ais-AutocompleteIndex'); + expect(allIndices.length).toBe(2); + + // Click the first item in the second index + const secondIndexItems = allIndices[1].querySelectorAll( + '.ais-AutocompleteIndexItem' + ); + expect(secondIndexItems.length).toBeGreaterThanOrEqual(1); + + userEvent.click(secondIndexItems[0]); + + expect(window.aa).toHaveBeenCalledTimes(1); + expect(window.aa).toHaveBeenCalledWith( + 'clickedObjectIDsAfterSearch', + { + eventName: 'Hit Clicked', + algoliaSource: ['instantsearch', 'instantsearch-internal'], + index: 'indexName2', + objectIDs: ['indexName2-0'], + positions: [1], + }, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Algolia-API-Key': 'apiKey', + 'X-Algolia-Application-Id': 'appId', + }), + }) + ); + }); + }); +}