diff --git a/pages/top_queries.html b/pages/top_queries.html new file mode 100644 index 00000000..e178c5f6 --- /dev/null +++ b/pages/top_queries.html @@ -0,0 +1,96 @@ + + + + + + Development + + + + + + + + + +
+ +
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ +
+ + diff --git a/src/Index.ts b/src/Index.ts index 0a5e1d55..52765275 100644 --- a/src/Index.ts +++ b/src/Index.ts @@ -14,3 +14,4 @@ export { ResultsFilter } from './components/ResultsFilter/ResultsFilter'; export { ViewedByCustomer } from './components/ViewedByCustomer/ViewedByCustomer'; export { ResultAction } from './components/ResultAction/ResultAction'; export { CopyToClipboard } from './components/CopyToClipboard/CopyToClipboard'; +export { TopQueries } from './components/TopQueries/TopQueries'; diff --git a/src/components/TopQueries/Strings.ts b/src/components/TopQueries/Strings.ts new file mode 100644 index 00000000..102eadc9 --- /dev/null +++ b/src/components/TopQueries/Strings.ts @@ -0,0 +1,5 @@ +import { Translation, Language } from '../../utils/translation'; + +Translation.register(Language.English, { + TopQueries_title: 'People also searched for', +}); diff --git a/src/components/TopQueries/TopQueries.scss b/src/components/TopQueries/TopQueries.scss new file mode 100644 index 00000000..bc6814a6 --- /dev/null +++ b/src/components/TopQueries/TopQueries.scss @@ -0,0 +1,31 @@ +@import '../../sass/Variables.scss'; + +$title-font-size: 1.1em; +$default-padding: 6px; + +.CoveoTopQueries { + border: $default-border; + border-radius: $border-radius; + max-width: 500px; + + h2 { + margin: 0; + padding: $default-padding; + background-color: $grey-3; + font-size: $title-font-size; + color: $color-greyish-teal-blue; + } + + ul { + margin: 0; + padding: 0; + list-style-type: none; + } + + li { + margin: 0; + border-top: $default-border; + padding: $default-padding; + padding-left: 20px; + } +} diff --git a/src/components/TopQueries/TopQueries.ts b/src/components/TopQueries/TopQueries.ts new file mode 100644 index 00000000..91deeeeb --- /dev/null +++ b/src/components/TopQueries/TopQueries.ts @@ -0,0 +1,123 @@ +import { + Component, + ComponentOptions, + IAnalyticsActionCause, + IComponentBindings, + Initialization, + IQuerySuggestRequest, + l, + IQuerySuggestResponse, + IAnalyticsNoMeta, +} from 'coveo-search-ui'; +import './Strings'; + +export interface ITopQueriesOptions { + /** + * The parameters sent to the suggestion query. + * The component uses this information to get better suggestions + * In most cases, the q attribute should be set to '' + */ + suggestionQueryParams?: IQuerySuggestRequest; + /** + * The displayed title over the suggestions + */ + title?: string; + /** + * Specifies the handler called when a suggestion is clicked. + * + * Default executes a search query using the suggestion + * + * This option must be set in JavaScript when initializing the component. + */ + onClick?: (expression: string, component: TopQueries) => void; +} + +/** + * The TopQueries component suggests the top searched queries in the specific context and links the search results of the suggestions to the user + */ +export class TopQueries extends Component { + static ID = 'TopQueries'; + + /** + * The possible options for the TopQueries component + * @componentOptions + */ + static options: ITopQueriesOptions = { + suggestionQueryParams: ComponentOptions.buildJsonOption({ defaultValue: { q: '' } }), + title: ComponentOptions.buildStringOption({ defaultValue: l('TopQueries_title') }), + onClick: ComponentOptions.buildCustomOption((s) => null, { + defaultValue: (expression: string, component: TopQueries) => { + component.usageAnalytics.logSearchEvent(TopQueries.topQueriesClickActionCause, {}); + component.queryStateModel.set('q', expression); + component.queryController.executeQuery({ origin: component }); + }, + }), + }; + + public static topQueriesClickActionCause: IAnalyticsActionCause = { + name: 'topQueriesClick', + type: 'interface', + }; + + private listElem: HTMLUListElement; + + /** + * Construct a TopQueries component. + * @param element The HTML element bound to this component. + * @param options The options that can be provided to this component. + * @param bindings The bindings, or environment within which this component exists. + */ + constructor(public element: HTMLElement, public options: ITopQueriesOptions, public bindings?: IComponentBindings) { + super(element, TopQueries.ID, bindings); + this.options = ComponentOptions.initComponentOptions(element, TopQueries, options); + + const titleElem = document.createElement('h2'); + titleElem.innerHTML = options.title; + + this.listElem = document.createElement('ul'); + + this.element.appendChild(titleElem); + this.element.appendChild(this.listElem); + + this.updateTopQueries(); + } + + public async updateTopQueries(suggestionQueryParams: IQuerySuggestRequest = this.options.suggestionQueryParams): Promise { + this.show(); + + let suggestions: IQuerySuggestResponse; + try { + suggestions = await this.queryController.getEndpoint().getQuerySuggest(suggestionQueryParams); + } catch (err) { + console.error(`Failed to fetch query suggestions: ${err}`); + this.hide(); + return; + } + + if (!suggestions?.completions?.length || suggestions.completions.length == 0) { + // Hide the widget if there are no query suggestions or data format is invalid + this.hide(); + } else { + suggestions.completions.forEach((completion) => { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.classList.add('coveo-link'); + a.addEventListener('click', () => this.options.onClick(completion.expression, this)); + a.innerHTML = completion.expression; + + li.appendChild(a); + this.listElem.appendChild(li); + }); + } + } + + private hide(): void { + this.element.classList.add('coveo-hidden'); + } + + private show(): void { + this.element.classList.remove('coveo-hidden'); + } +} + +Initialization.registerAutoCreateComponent(TopQueries); diff --git a/src/sass/Index.scss b/src/sass/Index.scss index 43b27f0b..00baefb1 100644 --- a/src/sass/Index.scss +++ b/src/sass/Index.scss @@ -5,3 +5,4 @@ @import '../components/UserActions/UserActions.scss'; @import '../components/ViewedByCustomer/ViewedByCustomer.scss'; @import './ResultActionMenu.scss'; +@import '../components/TopQueries/TopQueries.scss'; diff --git a/src/sass/Variables.scss b/src/sass/Variables.scss index ee41b51d..fb14204f 100644 --- a/src/sass/Variables.scss +++ b/src/sass/Variables.scss @@ -7,6 +7,7 @@ $lynch: #5d7289; $bali-hai: #8097aa; $nepal: #94a9bf; $heather: #b5c4cf; +$color-greyish-teal-blue: #1d4f76; // Grey Palette $grey-5: #b5c4cf; @@ -25,3 +26,5 @@ $calypso: #316084; $tangerine: #ef9700; $icon-container-size: 24px; +$default-border: 1px solid $grey-5; +$border-radius: 2px; diff --git a/tests/components/TopQueries/TopQueries.spec.ts b/tests/components/TopQueries/TopQueries.spec.ts new file mode 100644 index 00000000..9a9a32f0 --- /dev/null +++ b/tests/components/TopQueries/TopQueries.spec.ts @@ -0,0 +1,177 @@ +import { IQuerySuggestResponse, NoopAnalyticsClient } from 'coveo-search-ui'; +import { Mock } from 'coveo-search-ui-tests'; +import { IBasicComponentSetup } from 'coveo-search-ui-tests/MockEnvironment'; +import { createSandbox, SinonSandbox } from 'sinon'; +import { ITopQueriesOptions } from '../../../src/components/TopQueries/TopQueries'; +import { TopQueries } from '../../../src/Index'; + +describe('TopQueries', () => { + let sandbox: SinonSandbox; + + const EMPTY_SUGGESTION: IQuerySuggestResponse = { + completions: [], + }; + + const SUGGESTION: IQuerySuggestResponse = { + completions: [ + { + expression: 'test1', + score: 1, + highlighted: 'testHighlight', + executableConfidence: 1, + }, + ], + }; + + const SUGGESTIONS: IQuerySuggestResponse = { + completions: [ + { + expression: 'test1', + score: 1, + highlighted: 'testHighlight', + executableConfidence: 1, + }, + { + expression: 'test2', + score: 1, + highlighted: 'testHighlight', + executableConfidence: 1, + }, + { + expression: 'test3', + score: 1, + highlighted: 'testHighlight', + executableConfidence: 1, + }, + ], + }; + + beforeAll(() => { + sandbox = createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + async function basicTopQueriesSetup(querySuggestionResponse: any, options: ITopQueriesOptions = {}) { + const topQueries = Mock.advancedComponentSetup( + TopQueries, + new Mock.AdvancedComponentSetupOptions(null, options, (env) => { + env.searchInterface.usageAnalytics = new NoopAnalyticsClient(); + return env; + }) + ); + const suggestStub = stubGetQuerySuggest(topQueries, querySuggestionResponse); + await topQueries.cmp.updateTopQueries(); + return { topQueries, suggestStub }; + } + + function stubGetQuerySuggest(component: IBasicComponentSetup, returnValue: any) { + const suggestSub = sandbox.stub(component.env.searchEndpoint, 'getQuerySuggest'); + suggestSub.returns(Promise.resolve(returnValue)); + return suggestSub; + } + + it('should hide itself if there are no suggestions', async () => { + const { topQueries, suggestStub } = await basicTopQueriesSetup(EMPTY_SUGGESTION); + + expect(suggestStub.called).toBe(true); + expect(topQueries.cmp.element.classList.contains('coveo-hidden')).toBe(true); + }); + + it('should hide itself if the query throws an exception', async () => { + const topQueries = Mock.basicComponentSetup(TopQueries, {}); + const suggestStub = sandbox.stub(topQueries.env.searchEndpoint, 'getQuerySuggest'); + suggestStub.throws('Error'); + await topQueries.cmp.updateTopQueries(); + + expect(suggestStub.called).toBe(true); + expect(topQueries.cmp.element.classList.contains('coveo-hidden')).toBe(true); + }); + + it('should hide itself if the query returns invalid data', async () => { + const { topQueries, suggestStub } = await basicTopQueriesSetup({ this: { is: 'not valid data' } }); + + expect(suggestStub.called).toBe(true); + expect(topQueries.cmp.element.classList.contains('coveo-hidden')).toBe(true); + }); + + it('should be shown if there are suggestions', async () => { + const { topQueries, suggestStub } = await basicTopQueriesSetup(SUGGESTIONS); + + expect(suggestStub.called).toBe(true); + expect(topQueries.cmp.element.classList.contains('coveo-hidden')).toBe(false); + }); + + it('should containt all suggestions in ActionButtons', async () => { + const { topQueries, suggestStub } = await basicTopQueriesSetup(SUGGESTIONS); + + const elems = topQueries.cmp.element.querySelectorAll('li'); + + expect(suggestStub.called).toBe(true); + expect(elems.length).toBe(SUGGESTIONS.completions.length); + }); + + it('links should contain the suggestions expression', async () => { + const { topQueries, suggestStub } = await basicTopQueriesSetup(SUGGESTION); + + const elem = topQueries.cmp.element.querySelector('a'); + + expect(suggestStub.called).toBe(true); + expect(elem.innerHTML).toBe(SUGGESTION.completions[0].expression); + }); + + it('links should execute click method onClick with expression given as argument', async () => { + const { topQueries, suggestStub } = await basicTopQueriesSetup(SUGGESTION); + + const onClickSpy = sandbox.spy(topQueries.cmp.options, 'onClick'); + + const elem = topQueries.cmp.element.querySelector('a'); + elem.click(); + + expect(suggestStub.called).toBe(true); + expect(onClickSpy.args[0][0]).toBe(SUGGESTION.completions[0].expression); + expect(onClickSpy.args[0][1]).toBe(topQueries.cmp); + }); + + it('Default suggestion click should send a ua search event when clicking on suggestion', async () => { + const { topQueries, suggestStub } = await basicTopQueriesSetup(SUGGESTION); + + const logSearchStub = sandbox.stub(topQueries.env.searchInterface.usageAnalytics, 'logSearchEvent'); + + const elem = topQueries.cmp.element.querySelector('a'); + elem.click(); + + expect(suggestStub.called).toBe(true); + expect(logSearchStub.called).toBe(true); + expect(logSearchStub.args[0][0]).toBe(TopQueries.topQueriesClickActionCause); + expect(logSearchStub.args[0][1]).toEqual({}); + }); + + it('Default suggestion click should send an executeQuery request', async () => { + const { topQueries, suggestStub } = await basicTopQueriesSetup(SUGGESTION); + + const executeQuerySpy = sandbox.spy(topQueries.env.queryController, 'executeQuery'); + + const elem = topQueries.cmp.element.querySelector('a'); + elem.click(); + + expect(suggestStub.called).toBe(true); + expect(executeQuerySpy.called).toBe(true); + }); + + it('Should call getQuerySuggest with paramers given in options', async () => { + const options: ITopQueriesOptions = { + suggestionQueryParams: { + q: '', + count: 13, + }, + }; + + const { suggestStub } = await basicTopQueriesSetup(SUGGESTION, options); + + expect(suggestStub.called).toBe(true); + expect(suggestStub.args[0][0]).toBe(options.suggestionQueryParams); + }); +});