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);
+ });
+});