Skip to content

Commit

Permalink
feat(recommend): introduce <RelatedProducts> widget (#6162)
Browse files Browse the repository at this point in the history
  • Loading branch information
sarahdayan authored and dhayab committed May 21, 2024
1 parent 0eedb22 commit 313b0ea
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 10 deletions.
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
},
{
"path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js",
"maxSize": "59.75 kB"
"maxSize": "60.5 kB"
},
{
"path": "packages/vue-instantsearch/vue2/umd/index.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export type RecommendComponentProps<
TObject,
TComponentProps extends Record<string, unknown> = Record<string, unknown>
> = {
itemComponent: (
itemComponent?: (
props: RecommendItemComponentProps<RecordWithObjectID<TObject>> &
TComponentProps
) => JSX.Element;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
ToggleRefinement,
SortBy,
Stats,
RelatedProducts,
} from '..';

import type { TestOptionsMap, TestSetupsMap } from '@instantsearch/tests';
Expand Down Expand Up @@ -310,8 +311,13 @@ const testSetups: TestSetupsMap<TestSuites> = {
</InstantSearch>
);
},
createRelatedProductsWidgetTests() {
throw new Error('RelatedProduct is not supported in React InstantSearch');
createRelatedProductsWidgetTests({ instantSearchOptions, widgetParams }) {
render(
<InstantSearch {...instantSearchOptions}>
<RelatedProducts {...widgetParams} />
<GlobalErrorSwallower />
</InstantSearch>
);
},
createFrequentlyBoughtTogetherTests() {
throw new Error(
Expand Down Expand Up @@ -349,12 +355,7 @@ const testOptions: TestOptionsMap<TestSuites> = {
'NumericMenu widget common tests': true,
},
},
createRelatedProductsWidgetTests: {
act,
skippedTests: {
'RelatedProducts widget common tests': true,
},
},
createRelatedProductsWidgetTests: { act },
createFrequentlyBoughtTogetherTests: {
act,
skippedTests: {
Expand Down
75 changes: 75 additions & 0 deletions packages/react-instantsearch/src/widgets/RelatedProducts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createRelatedProductsComponent } from 'instantsearch-ui-components';
import React, { createElement, Fragment } from 'react';
import { useInstantSearch, useRelatedProducts } from 'react-instantsearch-core';

import type {
RelatedProductsProps as RelatedProductsUiComponentProps,
Pragma,
RecordWithObjectID,
} from 'instantsearch-ui-components';
import type { Hit } from 'instantsearch.js';
import type { UseRelatedProductsProps } from 'react-instantsearch-core';

type UiProps<TItem extends RecordWithObjectID> = Pick<
RelatedProductsUiComponentProps<TItem>,
| 'items'
| 'itemComponent'
| 'headerComponent'
| 'fallbackComponent'
| 'status'
| 'sendEvent'
>;

export type RelatedProductsProps<TItem extends RecordWithObjectID> = Omit<
RelatedProductsUiComponentProps<TItem>,
keyof UiProps<TItem>
> &
UseRelatedProductsProps & {
itemComponent?: RelatedProductsUiComponentProps<TItem>['itemComponent'];
headerComponent?: RelatedProductsUiComponentProps<TItem>['headerComponent'];
fallbackComponent?: RelatedProductsUiComponentProps<TItem>['fallbackComponent'];
};

const RelatedProductsUiComponent = createRelatedProductsComponent({
createElement: createElement as Pragma,
Fragment,
});

export function RelatedProducts<
TItem extends RecordWithObjectID = RecordWithObjectID
>({
objectIDs,
maxRecommendations,
threshold,
fallbackParameters,
queryParameters,
transformItems,
itemComponent,
headerComponent,
fallbackComponent,
...props
}: RelatedProductsProps<TItem>) {
const { status } = useInstantSearch();
const { recommendations } = useRelatedProducts(
{
objectIDs,
maxRecommendations,
threshold,
fallbackParameters,
queryParameters,
transformItems,
},
{ $$widgetType: 'ais.relatedProducts' }
);

const uiProps: UiProps<TItem> = {
items: recommendations as Array<Hit<TItem>>,
itemComponent,
headerComponent,
fallbackComponent,
status,
sendEvent: () => {},
};

return <RelatedProductsUiComponent {...props} {...uiProps} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/**
* @jest-environment jsdom
*/

import {
createMultiSearchResponse,
createSearchClient,
createSingleSearchResponse,
} from '@instantsearch/mocks';
import { InstantSearchTestWrapper } from '@instantsearch/testutils';
import { render, waitFor } from '@testing-library/react';
import React from 'react';

import { RelatedProducts } from '../RelatedProducts';

import type { SearchClient } from 'instantsearch.js';

describe('RelatedProducts', () => {
test('renders with translations', async () => {
const client = createMockedSearchClient();
const { container } = render(
<InstantSearchTestWrapper searchClient={client}>
<RelatedProducts
objectIDs={['1']}
translations={{ title: 'My related products' }}
/>
</InstantSearchTestWrapper>
);

await waitFor(() => {
expect(client.search).toHaveBeenCalledTimes(1);
});

await waitFor(() => {
expect(container.querySelector('.ais-RelatedProducts'))
.toMatchInlineSnapshot(`
<section
class="ais-RelatedProducts"
>
<h3
class="ais-RelatedProducts-title"
>
My related products
</h3>
<div
class="ais-RelatedProducts-container"
>
<ol
class="ais-RelatedProducts-list"
>
<li
class="ais-RelatedProducts-item"
>
{
"objectID": "1"
}
</li>
<li
class="ais-RelatedProducts-item"
>
{
"objectID": "2"
}
</li>
</ol>
</div>
</section>
`);
});
});

test('forwards custom class names and `div` props to the root element', () => {
const { container } = render(
<InstantSearchTestWrapper>
<RelatedProducts
objectIDs={['1']}
className="MyRelatedProducts"
classNames={{ root: 'ROOT' }}
aria-hidden={true}
/>
</InstantSearchTestWrapper>
);

const root = container.firstChild;
expect(root).toHaveClass('MyRelatedProducts', 'ROOT');
expect(root).toHaveAttribute('aria-hidden', 'true');
});
});

function createMockedSearchClient() {
return createSearchClient({
getRecommendations: jest.fn((requests) =>
Promise.resolve(
createMultiSearchResponse(
// @ts-ignore
// `request` will be implicitly typed as `any` in type-check:v3
// since `getRecommendations` is not available there
...requests.map((request) => {
return createSingleSearchResponse<any>({
hits:
request.maxRecommendations === 0
? []
: [{ objectID: '1' }, { objectID: '2' }],
});
})
)
)
) as SearchClient['getRecommendations'],
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ function Widget<TWidget extends SingleWidget>({
case 'SearchBox': {
return <widget.Component onSubmit={undefined} {...props} />;
}
case 'RelatedProducts': {
return <widget.Component objectIDs={['1']} {...props} />;
}
default: {
return <widget.Component {...props} />;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ describe('widgets', () => {
"$$widgetType": "ais.refinementList",
"name": "RefinementList",
},
{
"$$type": "ais.relatedProducts",
"$$widgetType": "ais.relatedProducts",
"name": "RelatedProducts",
},
{
"$$type": "ais.searchBox",
"$$widgetType": "ais.searchBox",
Expand Down
1 change: 1 addition & 0 deletions packages/react-instantsearch/src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './Pagination';
export * from './PoweredBy';
export * from './RangeInput';
export * from './RefinementList';
export * from './RelatedProducts';
export * from './SearchBox';
export * from './Snippet';
export * from './SortBy';
Expand Down

0 comments on commit 313b0ea

Please sign in to comment.