Skip to content

Commit

Permalink
feat(react-instantsearch): introduce Frequently Bought Together Hook …
Browse files Browse the repository at this point in the history
…and widget (#6155)

---------

Co-authored-by: Dhaya <154633+dhayab@users.noreply.github.com>
Co-authored-by: Aymeric Giraudet <aymeric.giraudet@algolia.com>
Co-authored-by: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com>
  • Loading branch information
4 people committed Apr 30, 2024
1 parent 99907be commit 7714719
Show file tree
Hide file tree
Showing 10 changed files with 259 additions and 18 deletions.
4 changes: 2 additions & 2 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@
},
{
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
"maxSize": "48 kB"
"maxSize": "48.5 kB"
},
{
"path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js",
"maxSize": "60.5 kB"
"maxSize": "61.25 kB"
},
{
"path": "packages/vue-instantsearch/vue2/umd/index.js",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import connectFrequentlyBoughtTogether from 'instantsearch.js/es/connectors/frequently-bought-together/connectFrequentlyBoughtTogether';

import { useConnector } from '../hooks/useConnector';

import type { AdditionalWidgetProperties } from '../hooks/useConnector';
import type { BaseHit } from 'instantsearch.js';
import type {
FrequentlyBoughtTogetherConnector,
FrequentlyBoughtTogetherConnectorParams,
FrequentlyBoughtTogetherWidgetDescription,
} from 'instantsearch.js/es/connectors/frequently-bought-together/connectFrequentlyBoughtTogether';

export type UseFrequentlyBoughtTogetherProps<THit extends BaseHit = BaseHit> =
FrequentlyBoughtTogetherConnectorParams<THit>;

export function useFrequentlyBoughtTogether<THit extends BaseHit = BaseHit>(
props?: UseFrequentlyBoughtTogetherProps<THit>,
additionalWidgetProperties?: AdditionalWidgetProperties
) {
return useConnector<
FrequentlyBoughtTogetherConnectorParams<THit>,
FrequentlyBoughtTogetherWidgetDescription<THit>
>(
connectFrequentlyBoughtTogether as FrequentlyBoughtTogetherConnector<THit>,
props,
additionalWidgetProperties
);
}
1 change: 1 addition & 0 deletions packages/react-instantsearch-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './connectors/useClearRefinements';
export * from './connectors/useConfigure';
export * from './connectors/useCurrentRefinements';
export * from './connectors/useDynamicWidgets';
export * from './connectors/useFrequentlyBoughtTogether';
export * from './connectors/useGeoSearch';
export * from './connectors/useHierarchicalMenu';
export * from './connectors/useHits';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
useToggleRefinement,
useCurrentRefinements,
useRelatedProducts,
useFrequentlyBoughtTogether,
} from '..';

import type {
Expand All @@ -34,6 +35,7 @@ import type {
UseRefinementListProps,
UseToggleRefinementProps,
UseRelatedProductsProps,
UseFrequentlyBoughtTogetherProps,
} from '..';
import type { TestOptionsMap, TestSetupsMap } from '@instantsearch/tests';
import type {
Expand Down Expand Up @@ -337,7 +339,30 @@ const testSetups: TestSetupsMap<TestSuites> = {
</InstantSearch>
);
},
createFrequentlyBoughtTogetherConnectorTests: () => {},
createFrequentlyBoughtTogetherConnectorTests: ({
instantSearchOptions,
widgetParams,
}) => {
function CustomFrequentlyBoughtTogether(
props: UseFrequentlyBoughtTogetherProps
) {
const { recommendations } = useFrequentlyBoughtTogether(props);

return (
<ul>
{recommendations.map((recommendation) => (
<li key={recommendation.objectID}>{recommendation.objectID}</li>
))}
</ul>
);
}

render(
<InstantSearch {...instantSearchOptions}>
<CustomFrequentlyBoughtTogether {...widgetParams} />
</InstantSearch>
);
},
};

