From e041a2f127c6f085e319bc7db632336794a4abc3 Mon Sep 17 00:00:00 2001 From: Aymeric Giraudet Date: Thu, 4 Apr 2024 15:46:29 +0200 Subject: [PATCH] wip: map recommend results to widgets --- packages/algoliasearch-helper/index.d.ts | 21 ++++++-- .../src/RecommendResults/index.js | 28 ++++++++++ .../src/algoliasearch.helper.js | 3 +- .../src/lib/utils/render-args.ts | 9 ++-- packages/instantsearch.js/src/types/widget.ts | 36 ++++++++++--- .../src/widgets/index/__tests__/index-test.ts | 54 ++++++++++++++++++- .../src/widgets/index/index.ts | 35 +++++++++++- tests/mocks/createAPIResponse.ts | 7 +++ tests/mocks/createSearchClient.ts | 4 ++ 9 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 packages/algoliasearch-helper/src/RecommendResults/index.js diff --git a/packages/algoliasearch-helper/index.d.ts b/packages/algoliasearch-helper/index.d.ts index 72cbe624ad..d00968d713 100644 --- a/packages/algoliasearch-helper/index.d.ts +++ b/packages/algoliasearch-helper/index.d.ts @@ -16,6 +16,7 @@ import type { RelatedProductsQuery as RecommendRelatedProductsQuery, TrendingFacetsQuery as RecommendTrendingFacetsQuery, TrendingItemsQuery as RecommendTrendingItemsQuery, + RecommendQueriesResponse, } from '@algolia/recommend'; /** @@ -42,7 +43,7 @@ declare namespace algoliasearchHelper { state: SearchParameters; recommendState: RecommendParameters; lastResults: SearchResults | null; - lastRecommendResults: unknown | null; // TODO: Define type in dedicated PR + lastRecommendResults: RecommendResults | null; derivedHelpers: DerivedHelper[]; on( @@ -402,7 +403,7 @@ declare namespace algoliasearchHelper { event: 'recommend:result', cb: (res: { recommend: { - results: unknown | null; // TODO: Define type in dedicated PR + results: RecommendResults | null; state: RecommendParameters; }; }) => void @@ -410,7 +411,7 @@ declare namespace algoliasearchHelper { on(event: 'error', cb: (res: { error: Error }) => void): this; lastResults: SearchResults | null; - lastRecommendResults: unknown | null; // TODO: Define type in dedicated PR + lastRecommendResults: RecommendResults | null; detach(): void; getModifiedState(): SearchParameters; getModifiedRecommendState(): RecommendParameters; @@ -1553,6 +1554,20 @@ declare namespace algoliasearchHelper { params: RecommendParametersWithId ): RecommendParameters; } + + type RecommendResponse = + RecommendQueriesResponse['results']; + + type RecommendResultItem = RecommendResponse[0]; + + export class RecommendResults { + constructor(state: RecommendParameters, results: RecommendResponse); + + _state: RecommendParameters; + _rawResults: RecommendResponse; + + [index: number]: RecommendResultItem; + } } export = algoliasearchHelper; diff --git a/packages/algoliasearch-helper/src/RecommendResults/index.js b/packages/algoliasearch-helper/src/RecommendResults/index.js new file mode 100644 index 0000000000..c4341c581d --- /dev/null +++ b/packages/algoliasearch-helper/src/RecommendResults/index.js @@ -0,0 +1,28 @@ +'use strict'; + +/** + * Constructor for SearchResults + * @class + * @classdesc SearchResults contains the results of a query to Algolia using the + * {@link AlgoliaSearchHelper}. + * @param {RecommendParameters} state state that led to the response + * @param {array.} results the results from algolia client + **/ +function RecommendResults(state, results) { + this._state = state; + this._rawResults = results; + + // eslint-disable-next-line consistent-this + var self = this; + + results.forEach(function (result, index) { + var id = state.params[index].$$id; + self[id] = result; + }); +} + +RecommendResults.prototype = { + constructor: RecommendResults, +}; + +module.exports = RecommendResults; diff --git a/packages/algoliasearch-helper/src/algoliasearch.helper.js b/packages/algoliasearch-helper/src/algoliasearch.helper.js index abba8cc4fd..8223bee970 100644 --- a/packages/algoliasearch-helper/src/algoliasearch.helper.js +++ b/packages/algoliasearch-helper/src/algoliasearch.helper.js @@ -9,6 +9,7 @@ var merge = require('./functions/merge'); var objectHasKeys = require('./functions/objectHasKeys'); var omit = require('./functions/omit'); var RecommendParameters = require('./RecommendParameters'); +var RecommendResults = require('./RecommendResults'); var requestBuilder = require('./requestBuilder'); var SearchParameters = require('./SearchParameters'); var SearchResults = require('./SearchResults'); @@ -1744,7 +1745,7 @@ AlgoliaSearchHelper.prototype._dispatchRecommendResponse = function ( return; } - helper.lastRecommendResults = results; + helper.lastRecommendResults = new RecommendResults(state, results); // eslint-disable-next-line no-warning-comments // TODO: emit "result" event when events for Recommend are implemented diff --git a/packages/instantsearch.js/src/lib/utils/render-args.ts b/packages/instantsearch.js/src/lib/utils/render-args.ts index eb90a80925..aa467d2372 100644 --- a/packages/instantsearch.js/src/lib/utils/render-args.ts +++ b/packages/instantsearch.js/src/lib/utils/render-args.ts @@ -1,4 +1,4 @@ -import type { InstantSearch, UiState } from '../../types'; +import type { InstantSearch, UiState, Widget } from '../../types'; import type { IndexWidget } from '../../widgets/index/index'; export function createInitArgs( @@ -27,9 +27,10 @@ export function createInitArgs( export function createRenderArgs( instantSearchInstance: InstantSearch, - parent: IndexWidget + parent: IndexWidget, + widget: IndexWidget | Widget ) { - const results = parent.getResults()!; + const results = parent.getResultsForWidget(widget)!; const helper = parent.getHelper()!; return { @@ -38,7 +39,7 @@ export function createRenderArgs( instantSearchInstance, results, scopedResults: parent.getScopedResults(), - state: results ? results._state : helper.state, + state: results && '_state' in results ? results._state : helper.state, renderState: instantSearchInstance.renderState, templatesConfig: instantSearchInstance.templatesConfig, createURL: parent.createURL, diff --git a/packages/instantsearch.js/src/types/widget.ts b/packages/instantsearch.js/src/types/widget.ts index a1577ee380..afdf6ce0f5 100644 --- a/packages/instantsearch.js/src/types/widget.ts +++ b/packages/instantsearch.js/src/types/widget.ts @@ -8,6 +8,7 @@ import type { SearchParameters, SearchResults, RecommendParameters, + RecommendResultItem, } from 'algoliasearch-helper'; export type ScopedResult = { @@ -137,7 +138,7 @@ export type WidgetDescription = { indexUiState?: Record; }; -type SearchWidgetLifeCycle = { +type SearchWidget = { dependsOn?: 'search'; getWidgetParameters?: ( state: SearchParameters, @@ -149,8 +150,15 @@ type SearchWidgetLifeCycle = { ) => SearchParameters; }; -type RecommendWidgetLifeCycle = { - dependsOn?: 'recommend'; +type RecommmendRenderOptions = SharedRenderOptions & { + results: RecommendResultItem; +}; + +type RecommendWidget< + TWidgetDescription extends WidgetDescription & WidgetParams +> = { + dependsOn: 'recommend'; + $$id: number; getWidgetParameters: ( state: RecommendParameters, widgetParametersOptions: { @@ -159,6 +167,20 @@ type RecommendWidgetLifeCycle = { >; } ) => RecommendParameters; + getRenderState: ( + renderState: Expand< + IndexRenderState & Partial + >, + renderOptions: InitOptions | RecommmendRenderOptions + ) => IndexRenderState & TWidgetDescription['indexRenderState']; + getWidgetRenderState: ( + renderOptions: InitOptions | RecommmendRenderOptions + ) => Expand< + WidgetRenderState< + TWidgetDescription['renderState'], + TWidgetDescription['widgetParams'] + > + >; }; type RequiredWidgetLifeCycle = { @@ -247,10 +269,7 @@ type RequiredUiStateLifeCycle = { >; } ) => SearchParameters; -} & ( - | SearchWidgetLifeCycle - | RecommendWidgetLifeCycle -); +}; type UiStateLifeCycle = TWidgetDescription extends RequiredKeys @@ -302,7 +321,8 @@ export type Widget< WidgetType & UiStateLifeCycle & RenderStateLifeCycle ->; +> & + (SearchWidget | RecommendWidget); export type TransformItemsMetadata = { results?: SearchResults; diff --git a/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts b/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts index bb36b3ff7f..acaf332d30 100644 --- a/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts +++ b/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts @@ -113,7 +113,10 @@ describe('index', () => { createWidget({ dependsOn: 'recommend', getWidgetParameters: jest.fn((parameters) => { - return parameters; + return parameters.addFrequentlyBoughtTogether({ + $$id: 1, + objectID: 'abc', + }); }), ...args, } as unknown as Widget); @@ -2396,6 +2399,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge const paginationCreateURL = jest.fn(); const searchBox = createSearchBox({ + dependsOn: 'search', getRenderState: jest.fn((renderState, { helper, searchMetadata }) => { return { ...renderState, @@ -2512,6 +2516,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge const mainHelper = algoliasearchHelper(searchClient, 'indexName', {}); const instantSearchInstance = createInstantSearch({ mainHelper }); const searchBox = createSearchBox({ + dependsOn: 'search', getRenderState: jest.fn((renderState, { helper, searchMetadata }) => { return { ...renderState, @@ -2956,6 +2961,53 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge }) ); }); + + it('forwards recommend results when `dependsOn` is `recommend`', async () => { + const instance = index({ indexName: 'indexName' }); + const searchClient = createSearchClient({ + getRecommendations: jest.fn(() => + Promise.resolve({ + results: [{ hits: [{ objectID: '1', title: 'Recommend' }] }], + }) + ), + }); + const mainHelper = algoliasearchHelper(searchClient, '', {}); + const instantSearchInstance = createInstantSearch({ + mainHelper, + }); + + const fbt = createFrequentlyBoughtTogether({ + $$id: 1, + dependsOn: 'recommend', + shouldRender: () => true, + }); + instance.addWidgets([fbt]); + + instance.init( + createIndexInitOptions({ + instantSearchInstance, + parent: null, + }) + ); + mainHelper.search(); + await wait(0); + mainHelper.recommend(); + await wait(0); + + instance.render({ + instantSearchInstance, + }); + + expect(fbt.render).toHaveBeenCalledWith( + expect.objectContaining({ + results: expect.objectContaining({ + hits: expect.arrayContaining([ + { objectID: '1', title: 'Recommend' }, + ]), + }), + }) + ); + }); }); describe('dispose', () => { diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index b08330eae1..c93c7fb37d 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -19,6 +19,7 @@ import type { ScopedResult, SearchClient, IndexRenderState, + RenderOptions, } from '../../types'; import type { AlgoliaSearchHelper as Helper, @@ -28,6 +29,7 @@ import type { SearchResults, AlgoliaSearchHelper, RecommendParameters, + RecommendResultItem, } from 'algoliasearch-helper'; const withUsage = createDocumentationMessageGenerator({ @@ -72,6 +74,9 @@ export type IndexWidget = Omit< getIndexId: () => string; getHelper: () => Helper | null; getResults: () => SearchResults | null; + getResultsForWidget: ( + widget: IndexWidget | Widget + ) => SearchResults | RecommendResultItem | null; getPreviousState: () => SearchParameters | null; getScopedResults: () => ScopedResult[]; getParent: () => IndexWidget | null; @@ -297,6 +302,22 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { return derivedHelper.lastResults; }, + getResultsForWidget(widget) { + if ( + widget.dependsOn !== 'recommend' || + isIndexWidget(widget) || + !widget.$$id + ) { + return this.getResults(); + } + + if (!helper?.lastRecommendResults) { + return null; + } + + return helper.lastRecommendResults[widget.$$id]; + }, + getPreviousState() { return lastValidSearchParameters; }, @@ -721,7 +742,11 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { if (widget.getRenderState) { const renderState = widget.getRenderState( instantSearchInstance.renderState[this.getIndexId()] || {}, - createRenderArgs(instantSearchInstance, this) + createRenderArgs( + instantSearchInstance, + this, + widget + ) as RenderOptions ); storeRenderState({ @@ -741,7 +766,13 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { // not have results yet. if (widget.render) { - widget.render(createRenderArgs(instantSearchInstance, this)); + widget.render( + createRenderArgs( + instantSearchInstance, + this, + widget + ) as RenderOptions + ); } }); }, diff --git a/tests/mocks/createAPIResponse.ts b/tests/mocks/createAPIResponse.ts index 13940fa5c9..f6f82e4bdc 100644 --- a/tests/mocks/createAPIResponse.ts +++ b/tests/mocks/createAPIResponse.ts @@ -1,3 +1,4 @@ +import type { RecommendResponse } from 'algoliasearch-helper'; import type { SearchResponse, SearchResponses, @@ -86,3 +87,9 @@ export const createSFFVResponse = ( processingTimeMS: 1, ...args, }); + +export const createRecommendResponse = ( + requests: Array<{ objectID: string; model: string }> +): { results: RecommendResponse> } => { + return { results: requests.map(() => createSingleSearchResponse()) }; +}; diff --git a/tests/mocks/createSearchClient.ts b/tests/mocks/createSearchClient.ts index d65c5f34e5..2e3569377f 100644 --- a/tests/mocks/createSearchClient.ts +++ b/tests/mocks/createSearchClient.ts @@ -1,6 +1,7 @@ import { createSingleSearchResponse, createMultiSearchResponse, + createRecommendResponse, createSFFVResponse, } from './createAPIResponse'; @@ -9,6 +10,9 @@ import type { SearchClient, SearchResponses } from 'instantsearch.js'; export const createSearchClient = ( args: Partial = {} ): SearchClient => ({ + getRecommendations: jest.fn((requests) => + Promise.resolve(createRecommendResponse(requests)) + ), search: jest.fn((requests) => Promise.resolve( createMultiSearchResponse(