From f333932c38efaf84ec21ff5d4b66d449d12a8a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-F=C3=A9lix=20Thibodeau?= Date: Tue, 28 Sep 2021 07:15:28 -0400 Subject: [PATCH 1/6] search box: don't register QS when numberOfSuggestions = 0 https://coveord.atlassian.net/browse/KIT-853 --- .../search-box/headless-search-box.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/headless/src/controllers/search-box/headless-search-box.ts b/packages/headless/src/controllers/search-box/headless-search-box.ts index 8b9e35ce77f..19f636c718a 100644 --- a/packages/headless/src/controllers/search-box/headless-search-box.ts +++ b/packages/headless/src/controllers/search-box/headless-search-box.ts @@ -157,13 +157,15 @@ export function buildSearchBox( validateOptions(engine, searchBoxOptionsSchema, options, 'buildSearchBox'); dispatch(registerQuerySetQuery({id, query: engine.state.query.q})); - dispatch( - registerQuerySuggest({ - id, - q: engine.state.query.q, - count: options.numberOfSuggestions, - }) - ); + if (options.numberOfSuggestions) { + dispatch( + registerQuerySuggest({ + id, + q: engine.state.query.q, + count: options.numberOfSuggestions, + }) + ); + } const getValue = () => engine.state.querySet[options.id]; From 903ddb7e8f3f19f89442a1dc00717e7317ab4115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-F=C3=A9lix=20Thibodeau?= Date: Tue, 28 Sep 2021 07:16:40 -0400 Subject: [PATCH 2/6] setup a mix of recent queries and query suggest https://coveord.atlassian.net/browse/KIT-853 --- packages/atomic/src/components.d.ts | 8 ++ .../atomic-search-box/atomic-search-box.tsx | 76 ++++++++++++++----- .../suggestions-common.tsx | 76 +++++++++++++++++++ 3 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 packages/atomic/src/components/search-box-suggestions/suggestions-common.tsx diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 7dbec66f9d9..2e963e15c30 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -600,6 +600,10 @@ export namespace Components { "initialChoice"?: number; } interface AtomicSearchBox { + /** + * The amount of queries displayed when the user interacts with the search box. By default, a mix of query suggestions and recent queries will be shown. You can configure those settings using the following components as children: - atomic-search-box-query-suggestions - atomic-search-box-recent-queries + */ + "numberOfQueries": number; } interface AtomicSearchInterface { /** @@ -1752,6 +1756,10 @@ declare namespace LocalJSX { "initialChoice"?: number; } interface AtomicSearchBox { + /** + * The amount of queries displayed when the user interacts with the search box. By default, a mix of query suggestions and recent queries will be shown. You can configure those settings using the following components as children: - atomic-search-box-query-suggestions - atomic-search-box-recent-queries + */ + "numberOfQueries"?: number; } interface AtomicSearchInterface { /** diff --git a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx index c86e36da8c5..9b9c8ba4d39 100644 --- a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx +++ b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx @@ -1,11 +1,10 @@ import SearchIcon from 'coveo-styleguide/resources/icons/svg/search.svg'; import ClearIcon from 'coveo-styleguide/resources/icons/svg/clear.svg'; -import {Component, h, State} from '@stencil/core'; +import {Component, h, State, Prop} from '@stencil/core'; import { SearchBox, SearchBoxState, buildSearchBox, - Suggestion, loadQuerySetActions, QuerySetActionCreators, } from '@coveo/headless'; @@ -17,6 +16,12 @@ import { import {Button} from '../common/button'; import {randomID} from '../../utils/utils'; import {isNullOrUndefined} from '@coveo/bueno'; +import { + querySuggestions, + recentQueries, + SearchBoxSuggestionElement, + SearchBoxSuggestions, +} from '../search-box-suggestions/suggestions-common'; /** * The `atomic-search-box` component creates a search box with built-in support for suggestions. @@ -41,6 +46,7 @@ export class AtomicSearchBox { private inputRef!: HTMLInputElement; private listRef!: HTMLElement; private querySetActions!: QuerySetActionCreators; + private suggestions: SearchBoxSuggestions[] = []; @BindStateToController('searchBox') @State() @@ -48,6 +54,16 @@ export class AtomicSearchBox { @State() public error!: Error; @State() private isExpanded = false; @State() private activeDescendant = ''; + @State() private suggestionElements: SearchBoxSuggestionElement[] = []; + + /** + * The amount of queries displayed when the user interacts with the search box. + * By default, a mix of query suggestions and recent queries will be shown. + * You can configure those settings using the following components as children: + * - atomic-search-box-query-suggestions + * - atomic-search-box-recent-queries + */ + @Prop() public numberOfQueries = 8; public initialize() { this.id = randomID('atomic-search-box-'); @@ -55,7 +71,7 @@ export class AtomicSearchBox { this.searchBox = buildSearchBox(this.bindings.engine, { options: { id: this.id, - numberOfSuggestions: 8, // TODO: handle when adding query suggestion component + numberOfSuggestions: 0, highlightOptions: { notMatchDelimiters: { open: '', @@ -68,14 +84,29 @@ export class AtomicSearchBox { }, }, }); + + // TODO: respond to child components + const suggestionBindings = { + ...this.bindings, + id: this.id, + searchBoxController: this.searchBox, + }; + this.suggestions = [ + recentQueries(suggestionBindings), + querySuggestions(suggestionBindings), + ]; } private get popupId() { return `${this.id}-popup`; } + private get hasInputValue() { + return this.searchBoxState.value !== ''; + } + private get hasSuggestions() { - return !!this.searchBoxState.suggestions.length; + return !!this.suggestionElements.length; } private get hasActiveDescendant() { @@ -148,16 +179,24 @@ export class AtomicSearchBox { this.scrollActiveDescendantIntoView(); } + private async triggerSuggestions() { + await Promise.all( + this.suggestions.map((suggestion) => suggestion.onInput()) + ); + this.suggestionElements = this.suggestions + .map((suggestion) => suggestion.renderItems()) + .flat(); + } + private onInput(value: string) { this.searchBox.updateText(value); this.updateActiveDescendant(); + this.triggerSuggestions(); } private onFocus() { this.isExpanded = true; - if (!this.searchBoxState.suggestions.length) { - this.searchBox.showSuggestions(); - } + this.triggerSuggestions(); } private onBlur() { @@ -251,7 +290,6 @@ export class AtomicSearchBox { private renderInputContainer() { const isLoading = this.searchBoxState.isLoading; - const hasValue = this.searchBoxState.value !== ''; return (
{this.renderInput()} @@ -261,13 +299,15 @@ export class AtomicSearchBox { class="loading w-5 h-5 rounded-full bg-gradient-to-r animate-spin mr-3 grid place-items-center" > )} - {!isLoading && hasValue && this.renderClearButton()} + {!isLoading && this.hasInputValue && this.renderClearButton()}
); } - // TODO: move inside the atomic-query-suggestions/atomic-recent-queries components - private renderSuggestion(suggestion: Suggestion, index: number) { + private renderSuggestion( + suggestion: SearchBoxSuggestionElement, + index: number + ) { const id = `${this.id}-suggestion-${index}`; const isSelected = id === this.activeDescendant; return ( @@ -275,20 +315,16 @@ export class AtomicSearchBox { id={id} role="option" aria-selected={`${isSelected}`} - key={suggestion.rawValue} - data-value={suggestion.rawValue} + key={suggestion.value} + data-value={suggestion.value} part={isSelected ? 'active-suggestion suggestion' : 'suggestion'} class={`flex px-4 h-10 items-center text-neutral-dark hover:bg-neutral-light cursor-pointer first:rounded-t-md last:rounded-b-md ${ isSelected ? 'bg-neutral-light' : '' }`} onMouseDown={(e) => e.preventDefault()} - onClick={() => { - this.searchBox.selectSuggestion(suggestion.rawValue); - this.inputRef.blur(); - }} + onClick={() => suggestion.onClick()} > - {/* TODO: add icon when mixed suggestions */} - + {suggestion.content} ); } @@ -307,7 +343,7 @@ export class AtomicSearchBox { showSuggestions ? '' : 'hidden' }`} > - {this.searchBoxState.suggestions.map((suggestion, index) => + {this.suggestionElements.map((suggestion, index) => this.renderSuggestion(suggestion, index) )} diff --git a/packages/atomic/src/components/search-box-suggestions/suggestions-common.tsx b/packages/atomic/src/components/search-box-suggestions/suggestions-common.tsx new file mode 100644 index 00000000000..ed5a628a276 --- /dev/null +++ b/packages/atomic/src/components/search-box-suggestions/suggestions-common.tsx @@ -0,0 +1,76 @@ +import { + buildRecentQueriesList, + loadQuerySuggestActions, + SearchBox, + SearchEngine, +} from '@coveo/headless'; +import {QuerySuggestionSection} from '@coveo/headless/dist/definitions/state/state-sections'; +import {VNode, h} from '@stencil/core'; +import {Bindings} from '../../utils/initialization-utils'; + +export interface SearchBoxSuggestionElement { + value: string; + onClick(): void; + content: VNode; +} + +export interface SearchBoxSuggestions { + onInput(): Promise | void; + renderItems(): SearchBoxSuggestionElement[]; +} + +export interface SearchBoxSuggestionsBindings extends Bindings { + id: string; + searchBoxController: SearchBox; +} + +// TODO: move into atomic-search-box-query-suggestions +export const querySuggestions: ( + bindings: SearchBoxSuggestionsBindings +) => SearchBoxSuggestions = ({engine, id, searchBoxController}) => { + const {registerQuerySuggest, fetchQuerySuggestions} = + loadQuerySuggestActions(engine); + + (engine as SearchEngine).dispatch( + registerQuerySuggest({ + id, + }) + ); + + return { + onInput: () => + (engine as SearchEngine).dispatch( + fetchQuerySuggestions({ + id, + }) + ), + renderItems: () => + // TODO: limit + searchBoxController.state.suggestions.map((suggestion) => ({ + content: , + value: suggestion.rawValue, + onClick: () => + searchBoxController.selectSuggestion(suggestion.rawValue), + })), + }; +}; + +// TODO: move into atomic-search-box-recent-queries +export const recentQueries: ( + bindings: SearchBoxSuggestionsBindings +) => SearchBoxSuggestions = ({engine}) => { + const recentQueriesList = buildRecentQueriesList(engine, { + initialState: {queries: ['hello', 'hola', 'bonjour', 'buongiorno']}, + }); + + return { + onInput: () => {}, + renderItems: () => + // TODO: filter, highlight, limit + recentQueriesList.state.queries.map((value, i) => ({ + value, + content: {value}, + onClick: () => recentQueriesList.executeRecentQuery(i), + })), + }; +}; From acd6b243d188ebec4d5cd2b3420e6d123938ccaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-F=C3=A9lix=20Thibodeau?= Date: Tue, 28 Sep 2021 07:46:56 -0400 Subject: [PATCH 3/6] add children configuration elements https://coveord.atlassian.net/browse/KIT-853 --- packages/atomic/src/components.d.ts | 34 +++++++++ .../atomic-search-box/atomic-search-box.tsx | 34 ++++++--- .../atomic-search-box-query-suggestions.tsx | 73 ++++++++++++++++++ .../atomic-search-box-recent-queries.tsx | 61 +++++++++++++++ .../suggestions-common.ts | 23 ++++++ .../suggestions-common.tsx | 76 ------------------- packages/atomic/src/pages/index.html | 6 +- 7 files changed, 221 insertions(+), 86 deletions(-) create mode 100644 packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx create mode 100644 packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx create mode 100644 packages/atomic/src/components/search-box-suggestions/suggestions-common.ts delete mode 100644 packages/atomic/src/components/search-box-suggestions/suggestions-common.tsx diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 2e963e15c30..c4636365a75 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -605,6 +605,14 @@ export namespace Components { */ "numberOfQueries": number; } + interface AtomicSearchBoxQuerySuggestions { + "maxWithQuery"?: number; + "maxWithoutQuery"?: number; + } + interface AtomicSearchBoxRecentQueries { + "maxWithQuery"?: number; + "maxWithoutQuery"?: number; + } interface AtomicSearchInterface { /** * The search interface headless engine. @@ -1058,6 +1066,18 @@ declare global { prototype: HTMLAtomicSearchBoxElement; new (): HTMLAtomicSearchBoxElement; }; + interface HTMLAtomicSearchBoxQuerySuggestionsElement extends Components.AtomicSearchBoxQuerySuggestions, HTMLStencilElement { + } + var HTMLAtomicSearchBoxQuerySuggestionsElement: { + prototype: HTMLAtomicSearchBoxQuerySuggestionsElement; + new (): HTMLAtomicSearchBoxQuerySuggestionsElement; + }; + interface HTMLAtomicSearchBoxRecentQueriesElement extends Components.AtomicSearchBoxRecentQueries, HTMLStencilElement { + } + var HTMLAtomicSearchBoxRecentQueriesElement: { + prototype: HTMLAtomicSearchBoxRecentQueriesElement; + new (): HTMLAtomicSearchBoxRecentQueriesElement; + }; interface HTMLAtomicSearchInterfaceElement extends Components.AtomicSearchInterface, HTMLStencilElement { } var HTMLAtomicSearchInterfaceElement: { @@ -1160,6 +1180,8 @@ declare global { "atomic-result-text": HTMLAtomicResultTextElement; "atomic-results-per-page": HTMLAtomicResultsPerPageElement; "atomic-search-box": HTMLAtomicSearchBoxElement; + "atomic-search-box-query-suggestions": HTMLAtomicSearchBoxQuerySuggestionsElement; + "atomic-search-box-recent-queries": HTMLAtomicSearchBoxRecentQueriesElement; "atomic-search-interface": HTMLAtomicSearchInterfaceElement; "atomic-sort-dropdown": HTMLAtomicSortDropdownElement; "atomic-sort-expression": HTMLAtomicSortExpressionElement; @@ -1761,6 +1783,14 @@ declare namespace LocalJSX { */ "numberOfQueries"?: number; } + interface AtomicSearchBoxQuerySuggestions { + "maxWithQuery"?: number; + "maxWithoutQuery"?: number; + } + interface AtomicSearchBoxRecentQueries { + "maxWithQuery"?: number; + "maxWithoutQuery"?: number; + } interface AtomicSearchInterface { /** * The search interface headless engine. @@ -1940,6 +1970,8 @@ declare namespace LocalJSX { "atomic-result-text": AtomicResultText; "atomic-results-per-page": AtomicResultsPerPage; "atomic-search-box": AtomicSearchBox; + "atomic-search-box-query-suggestions": AtomicSearchBoxQuerySuggestions; + "atomic-search-box-recent-queries": AtomicSearchBoxRecentQueries; "atomic-search-interface": AtomicSearchInterface; "atomic-sort-dropdown": AtomicSortDropdown; "atomic-sort-expression": AtomicSortExpression; @@ -2007,6 +2039,8 @@ declare module "@stencil/core" { "atomic-result-text": LocalJSX.AtomicResultText & JSXBase.HTMLAttributes; "atomic-results-per-page": LocalJSX.AtomicResultsPerPage & JSXBase.HTMLAttributes; "atomic-search-box": LocalJSX.AtomicSearchBox & JSXBase.HTMLAttributes; + "atomic-search-box-query-suggestions": LocalJSX.AtomicSearchBoxQuerySuggestions & JSXBase.HTMLAttributes; + "atomic-search-box-recent-queries": LocalJSX.AtomicSearchBoxRecentQueries & JSXBase.HTMLAttributes; "atomic-search-interface": LocalJSX.AtomicSearchInterface & JSXBase.HTMLAttributes; "atomic-sort-dropdown": LocalJSX.AtomicSortDropdown & JSXBase.HTMLAttributes; "atomic-sort-expression": LocalJSX.AtomicSortExpression & JSXBase.HTMLAttributes; diff --git a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx index 9b9c8ba4d39..5d01459d77b 100644 --- a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx +++ b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx @@ -1,6 +1,6 @@ import SearchIcon from 'coveo-styleguide/resources/icons/svg/search.svg'; import ClearIcon from 'coveo-styleguide/resources/icons/svg/clear.svg'; -import {Component, h, State, Prop} from '@stencil/core'; +import {Component, h, State, Prop, Listen} from '@stencil/core'; import { SearchBox, SearchBoxState, @@ -17,10 +17,10 @@ import {Button} from '../common/button'; import {randomID} from '../../utils/utils'; import {isNullOrUndefined} from '@coveo/bueno'; import { - querySuggestions, - recentQueries, SearchBoxSuggestionElement, SearchBoxSuggestions, + SearchBoxSuggestionsBindings, + SearchBoxSuggestionsEvent, } from '../search-box-suggestions/suggestions-common'; /** @@ -46,6 +46,7 @@ export class AtomicSearchBox { private inputRef!: HTMLInputElement; private listRef!: HTMLElement; private querySetActions!: QuerySetActionCreators; + private pendingSuggestionEvents: SearchBoxSuggestionsEvent[] = []; private suggestions: SearchBoxSuggestions[] = []; @BindStateToController('searchBox') @@ -85,16 +86,31 @@ export class AtomicSearchBox { }, }); - // TODO: respond to child components - const suggestionBindings = { + this.suggestions.push( + ...this.pendingSuggestionEvents.map((event) => + event(this.suggestionBindings) + ) + ); + this.pendingSuggestionEvents = []; + } + + @Listen('atomic/searchBoxSuggestion') + public setFormat(event: CustomEvent) { + event.preventDefault(); + event.stopPropagation(); + if (this.searchBox) { + this.suggestions.push(event.detail(this.suggestionBindings)); + return; + } + this.pendingSuggestionEvents.push(event.detail); + } + + private get suggestionBindings(): SearchBoxSuggestionsBindings { + return { ...this.bindings, id: this.id, searchBoxController: this.searchBox, }; - this.suggestions = [ - recentQueries(suggestionBindings), - querySuggestions(suggestionBindings), - ]; } private get popupId() { diff --git a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx new file mode 100644 index 00000000000..726fb7fac05 --- /dev/null +++ b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx @@ -0,0 +1,73 @@ +import {loadQuerySuggestActions, SearchEngine} from '@coveo/headless'; +import {QuerySuggestionSection} from '@coveo/headless/dist/definitions/state/state-sections'; +import {Component, Element, Prop, State, h} from '@stencil/core'; +import {buildCustomEvent} from '../../../utils/event-utils'; +import {SearchBoxSuggestionsEvent} from '../suggestions-common'; + +@Component({ + tag: 'atomic-search-box-query-suggestions', + shadow: true, +}) +export class AtomicSearchBoxQuerySuggestions { + @Element() private host!: HTMLElement; + + @State() public error!: Error; + + @Prop() public maxWithQuery?: number; + @Prop() public maxWithoutQuery?: number; + + componentWillLoad() { + try { + const event = buildCustomEvent( + 'atomic/searchBoxSuggestion', + ({engine, id, searchBoxController}) => { + const {registerQuerySuggest, fetchQuerySuggestions} = + loadQuerySuggestActions(engine); + + (engine as SearchEngine).dispatch( + registerQuerySuggest({ + id, + }) + ); + + return { + onInput: () => + (engine as SearchEngine).dispatch( + fetchQuerySuggestions({ + id, + }) + ), + renderItems: () => + // TODO: limit + searchBoxController.state.suggestions.map((suggestion) => ({ + content: , + value: suggestion.rawValue, + onClick: () => + searchBoxController.selectSuggestion(suggestion.rawValue), + })), + }; + } + ); + + const canceled = this.host.dispatchEvent(event); + if (canceled) { + throw new Error( + 'The "atomic-search-box-query-suggestions" component was not handled, as it is not a child of a "atomic-search-box" component.' + ); + } + } catch (error) { + this.error = error as Error; + } + } + + public render() { + if (this.error) { + return ( + + ); + } + } +} diff --git a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx new file mode 100644 index 00000000000..46bd95c37e9 --- /dev/null +++ b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx @@ -0,0 +1,61 @@ +import {buildRecentQueriesList} from '@coveo/headless'; +import {Component, Element, Prop, State, h} from '@stencil/core'; +import {buildCustomEvent} from '../../../utils/event-utils'; +import {SearchBoxSuggestionsEvent} from '../suggestions-common'; + +@Component({ + tag: 'atomic-search-box-recent-queries', + shadow: true, +}) +export class AtomicSearchBoxRecentQueries { + @Element() private host!: HTMLElement; + + @State() public error!: Error; + + @Prop() public maxWithQuery?: number; + @Prop() public maxWithoutQuery?: number; + + componentWillLoad() { + try { + const event = buildCustomEvent( + 'atomic/searchBoxSuggestion', + ({engine}) => { + const recentQueriesList = buildRecentQueriesList(engine, { + initialState: {queries: ['hello', 'hola', 'bonjour', 'buongiorno']}, + }); + + return { + onInput: () => {}, + renderItems: () => + // TODO: filter, highlight, limit + recentQueriesList.state.queries.map((value, i) => ({ + value, + content: {value}, + onClick: () => recentQueriesList.executeRecentQuery(i), + })), + }; + } + ); + + const canceled = this.host.dispatchEvent(event); + if (canceled) { + throw new Error( + 'The "atomic-search-box-recent-queries" component was not handled, as it is not a child of a "atomic-search-box" component.' + ); + } + } catch (error) { + this.error = error as Error; + } + } + + public render() { + if (this.error) { + return ( + + ); + } + } +} diff --git a/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts b/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts new file mode 100644 index 00000000000..f4da026cd8d --- /dev/null +++ b/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts @@ -0,0 +1,23 @@ +import {SearchBox} from '@coveo/headless'; +import {VNode} from '@stencil/core'; +import {Bindings} from '../../utils/initialization-utils'; + +export interface SearchBoxSuggestionElement { + value: string; + onClick(): void; + content: VNode; +} + +export interface SearchBoxSuggestions { + onInput(): Promise | void; + renderItems(): SearchBoxSuggestionElement[]; +} + +export type SearchBoxSuggestionsEvent = ( + bindings: SearchBoxSuggestionsBindings +) => SearchBoxSuggestions; + +export interface SearchBoxSuggestionsBindings extends Bindings { + id: string; + searchBoxController: SearchBox; +} diff --git a/packages/atomic/src/components/search-box-suggestions/suggestions-common.tsx b/packages/atomic/src/components/search-box-suggestions/suggestions-common.tsx deleted file mode 100644 index ed5a628a276..00000000000 --- a/packages/atomic/src/components/search-box-suggestions/suggestions-common.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { - buildRecentQueriesList, - loadQuerySuggestActions, - SearchBox, - SearchEngine, -} from '@coveo/headless'; -import {QuerySuggestionSection} from '@coveo/headless/dist/definitions/state/state-sections'; -import {VNode, h} from '@stencil/core'; -import {Bindings} from '../../utils/initialization-utils'; - -export interface SearchBoxSuggestionElement { - value: string; - onClick(): void; - content: VNode; -} - -export interface SearchBoxSuggestions { - onInput(): Promise | void; - renderItems(): SearchBoxSuggestionElement[]; -} - -export interface SearchBoxSuggestionsBindings extends Bindings { - id: string; - searchBoxController: SearchBox; -} - -// TODO: move into atomic-search-box-query-suggestions -export const querySuggestions: ( - bindings: SearchBoxSuggestionsBindings -) => SearchBoxSuggestions = ({engine, id, searchBoxController}) => { - const {registerQuerySuggest, fetchQuerySuggestions} = - loadQuerySuggestActions(engine); - - (engine as SearchEngine).dispatch( - registerQuerySuggest({ - id, - }) - ); - - return { - onInput: () => - (engine as SearchEngine).dispatch( - fetchQuerySuggestions({ - id, - }) - ), - renderItems: () => - // TODO: limit - searchBoxController.state.suggestions.map((suggestion) => ({ - content: , - value: suggestion.rawValue, - onClick: () => - searchBoxController.selectSuggestion(suggestion.rawValue), - })), - }; -}; - -// TODO: move into atomic-search-box-recent-queries -export const recentQueries: ( - bindings: SearchBoxSuggestionsBindings -) => SearchBoxSuggestions = ({engine}) => { - const recentQueriesList = buildRecentQueriesList(engine, { - initialState: {queries: ['hello', 'hola', 'bonjour', 'buongiorno']}, - }); - - return { - onInput: () => {}, - renderItems: () => - // TODO: filter, highlight, limit - recentQueriesList.state.queries.map((value, i) => ({ - value, - content: {value}, - onClick: () => recentQueriesList.executeRecentQuery(i), - })), - }; -}; diff --git a/packages/atomic/src/pages/index.html b/packages/atomic/src/pages/index.html index 3f3b6de4e08..91ce4775f7f 100644 --- a/packages/atomic/src/pages/index.html +++ b/packages/atomic/src/pages/index.html @@ -235,7 +235,11 @@
- + + + + + From e30b9813b17ec3e201d847db7bd27e55514571f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-F=C3=A9lix=20Thibodeau?= Date: Tue, 28 Sep 2021 07:58:40 -0400 Subject: [PATCH 4/6] add default components https://coveord.atlassian.net/browse/KIT-853 --- .../atomic-search-box/atomic-search-box.tsx | 14 +++++++++++--- .../atomic-search-box-query-suggestions.tsx | 3 ++- .../search-box-suggestions/suggestions-common.ts | 1 + packages/atomic/src/pages/index.html | 6 +----- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx index 5d01459d77b..aed17ecfed0 100644 --- a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx +++ b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx @@ -110,6 +110,7 @@ export class AtomicSearchBox { ...this.bindings, id: this.id, searchBoxController: this.searchBox, + numberOfQueries: this.numberOfQueries, }; } @@ -201,6 +202,7 @@ export class AtomicSearchBox { ); this.suggestionElements = this.suggestions .map((suggestion) => suggestion.renderItems()) + // .slice(0, this.numberOfQueries) .flat(); } @@ -381,7 +383,7 @@ export class AtomicSearchBox { } public render() { - return ( + return [
- ); +
, + !this.suggestions.length && ( + + + + + ), + ]; } } diff --git a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx index 726fb7fac05..acdbeb748c0 100644 --- a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx +++ b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx @@ -20,13 +20,14 @@ export class AtomicSearchBoxQuerySuggestions { try { const event = buildCustomEvent( 'atomic/searchBoxSuggestion', - ({engine, id, searchBoxController}) => { + ({engine, id, searchBoxController, numberOfQueries}) => { const {registerQuerySuggest, fetchQuerySuggestions} = loadQuerySuggestActions(engine); (engine as SearchEngine).dispatch( registerQuerySuggest({ id, + count: numberOfQueries, }) ); diff --git a/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts b/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts index f4da026cd8d..0ab8716cdb6 100644 --- a/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts +++ b/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts @@ -20,4 +20,5 @@ export type SearchBoxSuggestionsEvent = ( export interface SearchBoxSuggestionsBindings extends Bindings { id: string; searchBoxController: SearchBox; + numberOfQueries: number; } diff --git a/packages/atomic/src/pages/index.html b/packages/atomic/src/pages/index.html index 91ce4775f7f..3f3b6de4e08 100644 --- a/packages/atomic/src/pages/index.html +++ b/packages/atomic/src/pages/index.html @@ -235,11 +235,7 @@
- - - - - + From 87eddcffe30b165dde051de00f9fdd41d783ca89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-F=C3=A9lix=20Thibodeau?= Date: Tue, 28 Sep 2021 09:10:01 -0400 Subject: [PATCH 5/6] add todos and extract custom event dispatch https://coveord.atlassian.net/browse/KIT-853 --- .../atomic-search-box/atomic-search-box.tsx | 5 +- .../atomic-search-box-query-suggestions.tsx | 18 ++----- .../atomic-search-box-recent-queries.tsx | 49 +++++++++---------- .../suggestions-common.ts | 17 +++++++ 4 files changed, 47 insertions(+), 42 deletions(-) diff --git a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx index aed17ecfed0..ab6b07d9595 100644 --- a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx +++ b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx @@ -201,9 +201,10 @@ export class AtomicSearchBox { this.suggestions.map((suggestion) => suggestion.onInput()) ); this.suggestionElements = this.suggestions + // TODO: sort by position .map((suggestion) => suggestion.renderItems()) - // .slice(0, this.numberOfQueries) - .flat(); + .flat() + .slice(0, this.numberOfQueries); } private onInput(value: string) { diff --git a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx index acdbeb748c0..724523ae04e 100644 --- a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx +++ b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx @@ -1,8 +1,7 @@ import {loadQuerySuggestActions, SearchEngine} from '@coveo/headless'; import {QuerySuggestionSection} from '@coveo/headless/dist/definitions/state/state-sections'; import {Component, Element, Prop, State, h} from '@stencil/core'; -import {buildCustomEvent} from '../../../utils/event-utils'; -import {SearchBoxSuggestionsEvent} from '../suggestions-common'; +import {dispatchSearchBoxSuggestionsEvent} from '../suggestions-common'; @Component({ tag: 'atomic-search-box-query-suggestions', @@ -18,8 +17,7 @@ export class AtomicSearchBoxQuerySuggestions { componentWillLoad() { try { - const event = buildCustomEvent( - 'atomic/searchBoxSuggestion', + dispatchSearchBoxSuggestionsEvent( ({engine, id, searchBoxController, numberOfQueries}) => { const {registerQuerySuggest, fetchQuerySuggestions} = loadQuerySuggestActions(engine); @@ -39,7 +37,7 @@ export class AtomicSearchBoxQuerySuggestions { }) ), renderItems: () => - // TODO: limit + // TODO: limit values according to maxWithQuery/maxWithoutQuery searchBoxController.state.suggestions.map((suggestion) => ({ content: , value: suggestion.rawValue, @@ -47,15 +45,9 @@ export class AtomicSearchBoxQuerySuggestions { searchBoxController.selectSuggestion(suggestion.rawValue), })), }; - } + }, + this.host ); - - const canceled = this.host.dispatchEvent(event); - if (canceled) { - throw new Error( - 'The "atomic-search-box-query-suggestions" component was not handled, as it is not a child of a "atomic-search-box" component.' - ); - } } catch (error) { this.error = error as Error; } diff --git a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx index 46bd95c37e9..2a38754c886 100644 --- a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx +++ b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx @@ -1,7 +1,6 @@ import {buildRecentQueriesList} from '@coveo/headless'; import {Component, Element, Prop, State, h} from '@stencil/core'; -import {buildCustomEvent} from '../../../utils/event-utils'; -import {SearchBoxSuggestionsEvent} from '../suggestions-common'; +import {dispatchSearchBoxSuggestionsEvent} from '../suggestions-common'; @Component({ tag: 'atomic-search-box-recent-queries', @@ -17,32 +16,28 @@ export class AtomicSearchBoxRecentQueries { componentWillLoad() { try { - const event = buildCustomEvent( - 'atomic/searchBoxSuggestion', - ({engine}) => { - const recentQueriesList = buildRecentQueriesList(engine, { - initialState: {queries: ['hello', 'hola', 'bonjour', 'buongiorno']}, - }); + dispatchSearchBoxSuggestionsEvent(({engine, numberOfQueries}) => { + const recentQueriesList = buildRecentQueriesList(engine, { + // TODO: fetch initial state from cookies or local storage + initialState: {queries: ['hello', 'hola', 'bonjour', 'buongiorno']}, + options: {maxLength: numberOfQueries}, + }); - return { - onInput: () => {}, - renderItems: () => - // TODO: filter, highlight, limit - recentQueriesList.state.queries.map((value, i) => ({ - value, - content: {value}, - onClick: () => recentQueriesList.executeRecentQuery(i), - })), - }; - } - ); - - const canceled = this.host.dispatchEvent(event); - if (canceled) { - throw new Error( - 'The "atomic-search-box-recent-queries" component was not handled, as it is not a child of a "atomic-search-box" component.' - ); - } + return { + onInput: () => {}, + renderItems: () => + // TODO: limit values according to maxWithQuery/maxWithoutQuery + // TODO: filter values according to query + // TODO: add "clear recent queries element" + recentQueriesList.state.queries.map((value, i) => ({ + value, + // TODO: highlight values + content: {value}, + // TODO: save state to local storage + onClick: () => recentQueriesList.executeRecentQuery(i), + })), + }; + }, this.host); } catch (error) { this.error = error as Error; } diff --git a/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts b/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts index 0ab8716cdb6..60c46949ac6 100644 --- a/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts +++ b/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts @@ -1,5 +1,6 @@ import {SearchBox} from '@coveo/headless'; import {VNode} from '@stencil/core'; +import {buildCustomEvent} from '../../utils/event-utils'; import {Bindings} from '../../utils/initialization-utils'; export interface SearchBoxSuggestionElement { @@ -9,6 +10,8 @@ export interface SearchBoxSuggestionElement { } export interface SearchBoxSuggestions { + // TODO: add query context onInput + // TODO: add position (priority in the list) onInput(): Promise | void; renderItems(): SearchBoxSuggestionElement[]; } @@ -22,3 +25,17 @@ export interface SearchBoxSuggestionsBindings extends Bindings { searchBoxController: SearchBox; numberOfQueries: number; } + +export const dispatchSearchBoxSuggestionsEvent = ( + event: SearchBoxSuggestionsEvent, + element: Element +) => { + const canceled = element.dispatchEvent( + buildCustomEvent('atomic/searchBoxSuggestion', event) + ); + if (canceled) { + throw new Error( + 'The Atomic search box suggestion component was not handled, as it is not a child of a search box component' + ); + } +}; From e5a4cdc26f37a9665966e609e9105478c9812466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-F=C3=A9lix=20Thibodeau?= Date: Wed, 6 Oct 2021 07:46:16 -0400 Subject: [PATCH 6/6] PART 2 - configurable query suggestions & recent queries (#1267) https://coveord.atlassian.net/browse/KIT-853 --- packages/atomic/src/components.d.ts | 2 +- .../atomic-search-box/atomic-search-box.tsx | 40 ++-- .../atomic-search-box-query-suggestions.tsx | 108 +++++++---- .../atomic-search-box-recent-queries.tsx | 183 +++++++++++++++--- .../suggestions-common.ts | 15 +- packages/atomic/src/images/clock.svg | 1 + packages/atomic/src/locales.json | 4 + .../headless-recent-queries-list.ts | 10 +- 8 files changed, 286 insertions(+), 77 deletions(-) create mode 100644 packages/atomic/src/images/clock.svg diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index c4636365a75..18c47182336 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -610,7 +610,7 @@ export namespace Components { "maxWithoutQuery"?: number; } interface AtomicSearchBoxRecentQueries { - "maxWithQuery"?: number; + "maxWithQuery": number; "maxWithoutQuery"?: number; } interface AtomicSearchInterface { diff --git a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx index ab6b07d9595..ecad10fb967 100644 --- a/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx +++ b/packages/atomic/src/components/atomic-search-box/atomic-search-box.tsx @@ -94,8 +94,8 @@ export class AtomicSearchBox { this.pendingSuggestionEvents = []; } - @Listen('atomic/searchBoxSuggestion') - public setFormat(event: CustomEvent) { + @Listen('atomic/searchBoxSuggestion/register') + public registerSuggestions(event: CustomEvent) { event.preventDefault(); event.stopPropagation(); if (this.searchBox) { @@ -111,6 +111,9 @@ export class AtomicSearchBox { id: this.id, searchBoxController: this.searchBox, numberOfQueries: this.numberOfQueries, + inputRef: this.inputRef, + triggerSuggestions: () => this.triggerSuggestions(), + getSuggestions: () => this.suggestions, }; } @@ -179,7 +182,7 @@ export class AtomicSearchBox { return; } - const query = this.nextOrFirstValue.getAttribute('data-value'); + const query = this.nextOrFirstValue.getAttribute('data-query'); !isNullOrUndefined(query) && this.updateQuery(query); this.updateActiveDescendant(this.nextOrFirstValue.id); this.scrollActiveDescendantIntoView(); @@ -190,7 +193,7 @@ export class AtomicSearchBox { return; } - const query = this.previousOrLastValue.getAttribute('data-value'); + const query = this.previousOrLastValue.getAttribute('data-query'); !isNullOrUndefined(query) && this.updateQuery(query); this.updateActiveDescendant(this.previousOrLastValue.id); this.scrollActiveDescendantIntoView(); @@ -200,11 +203,15 @@ export class AtomicSearchBox { await Promise.all( this.suggestions.map((suggestion) => suggestion.onInput()) ); - this.suggestionElements = this.suggestions - // TODO: sort by position + const suggestionElements = this.suggestions + .sort((a, b) => a.position - b.position) .map((suggestion) => suggestion.renderItems()) - .flat() - .slice(0, this.numberOfQueries); + .flat(); + + const max = + this.numberOfQueries + + suggestionElements.filter((sug) => sug.query === undefined).length; + this.suggestionElements = suggestionElements.slice(0, max); } private onInput(value: string) { @@ -221,6 +228,7 @@ export class AtomicSearchBox { private onBlur() { this.isExpanded = false; this.updateActiveDescendant(); + this.clearSuggestionElements(); } private onSubmit() { @@ -298,6 +306,7 @@ export class AtomicSearchBox { class="w-8 h-8 mr-1.5 text-neutral-dark" onClick={() => { this.searchBox.clear(); + this.clearSuggestionElements(); this.inputRef.focus(); }} ariaLabel={this.bindings.i18n.t('clear')} @@ -323,6 +332,10 @@ export class AtomicSearchBox { ); } + private clearSuggestionElements() { + this.suggestionElements = []; + } + private renderSuggestion( suggestion: SearchBoxSuggestionElement, index: number @@ -334,14 +347,14 @@ export class AtomicSearchBox { id={id} role="option" aria-selected={`${isSelected}`} - key={suggestion.value} - data-value={suggestion.value} + key={suggestion.key} + data-query={suggestion.query} part={isSelected ? 'active-suggestion suggestion' : 'suggestion'} class={`flex px-4 h-10 items-center text-neutral-dark hover:bg-neutral-light cursor-pointer first:rounded-t-md last:rounded-b-md ${ isSelected ? 'bg-neutral-light' : '' }`} onMouseDown={(e) => e.preventDefault()} - onClick={() => suggestion.onClick()} + onClick={() => suggestion.onSelect()} > {suggestion.content} @@ -376,7 +389,10 @@ export class AtomicSearchBox { class="w-12 h-auto rounded-r-md rounded-l-none -my-px" part="submit-button" ariaLabel={this.bindings.i18n.t('search')} - onClick={() => this.searchBox.submit()} + onClick={() => { + this.searchBox.submit(); + this.clearSuggestionElements(); + }} > diff --git a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx index 724523ae04e..f94e382e4aa 100644 --- a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx +++ b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-query-suggestions/atomic-search-box-query-suggestions.tsx @@ -1,13 +1,23 @@ -import {loadQuerySuggestActions, SearchEngine} from '@coveo/headless'; +import SearchIcon from 'coveo-styleguide/resources/icons/svg/search.svg'; +import { + loadQuerySuggestActions, + SearchEngine, + Suggestion, +} from '@coveo/headless'; import {QuerySuggestionSection} from '@coveo/headless/dist/definitions/state/state-sections'; import {Component, Element, Prop, State, h} from '@stencil/core'; -import {dispatchSearchBoxSuggestionsEvent} from '../suggestions-common'; +import { + dispatchSearchBoxSuggestionsEvent, + SearchBoxSuggestionElement, + SearchBoxSuggestionsBindings, +} from '../suggestions-common'; @Component({ tag: 'atomic-search-box-query-suggestions', shadow: true, }) export class AtomicSearchBoxQuerySuggestions { + private bindings!: SearchBoxSuggestionsBindings; @Element() private host!: HTMLElement; @State() public error!: Error; @@ -17,42 +27,74 @@ export class AtomicSearchBoxQuerySuggestions { componentWillLoad() { try { - dispatchSearchBoxSuggestionsEvent( - ({engine, id, searchBoxController, numberOfQueries}) => { - const {registerQuerySuggest, fetchQuerySuggestions} = - loadQuerySuggestActions(engine); - - (engine as SearchEngine).dispatch( - registerQuerySuggest({ - id, - count: numberOfQueries, - }) - ); - - return { - onInput: () => - (engine as SearchEngine).dispatch( - fetchQuerySuggestions({ - id, - }) - ), - renderItems: () => - // TODO: limit values according to maxWithQuery/maxWithoutQuery - searchBoxController.state.suggestions.map((suggestion) => ({ - content: , - value: suggestion.rawValue, - onClick: () => - searchBoxController.selectSuggestion(suggestion.rawValue), - })), - }; - }, - this.host - ); + dispatchSearchBoxSuggestionsEvent((bindings) => { + this.bindings = bindings; + return this.initialize(); + }, this.host); } catch (error) { this.error = error as Error; } } + private initialize() { + const engine = this.bindings.engine as SearchEngine; + const {registerQuerySuggest, fetchQuerySuggestions} = + loadQuerySuggestActions(engine); + + engine.dispatch( + registerQuerySuggest({ + id: this.bindings.id, + count: this.bindings.numberOfQueries, + }) + ); + + return { + position: Array.from(this.host.parentNode!.children).indexOf(this.host), + onInput: () => + engine.dispatch( + fetchQuerySuggestions({ + id: this.bindings.id, + }) + ), + renderItems: () => this.renderItems(), + }; + } + + private renderItems(): SearchBoxSuggestionElement[] { + const hasQuery = this.bindings.searchBoxController.state.value !== ''; + const max = hasQuery ? this.maxWithQuery : this.maxWithoutQuery; + 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 !== ''; + return { + content: ( +
+ {this.bindings.getSuggestions().length > 1 && ( + + )} + {hasQuery ? ( + + ) : ( + {suggestion.rawValue} + )} +
+ ), + key: `qs-${suggestion.rawValue}`, + query: suggestion.rawValue, + onSelect: () => { + this.bindings.searchBoxController.selectSuggestion(suggestion.rawValue); + this.bindings.inputRef.blur(); + }, + }; + } + public render() { if (this.error) { return ( diff --git a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx index 2a38754c886..c8739456aab 100644 --- a/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx +++ b/packages/atomic/src/components/search-box-suggestions/atomic-search-box-recent-queries/atomic-search-box-recent-queries.tsx @@ -1,48 +1,183 @@ -import {buildRecentQueriesList} from '@coveo/headless'; +import Clock from '../../../images/clock.svg'; +import { + buildRecentQueriesList, + HighlightUtils, + RecentQueriesList, +} from '@coveo/headless'; import {Component, Element, Prop, State, h} from '@stencil/core'; -import {dispatchSearchBoxSuggestionsEvent} from '../suggestions-common'; +import { + dispatchSearchBoxSuggestionsEvent, + SearchBoxSuggestionElement, + SearchBoxSuggestionsBindings, +} from '../suggestions-common'; +import {once} from '../../../utils/utils'; + +const localStorageKey = 'coveo-recent-queries'; @Component({ tag: 'atomic-search-box-recent-queries', shadow: true, }) export class AtomicSearchBoxRecentQueries { + private bindings!: SearchBoxSuggestionsBindings; + private recentQueriesList!: RecentQueriesList; + @Element() private host!: HTMLElement; @State() public error!: Error; - @Prop() public maxWithQuery?: number; + @Prop() public maxWithQuery = 3; @Prop() public maxWithoutQuery?: number; componentWillLoad() { try { - dispatchSearchBoxSuggestionsEvent(({engine, numberOfQueries}) => { - const recentQueriesList = buildRecentQueriesList(engine, { - // TODO: fetch initial state from cookies or local storage - initialState: {queries: ['hello', 'hola', 'bonjour', 'buongiorno']}, - options: {maxLength: numberOfQueries}, - }); - - return { - onInput: () => {}, - renderItems: () => - // TODO: limit values according to maxWithQuery/maxWithoutQuery - // TODO: filter values according to query - // TODO: add "clear recent queries element" - recentQueriesList.state.queries.map((value, i) => ({ - value, - // TODO: highlight values - content: {value}, - // TODO: save state to local storage - onClick: () => recentQueriesList.executeRecentQuery(i), - })), - }; + dispatchSearchBoxSuggestionsEvent((bindings) => { + this.bindings = bindings; + return this.initialize(); }, this.host); } catch (error) { this.error = error as Error; } } + private initialize() { + this.recentQueriesList = buildRecentQueriesList(this.bindings.engine, { + initialState: {queries: this.retrieveLocalStorage()}, + options: {maxLength: 1000}, + }); + + this.recentQueriesList.subscribe(() => this.updateLocalStorage()); + + return { + position: Array.from(this.host.parentNode!.children).indexOf(this.host), + onInput: () => {}, + renderItems: () => this.renderItems(), + }; + } + + private retrieveLocalStorage() { + try { + return JSON.parse( + window.localStorage.getItem(localStorageKey) || '[]' + ) as string[]; + } catch (error) { + return []; + } + } + + private updateLocalStorage() { + if (!this.recentQueriesList.state.analyticsEnabled) { + return this.disableFeature(); + } + + try { + window.localStorage.setItem( + localStorageKey, + JSON.stringify(this.recentQueriesList.state.queries) + ); + } catch (error) { + return null; + } + } + + private warnUser = once(() => + this.bindings.engine.logger.warn( + 'Because analytics are disabled, the recent queries feature is deactivated.' + ) + ); + + private disableFeature() { + this.warnUser(); + try { + window.localStorage.removeItem(localStorageKey); + } catch (error) { + return; + } + } + + private renderItems(): SearchBoxSuggestionElement[] { + if (!this.recentQueriesList.state.analyticsEnabled) { + return []; + } + + const query = this.bindings.searchBoxController.state.value; + const hasQuery = query !== ''; + const max = hasQuery ? this.maxWithQuery : this.maxWithoutQuery; + const filteredQueries = this.recentQueriesList.state.queries + .filter( + (recentQuery) => + recentQuery !== query && + recentQuery.toLowerCase().startsWith(query.toLowerCase()) + ) + .slice(0, max); + + const suggestionElements = filteredQueries.map((value) => + this.renderItem(value) + ); + if (suggestionElements.length) { + suggestionElements.unshift(this.renderClear()); + } + + return suggestionElements; + } + + private renderClear(): SearchBoxSuggestionElement { + return { + key: 'recent-query-clear', + content: ( +
+ + {this.bindings.i18n.t('recent-searches')} + + {this.bindings.i18n.t('clear')} +
+ ), + onSelect: () => { + this.recentQueriesList.clear(); + this.bindings.triggerSuggestions(); + }, + }; + } + + private renderItem(value: string): SearchBoxSuggestionElement { + const query = this.bindings.searchBoxController.state.value; + return { + key: `recent-${value}`, + query: value, + content: ( +
+ + {query === '' ? ( + {value} + ) : ( + ', + closingDelimiter: '', + highlights: [ + { + offset: query.length, + length: value.length - query.length, + }, + ], + })} + > + )} +
+ ), + onSelect: () => { + this.recentQueriesList.executeRecentQuery( + this.recentQueriesList.state.queries.indexOf(value) + ); + this.bindings.inputRef.blur(); + }, + }; + } + public render() { if (this.error) { return ( diff --git a/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts b/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts index 60c46949ac6..57fc00e21b9 100644 --- a/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts +++ b/packages/atomic/src/components/search-box-suggestions/suggestions-common.ts @@ -4,14 +4,14 @@ import {buildCustomEvent} from '../../utils/event-utils'; import {Bindings} from '../../utils/initialization-utils'; export interface SearchBoxSuggestionElement { - value: string; - onClick(): void; + key: string; + query?: string; + onSelect(): void; content: VNode; } export interface SearchBoxSuggestions { - // TODO: add query context onInput - // TODO: add position (priority in the list) + position: number; onInput(): Promise | void; renderItems(): SearchBoxSuggestionElement[]; } @@ -24,6 +24,9 @@ export interface SearchBoxSuggestionsBindings extends Bindings { id: string; searchBoxController: SearchBox; numberOfQueries: number; + inputRef: HTMLInputElement; + triggerSuggestions(): void; + getSuggestions: () => SearchBoxSuggestions[]; } export const dispatchSearchBoxSuggestionsEvent = ( @@ -31,11 +34,11 @@ export const dispatchSearchBoxSuggestionsEvent = ( element: Element ) => { const canceled = element.dispatchEvent( - buildCustomEvent('atomic/searchBoxSuggestion', event) + buildCustomEvent('atomic/searchBoxSuggestion/register', event) ); if (canceled) { throw new Error( - 'The Atomic search box suggestion component was not handled, as it is not a child of a search box component' + `The "${element.nodeName.toLowerCase()}" component was not handled, as it is not a child of an "atomic-search-box" component` ); } }; diff --git a/packages/atomic/src/images/clock.svg b/packages/atomic/src/images/clock.svg new file mode 100644 index 00000000000..30e5f9fc041 --- /dev/null +++ b/packages/atomic/src/images/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/atomic/src/locales.json b/packages/atomic/src/locales.json index ac72505b33a..37e3640cf14 100644 --- a/packages/atomic/src/locales.json +++ b/packages/atomic/src/locales.json @@ -516,6 +516,10 @@ "en": "No matches found for {{query}}", "fr": "Aucun résultat pour {{query}}" }, + "recent-searches": { + "en": "Recent searches", + "fr": "Recherches récente" + }, "cancel-last-action": { "en": "Cancel last action", "fr": "Annuler la dernière action", diff --git a/packages/headless/src/controllers/recent-queries-list/headless-recent-queries-list.ts b/packages/headless/src/controllers/recent-queries-list/headless-recent-queries-list.ts index aec8d6c6005..ad608f4312c 100644 --- a/packages/headless/src/controllers/recent-queries-list/headless-recent-queries-list.ts +++ b/packages/headless/src/controllers/recent-queries-list/headless-recent-queries-list.ts @@ -94,6 +94,11 @@ export interface RecentQueriesState { * The maximum number of queries to retain in the list. */ maxLength: number; + /** + * Whether analytics & tracking are enabled. + * In the case where it is disabled, it is recommended not to save recent queries. + */ + analyticsEnabled: boolean; } export function validateRecentQueriesProps( @@ -153,7 +158,10 @@ export function buildRecentQueriesList( get state() { const state = getState(); - return state.recentQueries; + return { + ...state.recentQueries, + analyticsEnabled: state.configuration.analytics.enabled, + }; }, clear() {