From 5bfd8502fb94b2bc7519d2695ce14439e9a5daac Mon Sep 17 00:00:00 2001 From: Raed Date: Mon, 10 Feb 2025 08:15:54 +0100 Subject: [PATCH 01/15] feat(recommend): add Trending-Facets model --- examples/js/getting-started/src/app.js | 79 ----- examples/react/getting-started/src/App.tsx | 25 +- .../types/algoliasearch.d.ts | 18 ++ .../src/components/Carousel.tsx | 56 ++-- .../src/components/TrendingFacets.tsx | 158 ++++++++++ .../src/components/index.ts | 1 + .../src/types/Recommend.ts | 18 ++ .../instantsearch.js/src/connectors/index.ts | 1 + .../trending-facets/connectTrendingFacets.ts | 173 +++++++++++ .../instantsearch.js/src/widgets/index.ts | 1 + .../trending-facets/trending-facets.tsx | 271 ++++++++++++++++++ .../src/connectors/useTrendingFacets.ts | 26 ++ .../react-instantsearch-core/src/index.ts | 1 + .../src/widgets/TrendingFacets.tsx | 84 ++++++ .../__tests__/__utils__/all-widgets.tsx | 9 + .../widgets/__tests__/all-widgets.test.tsx | 5 + .../react-instantsearch/src/widgets/index.ts | 1 + 17 files changed, 814 insertions(+), 113 deletions(-) delete mode 100644 examples/js/getting-started/src/app.js create mode 100644 packages/instantsearch-ui-components/src/components/TrendingFacets.tsx create mode 100644 packages/instantsearch.js/src/connectors/trending-facets/connectTrendingFacets.ts create mode 100644 packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx create mode 100644 packages/react-instantsearch-core/src/connectors/useTrendingFacets.ts create mode 100644 packages/react-instantsearch/src/widgets/TrendingFacets.tsx diff --git a/examples/js/getting-started/src/app.js b/examples/js/getting-started/src/app.js deleted file mode 100644 index 88bfc54a49..0000000000 --- a/examples/js/getting-started/src/app.js +++ /dev/null @@ -1,79 +0,0 @@ -import { liteClient as algoliasearch } from 'algoliasearch/lite'; -import instantsearch from 'instantsearch.js'; -import { carousel } from 'instantsearch.js/es/templates'; -import { - configure, - hits, - pagination, - panel, - refinementList, - searchBox, - trendingItems, -} from 'instantsearch.js/es/widgets'; - -import 'instantsearch.css/themes/satellite.css'; - -const searchClient = algoliasearch( - 'latency', - '6be0576ff61c053d5f9a3225e2a90f76' -); - -const search = instantsearch({ - indexName: 'instant_search', - searchClient, - insights: true, -}); - -search.addWidgets([ - searchBox({ - container: '#searchbox', - }), - hits({ - container: '#hits', - templates: { - item: (hit, { html, components }) => html` -
-

- ${components.Highlight({ hit, attribute: 'name' })} -

-

${components.Highlight({ hit, attribute: 'description' })}

- See product -
- `, - }, - }), - configure({ - hitsPerPage: 8, - }), - panel({ - templates: { header: 'brand' }, - })(refinementList)({ - container: '#brand-list', - attribute: 'brand', - }), - pagination({ - container: '#pagination', - }), - trendingItems({ - container: '#trending', - limit: 6, - templates: { - item: (item, { html }) => html` -
- -
- `, - layout: carousel(), - }, - }), -]); - -search.start(); diff --git a/examples/react/getting-started/src/App.tsx b/examples/react/getting-started/src/App.tsx index 857391877e..dfd6ad741d 100644 --- a/examples/react/getting-started/src/App.tsx +++ b/examples/react/getting-started/src/App.tsx @@ -9,7 +9,7 @@ import { Pagination, RefinementList, SearchBox, - TrendingItems, + TrendingFacets, Carousel, } from 'react-instantsearch'; @@ -61,10 +61,15 @@ export function App() {
- ( +
+ {item.facetName}:{item.facetValue} +
+ )} />
@@ -96,17 +101,3 @@ function HitComponent({ hit }: { hit: HitType }) { ); } - -function ItemComponent({ item }: { item: Hit }) { - return ( -
- -
- ); -} diff --git a/packages/algoliasearch-helper/types/algoliasearch.d.ts b/packages/algoliasearch-helper/types/algoliasearch.d.ts index 01b7972c0e..62fdfda921 100644 --- a/packages/algoliasearch-helper/types/algoliasearch.d.ts +++ b/packages/algoliasearch-helper/types/algoliasearch.d.ts @@ -182,6 +182,24 @@ export type RecommendResponses = PickForClient<{ v5: AlgoliaSearch.GetRecommendationsResponse; }>; +export type TrendingFacetHit = PickForClient<{ + v3: any; + // @ts-ignore + v4: { + readonly _score: number; + readonly facetName: string; + readonly facetValue: string; + } & { + __position: number; + __queryID?: string; + }; + // @ts-ignore + v5: RecommendClient.TrendingFacetHit & { + __position: number; + __queryID?: string; + }; +}>; + // We remove `indexName` from the Recommend query types as the helper // will fill in this value before sending the queries type _OptionalKeys = Omit & Partial>; diff --git a/packages/instantsearch-ui-components/src/components/Carousel.tsx b/packages/instantsearch-ui-components/src/components/Carousel.tsx index 7916447a37..2ced71318a 100644 --- a/packages/instantsearch-ui-components/src/components/Carousel.tsx +++ b/packages/instantsearch-ui-components/src/components/Carousel.tsx @@ -1,7 +1,9 @@ /** @jsx createElement */ + import { cx } from '../lib'; import { createDefaultItemComponent } from './recommend-shared'; +import { isTrendingFacetHit } from './TrendingFacets'; import type { ComponentProps, @@ -10,6 +12,7 @@ import type { RecordWithObjectID, Renderer, SendEventForHits, + TrendingFacetHit, } from '../types'; export type CarouselProps< @@ -20,7 +23,7 @@ export type CarouselProps< nextButtonRef: MutableRef; previousButtonRef: MutableRef; carouselIdRef: MutableRef; - items: Array>; + items: Array | TrendingFacetHit>; itemComponent?: ( props: RecommendItemComponentProps> & TComponentProps @@ -232,22 +235,41 @@ export function createCarouselComponent({ createElement, Fragment }: Renderer) { } }} > - {items.map((item, index) => ( -
  • { - sendEvent('click:internal', item, 'Item Clicked'); - }} - onAuxClick={() => { - sendEvent('click:internal', item, 'Item Clicked'); - }} - > - -
  • - ))} + {items.map((item, index) => + isTrendingFacetHit(item) ? ( +
  • + + } + sendEvent={sendEvent} + /> +
  • + ) : ( +
  • { + sendEvent('click:internal', item, 'Item Clicked'); + }} + onAuxClick={() => { + sendEvent('click:internal', item, 'Item Clicked'); + }} + > + +
  • + ) + )} + + + + + + `); + }); + + test('renders with a carousel layout with options using `html`', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient({ + fixture: [ + { facetName: 'attr', facetValue: 'value1' }, + { facetName: 'attr', facetValue: 'value2' }, + ], + }); + const options: Parameters[0] = { + container, + templates: { + item(hit, { html }) { + return html`

    ${hit.objectID}

    `; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous({ html }) { + return html`

    Previous

    `; + }, + next({ html }) { + return html`

    Next

    `; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = trendingFacets(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending facets +

    + +
    +
    + `); + }); + + test('renders with templates using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient({ + fixture: [ + { facetName: 'attr', facetValue: 'value1' }, + { facetName: 'attr', facetValue: 'value2' }, + ], + }); + const options: Parameters[0] = { + container, + templates: { + header({ items, cssClasses }) { + return ( +

    + Trending facets ({items.length}) +

    + ); + }, + item(item) { + return

    {item.objectID}

    ; + }, + empty() { + return

    No recommendations.

    ; + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const trendingFacetsWidget = trendingFacets(options); + + search.addWidgets([trendingFacetsWidget]); + + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending facets ( + 2 + ) +

    +
    +
      +
    1. +

      + attr:value1 +

      +
    2. +
    3. +

      + attr:value2 +

      +
    4. +
    +
    +
    +
    + `); + + search.removeWidgets([trendingFacetsWidget]); + + search.addWidgets([trendingFacets({ ...options, limit: 0 })]); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + No recommendations. +

    +
    +
    + `); + }); + + test('renders with a custom layout using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient({ + fixture: [ + { objectID: '1', value: '1' }, + { objectID: '2', value: '2' }, + ], + }); + const options: Parameters[0] = { + container, + attribute: 'attribute', + templates: { + layout({ items }) { + return ( +
      + {items.map((item) => ( +
    • +

      {item.value}

      +
    • + ))} +
    + ); + }, + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = trendingFacets(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending facets +

    +
      +
    • +

      +

    • +
    • +

      +

    • +
    +
    +
    + `); + }); + + test('renders with a carousel layout without options using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient({ + fixture: [ + { objectID: '1', value: '1' }, + { objectID: '2', value: '2' }, + ], + }); + const options: Parameters[0] = { + container, + attribute: 'attribute', + templates: { + item(hit) { + return

    {hit.value}

    ; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous() { + return

    Previous

    ; + }, + next() { + return

    Next

    ; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = trendingFacets(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending facets +

    + +
    +
    + `); + }); + + test('renders with a carousel layout with options using JSX', async () => { + const container = document.createElement('div'); + const searchClient = createRecommendSearchClient({ + fixture: [ + { facetName: 'attr', facetValue: 'value1' }, + { facetName: 'attr', facetValue: 'value2' }, + ], + }); + const options: Parameters[0] = { + container, + templates: { + item(hit) { + return

    {hit.objectID}

    ; + }, + layout: carousel({ + cssClasses: { + root: 'ROOT', + list: 'LIST', + item: 'ITEM', + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }, + templates: { + previous() { + return

    Previous

    ; + }, + next() { + return

    Next

    ; + }, + }, + }), + }, + }; + + const search = instantsearch({ indexName: 'indexName', searchClient }); + const widget = trendingFacets(options); + + search.addWidgets([widget]); + search.start(); + + await wait(0); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Trending facets +

    + +
    +
    + `); + }); + }); +}); diff --git a/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx b/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx index 7cf8c708ec..0d0b9b3d69 100644 --- a/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx +++ b/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx @@ -15,6 +15,7 @@ import type { TrendingFacetsWidgetDescription, TrendingFacetsConnectorParams, TrendingFacetsRenderState, + TrendingFacetHit, } from '../../connectors/trending-facets/connectTrendingFacets'; import type { PreparedTemplateProps } from '../../lib/templating'; import type { @@ -23,7 +24,6 @@ import type { Renderer, RecommendResponse, TemplateWithBindEvent, - TrendingFacetHit, } from '../../types'; import type { RecommendClassNames, @@ -171,34 +171,39 @@ export type TrendingFacetsTemplates = Partial<{ /** * Template to use for the header of the widget. */ - header: Template< - Pick< - Parameters< - NonNullable['headerComponent']> - >[0], - 'items' - > & { cssClasses: RecommendClassNames } - >; - - /** - * Template to use to wrap all items. - */ - layout: Template< - Pick< - Parameters< - NonNullable['layout']> - >[0], - 'items' - > & { - templates: { - item: TrendingFacetsUiProps['itemComponent']; - }; - cssClasses: Pick; - } - >; -}> & { - item: TemplateWithBindEvent; -}; + header: Template<{ + items: TrendingFacetHit[]; + cssClasses: RecommendClassNames; + }>; +}> & + ( + | { + /** + * Template to use to wrap all items. + */ + layout: Template<{ + items: TrendingFacetHit[]; + templates: { + item: TrendingFacetsUiProps['itemComponent']; + }; + cssClasses: Pick; + }>; + item?: TemplateWithBindEvent; + } + | { + /** + * Template to use to wrap all items. + */ + layout?: Template<{ + items: TrendingFacetHit[]; + templates: { + item: TrendingFacetsUiProps['itemComponent']; + }; + cssClasses: Pick; + }>; + item: TemplateWithBindEvent; + } + ); type TrendingFacetsWidgetParams = { /** @@ -230,10 +235,9 @@ export default (function trendingFacets( ) { const { container, - facetName, + attribute, limit, threshold, - escapeHTML, transformItems, templates, cssClasses = {}, @@ -256,14 +260,11 @@ export default (function trendingFacets( render(null, containerNode) ); - const facetParameters = { facetName }; - return { ...makeWidget({ - ...facetParameters, + attribute, limit, threshold, - escapeHTML, transformItems, }), $$widgetType: 'ais.trendingFacets', diff --git a/packages/react-instantsearch-core/src/connectors/__tests__/useTrendingFacets.test.tsx b/packages/react-instantsearch-core/src/connectors/__tests__/useTrendingFacets.test.tsx new file mode 100644 index 0000000000..4057237984 --- /dev/null +++ b/packages/react-instantsearch-core/src/connectors/__tests__/useTrendingFacets.test.tsx @@ -0,0 +1,37 @@ +/** + * @jest-environment jsdom + */ + +import { createRecommendSearchClient } from '@instantsearch/mocks/fixtures'; +import { createInstantSearchTestWrapper } from '@instantsearch/testutils'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useTrendingFacets } from '../useTrendingFacets'; + +describe('useTrendingFacets', () => { + test('returns the connector render state', async () => { + const wrapper = createInstantSearchTestWrapper({ + searchClient: createRecommendSearchClient({ minimal: true }), + }); + const { result } = renderHook( + () => useTrendingFacets({ attribute: 'one' }), + { + wrapper, + } + ); + + // Initial render state from manual `getWidgetRenderState` + expect(result.current).toEqual({ + items: [], + }); + + await waitFor(() => { + expect(result.current).toEqual({ + items: expect.arrayContaining([ + { __position: 1, objectID: '1' }, + { __position: 2, objectID: '2' }, + ]), + }); + }); + }); +}); diff --git a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx index 65a8b8797d..f18e377edf 100644 --- a/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx +++ b/packages/react-instantsearch/src/__tests__/common-widgets.test.tsx @@ -27,6 +27,7 @@ import { Stats, RelatedProducts, FrequentlyBoughtTogether, + TrendingFacets, TrendingItems, LookingSimilar, PoweredBy, @@ -34,8 +35,10 @@ import { } from '..'; import type { TestOptionsMap, TestSetupsMap } from '@instantsearch/tests'; +import type { TrendingFacetHit } from 'instantsearch-ui-components'; import type { Hit } from 'instantsearch.js'; import type { SendEventForHits } from 'instantsearch.js/es/lib/utils'; +import type { ComponentProps } from 'react'; type TestSuites = typeof suites; const testSuites: TestSuites = suites; @@ -338,6 +341,22 @@ const testSetups: TestSetupsMap = { ); }, + createTrendingFacetsWidgetTests({ instantSearchOptions, widgetParams }) { + const { templates, ...params } = widgetParams; + const itemComponent: ComponentProps< + typeof TrendingFacets + >['itemComponent'] = ({ item }: { item: TrendingFacetHit }) => + typeof widgetParams.templates.item === 'function' + ? (widgetParams.templates.item(item, {} as any) as JSX.Element) + : ('Error: itemComponent should be a function' as unknown as JSX.Element); + + render( + + + + + ); + }, createTrendingItemsWidgetTests({ instantSearchOptions, widgetParams }) { const { facetName, facetValue, ...params } = widgetParams; const facetParams = @@ -425,6 +444,7 @@ const testOptions: TestOptionsMap = { }, createRelatedProductsWidgetTests: { act }, createFrequentlyBoughtTogetherWidgetTests: { act }, + createTrendingFacetsWidgetTests: { act }, createTrendingItemsWidgetTests: { act }, createLookingSimilarWidgetTests: { act }, createPoweredByWidgetTests: { act }, diff --git a/packages/react-instantsearch/src/widgets/TrendingFacets.tsx b/packages/react-instantsearch/src/widgets/TrendingFacets.tsx index 573260af0f..fbc1d42a1e 100644 --- a/packages/react-instantsearch/src/widgets/TrendingFacets.tsx +++ b/packages/react-instantsearch/src/widgets/TrendingFacets.tsx @@ -36,10 +36,9 @@ const TrendingFacetsUiComponent = createTrendingFacetsComponent({ }); export function TrendingFacets({ - facetName, + attribute, limit, threshold, - escapeHTML, transformItems, itemComponent, headerComponent, @@ -50,10 +49,9 @@ export function TrendingFacets({ const { status } = useInstantSearch(); const { items } = useTrendingFacets( { - facetName, + attribute, limit, threshold, - escapeHTML, transformItems, }, { $$widgetType: 'ais.trendingFacets' } diff --git a/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx new file mode 100644 index 0000000000..2796029a07 --- /dev/null +++ b/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx @@ -0,0 +1,340 @@ +/** + * @jest-environment jsdom + */ + +import { createRecommendSearchClient } from '@instantsearch/mocks/fixtures'; +import { InstantSearchTestWrapper } from '@instantsearch/testutils'; +import { render, waitFor } from '@testing-library/react'; +import { cx } from 'instantsearch-ui-components'; +import React from 'react'; + +import { Carousel } from '../../components/Carousel'; +import { TrendingFacets } from '../TrendingFacets'; + +describe('TrendingFacets', () => { + test('renders with translations', async () => { + const client = createRecommendSearchClient({ + minimal: true, + }); + const { container } = render( + + null} + /> + + ); + + await waitFor(() => { + expect(client.getRecommendations).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(container.querySelector('.ais-TrendingFacets')) + .toMatchInlineSnapshot(` +
    +

    + My trending facets +

    +
    +
      +
    1. +
    2. +
    +
    +
    + `); + }); + }); + + test('forwards custom class names and `div` props to the root element', () => { + const { container } = render( + + + + ); + + const root = container.firstChild; + expect(root).toHaveClass('MyTrendingFacets', 'ROOT'); + expect(root).toHaveAttribute('aria-hidden', 'true'); + }); + + test('renders custom layout component', async () => { + const client = createRecommendSearchClient({ + minimal: true, + }); + const { container } = render( + + ( +
      + {items.map((item) => ( +
    • +

      {item.objectID}

      +
    • + ))} +
    + )} + /> +
    + ); + + await waitFor(() => { + expect(client.getRecommendations).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(container.querySelector('.ais-TrendingFacets')) + .toMatchInlineSnapshot(` +
    +

    + Trending facets +

    +
      +
    • +

      + 1 +

      +
    • +
    • +

      + 2 +

      +
    • +
    +
    + `); + }); + }); + + test('renders Carousel as a layout component', async () => { + const client = createRecommendSearchClient({ + minimal: true, + }); + const { container } = render( + +

    {item.objectID}

    } + layoutComponent={Carousel} + /> +
    + ); + + await waitFor(() => { + expect(client.getRecommendations).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(container.querySelector('.ais-TrendingFacets')) + .toMatchInlineSnapshot(` +
    +

    + Trending facets +

    + +
    + `); + }); + }); + + test('renders Carousel with custom props as a layout component', async () => { + const client = createRecommendSearchClient({ + minimal: true, + }); + const { container } = render( + +

    {item.objectID}

    } + layoutComponent={(props) => ( +

    Previous

    } + nextIconComponent={() =>

    Next

    } + classNames={{ + root: 'ROOT', + list: cx('LIST', props.classNames.list), + item: cx('ITEM', props.classNames.item), + navigation: 'NAVIGATION', + navigationNext: 'NAVIGATION_NEXT', + navigationPrevious: 'NAVIGATION_PREVIOUS', + }} + translations={{ + nextButtonLabel: 'NEXT_BUTTON_LABEL', + nextButtonTitle: 'NEXT_BUTTON_TITLE', + previousButtonLabel: 'PREVIOUS_BUTTON_LABEL', + previousButtonTitle: 'PREVIOUS_BUTTON_TITLE', + listLabel: 'LIST_LABEL', + }} + /> + )} + /> +
    + ); + + await waitFor(() => { + expect(client.getRecommendations).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(container.querySelector('.ais-TrendingFacets')) + .toMatchInlineSnapshot(` +
    +

    + Trending facets +

    + +
    + `); + }); + }); +}); diff --git a/packages/vue-instantsearch/src/__tests__/common-widgets.test.js b/packages/vue-instantsearch/src/__tests__/common-widgets.test.js index 6333104c44..4cb023a5a1 100644 --- a/packages/vue-instantsearch/src/__tests__/common-widgets.test.js +++ b/packages/vue-instantsearch/src/__tests__/common-widgets.test.js @@ -517,6 +517,9 @@ const testSetups = { 'FrequentlyBoughtTogether is not supported in Vue InstantSearch' ); }, + createTrendingFacetsWidgetTests() { + throw new Error('TrendingFacets is not supported in Vue InstantSearch'); + }, createTrendingItemsWidgetTests() { throw new Error('TrendingItems is not supported in Vue InstantSearch'); }, @@ -618,6 +621,9 @@ const testOptions = { createTrendingItemsWidgetTests: { skippedTests: { 'TrendingItems widget common tests': true }, }, + createTrendingFacetsWidgetTests: { + skippedTests: { 'TrendingFacets widget common tests': true }, + }, createLookingSimilarWidgetTests: { skippedTests: { 'LookingSimilar widget common tests': true }, }, diff --git a/tests/common/widgets/index.ts b/tests/common/widgets/index.ts index 0e39a1f480..462ae66fea 100644 --- a/tests/common/widgets/index.ts +++ b/tests/common/widgets/index.ts @@ -19,6 +19,7 @@ export * from './stats'; export * from './numeric-menu'; export * from './related-products'; export * from './frequently-bought-together'; +export * from './trending-facets'; export * from './trending-items'; export * from './looking-similar'; export * from './powered-by'; diff --git a/tests/common/widgets/trending-facets/index.ts b/tests/common/widgets/trending-facets/index.ts new file mode 100644 index 0000000000..236b8bf882 --- /dev/null +++ b/tests/common/widgets/trending-facets/index.ts @@ -0,0 +1,24 @@ +import { fakeAct, skippableDescribe } from '../../common'; + +import { createOptionsTests } from './options'; + +import type { TestOptions, TestSetup } from '../../common'; +import type { TrendingFacetsWidget } from 'instantsearch.js/es/widgets/trending-facets/trending-facets'; + +type WidgetParams = Parameters[0]; +export type TrendingFacetsWidgetSetup = TestSetup<{ + widgetParams: Omit; +}>; + +export function createTrendingFacetsWidgetTests( + setup: TrendingFacetsWidgetSetup, + { act = fakeAct, skippedTests = {} }: TestOptions = {} +) { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + skippableDescribe('TrendingFacets widget common tests', skippedTests, () => { + createOptionsTests(setup, { act, skippedTests }); + }); +} diff --git a/tests/common/widgets/trending-facets/options.ts b/tests/common/widgets/trending-facets/options.ts new file mode 100644 index 0000000000..272330bd6f --- /dev/null +++ b/tests/common/widgets/trending-facets/options.ts @@ -0,0 +1,187 @@ +import { createRecommendSearchClient } from '@instantsearch/mocks/fixtures'; +import { wait } from '@instantsearch/testutils'; + +import type { TrendingFacetsWidgetSetup } from '.'; +import type { TestOptions } from '../../common'; + +export function createOptionsTests( + setup: TrendingFacetsWidgetSetup, + { act }: Required +) { + describe('options', () => { + test('renders with default props', async () => { + const searchClient = createRecommendSearchClient({ + fixture: [{ facetName: 'attr', facetValue: 'value' }], + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + attribute: 'name', + templates: { item: (item) => item.objectID }, + }, + }); + + await act(async () => { + await wait(0); + }); + + expect(document.querySelector('.ais-TrendingFacets')) + .toMatchInlineSnapshot(` +
    +

    + Trending facets +

    +
    +
      +
    1. + attr:value +
    2. +
    +
    +
    + `); + }); + + test('renders transformed items', async () => { + const searchClient = createRecommendSearchClient({ + fixture: [ + { facetName: 'attr', facetValue: 'value' }, + { facetName: 'attr2', facetValue: 'value2' }, + ], + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + attribute: 'name', + templates: { item: (item) => JSON.stringify(item) }, + transformItems(items) { + return items.map((item) => ({ + ...item, + objectID: `(${item.objectID})`, + })); + }, + }, + }); + + await act(async () => { + await wait(0); + }); + + expect(document.querySelector('.ais-TrendingFacets')) + .toMatchInlineSnapshot(` +
    +

    + Trending facets +

    +
    +
      +
    1. + {"objectID":"(attr:value)","attribute":"attr","value":"value","__position":1} +
    2. +
    3. + {"objectID":"(attr2:value2)","attribute":"attr2","value":"value2","__position":2} +
    4. +
    +
    +
    + `); + }); + + test('renders with no results', async () => { + const searchClient = createRecommendSearchClient({ + fixture: [], + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + // This simulates receiving no recommendations + limit: 0, + attribute: 'name', + templates: { item: (item) => item.objectID }, + }, + }); + + await act(async () => { + await wait(0); + }); + + expect(document.querySelector('.ais-TrendingFacets')) + .toMatchInlineSnapshot(` +
    + No results +
    + `); + }); + + test('passes parameters correctly', async () => { + const searchClient = createRecommendSearchClient({ + fixture: [ + { facetName: 'attr', facetValue: 'value' }, + { facetName: 'attr2', facetValue: 'value2' }, + ], + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + attribute: 'name', + templates: { item: (item) => item.objectID }, + threshold: 80, + limit: 3, + }, + }); + + await act(async () => { + await wait(0); + }); + + expect(searchClient.getRecommendations).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + threshold: 80, + maxRecommendations: 3, + }), + ]) + ); + }); + }); +} diff --git a/tests/mocks/fixtures/recommendations.ts b/tests/mocks/fixtures/recommendations.ts index 2f3cd98755..3434acc1dd 100644 --- a/tests/mocks/fixtures/recommendations.ts +++ b/tests/mocks/fixtures/recommendations.ts @@ -11,9 +11,15 @@ type Options = { * @default false */ minimal?: boolean; + /** + * The fixture to use for the recommendations. + * + * @default defaultFixture + */ + fixture?: any[]; }; -const fixture = [ +const defaultFixture = [ { _highlightResult: { name: { @@ -39,7 +45,7 @@ const fixture = [ ]; export function createRecommendSearchClient(options: Options = {}) { - const { minimal = false } = options; + const { minimal = false, fixture = defaultFixture } = options; return createSearchClient({ getRecommendations: jest.fn((requests) => Promise.resolve( From fd9fa0e08da0927b55afa0690e62fdeab858727d Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Fri, 4 Jul 2025 17:58:57 +0200 Subject: [PATCH 05/15] tests --- .../__tests__/useTrendingFacets.test.tsx | 23 ++++++++++-- .../widgets/__tests__/TrendingFacets.test.tsx | 36 ++++++++++++------- .../__tests__/__utils__/all-widgets.tsx | 2 +- 3 files changed, 45 insertions(+), 16 deletions(-) diff --git a/packages/react-instantsearch-core/src/connectors/__tests__/useTrendingFacets.test.tsx b/packages/react-instantsearch-core/src/connectors/__tests__/useTrendingFacets.test.tsx index 4057237984..97dc4c5e0d 100644 --- a/packages/react-instantsearch-core/src/connectors/__tests__/useTrendingFacets.test.tsx +++ b/packages/react-instantsearch-core/src/connectors/__tests__/useTrendingFacets.test.tsx @@ -11,7 +11,12 @@ import { useTrendingFacets } from '../useTrendingFacets'; describe('useTrendingFacets', () => { test('returns the connector render state', async () => { const wrapper = createInstantSearchTestWrapper({ - searchClient: createRecommendSearchClient({ minimal: true }), + searchClient: createRecommendSearchClient({ + fixture: [ + { facetName: 'attr1', facetValue: 'val1' }, + { facetName: 'attr2', facetValue: 'val2' }, + ], + }), }); const { result } = renderHook( () => useTrendingFacets({ attribute: 'one' }), @@ -28,8 +33,20 @@ describe('useTrendingFacets', () => { await waitFor(() => { expect(result.current).toEqual({ items: expect.arrayContaining([ - { __position: 1, objectID: '1' }, - { __position: 2, objectID: '2' }, + { + __position: 1, + _score: undefined, + attribute: 'attr1', + objectID: 'attr1:val1', + value: 'val1', + }, + { + __position: 2, + _score: undefined, + attribute: 'attr2', + objectID: 'attr2:val2', + value: 'val2', + }, ]), }); }); diff --git a/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx index 2796029a07..1c5196c72b 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx @@ -14,7 +14,10 @@ import { TrendingFacets } from '../TrendingFacets'; describe('TrendingFacets', () => { test('renders with translations', async () => { const client = createRecommendSearchClient({ - minimal: true, + fixture: [ + { facetName: 'facet1', facetValue: '1' }, + { facetName: 'facet2', facetValue: '2' }, + ], }); const { container } = render( @@ -77,7 +80,10 @@ describe('TrendingFacets', () => { test('renders custom layout component', async () => { const client = createRecommendSearchClient({ - minimal: true, + fixture: [ + { facetName: 'facet1', facetValue: '1' }, + { facetName: 'facet2', facetValue: '2' }, + ], }); const { container } = render( @@ -113,23 +119,26 @@ describe('TrendingFacets', () => {
    • - 1 + facet1:1

    • - 2 + facet2:2

    - `); + `); }); }); test('renders Carousel as a layout component', async () => { const client = createRecommendSearchClient({ - minimal: true, + fixture: [ + { facetName: 'facet1', facetValue: '1' }, + { facetName: 'facet2', facetValue: '2' }, + ], }); const { container } = render( @@ -193,7 +202,7 @@ describe('TrendingFacets', () => { class="ais-Carousel-item ais-TrendingFacets-item" >

    - 1 + facet1:1

  • { class="ais-Carousel-item ais-TrendingFacets-item" >

    - 2 + facet2:2

  • @@ -234,7 +243,10 @@ describe('TrendingFacets', () => { test('renders Carousel with custom props as a layout component', async () => { const client = createRecommendSearchClient({ - minimal: true, + fixture: [ + { facetName: 'facet1', facetValue: '1' }, + { facetName: 'facet2', facetValue: '2' }, + ], }); const { container } = render( @@ -309,7 +321,7 @@ describe('TrendingFacets', () => { class="ais-Carousel-item ITEM ais-TrendingFacets-item" >

    - 1 + facet1:1

  • { class="ais-Carousel-item ITEM ais-TrendingFacets-item" >

    - 2 + facet2:2

  • @@ -334,7 +346,7 @@ describe('TrendingFacets', () => { - `); + `); }); }); }); diff --git a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx index 919917a720..fc34851b33 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx @@ -100,7 +100,7 @@ function Widget({ return (
    {item.facetName}
    } + itemComponent={({ item }) =>
    {item.objectID}
    } {...props} /> ); From 67f3842923053dbb10043412534b49dc31246fb7 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 10:29:57 +0200 Subject: [PATCH 06/15] lints --- .../src/components/TrendingFacets.tsx | 63 ++----------------- .../__tests__/trending-facets.test.tsx | 9 ++- .../__tests__/__utils__/all-widgets.tsx | 2 +- 3 files changed, 12 insertions(+), 62 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx b/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx index b9436f933c..d201c861fa 100644 --- a/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx +++ b/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx @@ -5,52 +5,22 @@ import { cx } from '../lib'; import { createDefaultEmptyComponent, createDefaultHeaderComponent, + createListComponent, } from './recommend-shared'; import type { ComponentProps, RecommendClassNames, - RecommendInnerComponentProps, - RecommendItemComponentProps, - RecommendStatus, + RecommendComponentProps, RecommendTranslations, Renderer, - SendEventForHits, TrendingFacetHit, } from '../types'; -type TrendingFacetLayoutProps> = { - classNames: TClassNames; - itemComponent: ( - props: RecommendItemComponentProps - ) => JSX.Element; - items: TrendingFacetHit[]; - sendEvent: SendEventForHits; -}; - -export type TrendingFacetsComponentProps< - TComponentProps extends Record = Record -> = { - itemComponent: ( - props: RecommendItemComponentProps & TComponentProps - ) => JSX.Element; - items: TrendingFacetHit[]; - sendEvent: SendEventForHits; - classNames?: Partial; - emptyComponent?: (props: TComponentProps) => JSX.Element; - headerComponent?: ( - props: RecommendInnerComponentProps & TComponentProps - ) => JSX.Element; - status: RecommendStatus; - translations?: Partial; - layout?: ( - props: TrendingFacetLayoutProps> & TComponentProps - ) => JSX.Element; -}; - export type TrendingFacetsProps< TComponentProps extends Record = Record -> = ComponentProps<'div'> & TrendingFacetsComponentProps; +> = ComponentProps<'div'> & + RecommendComponentProps; export function createTrendingFacetsComponent({ createElement, @@ -123,28 +93,3 @@ export function createTrendingFacetsComponent({ ); }; } - -export function createListComponent({ createElement }: Renderer) { - return function List( - userProps: TrendingFacetLayoutProps> - ) { - const { - classNames = {}, - itemComponent: ItemComponent, - items, - sendEvent, - } = userProps; - - return ( -
    -
      - {items.map((item) => ( -
    1. - -
    2. - ))} -
    -
    - ); - }; -} diff --git a/packages/instantsearch.js/src/widgets/trending-facets/__tests__/trending-facets.test.tsx b/packages/instantsearch.js/src/widgets/trending-facets/__tests__/trending-facets.test.tsx index 7337576800..8ea68c6b8f 100644 --- a/packages/instantsearch.js/src/widgets/trending-facets/__tests__/trending-facets.test.tsx +++ b/packages/instantsearch.js/src/widgets/trending-facets/__tests__/trending-facets.test.tsx @@ -230,14 +230,14 @@ describe('trendingFacets', () => { class="ais-TrendingFacets-item" >

    - : + :

  • - : + :

  • @@ -275,6 +275,7 @@ describe('trendingFacets', () => { }); const options: Parameters[0] = { container, + attribute: 'attribute', templates: { layout({ items }, { html }) { return html`
      @@ -329,6 +330,7 @@ describe('trendingFacets', () => { }); const options: Parameters[0] = { container, + attribute: 'attribute', templates: { item(hit, { html }) { return html`

      ${hit.objectID}

      `; @@ -439,6 +441,7 @@ describe('trendingFacets', () => { }); const options: Parameters[0] = { container, + attribute: 'attribute', templates: { item(hit, { html }) { return html`

      ${hit.objectID}

      `; @@ -549,6 +552,7 @@ describe('trendingFacets', () => { }); const options: Parameters[0] = { container, + attribute: 'attribute', templates: { header({ items, cssClasses }) { return ( @@ -806,6 +810,7 @@ describe('trendingFacets', () => { }); const options: Parameters[0] = { container, + attribute: 'attribute', templates: { item(hit) { return

      {hit.objectID}

      ; diff --git a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx index fc34851b33..6bb9f1e3f0 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/__utils__/all-widgets.tsx @@ -99,7 +99,7 @@ function Widget({ case 'TrendingFacets': { return (
      {item.objectID}
      } {...props} /> From 97adf71ecce63311eb3f634a11ade4ac68f78a0d Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 10:40:50 +0200 Subject: [PATCH 07/15] build --- .../src/components/TrendingFacets.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx b/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx index d201c861fa..1413fe94cf 100644 --- a/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx +++ b/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx @@ -20,7 +20,16 @@ import type { export type TrendingFacetsProps< TComponentProps extends Record = Record > = ComponentProps<'div'> & - RecommendComponentProps; + Omit< + RecommendComponentProps, + 'itemComponent' + > & + Required< + Pick< + RecommendComponentProps, + 'itemComponent' + > + >; export function createTrendingFacetsComponent({ createElement, From 58fe23105e4cdc518e94d39f624f3d65666c3762 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 10:43:09 +0200 Subject: [PATCH 08/15] simplify --- .../src/components/TrendingFacets.tsx | 9 ++------- packages/instantsearch-ui-components/src/types/shared.ts | 3 +++ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx b/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx index 1413fe94cf..8c79cbaaa8 100644 --- a/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx +++ b/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx @@ -15,20 +15,15 @@ import type { RecommendTranslations, Renderer, TrendingFacetHit, + RequiredKeys, } from '../types'; export type TrendingFacetsProps< TComponentProps extends Record = Record > = ComponentProps<'div'> & - Omit< + RequiredKeys< RecommendComponentProps, 'itemComponent' - > & - Required< - Pick< - RecommendComponentProps, - 'itemComponent' - > >; export function createTrendingFacetsComponent({ diff --git a/packages/instantsearch-ui-components/src/types/shared.ts b/packages/instantsearch-ui-components/src/types/shared.ts index f45e01f6dd..7ddcee5924 100644 --- a/packages/instantsearch-ui-components/src/types/shared.ts +++ b/packages/instantsearch-ui-components/src/types/shared.ts @@ -10,3 +10,6 @@ type BuiltInSendEventForHits = ( ) => void; type CustomSendEventForHits = (customPayload: any) => void; export type SendEventForHits = BuiltInSendEventForHits & CustomSendEventForHits; + +export type RequiredKeys = Omit & + Required>; From 88e3d8a4ce056e6febdcf4558b11756e842d605b Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 10:55:29 +0200 Subject: [PATCH 09/15] tests --- .../__tests__/TrendingFacets.test.tsx | 66 ++++++++++++------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/__tests__/TrendingFacets.test.tsx b/packages/instantsearch-ui-components/src/components/__tests__/TrendingFacets.test.tsx index c969e73222..294ad69481 100644 --- a/packages/instantsearch-ui-components/src/components/__tests__/TrendingFacets.test.tsx +++ b/packages/instantsearch-ui-components/src/components/__tests__/TrendingFacets.test.tsx @@ -8,7 +8,6 @@ import { createElement, Fragment } from 'preact'; import { createTrendingFacetsComponent } from '../TrendingFacets'; -import type { RecordWithObjectID } from '../../types'; import type { TrendingFacetsProps } from '../TrendingFacets'; const TrendingFacets = createTrendingFacetsComponent({ @@ -16,8 +15,9 @@ const TrendingFacets = createTrendingFacetsComponent({ Fragment, }); -const ItemComponent: TrendingFacetsProps['itemComponent'] = - ({ item }) =>
      {item.objectID}
      ; +const ItemComponent: TrendingFacetsProps['itemComponent'] = ({ item }) => ( +
      {item.objectID}
      +); describe('TrendingFacets', () => { test('renders items with default layout and header', () => { @@ -26,11 +26,17 @@ describe('TrendingFacets', () => { status="idle" items={[ { - objectID: '1', + objectID: 'category:electronics', + _score: 1, + attribute: 'category', + value: 'electronics', __position: 1, }, { - objectID: '2', + objectID: 'category:books', + _score: 2, + attribute: 'category', + value: 'books', __position: 2, }, ]} @@ -59,14 +65,14 @@ describe('TrendingFacets', () => { class="ais-TrendingFacets-item" >
      - 1 + category:electronics
    • - 2 + category:books
    • @@ -101,7 +107,15 @@ describe('TrendingFacets', () => { const { container } = render( (
      My custom header
      )} @@ -130,7 +144,7 @@ describe('TrendingFacets', () => { class="ais-TrendingFacets-item" >
      - 1 + 1:1
      @@ -144,7 +158,15 @@ describe('TrendingFacets', () => { const { container } = render( (
      @@ -186,7 +208,7 @@ describe('TrendingFacets', () => { class="ais-TrendingFacets-item" >
      - 1 + 1:1
      @@ -218,9 +240,11 @@ describe('TrendingFacets', () => { `); }); - test('does not sends a `click` event when clicking on an item', () => { + test('sends a `click` event when clicking on an item', () => { const sendEvent = jest.fn(); - const items = [{ objectID: '1', __position: 1 }]; + const items = [ + { objectID: '1:1', _score: 1, attribute: '1', value: '1', __position: 1 }, + ]; const { container } = render( { userEvent.click(container.querySelectorAll('.ais-TrendingFacets-item')[0]!); - expect(sendEvent).toHaveBeenCalledTimes(0); - // When events are implemented, this should be uncommented: - // expect(sendEvent).toHaveBeenCalledTimes(1); - // expect(sendEvent).toHaveBeenNthCalledWith( - // 1, - // 'click:internal', - // items[0], - // 'Item Clicked' - // ); + expect(sendEvent).toHaveBeenCalledTimes(1); + expect(sendEvent).toHaveBeenNthCalledWith( + 1, + 'click:internal', + items[0], + 'Item Clicked' + ); }); test('accepts custom title translation', () => { From b80914877a80689064429d33460a19dfce2592ae Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 10:59:04 +0200 Subject: [PATCH 10/15] test --- .../__tests__/trending-facets.test.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/instantsearch.js/src/widgets/trending-facets/__tests__/trending-facets.test.tsx b/packages/instantsearch.js/src/widgets/trending-facets/__tests__/trending-facets.test.tsx index 8ea68c6b8f..8dc1c542da 100644 --- a/packages/instantsearch.js/src/widgets/trending-facets/__tests__/trending-facets.test.tsx +++ b/packages/instantsearch.js/src/widgets/trending-facets/__tests__/trending-facets.test.tsx @@ -177,8 +177,8 @@ describe('trendingFacets', () => { const container = document.createElement('div'); const searchClient = createRecommendSearchClient({ fixture: [ - { attribute: 'value1', value: 'value1' }, - { attribute: 'value2', value: 'value2' }, + { facetName: 'attr', facetValue: 'value1' }, + { facetName: 'attr', facetValue: 'value2' }, ], }); const options: Parameters[0] = { @@ -230,14 +230,18 @@ describe('trendingFacets', () => { class="ais-TrendingFacets-item" >

      - : + attr + : + value1

    • - : + attr + : + value2

    • From 9bd722db758a4a3eca7f2252e125f3abcc80657b Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 11:06:34 +0200 Subject: [PATCH 11/15] correct required --- .../src/widgets/TrendingFacets.tsx | 13 ++++++++++--- .../src/widgets/__tests__/TrendingFacets.test.tsx | 4 ++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/react-instantsearch/src/widgets/TrendingFacets.tsx b/packages/react-instantsearch/src/widgets/TrendingFacets.tsx index fbc1d42a1e..2613ef89ef 100644 --- a/packages/react-instantsearch/src/widgets/TrendingFacets.tsx +++ b/packages/react-instantsearch/src/widgets/TrendingFacets.tsx @@ -24,11 +24,18 @@ export type TrendingFacetsProps = Omit< keyof UiProps > & UseTrendingFacetsProps & { - itemComponent: TrendingFacetsUiComponentProps['itemComponent']; headerComponent?: TrendingFacetsUiComponentProps['headerComponent']; emptyComponent?: TrendingFacetsUiComponentProps['emptyComponent']; - layoutComponent?: TrendingFacetsUiComponentProps['layout']; - }; + } & ( + | { + itemComponent: TrendingFacetsUiComponentProps['itemComponent']; + layoutComponent?: TrendingFacetsUiComponentProps['layout']; + } + | { + itemComponent?: TrendingFacetsUiComponentProps['itemComponent']; + layoutComponent: TrendingFacetsUiComponentProps['layout']; + } + ); const TrendingFacetsUiComponent = createTrendingFacetsComponent({ createElement: createElement as Pragma, diff --git a/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx index 1c5196c72b..7a5c194e4c 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx @@ -22,6 +22,7 @@ describe('TrendingFacets', () => { const { container } = render( null} /> @@ -66,6 +67,8 @@ describe('TrendingFacets', () => { const { container } = render( null} className="MyTrendingFacets" classNames={{ root: 'ROOT' }} aria-hidden={true} @@ -88,6 +91,7 @@ describe('TrendingFacets', () => { const { container } = render( (
        {items.map((item) => ( From 44679b6c6e60c13207fb6108dea6fc959370446d Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 12:20:11 +0200 Subject: [PATCH 12/15] changes to type --- .../src/components/TrendingFacets.tsx | 18 +++++++++++++----- .../src/types/shared.ts | 3 --- .../src/widgets/TrendingFacets.tsx | 13 +++---------- .../widgets/__tests__/TrendingFacets.test.tsx | 4 ++++ 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx b/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx index 8c79cbaaa8..c06294ff1f 100644 --- a/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx +++ b/packages/instantsearch-ui-components/src/components/TrendingFacets.tsx @@ -15,16 +15,20 @@ import type { RecommendTranslations, Renderer, TrendingFacetHit, - RequiredKeys, } from '../types'; export type TrendingFacetsProps< TComponentProps extends Record = Record > = ComponentProps<'div'> & - RequiredKeys< + Omit< RecommendComponentProps, 'itemComponent' - >; + > & { + itemComponent: RecommendComponentProps< + TrendingFacetHit, + TComponentProps + >['itemComponent']; + }; export function createTrendingFacetsComponent({ createElement, @@ -41,7 +45,11 @@ export function createTrendingFacetsComponent({ createElement, Fragment, }), - itemComponent: ItemComponent, + // Fallback to a no-op component if no itemComponent is provided + // This is to ensure that the component can render when the layout + // the user provided does not need an itemComponent, but still allows + // us to later create a default itemComponent that has an action. + itemComponent: ItemComponent = () => null as any, layout: Layout = createListComponent({ createElement, Fragment }), items, status, @@ -89,7 +97,7 @@ export function createTrendingFacetsComponent({ null as any)} items={items} sendEvent={sendEvent} /> diff --git a/packages/instantsearch-ui-components/src/types/shared.ts b/packages/instantsearch-ui-components/src/types/shared.ts index 7ddcee5924..f45e01f6dd 100644 --- a/packages/instantsearch-ui-components/src/types/shared.ts +++ b/packages/instantsearch-ui-components/src/types/shared.ts @@ -10,6 +10,3 @@ type BuiltInSendEventForHits = ( ) => void; type CustomSendEventForHits = (customPayload: any) => void; export type SendEventForHits = BuiltInSendEventForHits & CustomSendEventForHits; - -export type RequiredKeys = Omit & - Required>; diff --git a/packages/react-instantsearch/src/widgets/TrendingFacets.tsx b/packages/react-instantsearch/src/widgets/TrendingFacets.tsx index 2613ef89ef..fbc1d42a1e 100644 --- a/packages/react-instantsearch/src/widgets/TrendingFacets.tsx +++ b/packages/react-instantsearch/src/widgets/TrendingFacets.tsx @@ -24,18 +24,11 @@ export type TrendingFacetsProps = Omit< keyof UiProps > & UseTrendingFacetsProps & { + itemComponent: TrendingFacetsUiComponentProps['itemComponent']; headerComponent?: TrendingFacetsUiComponentProps['headerComponent']; emptyComponent?: TrendingFacetsUiComponentProps['emptyComponent']; - } & ( - | { - itemComponent: TrendingFacetsUiComponentProps['itemComponent']; - layoutComponent?: TrendingFacetsUiComponentProps['layout']; - } - | { - itemComponent?: TrendingFacetsUiComponentProps['itemComponent']; - layoutComponent: TrendingFacetsUiComponentProps['layout']; - } - ); + layoutComponent?: TrendingFacetsUiComponentProps['layout']; + }; const TrendingFacetsUiComponent = createTrendingFacetsComponent({ createElement: createElement as Pragma, diff --git a/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx b/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx index 7a5c194e4c..281e396b70 100644 --- a/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx +++ b/packages/react-instantsearch/src/widgets/__tests__/TrendingFacets.test.tsx @@ -92,6 +92,8 @@ describe('TrendingFacets', () => { null} layoutComponent={({ items }) => (
          {items.map((item) => ( @@ -147,6 +149,7 @@ describe('TrendingFacets', () => { const { container } = render(

          {item.objectID}

          } layoutComponent={Carousel} /> @@ -255,6 +258,7 @@ describe('TrendingFacets', () => { const { container } = render(

          {item.objectID}

          } layoutComponent={(props) => ( Date: Tue, 8 Jul 2025 12:36:51 +0200 Subject: [PATCH 13/15] fixes --- .../__tests__/TrendingFacets.test.tsx | 34 ++++++++++++++++--- .../widgets/__tests__/TrendingFacets.test.tsx | 6 ++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/__tests__/TrendingFacets.test.tsx b/packages/instantsearch-ui-components/src/components/__tests__/TrendingFacets.test.tsx index 294ad69481..94cbb0bc55 100644 --- a/packages/instantsearch-ui-components/src/components/__tests__/TrendingFacets.test.tsx +++ b/packages/instantsearch-ui-components/src/components/__tests__/TrendingFacets.test.tsx @@ -270,7 +270,15 @@ describe('TrendingFacets', () => { const { container } = render( { class="ais-TrendingFacets-item" >
          - 1 + 1:1
          @@ -311,7 +319,15 @@ describe('TrendingFacets', () => { const { container } = render(
          ); @@ -68,7 +68,7 @@ describe('TrendingFacets', () => { null} + itemComponent={() => <>} className="MyTrendingFacets" classNames={{ root: 'ROOT' }} aria-hidden={true} @@ -93,7 +93,7 @@ describe('TrendingFacets', () => { null} + itemComponent={() => <>} layoutComponent={({ items }) => (
            {items.map((item) => ( From ccd9eee65283c1378d250938e7ed26e5994e0101 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 12:53:16 +0200 Subject: [PATCH 14/15] tieps --- .../trending-facets/trending-facets.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx b/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx index 0d0b9b3d69..361786aebc 100644 --- a/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx +++ b/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx @@ -24,6 +24,7 @@ import type { Renderer, RecommendResponse, TemplateWithBindEvent, + Hit, } from '../../types'; import type { RecommendClassNames, @@ -114,9 +115,8 @@ function createRenderer({ : undefined ) as TrendingFacetsUiProps['emptyComponent']; - // @TODO fix data type const layoutComponent = templates.layout - ? (data: any) => ( + ? (data) => ( ( + ? ({ item }: { item: Hit }) => ( >; + empty: Template>>; /** * Template to use for the header of the widget. */ header: Template<{ - items: TrendingFacetHit[]; + items: Array>; cssClasses: RecommendClassNames; }>; }> & @@ -182,26 +182,26 @@ export type TrendingFacetsTemplates = Partial<{ * Template to use to wrap all items. */ layout: Template<{ - items: TrendingFacetHit[]; + items: Array>; templates: { - item: TrendingFacetsUiProps['itemComponent']; + item: TrendingFacetsUiProps>['itemComponent']; }; cssClasses: Pick; }>; - item?: TemplateWithBindEvent; + item?: TemplateWithBindEvent>; } | { /** * Template to use to wrap all items. */ layout?: Template<{ - items: TrendingFacetHit[]; + items: Array>; templates: { - item: TrendingFacetsUiProps['itemComponent']; + item: TrendingFacetsUiProps>['itemComponent']; }; cssClasses: Pick; }>; - item: TemplateWithBindEvent; + item: TemplateWithBindEvent>; } ); From 0663bb78594464f248e4bd81d62b611995d8398c Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 8 Jul 2025 13:22:52 +0200 Subject: [PATCH 15/15] most --- .../trending-facets/trending-facets.tsx | 58 ++++++++++--------- .../src/__tests__/common-widgets.test.tsx | 2 +- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx b/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx index 361786aebc..bc705ecc40 100644 --- a/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx +++ b/packages/instantsearch.js/src/widgets/trending-facets/trending-facets.tsx @@ -115,34 +115,36 @@ function createRenderer({ : undefined ) as TrendingFacetsUiProps['emptyComponent']; - const layoutComponent = templates.layout - ? (data) => ( - }) => ( - - ) - : undefined, - }, - cssClasses: { - list: data.classNames.list, - item: data.classNames.item, - }, - }} - /> - ) - : undefined; + const layoutComponent = ( + templates.layout + ? (data) => ( + }) => ( + + ) + : undefined, + }, + cssClasses: { + list: data.classNames.list, + item: data.classNames.item, + }, + }} + /> + ) + : undefined + ) as TrendingFacetsUiProps['layout']; render( = { const { templates, ...params } = widgetParams; const itemComponent: ComponentProps< typeof TrendingFacets - >['itemComponent'] = ({ item }: { item: TrendingFacetHit }) => + >['itemComponent'] = ({ item }: { item: Hit }) => typeof widgetParams.templates.item === 'function' ? (widgetParams.templates.item(item, {} as any) as JSX.Element) : ('Error: itemComponent should be a function' as unknown as JSX.Element);