const testOptions: TestOptionsMap<TestSuites> = {
Expand All @@ -358,12 +383,7 @@ const testOptions: TestOptionsMap<TestSuites> = {
createRatingMenuConnectorTests: { act },
createToggleRefinementConnectorTests: { act },
createRelatedProductsConnectorTests: { act },
createFrequentlyBoughtTogetherConnectorTests: {
act,
skippedTests: {
options: true,
},
},
createFrequentlyBoughtTogetherConnectorTests: { act },
};

describe('Common connector tests (React InstantSearch)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
SortBy,
Stats,
RelatedProducts,
FrequentlyBoughtTogether,
} from '..';

import type { TestOptionsMap, TestSetupsMap } from '@instantsearch/tests';
Expand Down Expand Up @@ -319,9 +320,12 @@ const testSetups: TestSetupsMap<TestSuites> = {
</InstantSearch>
);
},
createFrequentlyBoughtTogetherTests() {
throw new Error(
'FrequentlyBoughtTogether is not implemented in React InstantSearch yet'
createFrequentlyBoughtTogetherTests({ instantSearchOptions, widgetParams }) {
render(
<InstantSearch {...instantSearchOptions}>
<FrequentlyBoughtTogether {...widgetParams} />
<GlobalErrorSwallower />
</InstantSearch>
);
},
};
Expand Down Expand Up @@ -356,12 +360,7 @@ const testOptions: TestOptionsMap<TestSuites> = {
},
},
createRelatedProductsWidgetTests: { act },
createFrequentlyBoughtTogetherTests: {
act,
skippedTests: {
'FrequentlyBoughtTogether widget common tests': true,
},
},
createFrequentlyBoughtTogetherTests: { act },
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createFrequentlyBoughtTogetherComponent } from 'instantsearch-ui-components';
import React, { createElement, Fragment } from 'react';
import {
useFrequentlyBoughtTogether,
useInstantSearch,
} from 'react-instantsearch-core';

import type {
FrequentlyBoughtTogetherProps as FrequentlyBoughtTogetherPropsUiComponentProps,
Pragma,
} from 'instantsearch-ui-components';
import type { Hit, BaseHit } from 'instantsearch.js';
import type { UseFrequentlyBoughtTogetherProps } from 'react-instantsearch-core';

type UiProps<THit extends BaseHit> = Pick<
FrequentlyBoughtTogetherPropsUiComponentProps<Hit<THit>>,
| 'items'
| 'itemComponent'
| 'headerComponent'
| 'fallbackComponent'
| 'status'
| 'sendEvent'
>;

export type FrequentlyBoughtTogetherProps<THit extends BaseHit> = Omit<
FrequentlyBoughtTogetherPropsUiComponentProps<Hit<THit>>,
keyof UiProps<THit>
> &
UseFrequentlyBoughtTogetherProps<THit> & {
itemComponent?: FrequentlyBoughtTogetherPropsUiComponentProps<THit>['itemComponent'];
headerComponent?: FrequentlyBoughtTogetherPropsUiComponentProps<THit>['headerComponent'];
fallbackComponent?: FrequentlyBoughtTogetherPropsUiComponentProps<THit>['fallbackComponent'];
};

const FrequentlyBoughtTogetherUiComponent =
createFrequentlyBoughtTogetherComponent({
createElement: createElement as Pragma,
Fragment,
});

export function FrequentlyBoughtTogether<THit extends BaseHit = BaseHit>({
objectIDs,
maxRecommendations,
threshold,
queryParameters,
transformItems,
itemComponent,
headerComponent,
fallbackComponent,
...props
}: FrequentlyBoughtTogetherProps<THit>) {
const { status } = useInstantSearch();
const { recommendations } = useFrequentlyBoughtTogether<THit>(
{
objectIDs,
maxRecommendations,
threshold,
queryParameters,
transformItems,
},
{ $$widgetType: 'ais.frequentlyBoughtTogether' }
);

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

return <FrequentlyBoughtTogetherUiComponent {...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 { FrequentlyBoughtTogether } from '../FrequentlyBoughtTogether';

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

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

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

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

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

const root = container.firstChild;
expect(root).toHaveClass('MyFrequentlyBoughtTogether', '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 'FrequentlyBoughtTogether': {
return <widget.Component objectIDs={['1']} {...props} />;
}
case 'RelatedProducts': {
return <widget.Component objectIDs={['1']} {...props} />;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ describe('widgets', () => {
"$$widgetType": "ais.currentRefinements",
"name": "CurrentRefinements",
},
{
"$$type": "ais.frequentlyBoughtTogether",
"$$widgetType": "ais.frequentlyBoughtTogether",
"name": "FrequentlyBoughtTogether",
},
{
"$$type": "ais.hierarchicalMenu",
"$$widgetType": "ais.hierarchicalMenu",
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
@@ -1,6 +1,7 @@
export * from './Breadcrumb';
export * from './ClearRefinements';
export * from './CurrentRefinements';
export * from './FrequentlyBoughtTogether';
export * from './HierarchicalMenu';
export * from './Highlight';
export * from './Hits';
Expand Down

0 comments on commit 7714719

Please sign in to comment.