diff --git a/packages/atomic/src/components/commerce/atomic-commerce-search-box/atomic-commerce-search-box.tsx b/packages/atomic/src/components/commerce/atomic-commerce-search-box/atomic-commerce-search-box.tsx index 2e5f573745d..949e299bf94 100644 --- a/packages/atomic/src/components/commerce/atomic-commerce-search-box/atomic-commerce-search-box.tsx +++ b/packages/atomic/src/components/commerce/atomic-commerce-search-box/atomic-commerce-search-box.tsx @@ -203,22 +203,46 @@ export class AtomicCommerceSearchBox @AriaLiveRegion('search-suggestions', true) protected suggestionsAriaMessage!: string; + public disconnectedCallback = () => {}; + + private isStandaloneSearchBox( + searchBox: SearchBox | StandaloneSearchBox + ): searchBox is StandaloneSearchBox { + return 'redirectTo' in searchBox; + } public initialize() { - this.id = randomID('atomic-commerce-search-box-'); + this.id ??= randomID('atomic-commerce-search-box-'); + + this.initializeSearchboxController(); + this.initializeSuggestionManager(); + } + + private updateRedirectionUrl() { + if (this.isStandaloneSearchBox(this.searchBox) && this.redirectionUrl) { + this.searchBox.updateRedirectUrl(this.redirectionUrl); + } else { + this.registerNewSearchBoxController(); + } + } + + private registerNewSearchBoxController() { + this.disconnectedCallback(); + this.initialize(); + } + private initializeSearchboxController() { this.searchBox = this.redirectionUrl ? buildStandaloneSearchBox(this.bindings.engine, { options: { ...this.searchBoxOptions, redirectionUrl: this.redirectionUrl, + overwrite: true, }, }) : buildSearchBox(this.bindings.engine, { options: this.searchBoxOptions, }); - - this.initializeSuggestionManager(); } public componentWillUpdate() { @@ -271,7 +295,7 @@ export class AtomicCommerceSearchBox this.suggestionManager.forceUpdate(); } - public componentWillRender() { + private registerSearchboxSuggestionEvents() { this.searchBoxSuggestionEventsQueue.forEach((evt) => { this.suggestionManager.registerSuggestionsFromEvent( evt, @@ -283,7 +307,7 @@ export class AtomicCommerceSearchBox @Watch('redirectionUrl') watchRedirectionUrl() { - this.initialize(); + this.updateRedirectionUrl(); } private initializeSuggestionManager() { @@ -324,8 +348,8 @@ export class AtomicCommerceSearchBox return { ...this.bindings, id: this.id, - isStandalone: !!this.redirectionUrl, - searchBoxController: this.searchBox, + isStandalone: () => !!this.redirectionUrl, + searchBoxController: () => this.searchBox, numberOfQueries: this.numberOfQueries, clearFilters: this.clearFilters, }; @@ -675,6 +699,9 @@ export class AtomicCommerceSearchBox const isDisabled = this.isSearchDisabledForEndUser( this.searchBoxState.value ); + if (!this.suggestionManager.suggestions.length) { + this.registerSearchboxSuggestionEvents(); + } return ( diff --git a/packages/atomic/src/components/commerce/atomic-commerce-search-box/e2e/atomic-commerce-search-box.e2e.ts b/packages/atomic/src/components/commerce/atomic-commerce-search-box/e2e/atomic-commerce-search-box.e2e.ts index 56cb9dc3add..e28c87f6c32 100644 --- a/packages/atomic/src/components/commerce/atomic-commerce-search-box/e2e/atomic-commerce-search-box.e2e.ts +++ b/packages/atomic/src/components/commerce/atomic-commerce-search-box/e2e/atomic-commerce-search-box.e2e.ts @@ -274,6 +274,35 @@ test.describe('with instant results & query suggestions', () => { await expect(searchBox.searchInput).toHaveValue('surf'); }); }); + + test.describe('after updating the redirect-url attribute', () => { + test.beforeEach(async ({searchBox}) => { + await searchBox.component.evaluate((node) => + node.setAttribute( + 'redirection-url', + './iframe.html?id=atomic-commerce-search-box--in-page&viewMode=story&args=enable-query-syntax:!true;suggestion-timeout:5000' + ) + ); + }); + + test('should redirect to the specified url after selecting a suggestion', async ({ + page, + searchBox, + }) => { + const suggestionText = await searchBox + .searchSuggestions() + .first() + .textContent(); + + expect(suggestionText).not.toBeNull(); + + await searchBox.searchSuggestions().first().click(); + await page.waitForURL( + '**/iframe.html?id=atomic-commerce-search-box--in-page*' + ); + await expect(searchBox.searchInput).toHaveValue(suggestionText ?? ''); + }); + }); }); }); diff --git a/packages/atomic/src/components/commerce/atomic-commerce-search-box/e2e/page-object.ts b/packages/atomic/src/components/commerce/atomic-commerce-search-box/e2e/page-object.ts index 9c0763a49c6..03023dfaae6 100644 --- a/packages/atomic/src/components/commerce/atomic-commerce-search-box/e2e/page-object.ts +++ b/packages/atomic/src/components/commerce/atomic-commerce-search-box/e2e/page-object.ts @@ -6,6 +6,10 @@ export class SearchBoxPageObject extends BasePageObject<'atomic-commerce-search- super(page, 'atomic-commerce-search-box'); } + get component() { + return this.page.locator('atomic-commerce-search-box'); + } + get submitButton() { return this.page.getByLabel('Search', {exact: true}); } diff --git a/packages/atomic/src/components/commerce/product-template/atomic-product-template.new.stories.tsx b/packages/atomic/src/components/commerce/product-template/atomic-product-template.new.stories.tsx index 59ab137fb36..2d169ea774b 100644 --- a/packages/atomic/src/components/commerce/product-template/atomic-product-template.new.stories.tsx +++ b/packages/atomic/src/components/commerce/product-template/atomic-product-template.new.stories.tsx @@ -94,7 +94,7 @@ export const InASearchBoxInstantProducts: Story = { commerceInterfaceDecorator, ], play: async (context) => { - initializeCommerceInterface(context); + await initializeCommerceInterface(context); const {canvasElement, step} = context; const canvas = within(canvasElement); await step('Click Searchbox', async () => { diff --git a/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-instant-products/atomic-commerce-search-box-instant-products.tsx b/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-instant-products/atomic-commerce-search-box-instant-products.tsx index d644140fc69..8a871088e16 100644 --- a/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-instant-products/atomic-commerce-search-box-instant-products.tsx +++ b/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-instant-products/atomic-commerce-search-box-instant-products.tsx @@ -176,10 +176,10 @@ export class AtomicCommerceSearchBoxInstantProducts content: , onSelect: () => { this.bindings.clearSuggestions(); - this.bindings.searchBoxController.updateText( - this.instantProducts.state.query - ); - this.bindings.searchBoxController.submit(); + this.bindings + .searchBoxController() + .updateText(this.instantProducts.state.query); + this.bindings.searchBoxController().submit(); }, }); } @@ -226,7 +226,7 @@ export class AtomicCommerceSearchBoxInstantProducts private onSuggestedQueryChange() { if ( !this.bindings.getSuggestionElements().length && - !this.bindings.searchBoxController.state.value + !this.bindings.searchBoxController().state.value ) { console.warn( "There doesn't seem to be any query suggestions configured. Make sure to include either an atomic-commerce-search-box-query-suggestions or atomic-commerce-search-box-recent-queries in your search box in order to see some instant products." diff --git a/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-query-suggestions/atomic-commerce-search-box-query-suggestions.tsx b/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-query-suggestions/atomic-commerce-search-box-query-suggestions.tsx index fe892b4f80d..ab5f5add258 100644 --- a/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-query-suggestions/atomic-commerce-search-box-query-suggestions.tsx +++ b/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-query-suggestions/atomic-commerce-search-box-query-suggestions.tsx @@ -87,15 +87,16 @@ export class AtomicCommerceSearchBoxQuerySuggestions { } private renderItems(): SearchBoxSuggestionElement[] { - const hasQuery = this.bindings.searchBoxController.state.value !== ''; + const hasQuery = this.bindings.searchBoxController().state.value !== ''; const max = hasQuery ? this.maxWithQuery : this.maxWithoutQuery; - return this.bindings.searchBoxController.state.suggestions - .slice(0, max) + return this.bindings + .searchBoxController() + .state.suggestions.slice(0, max) .map((suggestion) => this.renderItem(suggestion)); } private renderItem(suggestion: Suggestion) { - const hasQuery = this.bindings.searchBoxController.state.value !== ''; + const hasQuery = this.bindings.searchBoxController().state.value !== ''; const partialItem = getPartialSearchBoxSuggestionElement( suggestion, this.bindings.i18n @@ -114,7 +115,9 @@ export class AtomicCommerceSearchBoxQuerySuggestions { ), onSelect: () => { - this.bindings.searchBoxController.selectSuggestion(suggestion.rawValue); + this.bindings + .searchBoxController() + .selectSuggestion(suggestion.rawValue); }, }; } diff --git a/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-recent-queries/atomic-commerce-search-box-recent-queries.tsx b/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-recent-queries/atomic-commerce-search-box-recent-queries.tsx index b10687db4d6..9d7f67c8e9e 100644 --- a/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-recent-queries/atomic-commerce-search-box-recent-queries.tsx +++ b/packages/atomic/src/components/commerce/search-box-suggestions/atomic-commerce-search-box-recent-queries/atomic-commerce-search-box-recent-queries.tsx @@ -123,7 +123,7 @@ export class AtomicCommerceSearchBoxRecentQueries { return []; } - const query = this.bindings.searchBoxController.state.value; + const query = this.bindings.searchBoxController().state.value; const hasQuery = query !== ''; const max = hasQuery ? this.maxWithQuery : this.maxWithoutQuery; const filteredQueries = this.recentQueriesList.state.queries @@ -157,7 +157,7 @@ export class AtomicCommerceSearchBoxRecentQueries { } private renderItem(value: string): SearchBoxSuggestionElement { - const query = this.bindings.searchBoxController.state.value; + const query = this.bindings.searchBoxController().state.value; const partialItem = getPartialRecentQueryElement(value, this.bindings.i18n); return { ...partialItem, @@ -169,9 +169,9 @@ export class AtomicCommerceSearchBoxRecentQueries { ), onSelect: () => { - if (this.bindings.isStandalone) { - this.bindings.searchBoxController.updateText(value); - this.bindings.searchBoxController.submit(); + if (this.bindings.isStandalone()) { + this.bindings.searchBoxController().updateText(value); + this.bindings.searchBoxController().submit(); return; } diff --git a/packages/atomic/src/components/common/suggestions/suggestions-common.ts b/packages/atomic/src/components/common/suggestions/suggestions-common.ts index de127841a3b..5a12198f1fa 100644 --- a/packages/atomic/src/components/common/suggestions/suggestions-common.ts +++ b/packages/atomic/src/components/common/suggestions/suggestions-common.ts @@ -141,11 +141,11 @@ export type SearchBoxSuggestionsBindings< /** * Whether the search box is [standalone](https://docs.coveo.com/en/atomic/latest/usage/ssb/). */ - isStandalone: boolean; + isStandalone(): boolean; /** * The search box headless controller. */ - searchBoxController: SearchBoxController; + searchBoxController(): SearchBoxController; /** * The number of queries to display when the user interacts with the search box. */ diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.new.stories.tsx b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.new.stories.tsx index 65ae5f5f840..9268d8c69e0 100644 --- a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.new.stories.tsx +++ b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.new.stories.tsx @@ -1,7 +1,11 @@ import {parameters} from '@coveo/atomic/storybookUtils/common/common-meta-parameters'; import {renderComponent} from '@coveo/atomic/storybookUtils/common/render-component'; -import {wrapInSearchInterface} from '@coveo/atomic/storybookUtils/search/search-interface-wrapper'; +import { + playExecuteFirstSearch, + wrapInSearchInterface, +} from '@coveo/atomic/storybookUtils/search/search-interface-wrapper'; import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import {html} from 'lit/static-html.js'; const {decorator, play} = wrapInSearchInterface( { @@ -43,3 +47,385 @@ export const RichSearchBox: Story = { >`, }, }; + +export const InPage: Story = { + name: 'In a page', + decorators: [ + (story) => + html` +
+ + ${story()} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
`, + ], + play: async (context) => { + await play(context); + await playExecuteFirstSearch(context); + }, +}; diff --git a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx index 154410738de..f30cc025266 100644 --- a/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx +++ b/packages/atomic/src/components/search/atomic-search-box/atomic-search-box.tsx @@ -237,28 +237,44 @@ export class AtomicSearchBox implements InitializableComponent { @AriaLiveRegion('search-suggestions', true) protected suggestionsAriaMessage!: string; + private isStandaloneSearchBox( + searchBox: SearchBox | StandaloneSearchBox + ): searchBox is StandaloneSearchBox { + return 'redirectTo' in searchBox; + } + public initialize() { - this.id = randomID('atomic-search-box-'); + this.id ??= randomID('atomic-search-box-'); - if (!this.textarea) { - this.bindings.engine.logger.warn( - 'As of Atomic version 3.0.0, the searchbox will be enabled as a text area by default. To remove this warning, set textarea="true" on the search box.', - this.host - ); + this.initializeSearchboxController(); + this.initializeSuggestionManager(); + } + + private updateRedirectionUrl() { + if (this.isStandaloneSearchBox(this.searchBox) && this.redirectionUrl) { + this.searchBox.updateRedirectUrl(this.redirectionUrl); + } else { + this.registerNewSearchBoxController(); } + } + private registerNewSearchBoxController() { + this.disconnectedCallback(); + this.initialize(); + } + + private initializeSearchboxController() { this.searchBox = this.redirectionUrl ? buildStandaloneSearchBox(this.bindings.engine, { options: { ...this.searchBoxOptions, redirectionUrl: this.redirectionUrl, + overwrite: true, }, }) : buildSearchBox(this.bindings.engine, { options: this.searchBoxOptions, }); - - this.initializeSuggestionManager(); } public componentWillUpdate() { @@ -289,6 +305,8 @@ export class AtomicSearchBox implements InitializableComponent { } } + public disconnectedCallback = () => {}; + @Listen('atomic/searchBoxSuggestion/register') public registerSuggestions( event: CustomEvent< @@ -305,7 +323,7 @@ export class AtomicSearchBox implements InitializableComponent { } } - public componentWillRender() { + private registerSearchboxSuggestionEvents() { this.searchBoxSuggestionEventsQueue.forEach((evt) => { this.suggestionManager.registerSuggestionsFromEvent( evt, @@ -317,7 +335,7 @@ export class AtomicSearchBox implements InitializableComponent { @Watch('redirectionUrl') watchRedirectionUrl() { - this.initialize(); + this.updateRedirectionUrl(); } private initializeSuggestionManager() { @@ -358,8 +376,8 @@ export class AtomicSearchBox implements InitializableComponent { return { ...this.bindings, id: this.id, - isStandalone: !!this.redirectionUrl, - searchBoxController: this.searchBox, + isStandalone: () => !!this.redirectionUrl, + searchBoxController: () => this.searchBox, numberOfQueries: this.numberOfQueries, clearFilters: this.clearFilters, }; @@ -710,6 +728,9 @@ export class AtomicSearchBox implements InitializableComponent { const isDisabled = this.isSearchDisabledForEndUser( this.searchBoxState.value ); + if (!this.suggestionManager.suggestions.length) { + this.registerSearchboxSuggestionEvents(); + } return ( diff --git a/packages/atomic/src/components/search/atomic-search-box/e2e/atomic-search-box.e2e.ts b/packages/atomic/src/components/search/atomic-search-box/e2e/atomic-search-box.e2e.ts index 3f66cbed78b..71d19516183 100644 --- a/packages/atomic/src/components/search/atomic-search-box/e2e/atomic-search-box.e2e.ts +++ b/packages/atomic/src/components/search/atomic-search-box/e2e/atomic-search-box.e2e.ts @@ -126,6 +126,33 @@ test.describe('with instant results & query suggestions', () => { await expect(searchBox.searchInput).toHaveValue('shoe'); }); }); + + test.describe('after updating the redirect-url attribute', () => { + test.beforeEach(async ({searchBox}) => { + await searchBox.component.evaluate((node) => + node.setAttribute( + 'redirection-url', + './iframe.html?id=atomic-search-box--in-page&viewMode=story&args=enable-query-syntax:!true;suggestion-timeout:5000' + ) + ); + }); + + test('should redirect to the specified url after selecting a suggestion', async ({ + page, + searchBox, + }) => { + const suggestionText = await searchBox + .searchSuggestions() + .first() + .textContent(); + + expect(suggestionText).not.toBeNull(); + + await searchBox.searchSuggestions().first().click(); + await page.waitForURL('**/iframe.html?id=atomic-search-box--in-page*'); + await expect(searchBox.searchInput).toHaveValue(suggestionText ?? ''); + }); + }); }); }); diff --git a/packages/atomic/src/components/search/atomic-search-box/e2e/page-object.ts b/packages/atomic/src/components/search/atomic-search-box/e2e/page-object.ts index 21d70ac95a6..db5ee4cd0c2 100644 --- a/packages/atomic/src/components/search/atomic-search-box/e2e/page-object.ts +++ b/packages/atomic/src/components/search/atomic-search-box/e2e/page-object.ts @@ -6,6 +6,10 @@ export class SearchBoxPageObject extends BasePageObject<'atomic-search-box'> { super(page, 'atomic-search-box'); } + get component() { + return this.page.locator('atomic-search-box'); + } + get submitButton() { return this.page.getByLabel('Search', {exact: true}); } diff --git a/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-instant-results/atomic-search-box-instant-results.tsx b/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-instant-results/atomic-search-box-instant-results.tsx index 7e05b507da5..f1a9a9b0caf 100644 --- a/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-instant-results/atomic-search-box-instant-results.tsx +++ b/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-instant-results/atomic-search-box-instant-results.tsx @@ -178,10 +178,10 @@ export class AtomicSearchBoxInstantResults implements InitializableComponent { content: , onSelect: () => { this.bindings.clearSuggestions(); - this.bindings.searchBoxController.updateText( - this.instantResults.state.q - ); - this.bindings.searchBoxController.submit(); + this.bindings + .searchBoxController() + .updateText(this.instantResults.state.q); + this.bindings.searchBoxController().submit(); }, }); } @@ -223,7 +223,7 @@ export class AtomicSearchBoxInstantResults implements InitializableComponent { private onSuggestedQueryChange() { if ( !this.bindings.getSuggestionElements().length && - !this.bindings.searchBoxController.state.value + !this.bindings.searchBoxController().state.value ) { console.warn( "There doesn't seem to be any query suggestions configured. Make sure to include either an atomic-search-box-query-suggestions or atomic-search-box-recent-queries in your search box in order to see some instant results." diff --git a/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx b/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx index 107793f3ef4..3dbad9a8ca6 100644 --- a/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx +++ b/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx @@ -92,15 +92,16 @@ export class AtomicSearchBoxQuerySuggestions { } private renderItems(): SearchBoxSuggestionElement[] { - const hasQuery = this.bindings.searchBoxController.state.value !== ''; + const hasQuery = this.bindings.searchBoxController().state.value !== ''; const max = hasQuery ? this.maxWithQuery : this.maxWithoutQuery; - return this.bindings.searchBoxController.state.suggestions - .slice(0, max) + return this.bindings + .searchBoxController() + .state.suggestions.slice(0, max) .map((suggestion) => this.renderItem(suggestion)); } private renderItem(suggestion: Suggestion) { - const hasQuery = this.bindings.searchBoxController.state.value !== ''; + const hasQuery = this.bindings.searchBoxController().state.value !== ''; const partialItem = getPartialSearchBoxSuggestionElement( suggestion, this.bindings.i18n @@ -119,7 +120,9 @@ export class AtomicSearchBoxQuerySuggestions { ), onSelect: () => { - this.bindings.searchBoxController.selectSuggestion(suggestion.rawValue); + this.bindings + .searchBoxController() + .selectSuggestion(suggestion.rawValue); }, }; } diff --git a/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx b/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx index b497cfcd7a3..cd79c6b7a16 100644 --- a/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx +++ b/packages/atomic/src/components/search/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx @@ -121,7 +121,7 @@ export class AtomicSearchBoxRecentQueries { return []; } - const query = this.bindings.searchBoxController.state.value; + const query = this.bindings.searchBoxController().state.value; const hasQuery = query !== ''; const max = hasQuery ? this.maxWithQuery : this.maxWithoutQuery; const filteredQueries = this.recentQueriesList.state.queries @@ -155,7 +155,7 @@ export class AtomicSearchBoxRecentQueries { } private renderItem(value: string): SearchBoxSuggestionElement { - const query = this.bindings.searchBoxController.state.value; + const query = this.bindings.searchBoxController().state.value; const partialItem = getPartialRecentQueryElement(value, this.bindings.i18n); return { ...partialItem, @@ -167,9 +167,9 @@ export class AtomicSearchBoxRecentQueries { ), onSelect: () => { - if (this.bindings.isStandalone) { - this.bindings.searchBoxController.updateText(value); - this.bindings.searchBoxController.submit(); + if (this.bindings.isStandalone()) { + this.bindings.searchBoxController().updateText(value); + this.bindings.searchBoxController().submit(); return; } diff --git a/packages/atomic/storybookUtils/search/search-interface-wrapper.tsx b/packages/atomic/storybookUtils/search/search-interface-wrapper.tsx index 6f3f2f665b5..f1b742f1d6e 100644 --- a/packages/atomic/storybookUtils/search/search-interface-wrapper.tsx +++ b/packages/atomic/storybookUtils/search/search-interface-wrapper.tsx @@ -40,3 +40,17 @@ export const wrapInSearchInterface = ( }); }, }); + +export const playExecuteFirstSearch: ( + context: StoryContext +) => Promise = async ({canvasElement, step}) => { + const canvas = within(canvasElement); + + const searchInterface = + await canvas.findByTestId( + 'root-interface' + ); + await step('Execute the first search', async () => { + await searchInterface!.executeFirstSearch(); + }); +}; diff --git a/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box-options.ts b/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box-options.ts index ef9e0e273fb..894393f1ad8 100644 --- a/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box-options.ts +++ b/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box-options.ts @@ -1,4 +1,4 @@ -import {Schema, StringValue} from '@coveo/bueno'; +import {BooleanValue, Schema, StringValue} from '@coveo/bueno'; import { searchBoxOptionDefinitions, SearchBoxOptions, @@ -10,6 +10,10 @@ export interface StandaloneSearchBoxOptions extends SearchBoxOptions { * If a query pipeline redirect is triggered, it will redirect to that Url instead. */ redirectionUrl: string; + /** + * Whether to overwrite the existing standalone search box with the same id. + */ + overwrite?: boolean; } export const standaloneSearchBoxSchema = new Schema< @@ -20,4 +24,7 @@ export const standaloneSearchBoxSchema = new Schema< required: true, emptyAllowed: false, }), + overwrite: new BooleanValue({ + required: false, + }), }); diff --git a/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box.test.ts b/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box.test.ts index 4963566d806..5f73b128e94 100644 --- a/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box.test.ts +++ b/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box.test.ts @@ -80,6 +80,7 @@ describe('headless standalone searchBox', () => { expect(registerStandaloneSearchBox).toHaveBeenCalledWith({ id, redirectionUrl: options.redirectionUrl, + overwrite: false, }); }); diff --git a/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box.ts b/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box.ts index 21fd06b80fd..ece51239576 100644 --- a/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box.ts +++ b/packages/headless/src/controllers/commerce/standalone-search-box/headless-standalone-search-box.ts @@ -8,6 +8,7 @@ import { fetchRedirectUrl, registerStandaloneSearchBox, resetStandaloneSearchBox, + updateStandaloneSearchBoxRedirectionUrl, } from '../../../features/commerce/standalone-search-box-set/standalone-search-box-set-actions'; import {commerceStandaloneSearchBoxSetReducer as commerceStandaloneSearchBoxSet} from '../../../features/commerce/standalone-search-box-set/standalone-search-box-set-slice'; import {querySuggestReducer as querySuggest} from '../../../features/query-suggest/query-suggest-slice'; @@ -37,6 +38,11 @@ export interface StandaloneSearchBox extends SearchBox { * Triggers a redirection. */ submit(): void; + /** + * Updates the redirection url of the standalone search box. + * @param url - The new URL to redirect to. + */ + updateRedirectUrl(url: string): void; /** * Resets the standalone search box state. To be dispatched on single page applications after the redirection has been triggered. */ @@ -77,6 +83,7 @@ export function buildStandaloneSearchBox( id, highlightOptions: {...props.options.highlightOptions}, ...defaultSearchBoxOptions, + ...{overwrite: false}, ...props.options, }; @@ -89,7 +96,11 @@ export function buildStandaloneSearchBox( const searchBox = buildSearchBox(engine, {options}); dispatch( - registerStandaloneSearchBox({id, redirectionUrl: options.redirectionUrl}) + registerStandaloneSearchBox({ + id, + redirectionUrl: options.redirectionUrl, + overwrite: options.overwrite, + }) ); return { @@ -109,6 +120,12 @@ export function buildStandaloneSearchBox( dispatch(resetStandaloneSearchBox({id})); }, + updateRedirectUrl(url: string) { + dispatch( + updateStandaloneSearchBoxRedirectionUrl({id, redirectionUrl: url}) + ); + }, + submit() { dispatch( updateQuery({ 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 a2712467146..4f1bd602a76 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 @@ -1,4 +1,4 @@ -import {Schema, StringValue} from '@coveo/bueno'; +import {BooleanValue, Schema, StringValue} from '@coveo/bueno'; import { searchBoxOptionDefinitions, SearchBoxOptions, @@ -10,6 +10,10 @@ export interface StandaloneSearchBoxOptions extends SearchBoxOptions { * If a query pipeline redirect is triggered, it will redirect to that Url instead. */ redirectionUrl: string; + /** + * Whether to overwrite the existing standalone search box with the same id. + */ + overwrite?: boolean; } export const standaloneSearchBoxSchema = new Schema< @@ -20,4 +24,7 @@ export const standaloneSearchBoxSchema = new Schema< required: true, emptyAllowed: false, }), + overwrite: new BooleanValue({ + required: false, + }), }); 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 d469513df34..e436dace5d1 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 @@ -81,6 +81,7 @@ describe('headless standalone searchBox', () => { expect(registerStandaloneSearchBox).toHaveBeenCalledWith({ id, redirectionUrl: options.redirectionUrl, + overwrite: false, }); }); 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 c5163df2023..db60fddcf56 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 @@ -11,6 +11,7 @@ import { updateAnalyticsToOmniboxFromLink, updateAnalyticsToSearchFromLink, resetStandaloneSearchBox, + updateStandaloneSearchBoxRedirectionUrl, } from '../../features/standalone-search-box-set/standalone-search-box-set-actions'; import {standaloneSearchBoxSetReducer as standaloneSearchBoxSet} from '../../features/standalone-search-box-set/standalone-search-box-set-slice'; import {StandaloneSearchBoxAnalytics} from '../../features/standalone-search-box-set/standalone-search-box-set-state'; @@ -50,6 +51,12 @@ export interface StandaloneSearchBox extends SearchBox { */ submit(): void; + /** + * Updates the redirection url of the standalone search box. + * @param url - The new URL to redirect to. + */ + updateRedirectUrl(url: string): void; + /** * Resets the standalone search box state. To be dispatched on single page applications after the redirection has been triggered. */ @@ -96,6 +103,7 @@ export function buildStandaloneSearchBox( id, highlightOptions: {...props.options.highlightOptions}, ...defaultSearchBoxOptions, + ...{overwrite: false}, ...props.options, }; @@ -108,7 +116,11 @@ export function buildStandaloneSearchBox( const searchBox = buildSearchBox(engine, {options}); dispatch( - registerStandaloneSearchBox({id, redirectionUrl: options.redirectionUrl}) + registerStandaloneSearchBox({ + id, + redirectionUrl: options.redirectionUrl, + overwrite: options.overwrite, + }) ); return { @@ -135,6 +147,12 @@ export function buildStandaloneSearchBox( dispatch(resetStandaloneSearchBox({id})); }, + updateRedirectUrl(url: string) { + dispatch( + updateStandaloneSearchBoxRedirectionUrl({id, redirectionUrl: url}) + ); + }, + submit() { dispatch( updateQuery({ diff --git a/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-actions-loader.ts b/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-actions-loader.ts index ef6790342af..11ec0bd362e 100644 --- a/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-actions-loader.ts +++ b/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-actions-loader.ts @@ -10,6 +10,7 @@ import { fetchRedirectUrl, registerStandaloneSearchBox, resetStandaloneSearchBox, + updateStandaloneSearchBoxRedirectionUrl, } from './standalone-search-box-set-actions'; export type { @@ -55,6 +56,15 @@ export interface StandaloneSearchBoxSetActionCreators { resetStandaloneSearchBox( payload: ResetStandaloneSearchBoxPayload ): PayloadAction; + + /** + * Updates the redirection URL of the standalone search box. + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + updateStandaloneSearchBoxRedirectionUrl( + payload: RegisterStandaloneSearchBoxPayload + ): PayloadAction; } /** @@ -70,6 +80,7 @@ export function loadStandaloneSearchBoxSetActions( return { fetchRedirectUrl, registerStandaloneSearchBox, + updateStandaloneSearchBoxRedirectionUrl, resetStandaloneSearchBox, }; } diff --git a/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-actions.ts b/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-actions.ts index 0571dec40bf..f5956ecd4ff 100644 --- a/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-actions.ts +++ b/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-actions.ts @@ -1,4 +1,4 @@ -import {StringValue} from '@coveo/bueno'; +import {BooleanValue, StringValue} from '@coveo/bueno'; import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; import { AsyncThunkCommerceOptions, @@ -67,11 +67,38 @@ export interface RegisterStandaloneSearchBoxPayload { * The default URL to which to redirect the user. */ redirectionUrl: string; + + /** + * Whether to overwrite the existing standalone search box with the same id. + */ + overwrite?: boolean; +} + +export interface UpdateStandaloneSearchBoxPayload { + /** + * The standalone search box id. + */ + id: string; + + /** + * The default URL to which to redirect the user. + */ + redirectionUrl: string; } export const registerStandaloneSearchBox = createAction( 'commerce/standaloneSearchBox/register', (payload: RegisterStandaloneSearchBoxPayload) => + validatePayload(payload, { + id: requiredNonEmptyString, + redirectionUrl: requiredNonEmptyString, + overwrite: new BooleanValue({required: false}), + }) +); + +export const updateStandaloneSearchBoxRedirectionUrl = createAction( + 'commerce/standaloneSearchBox/updateRedirectionUrl', + (payload: UpdateStandaloneSearchBoxPayload) => validatePayload(payload, { id: requiredNonEmptyString, redirectionUrl: requiredNonEmptyString, diff --git a/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-slice.test.ts b/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-slice.test.ts index 918e3b3601a..1acd2f2d33d 100644 --- a/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-slice.test.ts +++ b/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-slice.test.ts @@ -3,6 +3,7 @@ import { fetchRedirectUrl, registerStandaloneSearchBox, resetStandaloneSearchBox, + updateStandaloneSearchBoxRedirectionUrl, } from './standalone-search-box-set-actions'; import {commerceStandaloneSearchBoxSetReducer} from './standalone-search-box-set-slice'; import {CommerceStandaloneSearchBoxSetState} from './standalone-search-box-set-state'; @@ -44,6 +45,43 @@ describe('commerce standalone search box slice', () => { expect(state[id]).toEqual(finalState[id]); }); + + it('when the id exists and the overwrite option is true, it registers the payload', () => { + const action = registerStandaloneSearchBox({ + id, + redirectionUrl: 'url', + overwrite: true, + }); + const finalState = commerceStandaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]).toEqual( + buildMockCommerceStandaloneSearchBoxEntry({ + defaultRedirectionUrl: 'url', + }) + ); + }); + }); + + describe('#updateStandaloneSearchBoxRedirectionUrl', () => { + it('when the id exists, it sets the default redirection url', () => { + const action = updateStandaloneSearchBoxRedirectionUrl({ + id, + redirectionUrl: '/newpage', + }); + const finalState = commerceStandaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.defaultRedirectionUrl).toBe('/newpage'); + }); + + it('when the id does not exist, it does not edit the state', () => { + const action = updateStandaloneSearchBoxRedirectionUrl({ + id: 'invalid', + redirectionUrl: '/newpage', + }); + const finalState = commerceStandaloneSearchBoxSetReducer(state, action); + + expect(finalState).toBe(state); + }); }); describe('#resetStandaloneSearchBox', () => { diff --git a/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-slice.ts b/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-slice.ts index e52a95c23db..29a62d69f88 100644 --- a/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-slice.ts +++ b/packages/headless/src/features/commerce/standalone-search-box-set/standalone-search-box-set-slice.ts @@ -3,6 +3,7 @@ import { fetchRedirectUrl, registerStandaloneSearchBox, resetStandaloneSearchBox, + updateStandaloneSearchBoxRedirectionUrl, } from './standalone-search-box-set-actions'; import { getCommerceStandaloneSearchBoxSetInitialState, @@ -14,14 +15,24 @@ export const commerceStandaloneSearchBoxSetReducer = createReducer( (builder) => builder .addCase(registerStandaloneSearchBox, (state, action) => { - const {id, redirectionUrl} = action.payload; + const {id, redirectionUrl, overwrite} = action.payload; - if (id in state) { + if (!overwrite && id in state) { return; } state[id] = buildStandaloneSearchBoxEntry(redirectionUrl); }) + .addCase(updateStandaloneSearchBoxRedirectionUrl, (state, action) => { + const {id, redirectionUrl} = action.payload; + const searchBox = state[id]; + + if (!searchBox) { + return; + } + + searchBox.defaultRedirectionUrl = redirectionUrl; + }) .addCase(resetStandaloneSearchBox, (state, action) => { const {id} = action.payload; const searchBox = state[id]; 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 index 144247669f5..c0aa83be778 100644 --- 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 @@ -14,6 +14,7 @@ import { StateNeededForRedirect, resetStandaloneSearchBox, ResetStandaloneSearchBoxActionCreatorPayload, + updateStandaloneSearchBoxRedirectionUrl, } from './standalone-search-box-set-actions'; export type { @@ -62,6 +63,15 @@ export interface StandaloneSearchBoxSetActionCreators { AsyncThunkSearchOptions >; + /** + * Updates the redirection URL of the standalone search box. + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + updateStandaloneSearchBoxRedirectionUrl( + payload: RegisterStandaloneSearchBoxActionCreatorPayload + ): PayloadAction; + /** * Updates the standalone search box analytics data to reflect a search submitted using the search box. * @@ -97,6 +107,7 @@ export function loadStandaloneSearchBoxSetActions( return { registerStandaloneSearchBox, fetchRedirectUrl, + updateStandaloneSearchBoxRedirectionUrl, updateAnalyticsToSearchFromLink, updateAnalyticsToOmniboxFromLink, resetStandaloneSearchBox, 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 index 38378e3ee14..3bf2951fbd0 100644 --- 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 @@ -1,4 +1,4 @@ -import {StringValue} from '@coveo/bueno'; +import {BooleanValue, StringValue} from '@coveo/bueno'; import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; import {getVisitorID} from '../../api/analytics/coveo-analytics-utils'; import {ExecutionPlan} from '../../api/search/plan/plan-endpoint'; @@ -34,11 +34,38 @@ export interface RegisterStandaloneSearchBoxActionCreatorPayload { * The default URL to which to redirect the user. */ redirectionUrl: string; + + /** + * Whether to overwrite the existing standalone search box with the same id. + */ + overwrite?: boolean; +} + +export interface UpdateStandaloneSearchBoxPayload { + /** + * 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, + overwrite: new BooleanValue({required: false}), + }) +); + +export const updateStandaloneSearchBoxRedirectionUrl = createAction( + 'standaloneSearchBox/updateRedirectionUrl', + (payload: UpdateStandaloneSearchBoxPayload) => validatePayload(payload, { id: requiredNonEmptyString, redirectionUrl: requiredNonEmptyString, 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 index 8656a77a869..f673afa32ba 100644 --- 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 @@ -6,6 +6,7 @@ import { updateAnalyticsToOmniboxFromLink, updateAnalyticsToSearchFromLink, resetStandaloneSearchBox, + updateStandaloneSearchBoxRedirectionUrl, } from './standalone-search-box-set-actions'; import {standaloneSearchBoxSetReducer} from './standalone-search-box-set-slice'; import {StandaloneSearchBoxSetState} from './standalone-search-box-set-state'; @@ -45,6 +46,41 @@ describe('standalone search box slice', () => { expect(state[id]).toEqual(finalState[id]); }); + + it('when the id exists and the overwrite option is true, it registers the payload', () => { + const action = registerStandaloneSearchBox({ + id, + redirectionUrl: 'url', + overwrite: true, + }); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]).toEqual( + buildMockStandaloneSearchBoxEntry({defaultRedirectionUrl: 'url'}) + ); + }); + }); + + describe('#updateStandaloneSearchBoxRedirectionUrl', () => { + it('when the id exists, it sets the default redirection url', () => { + const action = updateStandaloneSearchBoxRedirectionUrl({ + id, + redirectionUrl: '/newpage', + }); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState[id]!.defaultRedirectionUrl).toBe('/newpage'); + }); + + it('when the id does not exist, it does not edit the state', () => { + const action = updateStandaloneSearchBoxRedirectionUrl({ + id: 'invalid', + redirectionUrl: '/newpage', + }); + const finalState = standaloneSearchBoxSetReducer(state, action); + + expect(finalState).toBe(state); + }); }); describe('#resetStandaloneSearchBox', () => { 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 index be23c046f4b..9b05de9eeaa 100644 --- 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 @@ -5,6 +5,7 @@ import { resetStandaloneSearchBox, updateAnalyticsToOmniboxFromLink, updateAnalyticsToSearchFromLink, + updateStandaloneSearchBoxRedirectionUrl, } from './standalone-search-box-set-actions'; import { getStandaloneSearchBoxSetInitialState, @@ -16,9 +17,9 @@ export const standaloneSearchBoxSetReducer = createReducer( (builder) => builder .addCase(registerStandaloneSearchBox, (state, action) => { - const {id, redirectionUrl} = action.payload; + const {id, redirectionUrl, overwrite} = action.payload; - if (id in state) { + if (!overwrite && id in state) { return; } @@ -35,6 +36,15 @@ export const standaloneSearchBoxSetReducer = createReducer( return; } }) + .addCase(updateStandaloneSearchBoxRedirectionUrl, (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];