From 7d78f633628eb615cc8ef7377a3368f15e3adf98 Mon Sep 17 00:00:00 2001 From: Raed Date: Mon, 15 Apr 2024 15:15:50 +0200 Subject: [PATCH] feat(recommend): introduce `FrequentlyBoughtTogether` UI component (#6114) Co-authored-by: Dhaya <154633+dhayab@users.noreply.github.com> Co-authored-by: Aymeric Giraudet Co-authored-by: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> --- .gitignore | 1 + babel.config.js | 3 + bundlesize.config.json | 2 +- .../components/FrequentlyBoughtTogether.tsx | 85 +++++ .../src/components/Hits.tsx | 3 +- .../FrequentlyBoughtTogether.test.tsx | 340 ++++++++++++++++++ .../src/components/index.ts | 1 + .../recommend-shared/DefaultHeader.tsx | 21 ++ .../components/recommend-shared/ListView.tsx | 42 +++ .../src/types/Recommend.ts | 101 ++++++ .../src/types/index.ts | 2 + .../src/types/shared.ts | 2 + 12 files changed, 600 insertions(+), 3 deletions(-) create mode 100644 packages/instantsearch-ui-components/src/components/FrequentlyBoughtTogether.tsx create mode 100644 packages/instantsearch-ui-components/src/components/__tests__/FrequentlyBoughtTogether.test.tsx create mode 100644 packages/instantsearch-ui-components/src/components/recommend-shared/DefaultHeader.tsx create mode 100644 packages/instantsearch-ui-components/src/components/recommend-shared/ListView.tsx create mode 100644 packages/instantsearch-ui-components/src/types/Recommend.ts create mode 100644 packages/instantsearch-ui-components/src/types/shared.ts diff --git a/.gitignore b/.gitignore index eeec3cb1e8..001eb1d62f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ scripts/*/CHANGELOG.md # private files .env .idea/ +.vscode/ # Caches .eslintcache diff --git a/babel.config.js b/babel.config.js index 6b85cafb23..f33b615d1f 100644 --- a/babel.config.js +++ b/babel.config.js @@ -79,6 +79,9 @@ module.exports = (api) => { // false positive (babel doesn't know types) // this is actually only called on arrays 'String.prototype.includes', + + // false positive (spread) + 'Object.getOwnPropertyDescriptors', ]; if (defaultShouldInject && !exclude.includes(name)) { throw new Error( diff --git a/bundlesize.config.json b/bundlesize.config.json index b866303f84..2a66d24374 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -26,7 +26,7 @@ }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", - "maxSize": "66.75 kB" + "maxSize": "67 kB" }, { "path": "packages/vue-instantsearch/vue3/umd/index.js", diff --git a/packages/instantsearch-ui-components/src/components/FrequentlyBoughtTogether.tsx b/packages/instantsearch-ui-components/src/components/FrequentlyBoughtTogether.tsx new file mode 100644 index 0000000000..e69b813f52 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/FrequentlyBoughtTogether.tsx @@ -0,0 +1,85 @@ +/** @jsx createElement */ + +import { cx } from '../lib'; + +import { createDefaultHeaderComponent } from './recommend-shared/DefaultHeader'; +import { createListViewComponent } from './recommend-shared/ListView'; + +import type { + RecommendTranslations, + Renderer, + ComponentProps, + RecommendComponentProps, +} from '../types'; + +export type FrequentlyBoughtTogetherProps< + TObject, + TComponentProps extends Record = Record +> = ComponentProps<'div'> & RecommendComponentProps; + +export function createFrequentlyBoughtTogetherComponent({ + createElement, + Fragment, +}: Renderer) { + return function FrequentlyBoughtTogether( + userProps: FrequentlyBoughtTogetherProps + ) { + const { + classNames = {}, + fallbackComponent: FallbackComponent = () => null, + headerComponent: HeaderComponent = createDefaultHeaderComponent({ + createElement, + Fragment, + }), + itemComponent: ItemComponent, + view: View = createListViewComponent({ createElement, Fragment }), + items, + status, + translations: userTranslations, + sendEvent, + ...props + } = userProps; + + const translations: Required = { + title: 'Frequently bought together', + sliderLabel: 'Frequently bought together products', + ...userTranslations, + }; + + if (items.length === 0 && status === 'idle') { + return ; + } + + return ( +
+ + + +
+ ); + }; +} diff --git a/packages/instantsearch-ui-components/src/components/Hits.tsx b/packages/instantsearch-ui-components/src/components/Hits.tsx index 44fc478f88..ae65344b10 100644 --- a/packages/instantsearch-ui-components/src/components/Hits.tsx +++ b/packages/instantsearch-ui-components/src/components/Hits.tsx @@ -1,13 +1,12 @@ /** @jsx createElement */ import { cx } from '../lib'; -import type { ComponentProps, Renderer } from '../types'; +import type { ComponentProps, Renderer, SendEventForHits } from '../types'; // Should be imported from a shared package in the future type Hit = Record & { objectID: string; }; -type SendEventForHits = (...props: unknown[]) => void; type Banner = { image: { urls: Array<{ diff --git a/packages/instantsearch-ui-components/src/components/__tests__/FrequentlyBoughtTogether.test.tsx b/packages/instantsearch-ui-components/src/components/__tests__/FrequentlyBoughtTogether.test.tsx new file mode 100644 index 0000000000..aad0a052b4 --- /dev/null +++ b/packages/instantsearch-ui-components/src/components/__tests__/FrequentlyBoughtTogether.test.tsx @@ -0,0 +1,340 @@ +/** + * @jest-environment jsdom + */ +/** @jsx createElement */ +import { render } from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import { createElement, Fragment } from 'preact'; + +import { createFrequentlyBoughtTogetherComponent } from '../FrequentlyBoughtTogether'; + +import type { RecordWithObjectID } from '../../types'; +import type { FrequentlyBoughtTogetherProps } from '../FrequentlyBoughtTogether'; + +const FrequentlyBoughtTogether = createFrequentlyBoughtTogetherComponent({ + createElement, + Fragment, +}); + +const ItemComponent: FrequentlyBoughtTogetherProps['itemComponent'] = + ({ item, ...itemProps }) => ( +
  • +
    {item.objectID}
    +
  • + ); + +describe('FrequentlyBoughtTogether', () => { + test('renders items with default view and header', () => { + const { container } = render( + + ); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Frequently bought together +

    +
    +
      +
    1. +
      + 1 +
      +
    2. +
    3. +
      + 2 +
      +
    4. +
    +
    +
    +
    + `); + }); + + test('renders default fallback', () => { + const { container } = render( + + ); + + expect(container).toMatchInlineSnapshot(`
    `); + }); + + test('renders custom header', () => { + const { container } = render( + ( +
    My custom header
    + )} + itemComponent={ItemComponent} + sendEvent={jest.fn()} + /> + ); + + expect(container).toMatchInlineSnapshot(` +
    +
    +
    + My custom header +
    +
    +
      +
    1. +
      + 1 +
      +
    2. +
    +
    +
    +
    + `); + }); + + test('renders custom view', () => { + const { container } = render( + ( +
    +
      + {props.items.map((item) => ( +
    1. + +
    2. + ))} +
    +
    + )} + sendEvent={jest.fn()} + /> + ); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + Frequently bought together +

    +
    +
      +
    1. +
    2. +
      + 1 +
      +
    3. + +
    +
    +
    +
    + `); + }); + + test('renders custom fallback', () => { + const { container } = render( +
    My custom fallback
    } + itemComponent={ItemComponent} + sendEvent={jest.fn()} + /> + ); + + expect(container).toMatchInlineSnapshot(` +
    +
    + My custom fallback +
    +
    + `); + }); + + test('sends a `click` event when clicking on an item', () => { + const sendEvent = jest.fn(); + const items = [{ objectID: '1', __position: 1 }]; + + const { container } = render( + + ); + + userEvent.click( + container.querySelectorAll('.ais-FrequentlyBoughtTogether-item')[0]! + ); + + expect(sendEvent).toHaveBeenCalledTimes(1); + }); + + test('accepts custom title translation', () => { + const { container } = render( + + ); + + expect(container).toMatchInlineSnapshot(` +
    +
    +

    + My custom title +

    +
    +
      +
    1. +
      + 1 +
      +
    2. +
    +
    +
    +
    + `); + }); + + test('forwards `div` props to the root element', () => { + const { container } = render( +