diff --git a/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx b/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx index 389ee7b351..272511e8a8 100644 --- a/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx +++ b/packages/instantsearch.js/src/__tests__/common-widgets.test.tsx @@ -211,6 +211,14 @@ const testSetups: TestSetupsMap = { }, ...widgetParams, }), + hits({ + container: document.body.appendChild( + Object.assign(document.createElement('div'), { + id: 'hits-with-defaults', + }) + ), + ...widgetParams, + }), index({ indexName: 'nested' }).addWidgets([ hits({ container: document.body.appendChild( diff --git a/packages/instantsearch.js/src/widgets/hits/__tests__/hits-integration-test.ts b/packages/instantsearch.js/src/widgets/hits/__tests__/hits-integration-test.ts deleted file mode 100644 index 3748d0c877..0000000000 --- a/packages/instantsearch.js/src/widgets/hits/__tests__/hits-integration-test.ts +++ /dev/null @@ -1,459 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import { createSingleSearchResponse } from '@instantsearch/mocks'; -import { wait } from '@instantsearch/testutils/wait'; -import { getByText, fireEvent } from '@testing-library/dom'; -import userEvent from '@testing-library/user-event'; - -import { hits, configure } from '../..'; -import instantsearch from '../../../index.es'; -import { createInsightsMiddleware } from '../../../middlewares'; - -const createSearchClient = ({ - hitsPerPage, - includeQueryID, -}: { - hitsPerPage: number; - includeQueryID?: boolean; -}) => { - const page = 0; - - return { - 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}`, - ...(includeQueryID && { __queryID: 'test-query-id' }), - })), - }) - ), - }) - ), - applicationID: 'latency', - apiKey: '123', - }; -}; - -const createInstantSearch = ({ - hitsPerPage = 2, -}: { - hitsPerPage?: number; -} = {}) => { - const search = instantsearch({ - indexName: 'instant_search', - searchClient: createSearchClient({ hitsPerPage }), - }); - - search.addWidgets([ - configure({ - hitsPerPage, - }), - ]); - - return { - search, - }; -}; - -describe('hits', () => { - let container: HTMLElement; - - 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.use(insights); - - search.addWidgets([ - hits({ - container, - }), - ]); - search.start(); - await wait(0); - - expect(onEvent).toHaveBeenCalledTimes(1); - expect(onEvent).toHaveBeenCalledWith( - { - eventType: 'view', - eventModifier: 'internal', - hits: [ - { - __position: 1, - objectID: 'object-id0', - title: 'title 1', - }, - { - __position: 2, - objectID: 'object-id1', - title: 'title 2', - }, - ], - insightsMethod: 'viewedObjectIDs', - payload: { - eventName: 'Hits Viewed', - index: 'instant_search', - objectIDs: ['object-id0', 'object-id1'], - }, - widgetType: 'ais.hits', - }, - expect.any(Function) - ); - }); - - test('sends a default `click` event when clicking on a hit', async () => { - const { search } = createInstantSearch(); - const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); - - search.use(insights); - search.addWidgets([hits({ container })]); - search.start(); - - await wait(0); - - onEvent.mockClear(); - - userEvent.click(container.querySelectorAll('.ais-Hits-item')[0]); - - expect(onEvent).toHaveBeenCalledTimes(1); - expect(onEvent).toHaveBeenCalledWith( - { - eventType: 'click', - eventModifier: 'internal', - hits: [ - { - __position: 1, - objectID: 'object-id0', - title: 'title 1', - }, - ], - insightsMethod: 'clickedObjectIDsAfterSearch', - payload: { - eventName: 'Hit Clicked', - index: 'instant_search', - objectIDs: ['object-id0'], - positions: [1], - }, - widgetType: 'ais.hits', - }, - expect.any(Function) - ); - }); - - it('sends `click` event with `sendEvent`', async () => { - const { search } = createInstantSearch(); - const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); - search.use(insights); - - search.addWidgets([ - hits({ - container, - templates: { - item: (item, { html, sendEvent }) => html` - - `, - }, - }), - ]); - search.start(); - await wait(0); - - // view event by render - expect(onEvent).toHaveBeenCalledTimes(1); - onEvent.mockClear(); - - fireEvent.click(getByText(container, 'title 1')); - - // The custom one only - expect(onEvent).toHaveBeenCalledTimes(1); - expect(onEvent.mock.calls[0][0]).toEqual({ - eventType: 'click', - hits: [ - { - __hitIndex: 0, - __position: 1, - objectID: 'object-id0', - title: 'title 1', - }, - ], - insightsMethod: 'clickedObjectIDsAfterSearch', - payload: { - eventName: 'Item Clicked', - index: 'instant_search', - objectIDs: ['object-id0'], - positions: [1], - }, - widgetType: 'ais.hits', - }); - }); - - it('sends `conversion` event with `sendEvent`', async () => { - const { search } = createInstantSearch(); - const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); - search.use(insights); - - search.addWidgets([ - hits({ - container, - templates: { - item: (item, { html, sendEvent }) => html` - - `, - }, - }), - ]); - search.start(); - await wait(0); - - // view event by render - expect(onEvent).toHaveBeenCalledTimes(1); - onEvent.mockClear(); - - fireEvent.click(getByText(container, 'title 2')); - // The custom one + default click - expect(onEvent).toHaveBeenCalledTimes(2); - expect(onEvent.mock.calls[0][0]).toEqual({ - eventType: 'conversion', - hits: [ - { - __hitIndex: 1, - __position: 2, - objectID: 'object-id1', - title: 'title 2', - }, - ], - insightsMethod: 'convertedObjectIDsAfterSearch', - payload: { - eventName: 'Product Ordered', - index: 'instant_search', - objectIDs: ['object-id1'], - }, - widgetType: 'ais.hits', - }); - expect(onEvent.mock.calls[1][0]).toEqual({ - eventType: 'click', - eventModifier: 'internal', - hits: [ - { - __position: 2, - objectID: 'object-id1', - title: 'title 2', - }, - ], - insightsMethod: 'clickedObjectIDsAfterSearch', - payload: { - eventName: 'Hit Clicked', - index: 'instant_search', - objectIDs: ['object-id1'], - positions: [2], - }, - widgetType: 'ais.hits', - }); - }); - - it('sends `click` event with `bindEvent`', async () => { - const { search } = createInstantSearch(); - const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); - search.use(insights); - - search.addWidgets([ - hits({ - container, - templates: { - item: (item, bindEvent) => ` - - `, - }, - }), - ]); - search.start(); - await wait(0); - - // view event by render - expect(onEvent).toHaveBeenCalledTimes(1); - onEvent.mockClear(); - - fireEvent.click(getByText(container, 'title 1')); - // The custom one only - expect(onEvent).toHaveBeenCalledTimes(1); - expect(onEvent.mock.calls[0][0]).toEqual({ - eventType: 'click', - hits: [ - { - __hitIndex: 0, - __position: 1, - objectID: 'object-id0', - title: 'title 1', - }, - ], - insightsMethod: 'clickedObjectIDsAfterSearch', - payload: { - eventName: 'Item Clicked', - index: 'instant_search', - objectIDs: ['object-id0'], - positions: [1], - }, - widgetType: 'ais.hits', - }); - }); - - it('sends `conversion` event with `bindEvent`', async () => { - const { search } = createInstantSearch(); - const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); - search.use(insights); - - search.addWidgets([ - hits({ - container, - templates: { - item: (item, bindEvent) => ` - - `, - }, - }), - ]); - search.start(); - await wait(0); - - // view event by render - expect(onEvent).toHaveBeenCalledTimes(1); - onEvent.mockClear(); - - fireEvent.click(getByText(container, 'title 2')); - - // The custom one + default click - expect(onEvent).toHaveBeenCalledTimes(2); - expect(onEvent.mock.calls[0][0]).toEqual({ - eventType: 'conversion', - hits: [ - { - __hitIndex: 1, - __position: 2, - objectID: 'object-id1', - title: 'title 2', - }, - ], - insightsMethod: 'convertedObjectIDsAfterSearch', - payload: { - eventName: 'Product Ordered', - index: 'instant_search', - objectIDs: ['object-id1'], - }, - widgetType: 'ais.hits', - }); - expect(onEvent.mock.calls[1][0]).toEqual({ - eventType: 'click', - eventModifier: 'internal', - hits: [ - { - __position: 2, - objectID: 'object-id1', - title: 'title 2', - }, - ], - insightsMethod: 'clickedObjectIDsAfterSearch', - payload: { - eventName: 'Hit Clicked', - index: 'instant_search', - objectIDs: ['object-id1'], - positions: [2], - }, - widgetType: 'ais.hits', - }); - }); - }); - - describe('old insights methods', () => { - it('sends event', async () => { - const aa = jest.fn(); - const hitsPerPage = 2; - const search = instantsearch({ - indexName: 'instant_search', - searchClient: createSearchClient({ - hitsPerPage, - includeQueryID: true, - }), - insightsClient: aa, - }); - - search.addWidgets([ - configure({ - hitsPerPage, - }), - ]); - - search.addWidgets([ - hits({ - container, - templates: { - item: (item) => ` - - `, - }, - }), - ]); - search.start(); - await wait(0); - - fireEvent.click(getByText(container, 'title 1')); - expect(aa).toHaveBeenCalledTimes(1); - expect(aa).toHaveBeenCalledWith('clickedObjectIDsAfterSearch', { - eventName: 'Add to cart', - index: undefined, - objectIDs: ['object-id0'], - positions: [1], - queryID: 'test-query-id', - }); - }); - }); -}); diff --git a/packages/instantsearch.js/src/widgets/hits/__tests__/hits-test.ts b/packages/instantsearch.js/src/widgets/hits/__tests__/hits-test.ts deleted file mode 100644 index 2d088b4a64..0000000000 --- a/packages/instantsearch.js/src/widgets/hits/__tests__/hits-test.ts +++ /dev/null @@ -1,376 +0,0 @@ -/** - * @jest-environment jsdom - */ - -import { createSingleSearchResponse } from '@instantsearch/mocks'; -import { castToJestMock } from '@instantsearch/testutils/castToJestMock'; -import algoliasearchHelper, { - SearchParameters, - SearchResults, -} from 'algoliasearch-helper'; -import { render as preactRender } from 'preact'; - -import { createInstantSearch } from '../../../../test/createInstantSearch'; -import { - createInitOptions, - createRenderOptions, -} from '../../../../test/createWidget'; -import hits from '../hits'; - -import type { HitsProps } from '../../../components/Hits/Hits'; -import type { SearchClient } from '../../../types'; -import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; -import type { VNode } from 'preact'; - -const render = castToJestMock(preactRender); -jest.mock('preact', () => { - const module = jest.requireActual('preact'); - - module.render = jest.fn(); - - return module; -}); - -describe('Usage', () => { - it('throws without container', () => { - expect(() => { - // @ts-expect-error - hits({ container: undefined }); - }).toThrowErrorMatchingInlineSnapshot(` -"The \`container\` option is required. - -See documentation: https://www.algolia.com/doc/api-reference/widgets/hits/js/" -`); - }); -}); - -describe('hits()', () => { - let container: HTMLElement; - let widget: ReturnType; - let results: SearchResults; - let helper: AlgoliaSearchHelper; - - beforeEach(() => { - render.mockClear(); - - helper = algoliasearchHelper({} as SearchClient, '', {}); - container = document.createElement('div'); - widget = hits({ container, cssClasses: { root: ['root', 'cx'] } }); - widget.init!( - createInitOptions({ - helper, - instantSearchInstance: createInstantSearch({ - templatesConfig: undefined, - }), - }) - ); - results = new SearchResults(helper.state, [ - createSingleSearchResponse({ - hits: [{ objectID: '1', hit: 'first' }], - hitsPerPage: 4, - page: 2, - }), - ]); - }); - - it('calls twice render(, container)', () => { - widget.render!(createRenderOptions({ results })); - widget.render!(createRenderOptions({ results })); - - const firstRender = render.mock.calls[0][0] as VNode; - const secondRender = render.mock.calls[1][0] as VNode; - const firstContainer = render.mock.calls[0][1]; - const secondContainer = render.mock.calls[1][1]; - - expect(render).toHaveBeenCalledTimes(2); - expect(firstRender.props).toMatchInlineSnapshot(` - { - "bindEvent": [Function], - "cssClasses": { - "emptyRoot": "ais-Hits--empty", - "item": "ais-Hits-item", - "list": "ais-Hits-list", - "root": "ais-Hits root cx", - }, - "hits": [ - { - "__position": 9, - "hit": "first", - "objectID": "1", - }, - ], - "insights": [Function], - "results": SearchResults { - "_rawResults": [ - { - "exhaustiveFacetsCount": true, - "exhaustiveNbHits": true, - "hits": [ - { - "hit": "first", - "objectID": "1", - }, - ], - "hitsPerPage": 4, - "nbHits": 1, - "nbPages": 1, - "page": 2, - "params": "", - "processingTimeMS": 0, - "query": "", - }, - ], - "_state": SearchParameters { - "disjunctiveFacets": [], - "disjunctiveFacetsRefinements": {}, - "facets": [], - "facetsExcludes": {}, - "facetsRefinements": {}, - "hierarchicalFacets": [], - "hierarchicalFacetsRefinements": {}, - "index": "", - "numericRefinements": {}, - "tagRefinements": [], - }, - "disjunctiveFacets": [], - "exhaustiveFacetsCount": true, - "exhaustiveNbHits": true, - "facets": [], - "hierarchicalFacets": [], - "hits": [ - { - "hit": "first", - "objectID": "1", - }, - ], - "hitsPerPage": 4, - "nbHits": 1, - "nbPages": 1, - "page": 2, - "params": "", - "processingTimeMS": 0, - "query": "", - }, - "sendEvent": [Function], - "templateProps": { - "templates": { - "empty": [Function], - "item": [Function], - }, - "templatesConfig": undefined, - "useCustomCompileOptions": { - "empty": false, - "item": false, - }, - }, - } - `); - expect(firstContainer).toEqual(container); - expect(secondRender.props).toMatchInlineSnapshot(` - { - "bindEvent": [Function], - "cssClasses": { - "emptyRoot": "ais-Hits--empty", - "item": "ais-Hits-item", - "list": "ais-Hits-list", - "root": "ais-Hits root cx", - }, - "hits": [ - { - "__position": 9, - "hit": "first", - "objectID": "1", - }, - ], - "insights": [Function], - "results": SearchResults { - "_rawResults": [ - { - "exhaustiveFacetsCount": true, - "exhaustiveNbHits": true, - "hits": [ - { - "hit": "first", - "objectID": "1", - }, - ], - "hitsPerPage": 4, - "nbHits": 1, - "nbPages": 1, - "page": 2, - "params": "", - "processingTimeMS": 0, - "query": "", - }, - ], - "_state": SearchParameters { - "disjunctiveFacets": [], - "disjunctiveFacetsRefinements": {}, - "facets": [], - "facetsExcludes": {}, - "facetsRefinements": {}, - "hierarchicalFacets": [], - "hierarchicalFacetsRefinements": {}, - "index": "", - "numericRefinements": {}, - "tagRefinements": [], - }, - "disjunctiveFacets": [], - "exhaustiveFacetsCount": true, - "exhaustiveNbHits": true, - "facets": [], - "hierarchicalFacets": [], - "hits": [ - { - "hit": "first", - "objectID": "1", - }, - ], - "hitsPerPage": 4, - "nbHits": 1, - "nbPages": 1, - "page": 2, - "params": "", - "processingTimeMS": 0, - "query": "", - }, - "sendEvent": [Function], - "templateProps": { - "templates": { - "empty": [Function], - "item": [Function], - }, - "templatesConfig": undefined, - "useCustomCompileOptions": { - "empty": false, - "item": false, - }, - }, - } - `); - expect(secondContainer).toEqual(container); - }); - - it('renders transformed items', () => { - widget = hits({ - container, - transformItems: (items) => - items.map((item) => ({ ...item, transformed: true })), - }); - - widget.init!( - createInitOptions({ - helper, - instantSearchInstance: createInstantSearch({ - templatesConfig: undefined, - }), - }) - ); - widget.render!(createRenderOptions({ results })); - - const firstRender = render.mock.calls[0][0] as VNode; - - expect(firstRender.props).toMatchInlineSnapshot(` - { - "bindEvent": [Function], - "cssClasses": { - "emptyRoot": "ais-Hits--empty", - "item": "ais-Hits-item", - "list": "ais-Hits-list", - "root": "ais-Hits", - }, - "hits": [ - { - "__position": 9, - "hit": "first", - "objectID": "1", - "transformed": true, - }, - ], - "insights": [Function], - "results": SearchResults { - "_rawResults": [ - { - "exhaustiveFacetsCount": true, - "exhaustiveNbHits": true, - "hits": [ - { - "hit": "first", - "objectID": "1", - }, - ], - "hitsPerPage": 4, - "nbHits": 1, - "nbPages": 1, - "page": 2, - "params": "", - "processingTimeMS": 0, - "query": "", - }, - ], - "_state": SearchParameters { - "disjunctiveFacets": [], - "disjunctiveFacetsRefinements": {}, - "facets": [], - "facetsExcludes": {}, - "facetsRefinements": {}, - "hierarchicalFacets": [], - "hierarchicalFacetsRefinements": {}, - "index": "", - "numericRefinements": {}, - "tagRefinements": [], - }, - "disjunctiveFacets": [], - "exhaustiveFacetsCount": true, - "exhaustiveNbHits": true, - "facets": [], - "hierarchicalFacets": [], - "hits": [ - { - "hit": "first", - "objectID": "1", - }, - ], - "hitsPerPage": 4, - "nbHits": 1, - "nbPages": 1, - "page": 2, - "params": "", - "processingTimeMS": 0, - "query": "", - }, - "sendEvent": [Function], - "templateProps": { - "templates": { - "empty": [Function], - "item": [Function], - }, - "templatesConfig": undefined, - "useCustomCompileOptions": { - "empty": false, - "item": false, - }, - }, - } - `); - }); - - it('should add __position key with absolute position', () => { - results = new SearchResults(helper.state, [ - createSingleSearchResponse({ - hits: [{ objectID: '1', hit: 'first' }], - hitsPerPage: 10, - page: 4, - }), - ]); - const state = new SearchParameters({ page: results.page }); - - widget.render!(createRenderOptions({ results, state })); - - expect(render).toHaveBeenCalledTimes(1); - const firstRender = render.mock.calls[0][0] as VNode; - const props = firstRender.props as HitsProps; - - expect(props.hits[0].__position).toEqual(41); - }); -}); diff --git a/packages/instantsearch.js/src/widgets/hits/__tests__/hits.test.tsx b/packages/instantsearch.js/src/widgets/hits/__tests__/hits.test.tsx index 63de3bca7d..c1252e911e 100644 --- a/packages/instantsearch.js/src/widgets/hits/__tests__/hits.test.tsx +++ b/packages/instantsearch.js/src/widgets/hits/__tests__/hits.test.tsx @@ -8,10 +8,13 @@ import { createSingleSearchResponse, } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { within, fireEvent } from '@testing-library/dom'; +import { within, fireEvent, getByText } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; import { Fragment, h } from 'preact'; import instantsearch from '../../../index.es'; +import { createInsightsMiddleware } from '../../../middlewares'; +import configure from '../../configure/configure'; import searchBox from '../../search-box/search-box'; import hits from '../hits'; @@ -22,6 +25,139 @@ beforeEach(() => { }); describe('hits', () => { + describe('options', () => { + test('throws without a `container`', () => { + expect(() => { + const searchClient = createSearchClient(); + + const search = instantsearch({ + indexName: 'indexName', + searchClient, + }); + + search.addWidgets([ + hits({ + // @ts-expect-error + container: undefined, + }), + ]); + }).toThrowErrorMatchingInlineSnapshot(` +"The \`container\` option is required. + +See documentation: https://www.algolia.com/doc/api-reference/widgets/hits/js/" +`); + }); + + test('adds custom CSS classes', async () => { + const container = document.createElement('div'); + const searchClient = createMockedSearchClient(); + + const search = instantsearch({ + indexName: 'indexName', + searchClient, + initialUiState: { + indexName: { + refinementList: { + brand: ['Apple', 'Samsung'], + categories: ['Audio'], + }, + }, + }, + }); + + search.addWidgets([ + hits({ + container, + cssClasses: { + root: 'ROOT', + emptyRoot: 'EMPTY_ROOT', + list: 'LIST', + item: 'ITEM', + }, + }), + ]); + + search.start(); + + await wait(0); + + expect(container.querySelector('.ais-Hits')).toHaveClass('ROOT'); + expect(container.querySelector('.ais-Hits-list')).toHaveClass('LIST'); + expect(container.querySelector('.ais-Hits-item')).toHaveClass('ITEM'); + }); + + type CustomHit = { name: string; description: string }; + + function createMockedSearchClient( + subset: Partial> = {} + ) { + return createSearchClient({ + search: jest.fn((requests) => { + return Promise.resolve( + createMultiSearchResponse( + ...requests.map((request) => { + return createSingleSearchResponse({ + index: request.indexName, + query: request.params?.query, + hits: + request.params?.query === 'query with no results' + ? [] + : [ + { + objectID: '1', + name: 'Apple iPhone smartphone', + description: 'A smartphone by Apple.', + _highlightResult: { + name: { + value: `Apple iPhone smartphone`, + matchLevel: 'full' as const, + matchedWords: ['smartphone'], + }, + }, + _snippetResult: { + name: { + value: `Apple iPhone smartphone`, + matchLevel: 'full' as const, + }, + description: { + value: `A smartphone by Apple.`, + matchLevel: 'full' as const, + }, + }, + }, + { + objectID: '2', + name: 'Samsung Galaxy smartphone', + description: 'A smartphone by Samsung.', + _highlightResult: { + name: { + value: `Samsung Galaxy smartphone`, + matchLevel: 'full' as const, + matchedWords: ['smartphone'], + }, + }, + _snippetResult: { + name: { + value: `Samsung Galaxy smartphone`, + matchLevel: 'full' as const, + }, + description: { + value: `A smartphone by Samsung.`, + matchLevel: 'full' as const, + }, + }, + }, + ], + ...subset, + }); + }) + ) + ); + }), + }); + } + }); + describe('templates', () => { test('renders default templates', async () => { const container = document.createElement('div'); @@ -676,4 +812,456 @@ describe('hits', () => { }); } }); + + describe('insights', () => { + const createInsightsMiddlewareWithOnEvent = () => { + const onEvent = jest.fn(); + + const insights = createInsightsMiddleware({ + insightsClient: null, + onEvent, + }); + + return { onEvent, insights }; + }; + + test('sends view event when hits are rendered', async () => { + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createMockedSearchClient(), + }); + + search.use(insights); + search.addWidgets([hits({ container: document.createElement('div') })]); + search.start(); + + await wait(0); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith( + { + eventType: 'view', + eventModifier: 'internal', + hits: [ + { + __position: 1, + objectID: '1', + name: 'Name 1', + }, + { + __position: 2, + objectID: '2', + name: 'Name 2', + }, + ], + insightsMethod: 'viewedObjectIDs', + payload: { + eventName: 'Hits Viewed', + index: 'indexName', + objectIDs: ['1', '2'], + }, + widgetType: 'ais.hits', + }, + expect.any(Function) + ); + }); + + test('sends a default `click` event when clicking on a hit', async () => { + const container = document.createElement('div'); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createMockedSearchClient(), + }); + + search.use(insights); + search.addWidgets([hits({ container })]); + search.start(); + + await wait(0); + + onEvent.mockClear(); + + userEvent.click(container.querySelectorAll('.ais-Hits-item')[0]); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith( + { + eventType: 'click', + eventModifier: 'internal', + hits: [ + { + __position: 1, + objectID: '1', + name: 'Name 1', + }, + ], + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Hit Clicked', + index: 'indexName', + objectIDs: ['1'], + positions: [1], + }, + widgetType: 'ais.hits', + }, + expect.any(Function) + ); + }); + + test('sends `click` event with `sendEvent`', async () => { + const container = document.createElement('div'); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createMockedSearchClient(), + }); + + search.use(insights); + + search.addWidgets([ + hits({ + container, + templates: { + item: (item, { html, sendEvent }) => html` + + `, + }, + }), + ]); + + search.start(); + + await wait(0); + + // view event by render + expect(onEvent).toHaveBeenCalledTimes(1); + onEvent.mockClear(); + + fireEvent.click(getByText(container, 'Name 1')); + + // The custom one only + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0][0]).toEqual({ + eventType: 'click', + hits: [ + { + __hitIndex: 0, + __position: 1, + objectID: '1', + name: 'Name 1', + }, + ], + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Item Clicked', + index: 'indexName', + objectIDs: ['1'], + positions: [1], + }, + widgetType: 'ais.hits', + }); + }); + + test('sends `conversion` event with `sendEvent`', async () => { + const container = document.createElement('div'); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createMockedSearchClient(), + }); + + search.use(insights); + + search.addWidgets([ + hits({ + container, + templates: { + item: (item, { html, sendEvent }) => html` + + `, + }, + }), + ]); + + search.start(); + + await wait(0); + + // view event by render + expect(onEvent).toHaveBeenCalledTimes(1); + onEvent.mockClear(); + + fireEvent.click(getByText(container, 'Name 2')); + // The custom one + default click + expect(onEvent).toHaveBeenCalledTimes(2); + expect(onEvent.mock.calls[0][0]).toEqual({ + eventType: 'conversion', + hits: [ + { + __hitIndex: 1, + __position: 2, + objectID: '2', + name: 'Name 2', + }, + ], + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: 'indexName', + objectIDs: ['2'], + }, + widgetType: 'ais.hits', + }); + expect(onEvent.mock.calls[1][0]).toEqual({ + eventType: 'click', + eventModifier: 'internal', + hits: [ + { + __position: 2, + objectID: '2', + name: 'Name 2', + }, + ], + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Hit Clicked', + index: 'indexName', + objectIDs: ['2'], + positions: [2], + }, + widgetType: 'ais.hits', + }); + }); + + test('sends `click` event with `bindEvent`', async () => { + const container = document.createElement('div'); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createMockedSearchClient(), + }); + + search.use(insights); + + search.addWidgets([ + hits({ + container, + templates: { + item: (item, bindEvent) => ` + + `, + }, + }), + ]); + search.start(); + await wait(0); + + // view event by render + expect(onEvent).toHaveBeenCalledTimes(1); + onEvent.mockClear(); + + fireEvent.click(getByText(container, 'Name 1')); + // The custom one only + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent.mock.calls[0][0]).toEqual({ + eventType: 'click', + hits: [ + { + __hitIndex: 0, + __position: 1, + objectID: '1', + name: 'Name 1', + }, + ], + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Item Clicked', + index: 'indexName', + objectIDs: ['1'], + positions: [1], + }, + widgetType: 'ais.hits', + }); + }); + + test('sends `conversion` event with `bindEvent`', async () => { + const container = document.createElement('div'); + const { insights, onEvent } = createInsightsMiddlewareWithOnEvent(); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createMockedSearchClient(), + }); + + search.use(insights); + + search.addWidgets([ + hits({ + container, + templates: { + item: (item, bindEvent) => ` + + `, + }, + }), + ]); + search.start(); + await wait(0); + + // view event by render + expect(onEvent).toHaveBeenCalledTimes(1); + onEvent.mockClear(); + + fireEvent.click(getByText(container, 'Name 2')); + + // The custom one + default click + expect(onEvent).toHaveBeenCalledTimes(2); + expect(onEvent.mock.calls[0][0]).toEqual({ + eventType: 'conversion', + hits: [ + { + __hitIndex: 1, + __position: 2, + objectID: '2', + name: 'Name 2', + }, + ], + insightsMethod: 'convertedObjectIDsAfterSearch', + payload: { + eventName: 'Product Ordered', + index: 'indexName', + objectIDs: ['2'], + }, + widgetType: 'ais.hits', + }); + expect(onEvent.mock.calls[1][0]).toEqual({ + eventType: 'click', + eventModifier: 'internal', + hits: [ + { + __position: 2, + objectID: '2', + name: 'Name 2', + }, + ], + insightsMethod: 'clickedObjectIDsAfterSearch', + payload: { + eventName: 'Hit Clicked', + index: 'indexName', + objectIDs: ['2'], + positions: [2], + }, + widgetType: 'ais.hits', + }); + }); + + describe('old insights methods', () => { + it('sends event', async () => { + const aa = jest.fn(); + const hitsPerPage = 2; + const search = instantsearch({ + indexName: 'indexName', + searchClient: createMockedSearchClient({ + hitsPerPage, + clickAnalytics: true, + }), + insightsClient: aa, + }); + + const container = document.createElement('div'); + + search.addWidgets([configure({ hitsPerPage })]); + + search.addWidgets([ + hits({ + container, + templates: { + item: (item) => ` + + `, + }, + }), + ]); + search.start(); + await wait(0); + + fireEvent.click(getByText(container, 'Name 1')); + expect(aa).toHaveBeenCalledTimes(1); + expect(aa).toHaveBeenCalledWith('clickedObjectIDsAfterSearch', { + eventName: 'Add to cart', + index: 'indexName', + objectIDs: ['1'], + positions: [1], + queryID: 'test-query-id', + }); + }); + }); + + type CustomHit = { name: string }; + + function createMockedSearchClient( + subset: Partial> & { + clickAnalytics?: boolean; + } = { hitsPerPage: 2, page: 0, clickAnalytics: false } + ) { + return createSearchClient({ + search: jest.fn((requests) => { + return Promise.resolve( + createMultiSearchResponse( + ...requests.map((request) => { + return createSingleSearchResponse({ + index: request.indexName, + query: request.params?.query, + hits: Array(subset.hitsPerPage) + .fill(undefined) + .map((_, index) => ({ + objectID: `${index + 1}`, + name: `Name ${index + 1}`, + ...(subset.clickAnalytics && { + __queryID: 'test-query-id', + }), + })), + ...subset, + }); + }) + ) + ); + }), + }); + } + }); }); diff --git a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx index 7f91830d71..e664233237 100644 --- a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx @@ -184,6 +184,9 @@ const testSetups: TestSetupsMap = { +
+ +
diff --git a/packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx index 8ed42a3a43..5e12894267 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/Hits.test.tsx @@ -17,38 +17,18 @@ import type { MockSearchClient } from '@instantsearch/mocks'; import type { AlgoliaHit } from 'instantsearch.js'; describe('Hits', () => { - test('renders with default props', async () => { - const { container } = render( - - - - ); - - await waitFor(() => { - expect(container.querySelector('.ais-Hits')).toMatchInlineSnapshot(` -
-
    -
- `); - }); - }); - - test('renders with a non-default hit shape', async () => { - type CustomHit = { + test('renders with a custom hit component', async () => { + type CustomRecord = { somethingSpecial: string; }; - const client = createSearchClient({ + const searchClient = createSearchClient({ search: jest.fn((requests) => Promise.resolve( createMultiSearchResponse( ...requests.map( (request: Parameters[0][number]) => - createSingleSearchResponse>({ + createSingleSearchResponse>({ hits: [ { objectID: '1', somethingSpecial: 'a' }, { objectID: '2', somethingSpecial: 'b' }, @@ -63,8 +43,8 @@ describe('Hits', () => { }); const { container } = render( - - + + hitComponent={({ hit }) => ( {`${hit.__position} - ${hit.somethingSpecial}`} )} diff --git a/packages/vue-instantsearch/src/__tests__/common-widgets.test.js b/packages/vue-instantsearch/src/__tests__/common-widgets.test.js index ce268a16f3..04b39ce634 100644 --- a/packages/vue-instantsearch/src/__tests__/common-widgets.test.js +++ b/packages/vue-instantsearch/src/__tests__/common-widgets.test.js @@ -247,6 +247,9 @@ const testSetups = { ), }, }), + h('div', { attrs: { id: 'hits-with-defaults' } }, [ + h(AisHits, { props: widgetParams }), + ]), h(AisIndex, { props: { indexName: 'nested' } }, [ h(AisHits, { attrs: { id: 'nested-hits' }, diff --git a/packages/vue-instantsearch/src/components/__tests__/Hits.js b/packages/vue-instantsearch/src/components/__tests__/Hits.js index 2ead217ad0..73fa9c0304 100644 --- a/packages/vue-instantsearch/src/components/__tests__/Hits.js +++ b/packages/vue-instantsearch/src/components/__tests__/Hits.js @@ -28,32 +28,6 @@ it('accepts an escapeHTML prop', () => { expect(wrapper.vm.widgetParams.escapeHTML).toBe(true); }); -it('accepts a transformItems prop', () => { - __setState({ - ...defaultState, - }); - - const transformItems = () => {}; - - const wrapper = mount(Hits, { - propsData: { - transformItems, - }, - }); - - expect(wrapper.vm.widgetParams.transformItems).toBe(transformItems); -}); - -it('renders correctly', () => { - __setState({ - ...defaultState, - }); - - const wrapper = mount(Hits); - - expect(wrapper.html()).toMatchSnapshot(); -}); - it('exposes insights prop to the default slot', async () => { const insights = jest.fn(); __setState({ diff --git a/packages/vue-instantsearch/src/components/__tests__/__snapshots__/Hits.js.snap b/packages/vue-instantsearch/src/components/__tests__/__snapshots__/Hits.js.snap deleted file mode 100644 index f00af68362..0000000000 --- a/packages/vue-instantsearch/src/components/__tests__/__snapshots__/Hits.js.snap +++ /dev/null @@ -1,14 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`renders correctly 1`] = ` -
-
    -
  1. - objectID: one, index: 0 -
  2. -
  3. - objectID: two, index: 1 -
  4. -
-
-`; diff --git a/tests/common/widgets/hits/index.ts b/tests/common/widgets/hits/index.ts index 85c7624f7a..3bcec8ee7e 100644 --- a/tests/common/widgets/hits/index.ts +++ b/tests/common/widgets/hits/index.ts @@ -1,6 +1,7 @@ import { fakeAct } from '../../common'; import { createInsightsTests } from './insights'; +import { createOptionsTests } from './options'; import type { TestOptions, TestSetup } from '../../common'; import type { HitsWidget } from 'instantsearch.js/es/widgets/hits/hits'; @@ -20,5 +21,6 @@ export function createHitsWidgetTests( describe('Hits widget common tests', () => { createInsightsTests(setup, { act, skippedTests }); + createOptionsTests(setup, { act, skippedTests }); }); } diff --git a/tests/common/widgets/hits/insights.ts b/tests/common/widgets/hits/insights.ts index 903a611f48..363c2db444 100644 --- a/tests/common/widgets/hits/insights.ts +++ b/tests/common/widgets/hits/insights.ts @@ -81,7 +81,7 @@ export function createInsightsTests( // View event called for each index once { - expect(window.aa).toHaveBeenCalledTimes(2); + expect(window.aa).toHaveBeenCalledTimes(3); expect(window.aa).toHaveBeenCalledWith( 'viewedObjectIDs', { @@ -132,7 +132,7 @@ export function createInsightsTests( // @TODO: This is a bug, we should not send a view event when the results are the same. // see: https://github.com/algolia/instantsearch/issues/5442 - expect(window.aa).toHaveBeenCalledTimes(2); + expect(window.aa).toHaveBeenCalledTimes(3); } }); @@ -195,7 +195,7 @@ export function createInsightsTests( // View event called for each index, batched in chunks of 20 { - expect(window.aa).toHaveBeenCalledTimes(4); + expect(window.aa).toHaveBeenCalledTimes(6); expect(window.aa).toHaveBeenCalledWith( 'viewedObjectIDs', { diff --git a/tests/common/widgets/hits/options.ts b/tests/common/widgets/hits/options.ts new file mode 100644 index 0000000000..68faec5567 --- /dev/null +++ b/tests/common/widgets/hits/options.ts @@ -0,0 +1,156 @@ +import { + createMultiSearchResponse, + createSearchClient, + createSingleSearchResponse, +} from '@instantsearch/mocks'; +import { + normalizeSnapshot as commonNormalizeSnapshot, + wait, +} from '@instantsearch/testutils'; + +import type { HitsWidgetSetup } from '.'; +import type { TestOptions } from '../../common'; +import type { Hit, SearchResponse } from 'instantsearch.js'; + +function normalizeSnapshot(html: string) { + // Each flavor has its own way to render the hit by default. + // @MAJOR: Remove this once all flavors are aligned. + return commonNormalizeSnapshot(html) + .replace( + /(?:)?\s*?({.+?})…?\s*?(?:<\/div>)?/gs, + (_, captured) => { + return (captured as string) + .replace(/\s/g, '') + .replace(/,"__position":\d/, ''); + } + ) + .replace(/\s{0,}(objectID): (.+?), index: \d\s{0,}/gs, (_, ...captured) => { + return `{"objectID":"${captured[1]}"}`; + }); +} + +export function createOptionsTests( + setup: HitsWidgetSetup, + { act }: Required +) { + describe('options', () => { + test('renders with default props', async () => { + const searchClient = createMockedSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: {}, + }); + + await act(async () => { + await wait(0); + }); + + expect( + document.querySelector('#hits-with-defaults .ais-Hits') + ).toMatchNormalizedInlineSnapshot( + normalizeSnapshot, + ` +
+
    +
  1. + {"objectID":"1"} +
  2. +
  3. + {"objectID":"2"} +
  4. +
+
+ ` + ); + }); + + test('renders transformed items', async () => { + const searchClient = createMockedSearchClient(); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + transformItems(items) { + return (items as unknown as Array>).map( + (item) => ({ + ...item, + objectID: `(${item.objectID})`, + }) + ); + }, + }, + }); + + await act(async () => { + await wait(0); + }); + + expect( + document.querySelector('#hits-with-defaults .ais-Hits') + ).toMatchNormalizedInlineSnapshot( + normalizeSnapshot, + ` +
+
    +
  1. + {"objectID":"(1)"} +
  2. +
  3. + {"objectID":"(2)"} +
  4. +
+
+ ` + ); + }); + }); +} + +type CustomRecord = { name: string; description: string }; + +function createMockedSearchClient( + subset: Partial> = {} +) { + return createSearchClient({ + search: jest.fn((requests) => { + return Promise.resolve( + createMultiSearchResponse( + ...requests.map((request) => { + return createSingleSearchResponse({ + index: request.indexName, + query: request.params?.query, + hits: + request.params?.query === 'query with no results' + ? [] + : [{ objectID: '1' }, { objectID: '2' }], + ...subset, + }); + }) + ) + ); + }), + }); +}