From b18d0ce59e40c4cdf4d5a60a233e3eeebf7c092f Mon Sep 17 00:00:00 2001 From: mmitiche <86681870+mmitiche@users.noreply.github.com> Date: Mon, 11 Mar 2024 09:56:45 -0400 Subject: [PATCH] feat(headless): executeSearch action for insight refactored to be compatible with the new event protocol (#3678) ## [SFINT-5418](https://coveord.atlassian.net/browse/SFINT-5418) In this PR: - I adapted the `ExecuteSearch` action for the insight use case to be compatible with the new event protocol, instead of accepting an insight analytics action as a parameter now it accepts a transitive action: ``` interface TransitiveInsightSearchAction { legacy: LegacyInsightAction; next?: InsightSearchAction; } ``` when the analytics mode will be set to `legacy`, the `ExecuteSearch` action will keep working exactly how it was before by executing the search and logging a search event. However, when the analytics mode will be set to `next`, we will no longer send search event. - I refactored the logic executed inside the `ExecuteSearch` insight action to make it follow the pattern of the `ExecuteSearch` search action. This new pattern implements the class `AsyncInsightSearchThunkProcessor`, this class will take care of doing the search query and will take care of properly processing its response. This new logic allows us to better structure and organize the code and it makes it easier to test the logic executed by the `ExecuteSearch` insight action [SFINT-5418]: https://coveord.atlassian.net/browse/SFINT-5418?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Sylvie Allain <58052881+sallainCoveo@users.noreply.github.com> --- .../src/app/insight-engine/insight-engine.ts | 2 +- .../headless-insight-breadcrumb-manager.ts | 24 +- .../headless-insight-did-you-mean.ts | 2 +- .../headless-insight-category-facet.ts | 17 +- .../facets/facet/headless-insight-facet.ts | 21 +- .../date-facet/headless-insight-date-facet.ts | 16 +- .../headless-insight-date-filter.ts | 10 +- .../headless-insight-numeric-facet.ts | 16 +- .../headless-insight-numeric-filter.ts | 10 +- .../headless-insight-generated-answer.ts | 18 +- .../insight/pager/headless-insight-pager.ts | 6 +- .../headless-insight-results-per-page.ts | 2 +- .../search-box/headless-insight-search-box.ts | 2 +- ...adless-insight-search-parameter-manager.ts | 4 +- .../insight/sort/headless-insight-sort.ts | 2 +- .../insight/tab/headless-insight-tab.ts | 3 +- .../insight-search-actions-loader.ts | 10 +- ...ght-search-actions-thunk-processor.test.ts | 159 ++++++++++ .../insight-search-actions-thunk-processor.ts | 196 ++++++++++++ .../insight-search/insight-search-actions.ts | 293 +++++------------- ...ght-search-actions-thunk-processor.test.ts | 171 ++++++++++ .../insight-search-actions-thunk-processor.ts | 263 ++++++++++++++++ .../legacy/insight-search-actions.ts | 126 ++++++++ .../result-preview-slice.test.ts | 12 +- .../src/features/search/search-mappings.ts | 2 +- .../headless/src/test/mock-insight-request.ts | 14 + 26 files changed, 1105 insertions(+), 296 deletions(-) create mode 100644 packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.test.ts create mode 100644 packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.ts create mode 100644 packages/headless/src/features/insight-search/legacy/insight-search-actions-thunk-processor.test.ts create mode 100644 packages/headless/src/features/insight-search/legacy/insight-search-actions-thunk-processor.ts create mode 100644 packages/headless/src/features/insight-search/legacy/insight-search-actions.ts create mode 100644 packages/headless/src/test/mock-insight-request.ts diff --git a/packages/headless/src/app/insight-engine/insight-engine.ts b/packages/headless/src/app/insight-engine/insight-engine.ts index 78421549835..e99883310b7 100644 --- a/packages/headless/src/app/insight-engine/insight-engine.ts +++ b/packages/headless/src/app/insight-engine/insight-engine.ts @@ -116,7 +116,7 @@ export function buildInsightEngine( return; } - engine.dispatch(executeSearch(analyticsEvent)); + engine.dispatch(executeSearch({legacy: analyticsEvent})); }, }; } diff --git a/packages/headless/src/controllers/insight/breadcrumb-manager/headless-insight-breadcrumb-manager.ts b/packages/headless/src/controllers/insight/breadcrumb-manager/headless-insight-breadcrumb-manager.ts index 007f4f95f63..3473db301d1 100644 --- a/packages/headless/src/controllers/insight/breadcrumb-manager/headless-insight-breadcrumb-manager.ts +++ b/packages/headless/src/controllers/insight/breadcrumb-manager/headless-insight-breadcrumb-manager.ts @@ -107,7 +107,7 @@ export function buildBreadcrumbManager( dispatch( updateFreezeCurrentValues({facetId, freezeCurrentValues: false}) ); - dispatch(executeSearch(analyticsAction)); + dispatch(executeSearch({legacy: analyticsAction})); }, executeToggleExclude: ({facetId, selection}) => { const analyticsAction = logFacetBreadcrumb({ @@ -118,7 +118,7 @@ export function buildBreadcrumbManager( dispatch( updateFreezeCurrentValues({facetId, freezeCurrentValues: false}) ); - dispatch(executeSearch(analyticsAction)); + dispatch(executeSearch({legacy: analyticsAction})); }, facetValuesSelector: facetResponseActiveValuesSelector, }; @@ -133,11 +133,11 @@ export function buildBreadcrumbManager( facetSet: getState().numericFacetSet, executeToggleSelect: (payload) => { dispatch(toggleSelectNumericFacetValue(payload)); - dispatch(executeSearch(logNumericFacetBreadcrumb(payload))); + dispatch(executeSearch({legacy: logNumericFacetBreadcrumb(payload)})); }, executeToggleExclude: (payload) => { dispatch(toggleExcludeNumericFacetValue(payload)); - dispatch(executeSearch(logNumericFacetBreadcrumb(payload))); + dispatch(executeSearch({legacy: logNumericFacetBreadcrumb(payload)})); }, facetValuesSelector: numericFacetActiveValuesSelector, }; @@ -152,11 +152,11 @@ export function buildBreadcrumbManager( facetSet: getState().dateFacetSet, executeToggleSelect: (payload) => { dispatch(toggleSelectDateFacetValue(payload)); - dispatch(executeSearch(logDateFacetBreadcrumb(payload))); + dispatch(executeSearch({legacy: logDateFacetBreadcrumb(payload)})); }, executeToggleExclude: (payload) => { dispatch(toggleExcludeDateFacetValue(payload)); - dispatch(executeSearch(logDateFacetBreadcrumb(payload))); + dispatch(executeSearch({legacy: logDateFacetBreadcrumb(payload)})); }, facetValuesSelector: dateFacetActiveValuesSelector, }; @@ -176,14 +176,14 @@ export function buildBreadcrumbManager( deselect: () => { dispatch(deselectAllCategoryFacetValues(facetId)); dispatch( - executeSearch( - logCategoryFacetBreadcrumb({ + executeSearch({ + legacy: logCategoryFacetBreadcrumb({ categoryFacetPath: path.map( (categoryFacetValue) => categoryFacetValue.value ), categoryFacetId: facetId, - }) - ) + }), + }) ); }, }; @@ -229,7 +229,7 @@ export function buildBreadcrumbManager( } else if (value.state === 'excluded') { dispatch(toggleExcludeStaticFilterValue({id, value})); } - dispatch(executeSearch(analytics)); + dispatch(executeSearch({legacy: analytics})); }, }; }; @@ -260,7 +260,7 @@ export function buildBreadcrumbManager( deselectAll: () => { controller.deselectAll(); - dispatch(executeSearch(logClearBreadcrumbs())); + dispatch(executeSearch({legacy: logClearBreadcrumbs()})); }, }; } diff --git a/packages/headless/src/controllers/insight/did-you-mean/headless-insight-did-you-mean.ts b/packages/headless/src/controllers/insight/did-you-mean/headless-insight-did-you-mean.ts index 25c2b560417..6d1f25704b1 100644 --- a/packages/headless/src/controllers/insight/did-you-mean/headless-insight-did-you-mean.ts +++ b/packages/headless/src/controllers/insight/did-you-mean/headless-insight-did-you-mean.ts @@ -33,7 +33,7 @@ export function buildDidYouMean(engine: InsightEngine): DidYouMean { applyCorrection() { controller.applyCorrection(); - dispatch(executeSearch(logDidYouMeanClick())); + dispatch(executeSearch({legacy: logDidYouMeanClick()})); }, }; } diff --git a/packages/headless/src/controllers/insight/facets/category-facet/headless-insight-category-facet.ts b/packages/headless/src/controllers/insight/facets/category-facet/headless-insight-category-facet.ts index 64a40406fc1..d12e37bfe2b 100644 --- a/packages/headless/src/controllers/insight/facets/category-facet/headless-insight-category-facet.ts +++ b/packages/headless/src/controllers/insight/facets/category-facet/headless-insight-category-facet.ts @@ -92,20 +92,23 @@ export function buildCategoryFacet( getFacetId(), selection ); - dispatch(executeSearch(analyticsAction)); + dispatch(executeSearch({legacy: analyticsAction})); }, deselectAll() { coreController.deselectAll(); - dispatch(executeSearch(logFacetClearAll(getFacetId()))); + dispatch(executeSearch({legacy: logFacetClearAll(getFacetId())})); }, sortBy(criterion: CategoryFacetSortCriterion) { coreController.sortBy(criterion); dispatch( - executeSearch( - logFacetUpdateSort({facetId: getFacetId(), sortCriterion: criterion}) - ) + executeSearch({ + legacy: logFacetUpdateSort({ + facetId: getFacetId(), + sortCriterion: criterion, + }), + }) ); }, @@ -115,12 +118,12 @@ export function buildCategoryFacet( showMoreValues() { coreController.showMoreValues(); - dispatch(fetchFacetValues(logFacetShowMore(getFacetId()))); + dispatch(fetchFacetValues({legacy: logFacetShowMore(getFacetId())})); }, showLessValues() { coreController.showLessValues(); - dispatch(fetchFacetValues(logFacetShowLess(getFacetId()))); + dispatch(fetchFacetValues({legacy: logFacetShowLess(getFacetId())})); }, get state() { diff --git a/packages/headless/src/controllers/insight/facets/facet/headless-insight-facet.ts b/packages/headless/src/controllers/insight/facets/facet/headless-insight-facet.ts index e104e120f1b..c9d599294d9 100644 --- a/packages/headless/src/controllers/insight/facets/facet/headless-insight-facet.ts +++ b/packages/headless/src/controllers/insight/facets/facet/headless-insight-facet.ts @@ -127,23 +127,26 @@ export function buildFacet(engine: InsightEngine, props: FacetProps): Facet { toggleSelect(selection) { coreController.toggleSelect(selection); dispatch( - executeSearch( - getInsightAnalyticsActionForToggleFacetSelect(getFacetId(), selection) - ) + executeSearch({ + legacy: getInsightAnalyticsActionForToggleFacetSelect( + getFacetId(), + selection + ), + }) ); }, deselectAll() { coreController.deselectAll(); - dispatch(executeSearch(logFacetClearAll(getFacetId()))); + dispatch(executeSearch({legacy: logFacetClearAll(getFacetId())})); }, sortBy(sortCriterion: FacetSortCriterion) { coreController.sortBy(sortCriterion); dispatch( - executeSearch( - logFacetUpdateSort({facetId: getFacetId(), sortCriterion}) - ) + executeSearch({ + legacy: logFacetUpdateSort({facetId: getFacetId(), sortCriterion}), + }) ); }, @@ -153,12 +156,12 @@ export function buildFacet(engine: InsightEngine, props: FacetProps): Facet { showMoreValues() { coreController.showMoreValues(); - dispatch(fetchFacetValues(logFacetShowMore(getFacetId()))); + dispatch(fetchFacetValues({legacy: logFacetShowMore(getFacetId())})); }, showLessValues() { coreController.showLessValues(); - dispatch(fetchFacetValues(logFacetShowLess(getFacetId()))); + dispatch(fetchFacetValues({legacy: logFacetShowLess(getFacetId())})); }, get state() { diff --git a/packages/headless/src/controllers/insight/facets/range-facet/date-facet/headless-insight-date-facet.ts b/packages/headless/src/controllers/insight/facets/range-facet/date-facet/headless-insight-date-facet.ts index 9f632a54492..59c3eb063c5 100644 --- a/packages/headless/src/controllers/insight/facets/range-facet/date-facet/headless-insight-date-facet.ts +++ b/packages/headless/src/controllers/insight/facets/range-facet/date-facet/headless-insight-date-facet.ts @@ -50,27 +50,27 @@ export function buildDateFacet( deselectAll() { coreController.deselectAll(); - dispatch(executeSearch(logFacetClearAll(getFacetId()))); + dispatch(executeSearch({legacy: logFacetClearAll(getFacetId())})); }, sortBy(sortCriterion: RangeFacetSortCriterion) { coreController.sortBy(sortCriterion); dispatch( - executeSearch( - logFacetUpdateSort({facetId: getFacetId(), sortCriterion}) - ) + executeSearch({ + legacy: logFacetUpdateSort({facetId: getFacetId(), sortCriterion}), + }) ); }, toggleSelect: (selection: DateFacetValue) => { coreController.toggleSelect(selection); dispatch( - executeSearch( - getInsightAnalyticsActionForToggleRangeFacetSelect( + executeSearch({ + legacy: getInsightAnalyticsActionForToggleRangeFacetSelect( getFacetId(), selection - ) - ) + ), + }) ); }, diff --git a/packages/headless/src/controllers/insight/facets/range-facet/date-facet/headless-insight-date-filter.ts b/packages/headless/src/controllers/insight/facets/range-facet/date-facet/headless-insight-date-filter.ts index 18a3ad789a7..5cfa86204c1 100644 --- a/packages/headless/src/controllers/insight/facets/range-facet/date-facet/headless-insight-date-filter.ts +++ b/packages/headless/src/controllers/insight/facets/range-facet/date-facet/headless-insight-date-filter.ts @@ -54,18 +54,18 @@ export function buildDateFilter( ...coreController, clear: () => { coreController.clear(); - dispatch(executeSearch(logFacetClearAll(getFacetId()))); + dispatch(executeSearch({legacy: logFacetClearAll(getFacetId())})); }, setRange: (range) => { const success = coreController.setRange(range); if (success) { dispatch( - executeSearch( - logFacetSelect({ + executeSearch({ + legacy: logFacetSelect({ facetId: getFacetId(), facetValue: `${range.start}..${range.end}`, - }) - ) + }), + }) ); } return success; diff --git a/packages/headless/src/controllers/insight/facets/range-facet/numeric-facet/headless-insight-numeric-facet.ts b/packages/headless/src/controllers/insight/facets/range-facet/numeric-facet/headless-insight-numeric-facet.ts index 292c90b09fb..57966ef4cfe 100644 --- a/packages/headless/src/controllers/insight/facets/range-facet/numeric-facet/headless-insight-numeric-facet.ts +++ b/packages/headless/src/controllers/insight/facets/range-facet/numeric-facet/headless-insight-numeric-facet.ts @@ -49,27 +49,27 @@ export function buildNumericFacet( deselectAll() { coreController.deselectAll(); - dispatch(executeSearch(logFacetClearAll(getFacetId()))); + dispatch(executeSearch({legacy: logFacetClearAll(getFacetId())})); }, sortBy(sortCriterion: RangeFacetSortCriterion) { coreController.sortBy(sortCriterion); dispatch( - executeSearch( - logFacetUpdateSort({facetId: getFacetId(), sortCriterion}) - ) + executeSearch({ + legacy: logFacetUpdateSort({facetId: getFacetId(), sortCriterion}), + }) ); }, toggleSelect: (selection: NumericFacetValue) => { coreController.toggleSelect(selection); dispatch( - executeSearch( - getInsightAnalyticsActionForToggleRangeFacetSelect( + executeSearch({ + legacy: getInsightAnalyticsActionForToggleRangeFacetSelect( getFacetId(), selection - ) - ) + ), + }) ); }, diff --git a/packages/headless/src/controllers/insight/facets/range-facet/numeric-facet/headless-insight-numeric-filter.ts b/packages/headless/src/controllers/insight/facets/range-facet/numeric-facet/headless-insight-numeric-filter.ts index a1db854c167..5725f75181c 100644 --- a/packages/headless/src/controllers/insight/facets/range-facet/numeric-facet/headless-insight-numeric-filter.ts +++ b/packages/headless/src/controllers/insight/facets/range-facet/numeric-facet/headless-insight-numeric-filter.ts @@ -41,18 +41,18 @@ export function buildNumericFilter( ...coreController, clear: () => { coreController.clear(); - dispatch(executeSearch(logFacetClearAll(getFacetId()))); + dispatch(executeSearch({legacy: logFacetClearAll(getFacetId())})); }, setRange: (range) => { const success = coreController.setRange(range); if (success) { dispatch( - executeSearch( - logFacetSelect({ + executeSearch({ + legacy: logFacetSelect({ facetId: getFacetId(), facetValue: `${range.start}..${range.end}`, - }) - ) + }), + }) ); } return success; diff --git a/packages/headless/src/controllers/insight/generated-answer/headless-insight-generated-answer.ts b/packages/headless/src/controllers/insight/generated-answer/headless-insight-generated-answer.ts index 2663d9edbdb..6d3e731f0d7 100644 --- a/packages/headless/src/controllers/insight/generated-answer/headless-insight-generated-answer.ts +++ b/packages/headless/src/controllers/insight/generated-answer/headless-insight-generated-answer.ts @@ -45,20 +45,22 @@ export function buildGeneratedAnswer( retry() { dispatch( - executeSearch( - generatedAnswerInsightAnalyticsClient.logRetryGeneratedAnswer() - ) + executeSearch({ + legacy: + generatedAnswerInsightAnalyticsClient.logRetryGeneratedAnswer(), + }) ); }, rephrase(responseFormat: GeneratedResponseFormat) { controller.rephrase(responseFormat); dispatch( - executeSearch( - generatedAnswerInsightAnalyticsClient.logRephraseGeneratedAnswer( - responseFormat - ) - ) + executeSearch({ + legacy: + generatedAnswerInsightAnalyticsClient.logRephraseGeneratedAnswer( + responseFormat + ), + }) ); }, }; diff --git a/packages/headless/src/controllers/insight/pager/headless-insight-pager.ts b/packages/headless/src/controllers/insight/pager/headless-insight-pager.ts index 72c6eac3c4e..23b158de487 100644 --- a/packages/headless/src/controllers/insight/pager/headless-insight-pager.ts +++ b/packages/headless/src/controllers/insight/pager/headless-insight-pager.ts @@ -39,17 +39,17 @@ export function buildPager( selectPage(page: number) { pager.selectPage(page); - dispatch(fetchPage(logPageNumber())); + dispatch(fetchPage({legacy: logPageNumber()})); }, nextPage() { pager.nextPage(); - dispatch(fetchPage(logPageNext())); + dispatch(fetchPage({legacy: logPageNext()})); }, previousPage() { pager.previousPage(); - dispatch(fetchPage(logPagePrevious())); + dispatch(fetchPage({legacy: logPagePrevious()})); }, }; } diff --git a/packages/headless/src/controllers/insight/results-per-page/headless-insight-results-per-page.ts b/packages/headless/src/controllers/insight/results-per-page/headless-insight-results-per-page.ts index 35d559cb72d..c6774d55abf 100644 --- a/packages/headless/src/controllers/insight/results-per-page/headless-insight-results-per-page.ts +++ b/packages/headless/src/controllers/insight/results-per-page/headless-insight-results-per-page.ts @@ -41,7 +41,7 @@ export function buildResultsPerPage( set(num: number) { coreController.set(num); - dispatch(executeSearch(logPagerResize())); + dispatch(executeSearch({legacy: logPagerResize()})); }, }; } diff --git a/packages/headless/src/controllers/insight/search-box/headless-insight-search-box.ts b/packages/headless/src/controllers/insight/search-box/headless-insight-search-box.ts index ff272cc400d..3601e3a1317 100644 --- a/packages/headless/src/controllers/insight/search-box/headless-insight-search-box.ts +++ b/packages/headless/src/controllers/insight/search-box/headless-insight-search-box.ts @@ -80,7 +80,7 @@ export function buildSearchBox( ...props, executeSearchActionCreator: executeSearch, fetchQuerySuggestionsActionCreator: fetchQuerySuggestions, - isNextAnalyticsReady: false, + isNextAnalyticsReady: true, }); return { diff --git a/packages/headless/src/controllers/insight/search-parameter-manager/headless-insight-search-parameter-manager.ts b/packages/headless/src/controllers/insight/search-parameter-manager/headless-insight-search-parameter-manager.ts index 339f8c235e4..0939b3c14f4 100644 --- a/packages/headless/src/controllers/insight/search-parameter-manager/headless-insight-search-parameter-manager.ts +++ b/packages/headless/src/controllers/insight/search-parameter-manager/headless-insight-search-parameter-manager.ts @@ -47,7 +47,9 @@ export function buildSearchParameterManager( return; } controller.synchronize(parameters); - dispatch(executeSearch(logParametersChange(oldParams, newParams))); + dispatch( + executeSearch({legacy: logParametersChange(oldParams, newParams)}) + ); }, get state() { diff --git a/packages/headless/src/controllers/insight/sort/headless-insight-sort.ts b/packages/headless/src/controllers/insight/sort/headless-insight-sort.ts index 207c69aaccd..50a18ba12e9 100644 --- a/packages/headless/src/controllers/insight/sort/headless-insight-sort.ts +++ b/packages/headless/src/controllers/insight/sort/headless-insight-sort.ts @@ -22,7 +22,7 @@ export type {Sort, SortProps, SortState, SortInitialState}; export function buildSort(engine: InsightEngine, props: SortProps = {}): Sort { const {dispatch} = engine; const sort = buildCoreSort(engine, props); - const search = () => dispatch(executeSearch(logResultsSort())); + const search = () => dispatch(executeSearch({legacy: logResultsSort()})); return { ...sort, diff --git a/packages/headless/src/controllers/insight/tab/headless-insight-tab.ts b/packages/headless/src/controllers/insight/tab/headless-insight-tab.ts index 462741792e7..b04e28c3cc7 100644 --- a/packages/headless/src/controllers/insight/tab/headless-insight-tab.ts +++ b/packages/headless/src/controllers/insight/tab/headless-insight-tab.ts @@ -22,7 +22,8 @@ export type {Tab, TabProps, TabState, TabInitialState, TabOptions}; export function buildTab(engine: InsightEngine, props: TabProps): Tab { const {dispatch} = engine; const tab = buildCoreTab(engine, props); - const search = () => dispatch(executeSearch(logInsightInterfaceChange())); + const search = () => + dispatch(executeSearch({legacy: logInsightInterfaceChange()})); return { ...tab, diff --git a/packages/headless/src/features/insight-search/insight-search-actions-loader.ts b/packages/headless/src/features/insight-search/insight-search-actions-loader.ts index 56ae2803a3d..209205cfa3a 100644 --- a/packages/headless/src/features/insight-search/insight-search-actions-loader.ts +++ b/packages/headless/src/features/insight-search/insight-search-actions-loader.ts @@ -8,15 +8,17 @@ import { FetchQuerySuggestionsThunkReturn, } from '../query-suggest/query-suggest-actions'; import {ExecuteSearchThunkReturn} from '../search/legacy/search-actions'; +import { + StateNeededByExecuteSearch, + StateNeededByQuerySuggest, + fetchQuerySuggestions, +} from './insight-search-actions'; import { executeSearch, fetchFacetValues, fetchMoreResults, fetchPage, - fetchQuerySuggestions, - StateNeededByExecuteSearch, - StateNeededByQuerySuggest, -} from './insight-search-actions'; +} from './legacy/insight-search-actions'; export type {FetchQuerySuggestionsActionCreatorPayload}; diff --git a/packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.test.ts b/packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.test.ts new file mode 100644 index 00000000000..fb83269ff38 --- /dev/null +++ b/packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.test.ts @@ -0,0 +1,159 @@ +import {Logger} from 'pino'; +import {InsightAPIClient} from '../../api/service/insight/insight-api-client'; +import {InsightQueryRequest} from '../../api/service/insight/query/query-request'; +import {buildMockInsightQueryRequest} from '../../test/mock-insight-request'; +import {buildMockResult} from '../../test/mock-result'; +import {buildMockSearchResponse} from '../../test/mock-search-response'; +import {buildMockSearchState} from '../../test/mock-search-state'; +import {getConfigurationInitialState} from '../configuration/configuration-state'; +import {getInsightConfigurationInitialState} from '../insight-configuration/insight-configuration-state'; +import {updateQuery} from '../query/query-actions'; +import {ExecuteSearchThunkReturn} from '../search/search-actions'; +import {MappedSearchRequest, SearchMappings} from '../search/search-mappings'; +import { + AsyncInsightSearchThunkProcessor, + AsyncThunkConfig, +} from './insight-search-actions-thunk-processor'; +import {logQueryError} from './insight-search-analytics-actions'; + +jest.mock('./insight-search-analytics-actions'); + +const initialSearchMappings: () => SearchMappings = () => ({ + dateFacetValueMap: {}, +}); + +describe('AsyncInsightSearchThunkProcessor', () => { + let config: AsyncThunkConfig; + const results = [buildMockResult({uniqueId: '123'})]; + beforeEach(() => { + config = { + dispatch: jest.fn(), + extra: { + analyticsClientMiddleware: jest.fn(), + apiClient: {query: jest.fn()} as unknown as InsightAPIClient, + logger: jest.fn() as unknown as Logger, + validatePayload: jest.fn(), + preprocessRequest: jest.fn(), + }, + getState: jest.fn().mockReturnValue({ + insightConfiguration: getInsightConfigurationInitialState(), + configuration: getConfigurationInitialState(), + search: buildMockSearchState({ + results, + response: buildMockSearchResponse({results}), + }), + didYouMean: { + enableDidYouMean: true, + automaticallyCorrectQuery: true, + }, + }), + rejectWithValue: jest.fn(), + }; + }); + + it('process properly when there is no error, results are returned, and no modification applies', async () => { + const processor = new AsyncInsightSearchThunkProcessor<{}>(config); + + const searchResponse = buildMockSearchResponse({ + results: [buildMockResult({uniqueId: '123'})], + }); + const mappedRequest: MappedSearchRequest = { + request: buildMockInsightQueryRequest(), + mappings: initialSearchMappings(), + }; + + const fetched = { + response: { + success: searchResponse, + }, + duration: 123, + queryExecuted: 'foo', + requestExecuted: mappedRequest.request, + }; + + const processed = (await processor.process( + fetched + )) as ExecuteSearchThunkReturn; + + expect(processed.response).toEqual(searchResponse); + expect(config.extra.apiClient.query).not.toHaveBeenCalled(); + }); + + it('processes properly when there is an error returned by the API', async () => { + const processor = new AsyncInsightSearchThunkProcessor<{}>(config); + const theError = { + statusCode: 500, + message: 'Something went wrong', + type: 'error', + }; + const mappedRequest: MappedSearchRequest = { + request: buildMockInsightQueryRequest(), + mappings: initialSearchMappings(), + }; + + const fetched = { + response: { + error: theError, + }, + duration: 123, + queryExecuted: 'foo', + requestExecuted: mappedRequest.request, + }; + + (await processor.process(fetched)) as ExecuteSearchThunkReturn; + + expect(config.rejectWithValue).toHaveBeenCalledWith(theError); + expect(config.extra.apiClient.query).not.toHaveBeenCalled(); + expect(logQueryError).toHaveBeenCalledWith(theError); + }); + + it('process properly when there are no results returned and there is a did you mean correction', async () => { + const processor = new AsyncInsightSearchThunkProcessor<{}>(config); + const mappedRequest: MappedSearchRequest = { + request: buildMockInsightQueryRequest(), + mappings: initialSearchMappings(), + }; + + const originalResponseWithNoResultsAndCorrection = buildMockSearchResponse({ + results: [], + queryCorrections: [ + { + correctedQuery: 'bar', + wordCorrections: [ + {correctedWord: 'foo', length: 3, offset: 0, originalWord: 'foo'}, + ], + }, + ], + }); + + const responseAfterCorrection = buildMockSearchResponse({ + results: [buildMockResult({uniqueId: '123'})], + }); + + (config.extra.apiClient.query as jest.Mock).mockReturnValue( + Promise.resolve({success: responseAfterCorrection}) + ); + + const fetched = { + response: { + success: originalResponseWithNoResultsAndCorrection, + }, + duration: 123, + queryExecuted: 'foo', + requestExecuted: mappedRequest.request, + }; + + const processed = (await processor.process( + fetched + )) as ExecuteSearchThunkReturn; + + expect(config.dispatch).toHaveBeenCalledWith(updateQuery({q: 'bar'})); + expect(config.extra.apiClient.query).toHaveBeenCalled(); + expect(processed.response).toEqual({ + ...responseAfterCorrection, + queryCorrections: + originalResponseWithNoResultsAndCorrection.queryCorrections, + }); + expect(processed.automaticallyCorrected).toBe(true); + }); +}); diff --git a/packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.ts b/packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.ts new file mode 100644 index 00000000000..e6dc0b0a027 --- /dev/null +++ b/packages/headless/src/features/insight-search/insight-search-actions-thunk-processor.ts @@ -0,0 +1,196 @@ +import {ThunkDispatch, AnyAction} from '@reduxjs/toolkit'; +import { + SearchOptions, + isErrorResponse, + isSuccessResponse, +} from '../../api/search/search-api-client'; +import { + InsightAPIClient, + InsightAPIErrorStatusResponse, +} from '../../api/service/insight/insight-api-client'; +import {InsightQueryRequest} from '../../api/service/insight/query/query-request'; +import {ClientThunkExtraArguments} from '../../app/thunk-extra-arguments'; +import {applyDidYouMeanCorrection} from '../did-you-mean/did-you-mean-actions'; +import {emptyLegacyCorrection} from '../did-you-mean/did-you-mean-state'; +import {snapshot} from '../history/history-actions'; +import {extractHistory} from '../history/history-state'; +import {updateQuery} from '../query/query-actions'; +import {ExecuteSearchThunkReturn} from '../search/search-actions'; +import { + ErrorResponse, + MappedSearchRequest, + SuccessResponse, + mapSearchResponse, +} from '../search/search-mappings'; +import {StateNeededByExecuteSearch} from './insight-search-actions'; +import {logQueryError} from './insight-search-analytics-actions'; +import {buildInsightSearchRequest} from './insight-search-request'; + +export interface AsyncThunkConfig { + getState: () => StateNeededByExecuteSearch; + dispatch: ThunkDispatch< + StateNeededByExecuteSearch, + ClientThunkExtraArguments, + AnyAction + >; + rejectWithValue: (err: InsightAPIErrorStatusResponse) => unknown; + extra: ClientThunkExtraArguments; +} + +interface FetchedResponse { + response: SuccessResponse | ErrorResponse; + duration: number; + queryExecuted: string; + requestExecuted: InsightQueryRequest; +} + +type ValidReturnTypeFromProcessingStep = + | ExecuteSearchThunkReturn + | RejectionType; + +export class AsyncInsightSearchThunkProcessor { + constructor(private config: AsyncThunkConfig) {} + + public async fetchFromAPI( + {request, mappings}: MappedSearchRequest, + options?: SearchOptions + ) { + const startedAt = new Date().getTime(); + const response = mapSearchResponse( + await this.extra.apiClient.query(request, options), + mappings + ); + const duration = new Date().getTime() - startedAt; + const queryExecuted = this.getState().query?.q || ''; + return { + response, + duration, + queryExecuted, + requestExecuted: request, + }; + } + + public async process( + fetched: FetchedResponse + ): Promise> { + return ( + this.processQueryErrorOrContinue(fetched) ?? + (await this.processQueryCorrectionsOrContinue(fetched)) ?? + this.processSuccessResponse(fetched) + ); + } + + private processQueryErrorOrContinue( + fetched: FetchedResponse + ): ValidReturnTypeFromProcessingStep | null { + if (isErrorResponse(fetched.response)) { + this.dispatch(logQueryError(fetched.response.error)); + return this.rejectWithValue(fetched.response.error) as RejectionType; + } + + return null; + } + + private async processQueryCorrectionsOrContinue( + fetched: FetchedResponse + ): Promise | null> { + if ( + !this.shouldReExecuteTheQueryWithCorrections(fetched) || + isErrorResponse(fetched.response) + ) { + return null; + } + + const {correctedQuery} = fetched.response.success.queryCorrections + ? fetched.response.success.queryCorrections[0] + : emptyLegacyCorrection(); + const originalQuery = this.getCurrentQuery(); + + const retried = + await this.automaticallyRetryQueryWithCorrection(correctedQuery); + + if (isErrorResponse(retried.response)) { + this.dispatch(logQueryError(retried.response.error)); + return this.rejectWithValue(retried.response.error) as RejectionType; + } + + this.dispatch(snapshot(extractHistory(this.getState()))); + + return { + ...retried, + response: { + ...retried.response.success, + queryCorrections: fetched.response.success.queryCorrections, + }, + automaticallyCorrected: true, + originalQuery, + }; + } + + private shouldReExecuteTheQueryWithCorrections( + fetched: FetchedResponse + ): boolean { + const state = this.getState(); + const successResponse = this.getSuccessResponse(fetched); + + if ( + state.didYouMean?.enableDidYouMean === true && + successResponse?.results.length === 0 && + successResponse?.queryCorrections && + successResponse?.queryCorrections.length !== 0 + ) { + return true; + } + return false; + } + + private async automaticallyRetryQueryWithCorrection(correction: string) { + this.dispatch(updateQuery({q: correction})); + const state = this.getState(); + const fetched = await this.fetchFromAPI( + await buildInsightSearchRequest(state) + ); + this.dispatch(applyDidYouMeanCorrection(correction)); + return fetched; + } + + private processSuccessResponse( + fetched: FetchedResponse + ): ValidReturnTypeFromProcessingStep { + this.dispatch(snapshot(extractHistory(this.getState()))); + return { + ...fetched, + response: this.getSuccessResponse(fetched)!, + automaticallyCorrected: false, + originalQuery: this.getCurrentQuery(), + }; + } + + private getSuccessResponse(fetched: FetchedResponse) { + if (isSuccessResponse(fetched.response)) { + return fetched.response.success; + } + return null; + } + + private get extra() { + return this.config.extra; + } + + private getState() { + return this.config.getState(); + } + + private get dispatch() { + return this.config.dispatch; + } + + private get rejectWithValue() { + return this.config.rejectWithValue; + } + + private getCurrentQuery() { + const state = this.getState(); + return state.query?.q !== undefined ? state.query.q : ''; + } +} diff --git a/packages/headless/src/features/insight-search/insight-search-actions.ts b/packages/headless/src/features/insight-search/insight-search-actions.ts index e5e37098896..4b6d133dcad 100644 --- a/packages/headless/src/features/insight-search/insight-search-actions.ts +++ b/packages/headless/src/features/insight-search/insight-search-actions.ts @@ -1,17 +1,14 @@ -import {createAsyncThunk, ThunkDispatch, AnyAction} from '@reduxjs/toolkit'; +import {createAsyncThunk} from '@reduxjs/toolkit'; import {historyStore} from '../../api/analytics/coveo-analytics-utils'; -import {StateNeededByInsightAnalyticsProvider} from '../../api/analytics/insight-analytics'; import { SearchOptions, isErrorResponse, } from '../../api/search/search-api-client'; -import {SearchResponseSuccess} from '../../api/search/search/search-response'; import { AsyncThunkInsightOptions, InsightAPIClient, } from '../../api/service/insight/insight-api-client'; import {InsightQueryRequest} from '../../api/service/insight/query/query-request'; -import {ClientThunkExtraArguments} from '../../app/thunk-extra-arguments'; import { CategoryFacetSection, ConfigurationSection, @@ -33,35 +30,32 @@ import { TabSection, } from '../../state/state-sections'; import {requiredNonEmptyString} from '../../utils/validate-payload'; -import {InsightAction} from '../analytics/analytics-utils'; -import {applyDidYouMeanCorrection} from '../did-you-mean/did-you-mean-actions'; -import {logDidYouMeanAutomatic} from '../did-you-mean/did-you-mean-insight-analytics-actions'; -import {emptyLegacyCorrection} from '../did-you-mean/did-you-mean-state'; -import {snapshot} from '../history/history-actions'; -import {extractHistory} from '../history/history-state'; +import {InsightAction as LegacyInsightAction} from '../analytics/analytics-utils'; import { FetchQuerySuggestionsActionCreatorPayload, FetchQuerySuggestionsThunkReturn, } from '../query-suggest/query-suggest-actions'; -import {updateQuery} from '../query/query-actions'; -import {getQueryInitialState} from '../query/query-state'; -import {ExecuteSearchThunkReturn} from '../search/legacy/search-actions'; +import {ExecuteSearchThunkReturn, SearchAction} from '../search/search-actions'; import { MappedSearchRequest, mapSearchResponse, - SuccessResponse, } from '../search/search-mappings'; -import {getSearchInitialState} from '../search/search-state'; import {buildInsightQuerySuggestRequest} from './insight-query-suggest-request'; import { - logFetchMoreResults, - logQueryError, -} from './insight-search-analytics-actions'; + AsyncInsightSearchThunkProcessor, + AsyncThunkConfig, +} from './insight-search-actions-thunk-processor'; import { buildInsightFetchFacetValuesRequest, buildInsightFetchMoreResultsRequest, buildInsightSearchRequest, } from './insight-search-request'; +import { + legacyExecuteSearch, + legacyFetchPage, + legacyFetchFacetValues, + legacyFetchMoreResults, +} from './legacy/insight-search-actions'; export type StateNeededByExecuteSearch = ConfigurationSection & InsightConfigurationSection & @@ -103,118 +97,71 @@ export const fetchFromAPI = async ( }; }; +interface TransitiveInsightSearchAction { + legacy: LegacyInsightAction; + next?: SearchAction; +} + export const executeSearch = createAsyncThunk< ExecuteSearchThunkReturn, - InsightAction, + TransitiveInsightSearchAction, AsyncThunkInsightOptions >( 'search/executeSearch', async ( - analyticsAction: InsightAction, - {getState, dispatch, rejectWithValue, extra} + analyticsAction: TransitiveInsightSearchAction, + config: AsyncThunkConfig ) => { - const state = getState(); - addEntryInActionsHistory(state); - const mappedRequest = buildInsightSearchRequest(state); - - const fetched = await fetchFromAPI(extra.apiClient, state, mappedRequest); - - if (isErrorResponse(fetched.response)) { - dispatch(logQueryError(fetched.response.error)); - return rejectWithValue(fetched.response.error); - } - + const state = config.getState(); if ( - !shouldReExecuteTheQueryWithCorrections(state, fetched.response.success) + state.configuration.analytics.analyticsMode === 'legacy' || + !analyticsAction.next ) { - dispatch(snapshot(extractHistory(state))); - return { - ...fetched, - response: fetched.response.success, - automaticallyCorrected: false, - originalQuery: getOriginalQuery(state), - analyticsAction, - }; + return legacyExecuteSearch(state, config, analyticsAction.legacy); } - const {correctedQuery} = fetched.response.success.queryCorrections - ? fetched.response.success.queryCorrections[0] - : emptyLegacyCorrection(); - const retried = await automaticallyRetryQueryWithCorrection( - extra.apiClient, - correctedQuery, - getState, - dispatch - ); - if (isErrorResponse(retried.response)) { - dispatch(logQueryError(retried.response.error)); - return rejectWithValue(retried.response.error); - } + addEntryInActionsHistory(state); + + const processor = new AsyncInsightSearchThunkProcessor({ + ...config, + }); - const fetchedResponse = ( - mapSearchResponse( - fetched.response, - mappedRequest.mappings - ) as SuccessResponse - ).success; - analyticsAction()( - dispatch, - () => - getStateAfterResponse( - fetched.queryExecuted, - fetched.duration, - state, - fetchedResponse - ), - extra - ); - dispatch(snapshot(extractHistory(getState()))); + const request = buildInsightSearchRequest(state); + const fetched = await processor.fetchFromAPI(request); - return { - ...retried, - response: { - ...retried.response.success, - queryCorrections: fetched.response.success.queryCorrections, - }, - automaticallyCorrected: true, - originalQuery: getOriginalQuery(state), - analyticsAction: logDidYouMeanAutomatic(), - }; + return await processor.process(fetched); } ); export const fetchPage = createAsyncThunk< ExecuteSearchThunkReturn, - InsightAction, + TransitiveInsightSearchAction, AsyncThunkInsightOptions >( 'search/fetchPage', async ( - analyticsAction: InsightAction, - {getState, dispatch, rejectWithValue, extra} + analyticsAction: TransitiveInsightSearchAction, + config: AsyncThunkConfig ) => { - const state = getState(); + const state = config.getState(); + + if ( + state.configuration.analytics.analyticsMode === 'legacy' || + !analyticsAction.next + ) { + return legacyFetchPage(state, config, analyticsAction.legacy); + } + addEntryInActionsHistory(state); - const fetched = await fetchFromAPI( - extra.apiClient, - state, - buildInsightSearchRequest(state) - ); + const processor = new AsyncInsightSearchThunkProcessor({ + ...config, + }); - if (isErrorResponse(fetched.response)) { - dispatch(logQueryError(fetched.response.error)); - return rejectWithValue(fetched.response.error); - } + const request = buildInsightSearchRequest(state); + const fetched = await processor.fetchFromAPI(request); - dispatch(snapshot(extractHistory(state))); - return { - ...fetched, - response: fetched.response.success, - automaticallyCorrected: false, - originalQuery: getOriginalQuery(state), - analyticsAction, - }; + return await processor.process(fetched); } ); @@ -222,64 +169,50 @@ export const fetchMoreResults = createAsyncThunk< ExecuteSearchThunkReturn, void, AsyncThunkInsightOptions ->( - 'search/fetchMoreResults', - async (_, {getState, dispatch, rejectWithValue, extra: {apiClient}}) => { - const state = getState(); - const fetched = await fetchFromAPI( - apiClient, - state, - await buildInsightFetchMoreResultsRequest(state) - ); +>('search/fetchMoreResults', async (_, config: AsyncThunkConfig) => { + const state = config.getState(); - if (isErrorResponse(fetched.response)) { - dispatch(logQueryError(fetched.response.error)); - return rejectWithValue(fetched.response.error); - } + if (state.configuration.analytics.analyticsMode === 'legacy') { + return legacyFetchMoreResults(state, config); + } - dispatch(snapshot(extractHistory(state))); + const processor = new AsyncInsightSearchThunkProcessor({ + ...config, + }); - return { - ...fetched, - response: fetched.response.success, - automaticallyCorrected: false, - originalQuery: getOriginalQuery(state), - analyticsAction: logFetchMoreResults(), - }; - } -); + const request = await buildInsightFetchMoreResultsRequest(state); + const fetched = await processor.fetchFromAPI(request); + + return await processor.process(fetched); +}); export const fetchFacetValues = createAsyncThunk< ExecuteSearchThunkReturn, - InsightAction, + TransitiveInsightSearchAction, AsyncThunkInsightOptions >( 'search/fetchFacetValues', async ( - analyticsAction: InsightAction, - {getState, dispatch, rejectWithValue, extra: {apiClient}} + analyticsAction: TransitiveInsightSearchAction, + config: AsyncThunkConfig ) => { - const state = getState(); - const fetched = await fetchFromAPI( - apiClient, - state, - await buildInsightFetchFacetValuesRequest(state) - ); + const state = config.getState(); - if (isErrorResponse(fetched.response)) { - dispatch(logQueryError(fetched.response.error)); - return rejectWithValue(fetched.response.error); + if ( + state.configuration.analytics.analyticsMode === 'legacy' || + !analyticsAction.next + ) { + return legacyFetchFacetValues(state, config, analyticsAction.legacy); } - dispatch(snapshot(extractHistory(state))); + const processor = new AsyncInsightSearchThunkProcessor({ + ...config, + }); - return { - ...fetched, - response: fetched.response.success, - automaticallyCorrected: false, - originalQuery: getOriginalQuery(state), - analyticsAction, - }; + const request = await buildInsightFetchFacetValuesRequest(state); + const fetched = await processor.fetchFromAPI(request); + + return await processor.process(fetched); } ); @@ -321,73 +254,7 @@ export const fetchQuerySuggestions = createAsyncThunk< } ); -const automaticallyRetryQueryWithCorrection = async ( - client: InsightAPIClient, - correction: string, - getState: () => StateNeededByExecuteSearch, - dispatch: ThunkDispatch< - StateNeededByExecuteSearch, - ClientThunkExtraArguments & { - searchAPIClient?: InsightAPIClient | undefined; - }, - AnyAction - > -) => { - dispatch(updateQuery({q: correction})); - const fetched = await fetchFromAPI( - client, - getState(), - await buildInsightSearchRequest(getState()) - ); - dispatch(applyDidYouMeanCorrection(correction)); - return fetched; -}; - -const shouldReExecuteTheQueryWithCorrections = ( - state: StateNeededByExecuteSearch, - res: SearchResponseSuccess -) => { - if ( - state.didYouMean?.enableDidYouMean === true && - res.results.length === 0 && - res.queryCorrections && - res.queryCorrections.length !== 0 - ) { - return true; - } - return false; -}; - -const getOriginalQuery = (state: StateNeededByExecuteSearch) => - state.query?.q !== undefined ? state.query.q : ''; - -const getStateAfterResponse: ( - query: string, - duration: number, - previousState: StateNeededByExecuteSearch, - response: SearchResponseSuccess -) => StateNeededByInsightAnalyticsProvider = ( - query, - duration, - previousState, - response -) => ({ - ...previousState, - query: { - q: query, - enableQuerySyntax: - previousState.query?.enableQuerySyntax ?? - getQueryInitialState().enableQuerySyntax, - }, - search: { - ...getSearchInitialState(), - duration, - response, - results: response.results, - }, -}); - -const addEntryInActionsHistory = (state: StateNeededByExecuteSearch) => { +export const addEntryInActionsHistory = (state: StateNeededByExecuteSearch) => { if (state.configuration.analytics.enabled) { historyStore.addElement({ name: 'Query', diff --git a/packages/headless/src/features/insight-search/legacy/insight-search-actions-thunk-processor.test.ts b/packages/headless/src/features/insight-search/legacy/insight-search-actions-thunk-processor.test.ts new file mode 100644 index 00000000000..d1669072ef4 --- /dev/null +++ b/packages/headless/src/features/insight-search/legacy/insight-search-actions-thunk-processor.test.ts @@ -0,0 +1,171 @@ +import {Logger} from 'pino'; +import {InsightAPIClient} from '../../../api/service/insight/insight-api-client'; +import {InsightQueryRequest} from '../../../api/service/insight/query/query-request'; +import {buildMockInsightQueryRequest} from '../../../test/mock-insight-request'; +import {buildMockResult} from '../../../test/mock-result'; +import {buildMockSearchResponse} from '../../../test/mock-search-response'; +import {buildMockSearchState} from '../../../test/mock-search-state'; +import {getConfigurationInitialState} from '../../configuration/configuration-state'; +import {getInsightConfigurationInitialState} from '../../insight-configuration/insight-configuration-state'; +import {updateQuery} from '../../query/query-actions'; +import {ExecuteSearchThunkReturn} from '../../search/legacy/search-actions'; +import { + MappedSearchRequest, + SearchMappings, +} from '../../search/search-mappings'; +import { + logFetchMoreResults, + logQueryError, +} from '../insight-search-analytics-actions'; +import { + AsyncInsightSearchThunkProcessor, + AsyncThunkConfig, +} from './insight-search-actions-thunk-processor'; + +jest.mock('../insight-search-analytics-actions'); + +const initialSearchMappings: () => SearchMappings = () => ({ + dateFacetValueMap: {}, +}); + +describe('AsyncInsightSearchThunkProcessor', () => { + let config: AsyncThunkConfig; + const results = [buildMockResult({uniqueId: '123'})]; + beforeEach(() => { + config = { + analyticsAction: logFetchMoreResults(), + dispatch: jest.fn(), + extra: { + analyticsClientMiddleware: jest.fn(), + apiClient: {query: jest.fn()} as unknown as InsightAPIClient, + logger: jest.fn() as unknown as Logger, + validatePayload: jest.fn(), + preprocessRequest: jest.fn(), + }, + getState: jest.fn().mockReturnValue({ + insightConfiguration: getInsightConfigurationInitialState(), + configuration: getConfigurationInitialState(), + search: buildMockSearchState({ + results, + response: buildMockSearchResponse({results}), + }), + didYouMean: { + enableDidYouMean: true, + automaticallyCorrectQuery: true, + }, + }), + rejectWithValue: jest.fn(), + }; + }); + + it('process properly when there is no error, results are returned, and no modification applies', async () => { + const processor = new AsyncInsightSearchThunkProcessor<{}>(config); + + const searchResponse = buildMockSearchResponse({ + results: [buildMockResult({uniqueId: '123'})], + }); + const mappedRequest: MappedSearchRequest = { + request: buildMockInsightQueryRequest(), + mappings: initialSearchMappings(), + }; + + const fetched = { + response: { + success: searchResponse, + }, + duration: 123, + queryExecuted: 'foo', + requestExecuted: mappedRequest.request, + }; + + const processed = (await processor.process( + fetched, + mappedRequest + )) as ExecuteSearchThunkReturn; + + expect(processed.response).toEqual(searchResponse); + expect(config.extra.apiClient.query).not.toHaveBeenCalled(); + }); + + it('processes properly when there is an error returned by the API', async () => { + const processor = new AsyncInsightSearchThunkProcessor<{}>(config); + const theError = { + statusCode: 500, + message: 'Something went wrong', + type: 'error', + }; + const mappedRequest: MappedSearchRequest = { + request: buildMockInsightQueryRequest(), + mappings: initialSearchMappings(), + }; + + const fetched = { + response: { + error: theError, + }, + duration: 123, + queryExecuted: 'foo', + requestExecuted: mappedRequest.request, + }; + + (await processor.process( + fetched, + mappedRequest + )) as ExecuteSearchThunkReturn; + + expect(config.rejectWithValue).toHaveBeenCalledWith(theError); + expect(config.extra.apiClient.query).not.toHaveBeenCalled(); + expect(logQueryError).toHaveBeenCalledWith(theError); + }); + + it('process properly when there are no results returned and there is a did you mean correction', async () => { + const processor = new AsyncInsightSearchThunkProcessor<{}>(config); + const mappedRequest: MappedSearchRequest = { + request: buildMockInsightQueryRequest(), + mappings: initialSearchMappings(), + }; + + const originalResponseWithNoResultsAndCorrection = buildMockSearchResponse({ + results: [], + queryCorrections: [ + { + correctedQuery: 'bar', + wordCorrections: [ + {correctedWord: 'foo', length: 3, offset: 0, originalWord: 'foo'}, + ], + }, + ], + }); + + const responseAfterCorrection = buildMockSearchResponse({ + results: [buildMockResult({uniqueId: '123'})], + }); + + (config.extra.apiClient.query as jest.Mock).mockReturnValue( + Promise.resolve({success: responseAfterCorrection}) + ); + + const fetched = { + response: { + success: originalResponseWithNoResultsAndCorrection, + }, + duration: 123, + queryExecuted: 'foo', + requestExecuted: mappedRequest.request, + }; + + const processed = (await processor.process( + fetched, + mappedRequest + )) as ExecuteSearchThunkReturn; + + expect(config.dispatch).toHaveBeenCalledWith(updateQuery({q: 'bar'})); + expect(config.extra.apiClient.query).toHaveBeenCalled(); + expect(processed.response).toEqual({ + ...responseAfterCorrection, + queryCorrections: + originalResponseWithNoResultsAndCorrection.queryCorrections, + }); + expect(processed.automaticallyCorrected).toBe(true); + }); +}); diff --git a/packages/headless/src/features/insight-search/legacy/insight-search-actions-thunk-processor.ts b/packages/headless/src/features/insight-search/legacy/insight-search-actions-thunk-processor.ts new file mode 100644 index 00000000000..e198ec86f70 --- /dev/null +++ b/packages/headless/src/features/insight-search/legacy/insight-search-actions-thunk-processor.ts @@ -0,0 +1,263 @@ +import {ThunkDispatch, AnyAction} from '@reduxjs/toolkit'; +import {StateNeededByInsightAnalyticsProvider} from '../../../api/analytics/insight-analytics'; +import { + SearchOptions, + isErrorResponse, + isSuccessResponse, +} from '../../../api/search/search-api-client'; +import {SearchResponseSuccess} from '../../../api/search/search/search-response'; +import { + InsightAPIClient, + InsightAPIErrorStatusResponse, +} from '../../../api/service/insight/insight-api-client'; +import {InsightQueryRequest} from '../../../api/service/insight/query/query-request'; +import {ClientThunkExtraArguments} from '../../../app/thunk-extra-arguments'; +import {AnalyticsAsyncThunk} from '../../analytics/analytics-utils'; +import {applyDidYouMeanCorrection} from '../../did-you-mean/did-you-mean-actions'; +import {logDidYouMeanAutomatic} from '../../did-you-mean/did-you-mean-insight-analytics-actions'; +import {emptyLegacyCorrection} from '../../did-you-mean/did-you-mean-state'; +import {snapshot} from '../../history/history-actions'; +import {extractHistory} from '../../history/history-state'; +import {updateQuery} from '../../query/query-actions'; +import {getQueryInitialState} from '../../query/query-state'; +import {ExecuteSearchThunkReturn} from '../../search/legacy/search-actions'; +import { + ErrorResponse, + MappedSearchRequest, + SuccessResponse, + mapSearchResponse, +} from '../../search/search-mappings'; +import {getSearchInitialState} from '../../search/search-state'; +import {StateNeededByExecuteSearch} from '../insight-search-actions'; +import {logQueryError} from '../insight-search-analytics-actions'; +import {buildInsightSearchRequest} from '../insight-search-request'; + +export interface AsyncThunkConfig { + getState: () => StateNeededByExecuteSearch; + dispatch: ThunkDispatch< + StateNeededByExecuteSearch, + ClientThunkExtraArguments, + AnyAction + >; + rejectWithValue: (err: InsightAPIErrorStatusResponse) => unknown; + extra: ClientThunkExtraArguments; + analyticsAction: AnalyticsAsyncThunk | null; +} + +interface FetchedResponse { + response: SuccessResponse | ErrorResponse; + duration: number; + queryExecuted: string; + requestExecuted: InsightQueryRequest; +} + +type ValidReturnTypeFromProcessingStep = + | ExecuteSearchThunkReturn + | RejectionType; + +export class AsyncInsightSearchThunkProcessor { + constructor(private config: AsyncThunkConfig) {} + + public async fetchFromAPI( + {request, mappings}: MappedSearchRequest, + options?: SearchOptions + ): Promise<{ + response: SuccessResponse | ErrorResponse; + duration: number; + queryExecuted: string; + requestExecuted: InsightQueryRequest; + }> { + const startedAt = new Date().getTime(); + const response = mapSearchResponse( + await this.extra.apiClient.query(request, options), + mappings + ); + const duration = new Date().getTime() - startedAt; + const queryExecuted = this.getState().query?.q || ''; + return { + response, + duration, + queryExecuted, + requestExecuted: request, + }; + } + + public async process( + fetched: FetchedResponse, + mappedRequest: MappedSearchRequest + ): Promise> { + return ( + this.processQueryErrorOrContinue(fetched) ?? + (await this.processQueryCorrectionsOrContinue(fetched, mappedRequest)) ?? + this.processSuccessResponse(fetched) + ); + } + + private processQueryErrorOrContinue( + fetched: FetchedResponse + ): ValidReturnTypeFromProcessingStep | null { + if (isErrorResponse(fetched.response)) { + this.dispatch(logQueryError(fetched.response.error)); + return this.rejectWithValue(fetched.response.error) as RejectionType; + } + + return null; + } + + private async processQueryCorrectionsOrContinue( + fetched: FetchedResponse, + mappedRequest: MappedSearchRequest + ): Promise | null> { + if ( + !this.shouldReExecuteTheQueryWithCorrections(fetched) || + isErrorResponse(fetched.response) + ) { + return null; + } + + const {correctedQuery} = fetched.response.success.queryCorrections + ? fetched.response.success.queryCorrections[0] + : emptyLegacyCorrection(); + const originalQuery = this.getCurrentQuery(); + const retried = + await this.automaticallyRetryQueryWithCorrection(correctedQuery); + + if (isErrorResponse(retried.response)) { + this.dispatch(logQueryError(retried.response.error)); + return this.rejectWithValue(retried.response.error) as RejectionType; + } + + this.logOriginalAnalyticsQueryBeforeAutoCorrection(fetched, mappedRequest); + this.dispatch(snapshot(extractHistory(this.getState()))); + return { + ...retried, + response: { + ...retried.response.success, + queryCorrections: fetched.response.success.queryCorrections, + }, + automaticallyCorrected: true, + originalQuery, + analyticsAction: logDidYouMeanAutomatic(), + }; + } + + private shouldReExecuteTheQueryWithCorrections( + fetched: FetchedResponse + ): boolean { + const state = this.getState(); + const successResponse = this.getSuccessResponse(fetched); + + if ( + state.didYouMean?.enableDidYouMean === true && + successResponse?.results.length === 0 && + successResponse?.queryCorrections && + successResponse?.queryCorrections.length !== 0 + ) { + return true; + } + return false; + } + + private async automaticallyRetryQueryWithCorrection(correction: string) { + this.dispatch(updateQuery({q: correction})); + const state = this.getState(); + const fetched = await this.fetchFromAPI( + await buildInsightSearchRequest(state) + ); + this.dispatch(applyDidYouMeanCorrection(correction)); + return fetched; + } + + private processSuccessResponse( + fetched: FetchedResponse + ): ValidReturnTypeFromProcessingStep { + this.dispatch(snapshot(extractHistory(this.getState()))); + return { + ...fetched, + response: this.getSuccessResponse(fetched)!, + automaticallyCorrected: false, + originalQuery: this.getCurrentQuery(), + analyticsAction: this.analyticsAction!, + }; + } + + private getSuccessResponse(fetched: FetchedResponse) { + if (isSuccessResponse(fetched.response)) { + return fetched.response.success; + } + return null; + } + + private getStateAfterResponse( + query: string, + duration: number, + previousState: StateNeededByExecuteSearch, + response: SearchResponseSuccess + ): StateNeededByInsightAnalyticsProvider { + return { + ...previousState, + query: { + q: query, + enableQuerySyntax: + previousState.query?.enableQuerySyntax ?? + getQueryInitialState().enableQuerySyntax, + }, + search: { + ...getSearchInitialState(), + duration, + response, + results: response.results, + }, + }; + } + + private logOriginalAnalyticsQueryBeforeAutoCorrection( + originalFetchedResponse: FetchedResponse, + mappedRequest: MappedSearchRequest + ) { + const state = this.getState(); + const fetchedResponse = ( + mapSearchResponse( + originalFetchedResponse.response, + mappedRequest.mappings + ) as SuccessResponse + ).success; + this.analyticsAction && + this.analyticsAction()( + this.dispatch, + () => + this.getStateAfterResponse( + originalFetchedResponse.queryExecuted, + originalFetchedResponse.duration, + state, + fetchedResponse + ), + this.extra + ); + } + + private get extra() { + return this.config.extra; + } + + private getState() { + return this.config.getState(); + } + + private get dispatch() { + return this.config.dispatch; + } + + private get rejectWithValue() { + return this.config.rejectWithValue; + } + + private getCurrentQuery() { + const state = this.getState(); + return state.query?.q !== undefined ? state.query.q : ''; + } + + private get analyticsAction() { + return this.config.analyticsAction; + } +} diff --git a/packages/headless/src/features/insight-search/legacy/insight-search-actions.ts b/packages/headless/src/features/insight-search/legacy/insight-search-actions.ts new file mode 100644 index 00000000000..31fb35ac81c --- /dev/null +++ b/packages/headless/src/features/insight-search/legacy/insight-search-actions.ts @@ -0,0 +1,126 @@ +import {createAsyncThunk} from '@reduxjs/toolkit'; +import {AsyncThunkInsightOptions} from '../../../api/service/insight/insight-api-client'; +import {InsightAction} from '../../analytics/analytics-utils'; +import {ExecuteSearchThunkReturn} from '../../search/legacy/search-actions'; +import { + StateNeededByExecuteSearch, + addEntryInActionsHistory, +} from '../insight-search-actions'; +import {logFetchMoreResults} from '../insight-search-analytics-actions'; +import { + buildInsightFetchFacetValuesRequest, + buildInsightFetchMoreResultsRequest, + buildInsightSearchRequest, +} from '../insight-search-request'; +import {AsyncInsightSearchThunkProcessor} from './insight-search-actions-thunk-processor'; + +export async function legacyExecuteSearch( + state: StateNeededByExecuteSearch, + config: any, //eslint-disable-line @typescript-eslint/no-explicit-any + analyticsAction: InsightAction +) { + addEntryInActionsHistory(state); + + const processor = new AsyncInsightSearchThunkProcessor< + ReturnType + >({ + ...config, + analyticsAction, + }); + + const mappedRequest = buildInsightSearchRequest(state); + const fetched = await processor.fetchFromAPI(mappedRequest); + + return await processor.process(fetched, mappedRequest); +} + +export async function legacyFetchPage( + state: StateNeededByExecuteSearch, + config: any, //eslint-disable-line @typescript-eslint/no-explicit-any + analyticsAction: InsightAction +) { + addEntryInActionsHistory(state); + + const processor = new AsyncInsightSearchThunkProcessor< + ReturnType + >({ + ...config, + analyticsAction, + }); + + const mappedRequest = buildInsightSearchRequest(state); + const fetched = await processor.fetchFromAPI(mappedRequest); + + return await processor.process(fetched, mappedRequest); +} + +export async function legacyFetchMoreResults( + state: StateNeededByExecuteSearch, + config: any //eslint-disable-line @typescript-eslint/no-explicit-any +) { + const processor = new AsyncInsightSearchThunkProcessor< + ReturnType + >({ + ...config, + analyticsAction: logFetchMoreResults, + }); + + const mappedRequest = await buildInsightFetchMoreResultsRequest(state); + const fetched = await processor.fetchFromAPI(mappedRequest); + + return await processor.process(fetched, mappedRequest); +} + +export async function legacyFetchFacetValues( + state: StateNeededByExecuteSearch, + config: any, //eslint-disable-line @typescript-eslint/no-explicit-any + analyticsAction: InsightAction +) { + const processor = new AsyncInsightSearchThunkProcessor< + ReturnType + >({ + ...config, + analyticsAction, + }); + + const mappedRequest = await buildInsightFetchFacetValuesRequest(state); + const fetched = await processor.fetchFromAPI(mappedRequest); + + return await processor.process(fetched, mappedRequest); +} + +export const executeSearch = createAsyncThunk< + ExecuteSearchThunkReturn, + InsightAction, + AsyncThunkInsightOptions +>('search/executeSearch', async (analyticsAction: InsightAction, config) => { + const state = config.getState(); + return await legacyExecuteSearch(state, config, analyticsAction); +}); + +export const fetchPage = createAsyncThunk< + ExecuteSearchThunkReturn, + InsightAction, + AsyncThunkInsightOptions +>('search/fetchPage', async (analyticsAction: InsightAction, config) => { + const state = config.getState(); + return await legacyFetchPage(state, config, analyticsAction); +}); + +export const fetchMoreResults = createAsyncThunk< + ExecuteSearchThunkReturn, + void, + AsyncThunkInsightOptions +>('search/fetchMoreResults', async (_, config) => { + const state = config.getState(); + return await legacyFetchMoreResults(state, config); +}); + +export const fetchFacetValues = createAsyncThunk< + ExecuteSearchThunkReturn, + InsightAction, + AsyncThunkInsightOptions +>('search/fetchFacetValues', async (analyticsAction: InsightAction, config) => { + const state = config.getState(); + return await legacyFetchFacetValues(state, config, analyticsAction); +}); diff --git a/packages/headless/src/features/result-preview/result-preview-slice.test.ts b/packages/headless/src/features/result-preview/result-preview-slice.test.ts index 796b9340a73..cf135276b8d 100644 --- a/packages/headless/src/features/result-preview/result-preview-slice.test.ts +++ b/packages/headless/src/features/result-preview/result-preview-slice.test.ts @@ -43,11 +43,9 @@ describe('ResultPreview', () => { state.contentURL = 'url'; state.isLoading = true; state.uniqueId = 'uniqueId'; - const action = executeSearch.fulfilled( - buildMockLegacySearch(), - '', - logInterfaceLoad() - ); + const action = executeSearch.fulfilled(buildMockLegacySearch(), '', { + legacy: logInterfaceLoad(), + }); const finalState = resultPreviewReducer(state, action); @@ -67,7 +65,9 @@ describe('ResultPreview', () => { ], }), }); - const action = executeSearch.fulfilled(search, '', logInterfaceLoad()); + const action = executeSearch.fulfilled(search, '', { + legacy: logInterfaceLoad(), + }); const finalState = resultPreviewReducer(state, action); expect(finalState.resultsWithPreview).toEqual(['first', 'fourth']); }); diff --git a/packages/headless/src/features/search/search-mappings.ts b/packages/headless/src/features/search/search-mappings.ts index 91ccb34451b..430fc774fda 100644 --- a/packages/headless/src/features/search/search-mappings.ts +++ b/packages/headless/src/features/search/search-mappings.ts @@ -26,7 +26,7 @@ function formatEndFacetValue(value: string) { return `end${value}`; } -interface SearchMappings { +export interface SearchMappings { dateFacetValueMap: Record>; } diff --git a/packages/headless/src/test/mock-insight-request.ts b/packages/headless/src/test/mock-insight-request.ts new file mode 100644 index 00000000000..3f2997baee6 --- /dev/null +++ b/packages/headless/src/test/mock-insight-request.ts @@ -0,0 +1,14 @@ +import {InsightQueryRequest} from '../api/service/insight/query/query-request'; + +export function buildMockInsightQueryRequest( + config?: Partial +): InsightQueryRequest { + return { + accessToken: '', + url: '', + organizationId: '', + insightId: '', + tab: '', + ...config, + }; +}