diff --git a/README.md b/README.md index ce2c7300..6608c4ea 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Then, use the `Recommendations` component: model="related-products" searchClient={searchClient} indexName="YOUR_SOURCE_INDEX_NAME" - objectID={objectID} + objectIDs={[objectID]} hitComponent={Hit} /> @@ -39,7 +39,7 @@ Then, use the `Recommendations` component: model="bought-together" searchClient={searchClient} indexName="YOUR_SOURCE_INDEX_NAME" - objectID={objectID} + objectIDs={[objectID]} hitComponent={Hit} /> ``` @@ -64,11 +64,11 @@ The initialized Algolia search client. The name of the products index. -### `objectID` +### `objectIDs` -> `string` | **required** +> `string[]` | **required** -The objectID of the product to get recommendations from +An array of `objectID`s of the products to get recommendations from. ### `hitComponent` diff --git a/examples/demo/src/App.js b/examples/demo/src/App.js index f2317177..08a5ea5d 100644 --- a/examples/demo/src/App.js +++ b/examples/demo/src/App.js @@ -118,7 +118,7 @@ function App() { } maxRecommendations={3} searchParameters={{ @@ -130,7 +130,7 @@ function App() { } maxRecommendations={10} translations={{ @@ -151,7 +151,7 @@ function App() { } maxRecommendations={10} translations={{ diff --git a/packages/react-recommendations/src/FrequentlyBoughtTogether.tsx b/packages/react-recommendations/src/FrequentlyBoughtTogether.tsx index 42b987e9..9737df14 100644 --- a/packages/react-recommendations/src/FrequentlyBoughtTogether.tsx +++ b/packages/react-recommendations/src/FrequentlyBoughtTogether.tsx @@ -60,7 +60,7 @@ export function FrequentlyBoughtTogether( FrequentlyBoughtTogether.propTypes = { searchClient: PropTypes.object.isRequired, indexName: PropTypes.string.isRequired, - objectID: PropTypes.string.isRequired, + objectIDs: PropTypes.arrayOf(PropTypes.string).isRequired, hitComponent: PropTypes.elementType.isRequired, maxRecommendations: PropTypes.number, diff --git a/packages/react-recommendations/src/Recommendations.tsx b/packages/react-recommendations/src/Recommendations.tsx index d2a88b77..dc321630 100644 --- a/packages/react-recommendations/src/Recommendations.tsx +++ b/packages/react-recommendations/src/Recommendations.tsx @@ -56,7 +56,7 @@ Recommendations.propTypes = { model: PropTypes.string.isRequired, searchClient: PropTypes.object.isRequired, indexName: PropTypes.string.isRequired, - objectID: PropTypes.string.isRequired, + objectIDs: PropTypes.arrayOf(PropTypes.string).isRequired, hitComponent: PropTypes.elementType.isRequired, fallbackFilters: PropTypes.arrayOf( diff --git a/packages/react-recommendations/src/RelatedProducts.tsx b/packages/react-recommendations/src/RelatedProducts.tsx index b05fe255..2d011889 100644 --- a/packages/react-recommendations/src/RelatedProducts.tsx +++ b/packages/react-recommendations/src/RelatedProducts.tsx @@ -62,7 +62,7 @@ export function RelatedProducts( RelatedProducts.propTypes = { searchClient: PropTypes.object.isRequired, indexName: PropTypes.string.isRequired, - objectID: PropTypes.string.isRequired, + objectIDs: PropTypes.arrayOf(PropTypes.string).isRequired, hitComponent: PropTypes.elementType.isRequired, fallbackFilters: PropTypes.arrayOf( diff --git a/packages/react-recommendations/src/RelatedProductsSlider.tsx b/packages/react-recommendations/src/RelatedProductsSlider.tsx index a46d3f7f..4af7d00d 100644 --- a/packages/react-recommendations/src/RelatedProductsSlider.tsx +++ b/packages/react-recommendations/src/RelatedProductsSlider.tsx @@ -180,7 +180,7 @@ export function RelatedProductsSlider( RelatedProductsSlider.propTypes = { searchClient: PropTypes.object.isRequired, indexName: PropTypes.string.isRequired, - objectID: PropTypes.string.isRequired, + objectIDs: PropTypes.arrayOf(PropTypes.string).isRequired, hitComponent: PropTypes.elementType.isRequired, fallbackFilters: PropTypes.arrayOf( diff --git a/packages/react-recommendations/src/__tests__/Recommendations.test.tsx b/packages/react-recommendations/src/__tests__/Recommendations.test.tsx index cd8a9242..95a38a06 100644 --- a/packages/react-recommendations/src/__tests__/Recommendations.test.tsx +++ b/packages/react-recommendations/src/__tests__/Recommendations.test.tsx @@ -1,7 +1,7 @@ import { act, render, waitFor } from '@testing-library/react'; import React from 'react'; -import { createSingleSearchResponse } from '../../../../test-utils/createApiResponse'; +import { createMultiSearchResponse } from '../../../../test-utils/createApiResponse'; import { createSearchClient } from '../../../../test-utils/createSearchClient'; import { Recommendations } from '../Recommendations'; @@ -34,17 +34,17 @@ function Hit({ hit }) { function createRecommendationsClient() { const index = { - getObject: jest.fn(() => Promise.resolve(hit)), + getObjects: jest.fn(() => Promise.resolve({ results: [hit] })), + }; + const searchClient = createSearchClient({ + initIndex: jest.fn(() => index), search: jest.fn(() => Promise.resolve( - createSingleSearchResponse({ + createMultiSearchResponse({ hits: [hit], }) ) ), - }; - const searchClient = createSearchClient({ - initIndex: jest.fn(() => index), }); return { @@ -63,7 +63,7 @@ describe('Recommendations', () => { model="related-products" searchClient={searchClient} indexName="indexName" - objectID="objectID" + objectIDs={['objectID']} hitComponent={Hit} /> ); @@ -73,22 +73,27 @@ describe('Recommendations', () => { expect(searchClient.initIndex).toHaveBeenCalledWith( 'ai_recommend_related-products_indexName' ); - expect(index.getObject).toHaveBeenCalledTimes(1); - expect(index.getObject).toHaveBeenCalledWith('objectID'); + expect(index.getObjects).toHaveBeenCalledTimes(1); + expect(index.getObjects).toHaveBeenCalledWith(['objectID']); await waitFor(() => { - expect(index.search).toHaveBeenCalledTimes(1); - expect(index.search).toHaveBeenCalledWith('', { - analytics: false, - analyticsTags: ['alg-recommend_related-products'], - clickAnalytics: false, - enableABTest: false, - filters: 'NOT objectID:objectID', - hitsPerPage: 0, - optionalFilters: [], - ruleContexts: ['alg-recommend_related-products_objectID'], - typoTolerance: false, - }); + expect(searchClient.search).toHaveBeenCalledTimes(1); + expect(searchClient.search).toHaveBeenCalledWith([ + { + indexName: 'indexName', + params: { + analytics: false, + analyticsTags: ['alg-recommend_related-products'], + clickAnalytics: false, + enableABTest: false, + filters: 'NOT objectID:objectID', + hitsPerPage: 0, + optionalFilters: [], + ruleContexts: ['alg-recommend_related-products_objectID'], + typoTolerance: false, + }, + }, + ]); }); }); @@ -101,7 +106,7 @@ describe('Recommendations', () => { model="bought-together" searchClient={searchClient} indexName="indexName" - objectID="objectID" + objectIDs={['objectID']} hitComponent={Hit} /> ); @@ -111,22 +116,27 @@ describe('Recommendations', () => { expect(searchClient.initIndex).toHaveBeenCalledWith( 'ai_recommend_bought-together_indexName' ); - expect(index.getObject).toHaveBeenCalledTimes(1); - expect(index.getObject).toHaveBeenCalledWith('objectID'); + expect(index.getObjects).toHaveBeenCalledTimes(1); + expect(index.getObjects).toHaveBeenCalledWith(['objectID']); await waitFor(() => { - expect(index.search).toHaveBeenCalledTimes(1); - expect(index.search).toHaveBeenCalledWith('', { - analytics: false, - analyticsTags: ['alg-recommend_bought-together'], - clickAnalytics: false, - enableABTest: false, - filters: 'NOT objectID:objectID', - hitsPerPage: 0, - optionalFilters: [], - ruleContexts: ['alg-recommend_bought-together_objectID'], - typoTolerance: false, - }); + expect(searchClient.search).toHaveBeenCalledTimes(1); + expect(searchClient.search).toHaveBeenCalledWith([ + { + indexName: 'indexName', + params: { + analytics: false, + analyticsTags: ['alg-recommend_bought-together'], + clickAnalytics: false, + enableABTest: false, + filters: 'NOT objectID:objectID', + hitsPerPage: 0, + optionalFilters: [], + ruleContexts: ['alg-recommend_bought-together_objectID'], + typoTolerance: false, + }, + }, + ]); }); }); }); diff --git a/packages/react-recommendations/src/types/ProductRecord.ts b/packages/react-recommendations/src/types/ProductRecord.ts new file mode 100644 index 00000000..3a317507 --- /dev/null +++ b/packages/react-recommendations/src/types/ProductRecord.ts @@ -0,0 +1,6 @@ +export type ProductRecord = TObject & { + __indexName: string; + __queryID: string | undefined; + __position: number; + __recommendScore: number | null; +}; diff --git a/packages/react-recommendations/src/types/index.ts b/packages/react-recommendations/src/types/index.ts index 664668fd..29db3189 100644 --- a/packages/react-recommendations/src/types/index.ts +++ b/packages/react-recommendations/src/types/index.ts @@ -1,5 +1,6 @@ -export * from './UseRecommendationsInternalProps'; export * from './ProductBaseRecord'; +export * from './ProductRecord'; export * from './RecommendationModel'; export * from './RecommendationRecord'; export * from './RecommendationTranslations'; +export * from './UseRecommendationsInternalProps'; diff --git a/packages/react-recommendations/src/useFrequentlyBoughtTogether.ts b/packages/react-recommendations/src/useFrequentlyBoughtTogether.ts index d9d35ba0..d6679fa8 100644 --- a/packages/react-recommendations/src/useFrequentlyBoughtTogether.ts +++ b/packages/react-recommendations/src/useFrequentlyBoughtTogether.ts @@ -10,7 +10,7 @@ import { export type UseFrequentlyBoughtTogetherProps = { indexName: string; - objectID: string; + objectIDs: string[]; searchClient: SearchClient; maxRecommendations?: number; diff --git a/packages/react-recommendations/src/useRecommendations.ts b/packages/react-recommendations/src/useRecommendations.ts index e4c40a08..c794d677 100644 --- a/packages/react-recommendations/src/useRecommendations.ts +++ b/packages/react-recommendations/src/useRecommendations.ts @@ -3,20 +3,23 @@ import type { SearchClient } from 'algoliasearch'; import { useMemo, useEffect, useState } from 'react'; import { - UseRecommendationsInternalProps, ProductBaseRecord, + ProductRecord, RecommendationModel, + UseRecommendationsInternalProps, } from './types'; import { getHitsPerPage, getIndexNameFromModel, getOptionalFilters, + sortBy, + uniqBy, } from './utils'; export type UseRecommendationsProps = { model: RecommendationModel; indexName: string; - objectID: string; + objectIDs: string[]; searchClient: SearchClient; fallbackFilters?: SearchOptions['optionalFilters']; @@ -40,8 +43,12 @@ function getDefaultedProps( analyticsTags: [`alg-recommend_${props.model}`], clickAnalytics: false, enableABTest: false, - filters: `NOT objectID:${props.objectID}`, - ruleContexts: [`alg-recommend_${props.model}_${props.objectID}`], + filters: props.objectIDs + .map((objectID) => `NOT objectID:${objectID}`) + .join(' AND '), + ruleContexts: props.objectIDs.map( + (objectID) => `alg-recommend_${props.model}_${objectID}` + ), typoTolerance: false, ...props.searchParameters, }, @@ -53,47 +60,88 @@ function getDefaultedProps( export function useRecommendations( userProps: UseRecommendationsProps ): UseRecommendationReturn { - const [products, setProducts] = useState([]); + const [products, setProducts] = useState>>([]); const props = useMemo(() => getDefaultedProps(userProps), [userProps]); useEffect(() => { props.searchClient .initIndex(getIndexNameFromModel(props.model, props.indexName)) - .getObject(props.objectID) - .then((record) => { - const recommendations = record.recommendations ?? []; + .getObjects(props.objectIDs) + .then((response) => { + const recommendationsList = response.results.map( + (result) => result?.recommendations ?? [] + ); props.searchClient - .initIndex(props.indexName) - .search('', { - hitsPerPage: getHitsPerPage({ - fallbackFilters: props.fallbackFilters, - maxRecommendations: props.maxRecommendations, - recommendations, - }), - ...props.searchParameters, - optionalFilters: getOptionalFilters({ - fallbackFilters: props.fallbackFilters, - recommendations, - threshold: props.threshold, - }).concat(props.searchParameters.optionalFilters as any), - }) - .then((result) => { - const hits = result.hits.map((hit, index) => { - const match = recommendations.find( - (x) => x.objectID === hit.objectID - ); + .search( + recommendationsList.map((recommendations) => { + // This computes the `hitsPerPage` value as if a single `objectID` + // was passed. + const globalHitsPerPage = getHitsPerPage({ + fallbackFilters: props.fallbackFilters, + maxRecommendations: props.maxRecommendations, + recommendationsCount: recommendations.length, + }); + // This reduces the `globalHitsPerPage` value to get a `hitsPerPage` + // that is divided among all requests. + const hitsPerPage = + globalHitsPerPage > 0 + ? Math.ceil(globalHitsPerPage / props.objectIDs.length) + : globalHitsPerPage; return { - ...hit, - __indexName: props.indexName, - __queryID: result.queryID, - __position: index + 1, - // @TODO: this is for debugging purpose and can be removed - // before stable release. - __recommendScore: match?.score, + indexName: props.indexName, + params: { + hitsPerPage, + optionalFilters: getOptionalFilters({ + fallbackFilters: props.fallbackFilters, + recommendations, + threshold: props.threshold, + }), + ...props.searchParameters, + }, }; - }); + }) + ) + .then((response) => { + const hits = + // Since recommendations from multiple indices are returned, we + // need to sort them descending based on their score. + sortBy>( + (a, b) => { + const scoreA = a.__recommendScore || 0; + const scoreB = b.__recommendScore || 0; + + return scoreA < scoreB ? 1 : -1; + }, + // Multiple identical recommended `objectID`s can be returned b + // the engine, so we need to remove duplicates. + uniqBy>( + 'objectID', + response.results.flatMap((result) => + result.hits.map((hit, index) => { + const match = recommendationsList + .flat() + .find((x) => x.objectID === hit.objectID); + + return { + ...hit, + __indexName: props.indexName, + __queryID: result.queryID, + __position: index + 1, + __recommendScore: match?.score ?? null, + }; + }) + ) + ) + ).slice( + 0, + // We cap the number of recommendations because the previously + // computed `hitsPerPage` was an approximation due to `Math.ceil`. + props.maxRecommendations > 0 + ? props.maxRecommendations + : undefined + ); setProducts(hits); }); diff --git a/packages/react-recommendations/src/useRelatedProducts.ts b/packages/react-recommendations/src/useRelatedProducts.ts index 80aee57a..1596a549 100644 --- a/packages/react-recommendations/src/useRelatedProducts.ts +++ b/packages/react-recommendations/src/useRelatedProducts.ts @@ -10,7 +10,7 @@ import { export type UseRelatedProductsProps = { indexName: string; - objectID: string; + objectIDs: string[]; searchClient: SearchClient; fallbackFilters?: SearchOptions['optionalFilters']; diff --git a/packages/react-recommendations/src/utils/getHitsPerPage.ts b/packages/react-recommendations/src/utils/getHitsPerPage.ts index 6c1964c2..88b9d57b 100644 --- a/packages/react-recommendations/src/utils/getHitsPerPage.ts +++ b/packages/react-recommendations/src/utils/getHitsPerPage.ts @@ -1,22 +1,19 @@ -import { - UseRecommendationsInternalProps, - RecommendationRecord, -} from '../types'; +import { UseRecommendationsInternalProps } from '../types'; type GetHitsPerPageParams = { fallbackFilters: UseRecommendationsInternalProps['fallbackFilters']; maxRecommendations: UseRecommendationsInternalProps['maxRecommendations']; - recommendations: RecommendationRecord[]; + recommendationsCount: number; }; export function getHitsPerPage({ fallbackFilters, maxRecommendations, - recommendations, + recommendationsCount, }: GetHitsPerPageParams) { const hasFallback = fallbackFilters.length > 0; - if (recommendations.length === 0) { + if (recommendationsCount === 0) { return hasFallback ? maxRecommendations : 0; } @@ -28,6 +25,6 @@ export function getHitsPerPage({ // Otherwise, cap the hits retrieved with `maxRecommendations` return maxRecommendations > 0 - ? Math.min(recommendations.length, maxRecommendations) - : recommendations.length; + ? Math.min(recommendationsCount, maxRecommendations) + : recommendationsCount; } diff --git a/packages/react-recommendations/src/utils/getOptionalFilters.ts b/packages/react-recommendations/src/utils/getOptionalFilters.ts index 4675943d..c11ad7ed 100644 --- a/packages/react-recommendations/src/utils/getOptionalFilters.ts +++ b/packages/react-recommendations/src/utils/getOptionalFilters.ts @@ -19,12 +19,8 @@ export function getOptionalFilters({ } const recommendationFilters = recommendations - .reverse() .filter((recommendation) => recommendation.score > threshold) - .map( - ({ objectID, score }, i) => - `objectID:${objectID}` - ); + .map(({ objectID, score }) => `objectID:${objectID}`); return [...recommendationFilters, ...fallbackFilters]; } diff --git a/packages/react-recommendations/src/utils/index.ts b/packages/react-recommendations/src/utils/index.ts index 31466fac..69b2cf11 100644 --- a/packages/react-recommendations/src/utils/index.ts +++ b/packages/react-recommendations/src/utils/index.ts @@ -1,3 +1,5 @@ export * from './getHitsPerPage'; export * from './getIndexNameFromModel'; export * from './getOptionalFilters'; +export * from './sortBy'; +export * from './uniqBy'; diff --git a/packages/react-recommendations/src/utils/sortBy.ts b/packages/react-recommendations/src/utils/sortBy.ts new file mode 100644 index 00000000..34921134 --- /dev/null +++ b/packages/react-recommendations/src/utils/sortBy.ts @@ -0,0 +1,8 @@ +type Predicate = (a: TItem, b: TItem) => number; + +export function sortBy(predicate: Predicate, items: TItem[]) { + const itemsCopy = [...items]; + itemsCopy.sort(predicate); + + return itemsCopy; +} diff --git a/packages/react-recommendations/src/utils/uniqBy.ts b/packages/react-recommendations/src/utils/uniqBy.ts new file mode 100644 index 00000000..a1a52e8a --- /dev/null +++ b/packages/react-recommendations/src/utils/uniqBy.ts @@ -0,0 +1,3 @@ +export function uniqBy(key: keyof TItem, items: TItem[]) { + return [...new Map(items.map((item) => [item[key], item])).values()]; +}