diff --git a/bundlesize.config.json b/bundlesize.config.json index 6c1b0b5facf..93fc6efaa88 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "122.75 kB" + "maxSize": "123 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.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..faeb1efeb0e 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, + 'Hit Clicked' + ); + }} + onAuxClick={() => { + sendEvent?.( + 'click:internal', + item, + '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 ba44331310e..af67dae7ddd 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 a7e371c5a46..9532683bfb8 100644 --- a/packages/react-instantsearch/src/widgets/Autocomplete.tsx +++ b/packages/react-instantsearch/src/widgets/Autocomplete.tsx @@ -841,7 +841,7 @@ function InnerAutocomplete({ ); } - indicesForPanel.forEach(({ indexId, indexName, hits }) => { + indicesForPanel.forEach(({ indexId, indexName, hits, sendEvent }) => { let elementId = indexName; if (indexName === showQuerySuggestions?.indexName) { elementId = 'suggestions'; @@ -869,6 +869,7 @@ function InnerAutocomplete({ __indexName: indexId, }))} getItemProps={getItemProps} + sendEvent={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..18cfe7d1a4f --- /dev/null +++ b/tests/common/widgets/autocomplete/insights.tsx @@ -0,0 +1,277 @@ +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, + queryID: 'test-query-id', + 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], + queryID: 'test-query-id', + }, + 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], + queryID: 'test-query-id', + }, + 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], + queryID: 'test-query-id', + }, + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Algolia-API-Key': 'apiKey', + 'X-Algolia-Application-Id': 'appId', + }), + }) + ); + }); + }); +} diff --git a/tests/common/widgets/hits/insights.ts b/tests/common/widgets/hits/insights.ts index 11a19a249f3..5fd5397f523 100644 --- a/tests/common/widgets/hits/insights.ts +++ b/tests/common/widgets/hits/insights.ts @@ -51,6 +51,9 @@ export function createInsightsTests( query, page, nbPages: 20, + queryID: query + ? `test-query-id-${query}` + : 'test-query-id', }) ) ); @@ -163,6 +166,7 @@ export function createInsightsTests( query, page, nbPages: 20, + queryID: 'test-query-id', }) ) ); @@ -299,6 +303,7 @@ export function createInsightsTests( query, page, nbPages: 20, + queryID: 'test-query-id', }) ) ); @@ -341,6 +346,7 @@ export function createInsightsTests( index: 'indexName', objectIDs: ['indexName-0'], positions: [1], + queryID: 'test-query-id', }, { headers: { @@ -381,6 +387,7 @@ export function createInsightsTests( query, page, nbPages: 20, + queryID: 'test-query-id', }) ) ); @@ -430,6 +437,7 @@ export function createInsightsTests( index: 'indexName', objectIDs: ['indexName-0'], positions: [1], + queryID: 'test-query-id', }, { headers: { @@ -470,6 +478,7 @@ export function createInsightsTests( query, page, nbPages: 20, + queryID: 'test-query-id', }) ) ); @@ -516,6 +525,7 @@ export function createInsightsTests( index: 'indexName', objectIDs: ['indexName-0'], positions: [1], + queryID: 'test-query-id', }, { headers: { @@ -556,6 +566,7 @@ export function createInsightsTests( query, page, nbPages: 20, + queryID: 'test-query-id', }) ) ); @@ -597,6 +608,7 @@ export function createInsightsTests( algoliaSource: ['instantsearch'], index: 'indexName', objectIDs: ['indexName-0'], + queryID: 'test-query-id', }, { headers: { @@ -613,6 +625,7 @@ export function createInsightsTests( index: 'indexName', objectIDs: ['indexName-0'], positions: [1], + queryID: 'test-query-id', }, { headers: { @@ -653,6 +666,7 @@ export function createInsightsTests( query, page, nbPages: 20, + queryID: 'test-query-id', }) ) ); @@ -695,6 +709,7 @@ export function createInsightsTests( index: 'indexName', objectIDs: ['indexName-0'], positions: [1], + queryID: 'test-query-id', }, { headers: { @@ -735,6 +750,7 @@ export function createInsightsTests( query, page, nbPages: 20, + queryID: 'test-query-id', }) ) ); @@ -779,6 +795,7 @@ export function createInsightsTests( index: 'nested', objectIDs: ['nested-0'], positions: [1], + queryID: 'test-query-id', }, { headers: { @@ -795,6 +812,7 @@ export function createInsightsTests( index: 'indexName', objectIDs: ['indexName-0'], positions: [1], + queryID: 'test-query-id', }, { headers: {