From 9d8d0cb922f0abf57b44bbe70e36202c168bffd3 Mon Sep 17 00:00:00 2001 From: Sami Date: Mon, 9 Aug 2021 20:43:47 -0400 Subject: [PATCH] feat(headless): allow relative redirection urls, communicate state using localstorage (#1042) https://coveord.atlassian.net/browse/KIT-870 --- .../integration/search-engine.cypress.ts | 65 ++++++-- .../headless/doc-parser/use-cases/search.ts | 6 +- .../src/api/search/search-api-client.test.ts | 2 +- packages/headless/src/app/reducers.ts | 5 + .../src/app/search-engine/search-engine.ts | 28 +++- packages/headless/src/controllers/index.ts | 1 + .../headless-standalone-search-box-options.ts | 1 - .../headless-standalone-search-box.test.ts | 97 ++++++++--- .../headless-standalone-search-box.ts | 98 +++++------- packages/headless/src/features/index.ts | 1 + .../query-suggest-analytics-actions.ts | 6 +- .../query-suggest/query-suggest-slice.test.ts | 19 ++- .../query-suggest/query-suggest-slice.ts | 8 +- .../redirection/redirection-actions-loader.ts | 5 + .../redirection/redirection-actions.ts | 17 +- .../redirection/redirection-slice.test.ts | 36 ++++- .../features/redirection/redirection-slice.ts | 3 + ...tandalone-search-box-set-actions-loader.ts | 90 +++++++++++ .../standalone-search-box-set-actions.ts | 137 ++++++++++++++++ .../standalone-search-box-set-slice.test.ts | 150 ++++++++++++++++++ .../standalone-search-box-set-slice.ts | 88 ++++++++++ .../standalone-search-box-set-state.ts | 37 +++++ .../headless/src/state/search-app-state.ts | 2 + packages/headless/src/state/state-sections.ts | 9 ++ packages/headless/src/test/mock-engine.ts | 1 + .../test/mock-omnibox-suggestion-metadata.ts | 13 ++ .../test/mock-standalone-search-box-entry.ts | 16 ++ packages/headless/src/test/mock-state.ts | 2 + packages/samples/headless-react/src/App.tsx | 3 + .../standalone-search-box-storage-key.ts | 1 + .../standalone-search-box.class.tsx | 12 +- .../standalone-search-box.fn.tsx | 31 ++-- .../headless-react/src/pages/SearchPage.tsx | 23 +++ .../src/pages/StandaloneSearchBoxPage.tsx | 7 +- 34 files changed, 862 insertions(+), 158 deletions(-) create mode 100644 packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-actions-loader.ts create mode 100644 packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-actions.ts create mode 100644 packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-slice.test.ts create mode 100644 packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-slice.ts create mode 100644 packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-state.ts create mode 100644 packages/headless/src/test/mock-omnibox-suggestion-metadata.ts create mode 100644 packages/headless/src/test/mock-standalone-search-box-entry.ts create mode 100644 packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box-storage-key.ts diff --git a/packages/atomic/cypress/integration/search-engine.cypress.ts b/packages/atomic/cypress/integration/search-engine.cypress.ts index a9c6c279963..d8d94fcf2ee 100644 --- a/packages/atomic/cypress/integration/search-engine.cypress.ts +++ b/packages/atomic/cypress/integration/search-engine.cypress.ts @@ -1,5 +1,6 @@ import { buildSearchEngine, + buildStandaloneSearchBox, getSampleSearchEngineConfiguration, loadSearchAnalyticsActions, SearchEngine, @@ -17,22 +18,64 @@ describe('search engine tests', () => { setupIntercept(); }); - it('calling #executeFirstSearch with no arguments logs an interfaceLoad analytics event', () => { - engine.executeFirstSearch(); + describe('#executeFirstSearch', () => { + it('when passed no arguments, it logs an interfaceLoad analytics event', () => { + engine.executeFirstSearch(); - cy.wait(RouteAlias.analytics).then(({request}) => { - const analyticsBody = request.body; - expect(analyticsBody).to.have.property('actionCause', 'interfaceLoad'); + cy.wait(RouteAlias.analytics).then(({request}) => { + const analyticsBody = request.body; + expect(analyticsBody).to.have.property('actionCause', 'interfaceLoad'); + }); + }); + + it('when passed an analytics action, it logs the passed action', () => { + const {logSearchFromLink} = loadSearchAnalyticsActions(engine); + engine.executeFirstSearch(logSearchFromLink()); + + cy.wait(RouteAlias.analytics).then(({request}) => { + const analyticsBody = request.body; + expect(analyticsBody).to.have.property('actionCause', 'searchFromLink'); + }); }); }); - it('calling #executeFirstSearch with an analytics action logs the passed action', () => { - const {logSearchFromLink} = loadSearchAnalyticsActions(engine); - engine.executeFirstSearch(logSearchFromLink()); + describe('#executeFirstSearchAfterStandaloneSearchBoxRedirect', () => { + it(`when a search is executed from a standalone search box, + passing the analytics object logs a searchFromLink`, () => { + const searchBox = buildStandaloneSearchBox(engine, { + options: {redirectionUrl: '/search-page'}, + }); + + searchBox.submit(); + + engine.executeFirstSearchAfterStandaloneSearchBoxRedirect( + searchBox.state.analytics + ); + + cy.wait(RouteAlias.analytics).then(({request}) => { + const analyticsBody = request.body; + expect(analyticsBody).to.have.property('actionCause', 'searchFromLink'); + }); + }); + + it(`when a suggestion is selected from a standalone search box, + passing the analytics object logs a omniboxFromLink`, () => { + const searchBox = buildStandaloneSearchBox(engine, { + options: {redirectionUrl: '/search-page'}, + }); + + searchBox.selectSuggestion('hello'); + engine.executeFirstSearchAfterStandaloneSearchBoxRedirect( + searchBox.state.analytics + ); - cy.wait(RouteAlias.analytics).then(({request}) => { - const analyticsBody = request.body; - expect(analyticsBody).to.have.property('actionCause', 'searchFromLink'); + cy.wait(RouteAlias.analytics).then(({request}) => { + const analyticsBody = request.body; + expect(analyticsBody).to.have.property( + 'actionCause', + 'omniboxFromLink' + ); + }); }); }); }); diff --git a/packages/headless/doc-parser/use-cases/search.ts b/packages/headless/doc-parser/use-cases/search.ts index 9b3fb32ab22..e70f140ca3d 100644 --- a/packages/headless/doc-parser/use-cases/search.ts +++ b/packages/headless/doc-parser/use-cases/search.ts @@ -408,9 +408,6 @@ const actionLoaders: ActionLoaderConfiguration[] = [ { initializer: 'loadQuestionAnsweringActions', }, - { - initializer: 'loadRedirectionActions', - }, { initializer: 'loadSearchHubActions', }, @@ -429,6 +426,9 @@ const actionLoaders: ActionLoaderConfiguration[] = [ { initializer: 'loadGenericAnalyticsActions', }, + { + initializer: 'loadStandaloneSearchBoxSetActions', + }, ]; const engine: EngineConfiguration = { diff --git a/packages/headless/src/api/search/search-api-client.test.ts b/packages/headless/src/api/search/search-api-client.test.ts index f6bab50eb20..3603a8d3615 100644 --- a/packages/headless/src/api/search/search-api-client.test.ts +++ b/packages/headless/src/api/search/search-api-client.test.ts @@ -13,7 +13,6 @@ import {buildMockFacetRequest} from '../../test/mock-facet-request'; import {buildMockCategoryFacetSearch} from '../../test/mock-category-facet-search'; import {buildMockCategoryFacetRequest} from '../../test/mock-category-facet-request'; import {SearchAppState} from '../../state/search-app-state'; -import {buildPlanRequest} from '../../features/redirection/redirection-actions'; import {buildQuerySuggestRequest} from '../../features/query-suggest/query-suggest-actions'; import {buildSpecificFacetSearchRequest} from '../../features/facets/facet-search-set/specific/specific-facet-search-request-builder'; import {buildCategoryFacetSearchRequest} from '../../features/facets/facet-search-set/category/category-facet-search-request-builder'; @@ -34,6 +33,7 @@ import {buildMockAnalyticsState} from '../../test/mock-analytics-state'; import {SearchResponseSuccess} from './search/search-response'; import {emptyQuestionAnswer} from '../../features/search/search-state'; import {QuestionsAnswers} from './search/question-answering'; +import {buildPlanRequest} from '../../features/standalone-search-box-set/standalone-search-box-set-actions'; jest.mock('../platform-client'); describe('search api client', () => { diff --git a/packages/headless/src/app/reducers.ts b/packages/headless/src/app/reducers.ts index 4d61277150e..974fafa215c 100644 --- a/packages/headless/src/app/reducers.ts +++ b/packages/headless/src/app/reducers.ts @@ -29,6 +29,7 @@ import {resultPreviewReducer} from '../features/result-preview/result-preview-sl import {searchHubReducer} from '../features/search-hub/search-hub-slice'; import {searchReducer} from '../features/search/search-slice'; import {sortCriteriaReducer} from '../features/sort-criteria/sort-criteria-slice'; +import {standaloneSearchBoxSetReducer} from '../features/standalone-search-box-set/standalone-search-box-set-slice'; import {triggerReducer} from '../features/triggers/triggers-slice'; import {undoable} from './undoable'; @@ -48,6 +49,9 @@ export const categoryFacetSearchSet = categoryFacetSearchSetReducer; export const query = queryReducer; export const advancedSearchQueries = advancedSearchQueriesReducer; export const querySet = querySetReducer; +/** + * @deprecated - The `redirection` functionality is now handled by the `standaloneSearchBoxSet`. + */ export const redirection = redirectionReducer; export const querySuggest = querySuggestReducer; export const sortCriteria = sortCriteriaReducer; @@ -61,6 +65,7 @@ export const resultPreview = resultPreviewReducer; export const version = versionReducer; export const triggers = triggerReducer; export const questionAnswering = questionAnsweringReducer; +export const standaloneSearchBoxSet = standaloneSearchBoxSetReducer; export const history = undoable({ actionTypes: { diff --git a/packages/headless/src/app/search-engine/search-engine.ts b/packages/headless/src/app/search-engine/search-engine.ts index c79498a6f6c..f4616a6cae8 100644 --- a/packages/headless/src/app/search-engine/search-engine.ts +++ b/packages/headless/src/app/search-engine/search-engine.ts @@ -24,11 +24,16 @@ import { getSampleSearchEngineConfiguration, } from './search-engine-configuration'; import {executeSearch} from '../../features/search/search-actions'; -import {logInterfaceLoad} from '../../features/analytics/analytics-actions'; +import { + logInterfaceLoad, + logOmniboxFromLink, + logSearchFromLink, +} from '../../features/analytics/analytics-actions'; import {firstSearchExecutedSelector} from '../../features/search/search-selectors'; import {SearchAppState} from '../../state/search-app-state'; import {SearchThunkExtraArguments} from '../search-thunk-extra-arguments'; import {SearchAction} from '../../features/analytics/analytics-utils'; +import {StandaloneSearchBoxAnalytics} from '../../features/standalone-search-box-set/standalone-search-box-set-state'; export { SearchEngineConfiguration, @@ -52,6 +57,15 @@ export interface SearchEngine * @param analyticsEvent - The analytics event to log in association with the first search. If unspecified, `logInterfaceLoad` will be used. */ executeFirstSearch(analyticsEvent?: SearchAction): void; + + /** + * Executes the first search, and logs the analytics event that triggered a redirection from a standalone search box. + * + * @param analytics - The standalone search box analytics data. + */ + executeFirstSearchAfterStandaloneSearchBoxRedirect( + analytics: StandaloneSearchBoxAnalytics + ): void; } /** @@ -112,6 +126,18 @@ export function buildSearchEngine(options: SearchEngineOptions): SearchEngine { const action = executeSearch(analyticsEvent); engine.dispatch(action); }, + + executeFirstSearchAfterStandaloneSearchBoxRedirect( + analytics: StandaloneSearchBoxAnalytics + ) { + const {cause, metadata} = analytics; + const event = + metadata && cause === 'omniboxFromLink' + ? logOmniboxFromLink(metadata) + : logSearchFromLink(); + + this.executeFirstSearch(event); + }, }; } diff --git a/packages/headless/src/controllers/index.ts b/packages/headless/src/controllers/index.ts index 48ccc40e55f..6de8d88837f 100644 --- a/packages/headless/src/controllers/index.ts +++ b/packages/headless/src/controllers/index.ts @@ -203,6 +203,7 @@ export { export { StandaloneSearchBoxOptions, + StandaloneSearchBoxAnalytics, StandaloneSearchBoxProps, StandaloneSearchBoxState, StandaloneSearchBox, diff --git a/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box-options.ts b/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box-options.ts index 6f3195ef974..ef9e0e273fb 100644 --- a/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box-options.ts +++ b/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box-options.ts @@ -19,6 +19,5 @@ export const standaloneSearchBoxSchema = new Schema< redirectionUrl: new StringValue({ required: true, emptyAllowed: false, - url: true, }), }); diff --git a/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box.test.ts b/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box.test.ts index 66dd7a7b847..2315302510b 100644 --- a/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box.test.ts +++ b/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box.test.ts @@ -3,7 +3,6 @@ import { StandaloneSearchBox, StandaloneSearchBoxOptions, } from './headless-standalone-search-box'; -import {checkForRedirection} from '../../features/redirection/redirection-actions'; import {createMockState} from '../../test/mock-state'; import {updateQuery} from '../../features/query/query-actions'; import {buildMockQuerySuggest} from '../../test/mock-query-suggest'; @@ -20,9 +19,19 @@ import {selectQuerySuggestion} from '../../features/query-suggest/query-suggest- import { configuration, query, - redirection, + standaloneSearchBoxSet, querySuggest, + redirection, } from '../../app/reducers'; +import { + fetchRedirectUrl, + registerStandaloneSearchBox, + updateAnalyticsToOmniboxFromLink, + updateAnalyticsToSearchFromLink, +} from '../../features/standalone-search-box-set/standalone-search-box-set-actions'; +import {buildMockStandaloneSearchBoxEntry} from '../../test/mock-standalone-search-box-entry'; +import {buildMockOmniboxSuggestionMetadata} from '../../test/mock-omnibox-suggestion-metadata'; +import {StandaloneSearchBoxAnalytics} from '../../features/standalone-search-box-set/standalone-search-box-set-state'; describe('headless standalone searchBox', () => { const id = 'search-box-123'; @@ -44,9 +53,9 @@ describe('headless standalone searchBox', () => { function initState() { state = createMockState(); - state.redirection.redirectTo = 'coveo.com'; state.querySet[id] = 'query'; state.querySuggest[id] = buildMockQuerySuggest({id, q: 'some value'}); + state.standaloneSearchBoxSet[id] = buildMockStandaloneSearchBoxEntry(); } function initController() { @@ -56,13 +65,22 @@ describe('headless standalone searchBox', () => { it('it adds the correct reducers to engine', () => { expect(engine.addReducers).toHaveBeenCalledWith({ - redirection, + standaloneSearchBoxSet, configuration, query, querySuggest, + redirection, }); }); + it('dispatches #registerStandaloneSearchBox with the correct options', () => { + const action = registerStandaloneSearchBox({ + id, + redirectionUrl: options.redirectionUrl, + }); + expect(engine.actions).toContainEqual(action); + }); + it('when no id is passed, it creates an id prefixed with standalone_search_box', () => { options = {redirectionUrl: 'https://www.coveo.com/en/search'}; initController(); @@ -85,13 +103,18 @@ describe('headless standalone searchBox', () => { ); }); + it('when the redirectionUrl is a relative url, it does not throw', () => { + options.redirectionUrl = '/search-page'; + expect(() => initController()).not.toThrow(); + }); + it('should return the right state', () => { expect(searchBox.state).toEqual({ value: state.querySet[id], suggestions: state.querySuggest[id]!.completions.map((completion) => ({ value: completion.expression, })), - redirectTo: state.redirection.redirectTo, + redirectTo: '', isLoading: false, isLoadingSuggestions: false, analytics: { @@ -101,6 +124,34 @@ describe('headless standalone searchBox', () => { }); }); + it('#state.isLoading uses the value in the standalone search-box reducer', () => { + engine.state.standaloneSearchBoxSet[ + id + ] = buildMockStandaloneSearchBoxEntry({isLoading: true}); + expect(searchBox.state.isLoading).toBe(true); + }); + + it('#state.redirectTo uses the value in the standalone search-box reducer', () => { + const redirectTo = '/search-page'; + engine.state.standaloneSearchBoxSet[ + id + ] = buildMockStandaloneSearchBoxEntry({redirectTo}); + expect(searchBox.state.redirectTo).toBe(redirectTo); + }); + + it('#state.analytics uses the value inside the standalone search-box reducer', () => { + const metadata = buildMockOmniboxSuggestionMetadata(); + const analytics: StandaloneSearchBoxAnalytics = { + cause: 'omniboxFromLink', + metadata, + }; + engine.state.standaloneSearchBoxSet[ + id + ] = buildMockStandaloneSearchBoxEntry({analytics}); + + expect(searchBox.state.analytics).toEqual(analytics); + }); + describe('#updateText', () => { const query = 'a'; @@ -108,13 +159,9 @@ describe('headless standalone searchBox', () => { searchBox.updateText(query); }); - it('sets the analytics cause to "searchFromLink"', () => { - searchBox.updateText(''); - - expect(searchBox.state.analytics).toEqual({ - cause: 'searchFromLink', - metadata: null, - }); + it('dispatches an action to update analytics to searchFromLink', () => { + const action = updateAnalyticsToSearchFromLink({id}); + expect(engine.actions).toContainEqual(action); }); it('dispatches #updateQuerySetQuery', () => { @@ -133,18 +180,18 @@ describe('headless standalone searchBox', () => { ); }); - it('sets #state.analytics to the correct data', () => { - searchBox.selectSuggestion('a'); + it('dispatchs an action to update analytics to omniboxFromLink', () => { + const metadata = { + partialQueries: [], + partialQuery: '', + suggestionRanking: -1, + suggestions: [], + }; - expect(searchBox.state.analytics).toEqual({ - cause: 'omniboxFromLink', - metadata: { - partialQueries: [], - partialQuery: undefined, - suggestionRanking: -1, - suggestions: [], - }, - }); + const action = updateAnalyticsToOmniboxFromLink({id, metadata}); + + searchBox.selectSuggestion('a'); + expect(engine.actions).toContainEqual(action); }); it('calls #submit', () => { @@ -165,11 +212,11 @@ describe('headless standalone searchBox', () => { ); }); - it('should dispatch a checkForRedirection action', () => { + it('should dispatch a fetchRedirectUrl action', () => { searchBox.submit(); const action = engine.actions.find( - (a) => a.type === checkForRedirection.pending.type + (a) => a.type === fetchRedirectUrl.pending.type ); expect(action).toBeTruthy(); }); diff --git a/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box.ts b/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box.ts index e7b41b3d3c4..7391cedf9b7 100644 --- a/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box.ts +++ b/packages/headless/src/controllers/standalone-search-box/headless-standalone-search-box.ts @@ -1,22 +1,26 @@ import { configuration, query, - redirection, querySuggest, + standaloneSearchBoxSet, + redirection, } from '../../app/reducers'; import {SearchEngine} from '../../app/search-engine/search-engine'; import {selectQuerySuggestion} from '../../features/query-suggest/query-suggest-actions'; -import { - buildOmniboxSuggestionMetadata, - OmniboxSuggestionMetadata, -} from '../../features/query-suggest/query-suggest-analytics-actions'; +import {buildOmniboxSuggestionMetadata} from '../../features/query-suggest/query-suggest-analytics-actions'; import {updateQuery} from '../../features/query/query-actions'; -import {checkForRedirection} from '../../features/redirection/redirection-actions'; +import { + fetchRedirectUrl, + registerStandaloneSearchBox, + updateAnalyticsToOmniboxFromLink, + updateAnalyticsToSearchFromLink, +} from '../../features/standalone-search-box-set/standalone-search-box-set-actions'; +import {StandaloneSearchBoxAnalytics} from '../../features/standalone-search-box-set/standalone-search-box-set-state'; import { ConfigurationSection, QuerySection, QuerySuggestionSection, - RedirectionSection, + StandaloneSearchBoxSection, } from '../../state/state-sections'; import {loadReducerError} from '../../utils/errors'; import {randomID} from '../../utils/utils'; @@ -32,7 +36,7 @@ import { standaloneSearchBoxSchema, } from './headless-standalone-search-box-options'; -export {StandaloneSearchBoxOptions}; +export {StandaloneSearchBoxOptions, StandaloneSearchBoxAnalytics}; export interface StandaloneSearchBoxProps { options: StandaloneSearchBoxOptions; @@ -58,7 +62,7 @@ export interface StandaloneSearchBoxState extends SearchBoxState { /** * The analytics data to send when performing the first query on the search page the user is redirected to. */ - analytics: StandaloneSearchBoxAnalyticsData; + analytics: StandaloneSearchBoxAnalytics; /** * The Url to redirect to. @@ -66,26 +70,6 @@ export interface StandaloneSearchBoxState extends SearchBoxState { redirectTo: string | null; } -interface InitialData { - cause: ''; - metadata: null; -} - -interface SearchFromLinkData { - cause: 'searchFromLink'; - metadata: null; -} - -interface OmniboxFromLinkData { - cause: 'omniboxFromLink'; - metadata: OmniboxSuggestionMetadata; -} - -type StandaloneSearchBoxAnalyticsData = - | InitialData - | SearchFromLinkData - | OmniboxFromLinkData; - /** * Creates a `StandaloneSearchBox` instance. * @@ -120,23 +104,27 @@ export function buildStandaloneSearchBox( ); const searchBox = buildSearchBox(engine, {options}); - - let analytics: StandaloneSearchBoxAnalyticsData = { - cause: '', - metadata: null, - }; + dispatch( + registerStandaloneSearchBox({id, redirectionUrl: options.redirectionUrl}) + ); return { ...searchBox, updateText(value: string) { - analytics = buildSearchFromLinkData(); searchBox.updateText(value); + dispatch(updateAnalyticsToSearchFromLink({id})); }, selectSuggestion(value: string) { - analytics = buildOmniboxFromLinkData(getState(), id, value); + const metadata = buildOmniboxSuggestionMetadata(getState(), { + id, + suggestion: value, + }); + dispatch(selectQuerySuggestion({id, expression: value})); + dispatch(updateAnalyticsToOmniboxFromLink({id, metadata})); + this.submit(); }, @@ -147,17 +135,17 @@ export function buildStandaloneSearchBox( enableQuerySyntax: options.enableQuerySyntax, }) ); - dispatch( - checkForRedirection({defaultRedirectionUrl: options.redirectionUrl}) - ); + dispatch(fetchRedirectUrl({id})); }, get state() { const state = getState(); + const standaloneSearchBoxState = state.standaloneSearchBoxSet[id]!; return { ...searchBox.state, - redirectTo: state.redirection.redirectTo, - analytics, + isLoading: standaloneSearchBoxState.isLoading, + redirectTo: standaloneSearchBoxState.redirectTo, + analytics: standaloneSearchBoxState.analytics, }; }, }; @@ -166,29 +154,17 @@ export function buildStandaloneSearchBox( function loadStandaloneSearchBoxReducers( engine: SearchEngine ): engine is SearchEngine< - RedirectionSection & + StandaloneSearchBoxSection & ConfigurationSection & QuerySection & QuerySuggestionSection > { - engine.addReducers({redirection, configuration, query, querySuggest}); + engine.addReducers({ + standaloneSearchBoxSet, + configuration, + query, + querySuggest, + redirection, + }); return true; } - -function buildSearchFromLinkData(): SearchFromLinkData { - return { - cause: 'searchFromLink', - metadata: null, - }; -} - -function buildOmniboxFromLinkData( - state: QuerySuggestionSection, - id: string, - suggestion: string -): OmniboxFromLinkData { - return { - cause: 'omniboxFromLink', - metadata: buildOmniboxSuggestionMetadata(state, {id, suggestion}), - }; -} diff --git a/packages/headless/src/features/index.ts b/packages/headless/src/features/index.ts index fe99375ff47..8c59fb72e2d 100644 --- a/packages/headless/src/features/index.ts +++ b/packages/headless/src/features/index.ts @@ -21,6 +21,7 @@ export * from './redirection/redirection-actions-loader'; export * from './search/search-actions-loader'; export * from './search-hub/search-hub-actions-loader'; export * from './sort-criteria/sort-criteria-actions-loader'; +export * from './standalone-search-box-set/standalone-search-box-set-actions-loader'; export * from './question-answering/question-answering-actions-loader'; export * from './facets/generic/breadcrumb-actions-loader'; diff --git a/packages/headless/src/features/query-suggest/query-suggest-analytics-actions.ts b/packages/headless/src/features/query-suggest/query-suggest-analytics-actions.ts index 4a8f1e512b8..0cc1dc1d42f 100644 --- a/packages/headless/src/features/query-suggest/query-suggest-analytics-actions.ts +++ b/packages/headless/src/features/query-suggest/query-suggest-analytics-actions.ts @@ -45,10 +45,12 @@ export function buildOmniboxSuggestionMetadata( (completion) => completion.expression ); + const lastIndex = querySuggest.partialQueries.length - 1; + const partialQuery = querySuggest.partialQueries[lastIndex] || ''; + return { suggestionRanking: suggestions.indexOf(suggestion), - partialQuery: - querySuggest.partialQueries[querySuggest.partialQueries.length - 1], + partialQuery, partialQueries: querySuggest.partialQueries, suggestions, }; diff --git a/packages/headless/src/features/query-suggest/query-suggest-slice.test.ts b/packages/headless/src/features/query-suggest/query-suggest-slice.test.ts index da89cd13981..fe49c601d21 100644 --- a/packages/headless/src/features/query-suggest/query-suggest-slice.test.ts +++ b/packages/headless/src/features/query-suggest/query-suggest-slice.test.ts @@ -39,12 +39,21 @@ describe('querySuggest slice', () => { expect(querySuggestReducer(undefined, {type: 'randomAction'})).toEqual({}); }); - it('should handle registerQuerySuggest on initial state', () => { - const expectedState = buildMockQuerySuggest({id, q: 'test', count: 10}); - const action = registerQuerySuggest({id, q: 'test', count: 10}); - const finalState = querySuggestReducer(undefined, action); + describe('registerQuerySuggest', () => { + it('when the id does not exist, it adds an entry with the correct state', () => { + const expectedState = buildMockQuerySuggest({id, q: 'test', count: 10}); + const action = registerQuerySuggest({id, q: 'test', count: 10}); + const finalState = querySuggestReducer(undefined, action); - expect(finalState[id]).toEqual(expectedState); + expect(finalState[id]).toEqual(expectedState); + }); + + it('when the id exists, it does not modify the registered state', () => { + const action = registerQuerySuggest({id, q: 'test', count: 10}); + const finalState = querySuggestReducer(state, action); + + expect(state[id]).toEqual(finalState[id]); + }); }); describe('selectQuerySuggestion', () => { diff --git a/packages/headless/src/features/query-suggest/query-suggest-slice.ts b/packages/headless/src/features/query-suggest/query-suggest-slice.ts index 3570b53f8e7..367605c197c 100644 --- a/packages/headless/src/features/query-suggest/query-suggest-slice.ts +++ b/packages/headless/src/features/query-suggest/query-suggest-slice.ts @@ -17,7 +17,13 @@ export const querySuggestReducer = createReducer( (builder) => builder .addCase(registerQuerySuggest, (state, action) => { - state[action.payload.id] = buildQuerySuggest(action.payload); + const id = action.payload.id; + + if (id in state) { + return; + } + + state[id] = buildQuerySuggest(action.payload); }) .addCase(unregisterQuerySuggest, (state, action) => { delete state[action.payload.id]; diff --git a/packages/headless/src/features/redirection/redirection-actions-loader.ts b/packages/headless/src/features/redirection/redirection-actions-loader.ts index fb81843d531..1c85f1adb42 100644 --- a/packages/headless/src/features/redirection/redirection-actions-loader.ts +++ b/packages/headless/src/features/redirection/redirection-actions-loader.ts @@ -32,12 +32,17 @@ export interface RedirectionActionCreators { /** * Loads the `redirection` reducer and returns possible action creators. * + * @deprecated - Please use `loadStandaloneSearchBoxSetActions` instead. + * * @param engine - The headless engine. * @returns An object holding the action creators. */ export function loadRedirectionActions( engine: SearchEngine ): RedirectionActionCreators { + engine.logger.warn( + 'The "loadRedirectionActions" function is deprecated. Please use "loadStandaloneSearchBoxSetActions" instead.' + ); engine.addReducers({redirection}); return {checkForRedirection}; diff --git a/packages/headless/src/features/redirection/redirection-actions.ts b/packages/headless/src/features/redirection/redirection-actions.ts index b20892c7457..ed7d7a80eeb 100644 --- a/packages/headless/src/features/redirection/redirection-actions.ts +++ b/packages/headless/src/features/redirection/redirection-actions.ts @@ -13,7 +13,7 @@ import { QuerySection, SearchHubSection, } from '../../state/state-sections'; -import {PlanRequest} from '../../api/search/plan/plan-request'; +import {buildPlanRequest} from '../standalone-search-box-set/standalone-search-box-set-actions'; export type RedirectionState = ConfigurationSection & QuerySection & @@ -48,7 +48,6 @@ export const checkForRedirection = createAsyncThunk< validatePayload(payload, { defaultRedirectionUrl: new StringValue({ emptyAllowed: false, - url: true, }), }); const response = await searchAPIClient.plan(buildPlanRequest(getState())); @@ -64,17 +63,3 @@ export const checkForRedirection = createAsyncThunk< return planRedirection || payload.defaultRedirectionUrl; } ); - -export const buildPlanRequest = (state: RedirectionState): PlanRequest => { - return { - accessToken: state.configuration.accessToken, - organizationId: state.configuration.organizationId, - url: state.configuration.search.apiBaseUrl, - locale: state.configuration.search.locale, - timezone: state.configuration.search.timezone, - q: state.query.q, - ...(state.context && {context: state.context.contextValues}), - ...(state.pipeline && {pipeline: state.pipeline}), - ...(state.searchHub && {searchHub: state.searchHub}), - }; -}; diff --git a/packages/headless/src/features/redirection/redirection-slice.test.ts b/packages/headless/src/features/redirection/redirection-slice.test.ts index 6c9311a397e..cf28b97a0b9 100644 --- a/packages/headless/src/features/redirection/redirection-slice.test.ts +++ b/packages/headless/src/features/redirection/redirection-slice.test.ts @@ -51,9 +51,17 @@ describe('redirection slice', () => { }); let engine: MockSearchEngine; - async function mockPlan(trigger?: Trigger) { - const apiClient = buildMockSearchAPIClient(); + interface MockPlanConfiguration { + trigger?: Trigger; + defaultRedirectionUrl?: string; + } + + async function mockPlan(config: MockPlanConfiguration = {}) { + const {defaultRedirectionUrl: url, trigger} = config; + const defaultRedirectionUrl = url || 'https://www.test.com'; const triggers = trigger ? [trigger] : []; + + const apiClient = buildMockSearchAPIClient(); jest.spyOn(apiClient, 'plan').mockResolvedValue({ success: { parsedInput: {basicExpression: '', largeExpression: ''}, @@ -64,7 +72,7 @@ describe('redirection slice', () => { engine = buildMockSearchAppEngine(); const response = await checkForRedirection({ - defaultRedirectionUrl: 'https://www.test.com', + defaultRedirectionUrl, })(engine.dispatch, () => createMockState(), { searchAPIClient: apiClient, analyticsClientMiddleware: (_, p) => p, @@ -86,6 +94,16 @@ describe('redirection slice', () => { done(); }); + it(`when the plan endpoint doesn't return a redirection trigger, + and the defaultRedirectionUrl is a relative url, + payload should contain the defaultRedirectionUrl`, async (done) => { + const defaultRedirectionUrl = '/search-page'; + const response = await mockPlan({defaultRedirectionUrl}); + + expect(response.payload).toBe(defaultRedirectionUrl); + done(); + }); + it(`when the plan endpoint doesn't return a redirection trigger should not dispatch a logRedirection action`, async (done) => { await mockPlan(); @@ -96,8 +114,10 @@ describe('redirection slice', () => { it(`when the plan endpoint returns a redirection trigger payload should contain the redirection trigger URL`, async (done) => { const response = await mockPlan({ - type: 'redirect', - content: 'https://www.coveo.com', + trigger: { + type: 'redirect', + content: 'https://www.coveo.com', + }, }); expect(response.payload).toBe('https://www.coveo.com'); expect(getlogRedirectionAction()).toBeTruthy(); @@ -107,8 +127,10 @@ describe('redirection slice', () => { it(`when the plan endpoint returns a redirection trigger should dispatch a logRedirection action`, async (done) => { const response = await mockPlan({ - type: 'redirect', - content: 'https://www.coveo.com', + trigger: { + type: 'redirect', + content: 'https://www.coveo.com', + }, }); expect(response.payload).toBe('https://www.coveo.com'); expect(getlogRedirectionAction()).toBeTruthy(); diff --git a/packages/headless/src/features/redirection/redirection-slice.ts b/packages/headless/src/features/redirection/redirection-slice.ts index 6b1086bc54f..727ef428d28 100644 --- a/packages/headless/src/features/redirection/redirection-slice.ts +++ b/packages/headless/src/features/redirection/redirection-slice.ts @@ -2,6 +2,9 @@ import {createReducer} from '@reduxjs/toolkit'; import {checkForRedirection} from './redirection-actions'; import {getRedirectionInitialState} from './redirection-state'; +/** + * @deprecated - Please use `standaloneSearchBoxSetReducer` instead. + */ export const redirectionReducer = createReducer( getRedirectionInitialState(), (builder) => diff --git a/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-actions-loader.ts b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-actions-loader.ts new file mode 100644 index 00000000000..f383d4f0393 --- /dev/null +++ b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-actions-loader.ts @@ -0,0 +1,90 @@ +import {AsyncThunkAction, PayloadAction} from '@reduxjs/toolkit'; +import {AsyncThunkSearchOptions} from '../../api/search/search-api-client'; +import {standaloneSearchBoxSet} from '../../app/reducers'; +import {SearchEngine} from '../../app/search-engine/search-engine'; +import { + registerStandaloneSearchBox, + RegisterStandaloneSearchBoxActionCreatorPayload, + fetchRedirectUrl, + FetchRedirectUrlActionCreatorPayload, + updateAnalyticsToSearchFromLink, + UpdateAnalyticsToSearchFromLinkActionCreatorPayload, + updateAnalyticsToOmniboxFromLink, + UpdateAnalyticsToOmniboxFromLinkActionCreatorPayload, + StateNeededForRedirect, +} from './standalone-search-box-set-actions'; + +export { + RegisterStandaloneSearchBoxActionCreatorPayload, + FetchRedirectUrlActionCreatorPayload, + UpdateAnalyticsToSearchFromLinkActionCreatorPayload, + UpdateAnalyticsToOmniboxFromLinkActionCreatorPayload, +}; + +/** + * The standalone search box set action creators. + */ +export interface StandaloneSearchBoxSetActionCreators { + /** + * Registers a standalone search box. + * + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + registerStandaloneSearchBox( + payload: RegisterStandaloneSearchBoxActionCreatorPayload + ): PayloadAction; + + /** + * Preprocesses the query for the current headless state, and retrieves a redirection URL if a redirect trigger was fired in the query pipeline. + * + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + fetchRedirectUrl( + payload: FetchRedirectUrlActionCreatorPayload + ): AsyncThunkAction< + string, + FetchRedirectUrlActionCreatorPayload, + AsyncThunkSearchOptions + >; + + /** + * Updates the standalone search box analytics data to reflect a search submitted using the search box. + * + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + updateAnalyticsToSearchFromLink( + payload: UpdateAnalyticsToSearchFromLinkActionCreatorPayload + ): PayloadAction; + + /** + * Updates the standalone search box analytics data to reflect a search submitted by selecting a query suggestion. + * + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + updateAnalyticsToOmniboxFromLink( + payload: UpdateAnalyticsToOmniboxFromLinkActionCreatorPayload + ): PayloadAction; +} + +/** + * Loads the `standaloneSearchBoxSet` reducer and returns possible action creators. + * + * @param engine - The headless engine. + * @returns An object holding the action creators. + */ +export function loadStandaloneSearchBoxSetActions( + engine: SearchEngine +): StandaloneSearchBoxSetActionCreators { + engine.addReducers({standaloneSearchBoxSet}); + + return { + registerStandaloneSearchBox, + fetchRedirectUrl, + updateAnalyticsToSearchFromLink, + updateAnalyticsToOmniboxFromLink, + }; +} diff --git a/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-actions.ts b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-actions.ts new file mode 100644 index 00000000000..01ac0125e0a --- /dev/null +++ b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-actions.ts @@ -0,0 +1,137 @@ +import {StringValue} from '@coveo/bueno'; +import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; +import {ExecutionPlan} from '../../api/search/plan/plan-endpoint'; +import {PlanRequest} from '../../api/search/plan/plan-request'; +import { + AsyncThunkSearchOptions, + isErrorResponse, +} from '../../api/search/search-api-client'; +import { + ConfigurationSection, + ContextSection, + PipelineSection, + QuerySection, + SearchHubSection, +} from '../../state/state-sections'; +import { + requiredNonEmptyString, + validatePayload, +} from '../../utils/validate-payload'; +import {AnalyticsType, makeAnalyticsAction} from '../analytics/analytics-utils'; +import {OmniboxSuggestionMetadata} from '../query-suggest/query-suggest-analytics-actions'; + +export interface RegisterStandaloneSearchBoxActionCreatorPayload { + /** + * The standalone search box id. + */ + id: string; + + /** + * The default URL to which to redirect the user. + */ + redirectionUrl: string; +} + +export const registerStandaloneSearchBox = createAction( + 'standaloneSearchBox/register', + (payload: RegisterStandaloneSearchBoxActionCreatorPayload) => + validatePayload(payload, { + id: requiredNonEmptyString, + redirectionUrl: requiredNonEmptyString, + }) +); + +export interface UpdateAnalyticsToSearchFromLinkActionCreatorPayload { + /** + * The standalone search box id. + */ + id: string; +} + +export const updateAnalyticsToSearchFromLink = createAction( + 'standaloneSearchBox/updateAnalyticsToSearchFromLink', + (payload: UpdateAnalyticsToSearchFromLinkActionCreatorPayload) => + validatePayload(payload, {id: requiredNonEmptyString}) +); + +export interface UpdateAnalyticsToOmniboxFromLinkActionCreatorPayload { + /** + * The standalone search box id. + */ + id: string; + + /** + * The metadata of the suggestion selected from the standalone search box. + */ + metadata: OmniboxSuggestionMetadata; +} + +export const updateAnalyticsToOmniboxFromLink = createAction< + UpdateAnalyticsToOmniboxFromLinkActionCreatorPayload +>('standaloneSearchBox/updateAnalyticsToOmniboxFromLink'); + +export type StateNeededForRedirect = ConfigurationSection & + QuerySection & + Partial; + +export interface FetchRedirectUrlActionCreatorPayload { + /** + * The standalone search box id. + */ + id: string; +} + +export const fetchRedirectUrl = createAsyncThunk< + string, + FetchRedirectUrlActionCreatorPayload, + AsyncThunkSearchOptions +>( + 'standaloneSearchBox/fetchRedirect', + async ( + payload, + { + dispatch, + getState, + rejectWithValue, + extra: {searchAPIClient, validatePayload}, + } + ) => { + validatePayload(payload, {id: new StringValue({emptyAllowed: false})}); + + const response = await searchAPIClient.plan(buildPlanRequest(getState())); + if (isErrorResponse(response)) { + return rejectWithValue(response.error); + } + + const {redirectionUrl} = new ExecutionPlan(response.success); + + if (redirectionUrl) { + dispatch(logRedirect(redirectionUrl)); + } + + return redirectionUrl || ''; + } +); + +const logRedirect = (url: string) => + makeAnalyticsAction( + 'analytics/standaloneSearchBox/redirect', + AnalyticsType.Custom, + (client) => client.logTriggerRedirect({redirectedTo: url}) + )(); + +export const buildPlanRequest = ( + state: StateNeededForRedirect +): PlanRequest => { + return { + accessToken: state.configuration.accessToken, + organizationId: state.configuration.organizationId, + url: state.configuration.search.apiBaseUrl, + locale: state.configuration.search.locale, + timezone: state.configuration.search.timezone, + q: state.query.q, + ...(state.context && {context: state.context.contextValues}), + ...(state.pipeline && {pipeline: state.pipeline}), + ...(state.searchHub && {searchHub: state.searchHub}), + }; +}; diff --git a/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-slice.test.ts b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-slice.test.ts new file mode 100644 index 00000000000..a8b6ef61125 --- /dev/null +++ b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-slice.test.ts @@ -0,0 +1,150 @@ +import {buildMockOmniboxSuggestionMetadata} from '../../test/mock-omnibox-suggestion-metadata'; +import {buildMockStandaloneSearchBoxEntry} from '../../test/mock-standalone-search-box-entry'; +import { + fetchRedirectUrl, + registerStandaloneSearchBox, + updateAnalyticsToOmniboxFromLink, + updateAnalyticsToSearchFromLink, +} from './standalone-search-box-set-actions'; +import {standaloneSearchBoxSetReducer} from './standalone-search-box-set-slice'; +import {StandaloneSearchBoxSetState} from './standalone-search-box-set-state'; + +describe('standalone search box slice', () => { + const id = '1'; + let state: StandaloneSearchBoxSetState; + + beforeEach(() => { + state = { + [id]: buildMockStandaloneSearchBoxEntry(), + }; + }); + + it('initializes the state to an empty object', () => { + const finalState = standaloneSearchBoxSetReducer(undefined, {type: ''}); + expect(finalState).toEqual({}); + }); + + describe('#registerStandaloneSearchBox', () => { + it('when the id does not exist, it registers the payload in the set', () => { + const id = 'new id'; + const redirectionUrl = 'url'; + const action = registerStandaloneSearchBox({id, redirectionUrl}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]).toEqual( + buildMockStandaloneSearchBoxEntry({ + defaultRedirectionUrl: redirectionUrl, + }) + ); + }); + + it('when the id exists, it does not register the payload', () => { + const action = registerStandaloneSearchBox({id, redirectionUrl: 'url'}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(state[id]).toEqual(finalState[id]); + }); + }); + + describe('#fetchRedirectUrl.pending', () => { + it('when the id exists, it sets isLoading to true', () => { + const action = fetchRedirectUrl.pending('', {id}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.isLoading).toBe(true); + }); + + it('when the id does not exist, it does not throw', () => { + const action = fetchRedirectUrl.pending('', {id: 'invalid'}); + expect(() => standaloneSearchBoxSetReducer(state, action)).not.toThrow(); + }); + }); + + describe('#fetchRedirectUrl.rejected', () => { + it('when the id exists, it sets isLoading to false', () => { + state[id]!.isLoading = true; + + const action = fetchRedirectUrl.rejected(null, '', {id}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.isLoading).toBe(false); + }); + + it('when the id does not exist, it does not throw', () => { + const action = fetchRedirectUrl.rejected(null, '', {id: 'invalid'}); + expect(() => standaloneSearchBoxSetReducer(state, action)).not.toThrow(); + }); + }); + + describe('#fetchRedirectUrl.fulfilled', () => { + it(`when the id exists, and the payload url is non-empty string, + it sets #redirectTo to the payload value`, () => { + const url = '/search-page'; + const action = fetchRedirectUrl.fulfilled(url, '', {id}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.redirectTo).toBe(url); + }); + + it(`when the id exists, and the payload url is an empty string, + it sets #redirectTo to the default redirection url`, () => { + const url = '/search-page'; + state[id] = buildMockStandaloneSearchBoxEntry({ + defaultRedirectionUrl: url, + }); + + const action = fetchRedirectUrl.fulfilled('', '', {id}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.redirectTo).toBe(url); + }); + + it('sets #isLoading to false', () => { + state[id] = buildMockStandaloneSearchBoxEntry({isLoading: true}); + + const action = fetchRedirectUrl.fulfilled('', '', {id}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.isLoading).toBe(false); + }); + + it('when the id does not exist, it does not throw', () => { + const action = fetchRedirectUrl.fulfilled('', '', {id: 'invalid url'}); + expect(() => standaloneSearchBoxSetReducer(state, action)).not.toThrow(); + }); + }); + + describe('#updateAnalyticsToSearchFromLink', () => { + it('when the id exists, it sets the analytics cause to searchFromLink', () => { + const action = updateAnalyticsToSearchFromLink({id}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.analytics.cause).toBe('searchFromLink'); + }); + + it('when the id does not exist, it does not throw', () => { + const action = updateAnalyticsToSearchFromLink({id: 'invalid id'}); + expect(() => standaloneSearchBoxSetReducer(state, action)).not.toThrow(); + }); + }); + + describe('#updateAnalyticsToOmniboxFromLink', () => { + it('when the id exists, it sets the analytics cause to omniboxFromLink and stores the payload metadata', () => { + const metadata = buildMockOmniboxSuggestionMetadata(); + const action = updateAnalyticsToOmniboxFromLink({id, metadata}); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.analytics.cause).toBe('omniboxFromLink'); + expect(finalState[id]!.analytics.metadata).toEqual(metadata); + }); + + it('when the id does not exist, it does not throw', () => { + const metadata = buildMockOmniboxSuggestionMetadata(); + const action = updateAnalyticsToOmniboxFromLink({ + id: 'invalid id', + metadata, + }); + expect(() => standaloneSearchBoxSetReducer(state, action)).not.toThrow(); + }); + }); +}); diff --git a/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-slice.ts b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-slice.ts new file mode 100644 index 00000000000..a55d8bd72c7 --- /dev/null +++ b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-slice.ts @@ -0,0 +1,88 @@ +import {createReducer} from '@reduxjs/toolkit'; +import { + fetchRedirectUrl, + registerStandaloneSearchBox, + updateAnalyticsToOmniboxFromLink, + updateAnalyticsToSearchFromLink, +} from './standalone-search-box-set-actions'; +import { + getStandaloneSearchBoxSetInitialState, + StandaloneSearchBoxEntry, +} from './standalone-search-box-set-state'; + +export const standaloneSearchBoxSetReducer = createReducer( + getStandaloneSearchBoxSetInitialState(), + (builder) => + builder + .addCase(registerStandaloneSearchBox, (state, action) => { + const {id, redirectionUrl} = action.payload; + + if (id in state) { + return; + } + + state[id] = buildStandaloneSearchBoxEntry(redirectionUrl); + }) + .addCase(fetchRedirectUrl.pending, (state, action) => { + const searchBox = state[action.meta.arg.id]; + + if (!searchBox) { + return; + } + + searchBox.isLoading = true; + }) + .addCase(fetchRedirectUrl.fulfilled, (state, action) => { + const url = action.payload; + const searchBox = state[action.meta.arg.id]; + + if (!searchBox) { + return; + } + + searchBox.redirectTo = url ? url : searchBox.defaultRedirectionUrl; + searchBox.isLoading = false; + }) + .addCase(fetchRedirectUrl.rejected, (state, action) => { + const searchBox = state[action.meta.arg.id]; + + if (!searchBox) { + return; + } + + searchBox.isLoading = false; + }) + .addCase(updateAnalyticsToSearchFromLink, (state, action) => { + const searchBox = state[action.payload.id]; + + if (!searchBox) { + return; + } + + searchBox.analytics.cause = 'searchFromLink'; + }) + .addCase(updateAnalyticsToOmniboxFromLink, (state, action) => { + const searchBox = state[action.payload.id]; + + if (!searchBox) { + return; + } + + searchBox.analytics.cause = 'omniboxFromLink'; + searchBox.analytics.metadata = action.payload.metadata; + }) +); + +function buildStandaloneSearchBoxEntry( + defaultRedirectionUrl: string +): StandaloneSearchBoxEntry { + return { + defaultRedirectionUrl, + redirectTo: '', + isLoading: false, + analytics: { + cause: '', + metadata: null, + }, + }; +} diff --git a/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-state.ts b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-state.ts new file mode 100644 index 00000000000..20f6c51d4f0 --- /dev/null +++ b/packages/headless/src/features/standalone-search-box-set/standalone-search-box-set-state.ts @@ -0,0 +1,37 @@ +import {OmniboxSuggestionMetadata} from '../query-suggest/query-suggest-analytics-actions'; + +export type StandaloneSearchBoxSetState = Record< + string, + StandaloneSearchBoxEntry | undefined +>; + +export type StandaloneSearchBoxEntry = { + defaultRedirectionUrl: string; + analytics: StandaloneSearchBoxAnalytics; + redirectTo: string; + isLoading: boolean; +}; + +export type StandaloneSearchBoxAnalytics = + | InitialData + | SearchFromLinkData + | OmniboxFromLinkData; + +interface InitialData { + cause: ''; + metadata: null; +} + +interface SearchFromLinkData { + cause: 'searchFromLink'; + metadata: null; +} + +interface OmniboxFromLinkData { + cause: 'omniboxFromLink'; + metadata: OmniboxSuggestionMetadata; +} + +export function getStandaloneSearchBoxSetInitialState(): StandaloneSearchBoxSetState { + return {}; +} diff --git a/packages/headless/src/state/search-app-state.ts b/packages/headless/src/state/search-app-state.ts index 05f9ff84784..1eb3890b8f5 100644 --- a/packages/headless/src/state/search-app-state.ts +++ b/packages/headless/src/state/search-app-state.ts @@ -28,6 +28,7 @@ import { FoldingSection, TriggerSection, QuestionAnsweringSection, + StandaloneSearchBoxSection, } from './state-sections'; export type SearchParametersState = FacetSection & @@ -50,6 +51,7 @@ export type SearchAppState = SearchParametersState & FacetSearchSection & CategoryFacetSearchSection & RedirectionSection & + StandaloneSearchBoxSection & QuerySuggestionSection & SearchSection & ResultPreviewSection & diff --git a/packages/headless/src/state/state-sections.ts b/packages/headless/src/state/state-sections.ts index 6735058608b..abca688c417 100644 --- a/packages/headless/src/state/state-sections.ts +++ b/packages/headless/src/state/state-sections.ts @@ -26,6 +26,7 @@ import {HistoryState} from '../features/history/history-state'; import {FoldingState} from '../features/folding/folding-state'; import {TriggerState} from '../features/triggers/triggers-state'; import {QuestionAnsweringState} from '../features/question-answering/question-answering-state'; +import {StandaloneSearchBoxSetState} from '../features/standalone-search-box-set/standalone-search-box-set-state'; export interface QuerySection { /** @@ -158,10 +159,18 @@ export interface CategoryFacetSearchSection { export interface RedirectionSection { /** * The URL redirection triggered by the preprocessed query. + * @deprecated - The `redirection` property will be removed in the future. Please use `standaloneSearchBoxSet` instead. */ redirection: RedirectionState; } +export interface StandaloneSearchBoxSection { + /** + * The set of standalone search boxes. + */ + standaloneSearchBoxSet: StandaloneSearchBoxSetState; +} + export interface QuerySuggestionSection { /** * The query suggestions returned by Coveo ML. diff --git a/packages/headless/src/test/mock-engine.ts b/packages/headless/src/test/mock-engine.ts index 82dd7f5653e..773e06254cd 100644 --- a/packages/headless/src/test/mock-engine.ts +++ b/packages/headless/src/test/mock-engine.ts @@ -58,6 +58,7 @@ export function buildMockSearchAppEngine( return { ...engine, executeFirstSearch: jest.fn(), + executeFirstSearchAfterStandaloneSearchBoxRedirect: jest.fn(), }; } diff --git a/packages/headless/src/test/mock-omnibox-suggestion-metadata.ts b/packages/headless/src/test/mock-omnibox-suggestion-metadata.ts new file mode 100644 index 00000000000..2aa59852b36 --- /dev/null +++ b/packages/headless/src/test/mock-omnibox-suggestion-metadata.ts @@ -0,0 +1,13 @@ +import {OmniboxSuggestionMetadata} from '../features/query-suggest/query-suggest-analytics-actions'; + +export function buildMockOmniboxSuggestionMetadata( + config: Partial = {} +): OmniboxSuggestionMetadata { + return { + partialQueries: [], + partialQuery: '', + suggestionRanking: -1, + suggestions: [], + ...config, + }; +} diff --git a/packages/headless/src/test/mock-standalone-search-box-entry.ts b/packages/headless/src/test/mock-standalone-search-box-entry.ts new file mode 100644 index 00000000000..3e43e5526bb --- /dev/null +++ b/packages/headless/src/test/mock-standalone-search-box-entry.ts @@ -0,0 +1,16 @@ +import {StandaloneSearchBoxEntry} from '../features/standalone-search-box-set/standalone-search-box-set-state'; + +export function buildMockStandaloneSearchBoxEntry( + config: Partial = {} +): StandaloneSearchBoxEntry { + return { + analytics: { + cause: '', + metadata: null, + }, + defaultRedirectionUrl: '', + isLoading: false, + redirectTo: '', + ...config, + }; +} diff --git a/packages/headless/src/test/mock-state.ts b/packages/headless/src/test/mock-state.ts index b14ba0979a9..efa9fed3e8e 100644 --- a/packages/headless/src/test/mock-state.ts +++ b/packages/headless/src/test/mock-state.ts @@ -27,6 +27,7 @@ import {getResultPreviewInitialState} from '../features/result-preview/result-pr import {getFoldingInitialState} from '../features/folding/folding-state'; import {getTriggerInitialState} from '../features/triggers/triggers-state'; import {getQuestionAnsweringInitialState} from '../features/question-answering/question-answering-state'; +import {getStandaloneSearchBoxSetInitialState} from '../features/standalone-search-box-set/standalone-search-box-set-state'; export function createMockState( config: Partial = {} @@ -61,6 +62,7 @@ export function createMockState( folding: getFoldingInitialState(), triggers: getTriggerInitialState(), questionAnswering: getQuestionAnsweringInitialState(), + standaloneSearchBoxSet: getStandaloneSearchBoxSetInitialState(), ...config, }; } diff --git a/packages/samples/headless-react/src/App.tsx b/packages/samples/headless-react/src/App.tsx index d32c658a456..12221bc98f8 100644 --- a/packages/samples/headless-react/src/App.tsx +++ b/packages/samples/headless-react/src/App.tsx @@ -42,6 +42,9 @@ function App(props: SearchPageProps) { + + + diff --git a/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box-storage-key.ts b/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box-storage-key.ts new file mode 100644 index 00000000000..45904b536d5 --- /dev/null +++ b/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box-storage-key.ts @@ -0,0 +1 @@ +export const standaloneSearchBoxStorageKey = 'coveo-standalone-search-box'; diff --git a/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box.class.tsx b/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box.class.tsx index 6d9c9d488d9..cac1fea3cd5 100644 --- a/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box.class.tsx +++ b/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box.class.tsx @@ -6,6 +6,7 @@ import { Unsubscribe, } from '@coveo/headless'; import {AppContext} from '../../context/engine'; +import {standaloneSearchBoxStorageKey} from './standalone-search-box-storage-key'; export class StandaloneSearchBox extends Component< {}, @@ -19,7 +20,7 @@ export class StandaloneSearchBox extends Component< componentDidMount() { this.controller = buildStandaloneSearchBox(this.context.engine!, { - options: {redirectionUrl: 'https://mywebsite.com/search'}, + options: {redirectionUrl: '/search-page'}, }); this.updateState(); @@ -31,10 +32,15 @@ export class StandaloneSearchBox extends Component< } componentDidUpdate() { - if (!this.state?.redirectTo) { + const {redirectTo, value, analytics} = this.state; + + if (!redirectTo) { return; } - window.location.href = this.state.redirectTo; + + const data = JSON.stringify({value, analytics}); + localStorage.setItem(standaloneSearchBoxStorageKey, data); + window.location.href = redirectTo; } private updateState() { diff --git a/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box.fn.tsx b/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box.fn.tsx index 097af1359f3..88e461be0a7 100644 --- a/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box.fn.tsx +++ b/packages/samples/headless-react/src/components/standalone-search-box/standalone-search-box.fn.tsx @@ -1,14 +1,16 @@ -import {useEffect, useState, FunctionComponent} from 'react'; -import {StandaloneSearchBox as HeadlessStandaloneSearchBox} from '@coveo/headless'; - -interface StandaloneSearchBoxProps { - controller: HeadlessStandaloneSearchBox; -} - -export const StandaloneSearchBox: FunctionComponent = ( +import {useEffect, useState, FunctionComponent, useContext} from 'react'; +import { + buildStandaloneSearchBox, + StandaloneSearchBoxOptions, +} from '@coveo/headless'; +import {AppContext} from '../../context/engine'; +import {standaloneSearchBoxStorageKey} from './standalone-search-box-storage-key'; + +export const StandaloneSearchBox: FunctionComponent = ( props ) => { - const {controller} = props; + const {engine} = useContext(AppContext); + const controller = buildStandaloneSearchBox(engine!, {options: props}); const [state, setState] = useState(controller.state); useEffect(() => controller.subscribe(() => setState(controller.state)), []); @@ -22,7 +24,10 @@ export const StandaloneSearchBox: FunctionComponent = } if (state.redirectTo) { - window.location.href = state.redirectTo; + const {redirectTo, value, analytics} = state; + const data = JSON.stringify({value, analytics}); + localStorage.setItem(standaloneSearchBoxStorageKey, data); + window.location.href = redirectTo; return null; } @@ -51,10 +56,6 @@ export const StandaloneSearchBox: FunctionComponent = /** * ```tsx - * const controller = buildStandaloneSearchBox(engine, { - * options: {redirectionUrl: 'https://mywebsite.com/search'}, - * }); - * - * ; + * ; * ``` */ diff --git a/packages/samples/headless-react/src/pages/SearchPage.tsx b/packages/samples/headless-react/src/pages/SearchPage.tsx index 5f0fb31861e..8a1303a5967 100644 --- a/packages/samples/headless-react/src/pages/SearchPage.tsx +++ b/packages/samples/headless-react/src/pages/SearchPage.tsx @@ -116,11 +116,14 @@ import { buildSmartSnippet, SmartSnippetQuestionsList as HeadlessSmartSnippetQuestionsList, buildSmartSnippetQuestionsList, + loadQueryActions, + StandaloneSearchBoxAnalytics, } from '@coveo/headless'; import {bindUrlManager} from '../components/url-manager/url-manager'; import {setContext} from '../components/context/context'; import {dateRanges} from '../components/date-facet/date-utils'; import {relativeDateRanges} from '../components/relative-date-facet/relative-date-utils'; +import {standaloneSearchBoxStorageKey} from '../components/standalone-search-box/standalone-search-box-storage-key'; declare global { interface Window { @@ -357,9 +360,29 @@ export class SearchPage extends Component { return; } + const data = localStorage.getItem(standaloneSearchBoxStorageKey); + + if (data) { + this.executeFirstSearchAfterStandaloneSearchBoxRedirect(data); + return; + } + this.engine.executeFirstSearch(); } + private executeFirstSearchAfterStandaloneSearchBoxRedirect(data: string) { + localStorage.removeItem(standaloneSearchBoxStorageKey); + const parsed: { + value: string; + analytics: StandaloneSearchBoxAnalytics; + } = JSON.parse(data); + const {value, analytics} = parsed; + const {updateQuery} = loadQueryActions(this.engine); + + this.engine.dispatch(updateQuery({q: value})); + this.engine.executeFirstSearchAfterStandaloneSearchBoxRedirect(analytics); + } + private updateAnalyticsContext() { setContext(this.engine, '30-45', ['sports', 'camping', 'electronics']); } diff --git a/packages/samples/headless-react/src/pages/StandaloneSearchBoxPage.tsx b/packages/samples/headless-react/src/pages/StandaloneSearchBoxPage.tsx index c223a7a9b63..ac7fb649c00 100644 --- a/packages/samples/headless-react/src/pages/StandaloneSearchBoxPage.tsx +++ b/packages/samples/headless-react/src/pages/StandaloneSearchBoxPage.tsx @@ -1,6 +1,5 @@ import { buildSearchEngine, - buildStandaloneSearchBox, getSampleSearchEngineConfiguration, } from '@coveo/headless'; import {StandaloneSearchBox} from '../components/standalone-search-box/standalone-search-box.class'; @@ -14,15 +13,11 @@ export function StandaloneSearchBoxPage() { configuration: getSampleSearchEngineConfiguration(), }); - const searchBox = buildStandaloneSearchBox(engine, { - options: {redirectionUrl: 'https://mywebsite.com/search'}, - }); - return (
- +
);