UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

955 lines (849 loc) • 38.1 kB
///<reference path="FieldAddon.ts" /> ///<reference path="QueryExtensionAddon.ts" /> ///<reference path="QuerySuggestAddon.ts" /> ///<reference path="OldOmniboxAddon.ts" /> import 'styling/_Omnibox'; import * as _ from 'underscore'; import { findWhere } from 'underscore'; import { BreadcrumbEvents } from '../../events/BreadcrumbEvents'; import { IOmniboxPreprocessResultForQueryEventArgs, IPopulateOmniboxSuggestionsEventArgs, IQuerySuggestSelection, OmniboxEvents } from '../../events/OmniboxEvents'; import { IBuildingQueryEventArgs, IDuringQueryEventArgs, INewQueryEventArgs, QueryEvents } from '../../events/QueryEvents'; import { StandaloneSearchInterfaceEvents } from '../../events/StandaloneSearchInterfaceEvents'; import { exportGlobally } from '../../GlobalExports'; import { ExpressionDef } from '../../magicbox/Expression/Expression'; import { Grammar } from '../../magicbox/Grammar'; import { Complete } from '../../magicbox/Grammars/Complete'; import { Expressions } from '../../magicbox/Grammars/Expressions'; import { createMagicBox, MagicBoxInstance } from '../../magicbox/MagicBox'; import { Result } from '../../magicbox/Result/Result'; import { Suggestion } from '../../magicbox/SuggestionsManager'; import { Assert } from '../../misc/Assert'; import { COMPONENT_OPTIONS_ATTRIBUTES } from '../../models/ComponentOptionsModel'; import { IAttributeChangedEventArg, MODEL_EVENTS } from '../../models/Model'; import { QueryStateModel, QUERY_STATE_ATTRIBUTES } from '../../models/QueryStateModel'; import { l } from '../../strings/Strings'; import { $$, Dom } from '../../utils/Dom'; import { Utils } from '../../utils/Utils'; import { analyticsActionCauseList, IAnalyticsActionCause, IAnalyticsNoMeta, IAnalyticsOmniboxSuggestionMeta } from '../Analytics/AnalyticsActionListMeta'; import { PendingSearchAsYouTypeSearchEvent } from '../Analytics/PendingSearchAsYouTypeSearchEvent'; import { logSearchBoxSubmitEvent } from '../Analytics/SharedAnalyticsCalls'; import { Component } from '../Base/Component'; import { IComponentBindings } from '../Base/ComponentBindings'; import { ComponentOptions } from '../Base/ComponentOptions'; import { IFieldOption } from '../Base/IComponentOptions'; import { Initialization } from '../Base/Initialization'; import { IQueryboxOptions, Querybox } from '../Querybox/Querybox'; import { QueryboxOptionsProcessing } from '../Querybox/QueryboxOptionsProcessing'; import { QueryboxQueryParameters } from '../Querybox/QueryboxQueryParameters'; import { StandaloneSearchInterface } from '../SearchInterface/SearchInterface'; import { FieldAddon } from './FieldAddon'; import { OldOmniboxAddon } from './OldOmniboxAddon'; import { OmniboxAnalytics } from './OmniboxAnalytics'; import { QueryExtensionAddon } from './QueryExtensionAddon'; import { IQuerySuggestAddon, QuerySuggestAddon, VoidQuerySuggestAddon } from './QuerySuggestAddon'; export interface IOmniboxSuggestion extends Suggestion { executableConfidence?: number; } export interface IOmniboxOptions extends IQueryboxOptions { inline?: boolean; enableFieldAddon?: boolean; enableSimpleFieldAddon?: boolean; listOfFields?: IFieldOption[]; fieldAlias?: { [alias: string]: IFieldOption }; enableQuerySuggestAddon?: boolean; enableQueryExtensionAddon?: boolean; omniboxTimeout?: number; placeholder?: string; numberOfSuggestions?: number; querySuggestCharacterThreshold?: number; grammar?: (grammar: { start: string; expressions: { [id: string]: ExpressionDef }; }) => { start: string; expressions: { [id: string]: ExpressionDef } }; clearFiltersOnNewQuery?: boolean; } const MINIMUM_EXECUTABLE_CONFIDENCE = 0.8; /** * The `Omnibox` component extends the [`Querybox`]{@link Querybox}, and thus provides the same basic options and * behaviors. Furthermore, the `Omnibox` adds a type-ahead capability to the search input. * * You can configure the type-ahead feature by enabling or disabling certain addons, which the Coveo JavaScript Search * Framework provides out-of-the-box (see the [`enableFieldAddon`]{@link Omnibox.options.enableFieldAddon}, * [`enableQueryExtension`]{@link Omnibox.options.enableQueryExtensionAddon}, and * [`enableQuerySuggestAddon`]{@link Omnibox.options.enableQuerySuggestAddon} options). * * Custom components and external code can also extend or customize the type-ahead feature and the query completion * suggestions it provides by attaching their own handlers to the * [`populateOmniboxSuggestions`]{@link OmniboxEvents.populateOmniboxSuggestions} event. * * See also the [`Searchbox`]{@link Searchbox} component, which can automatically instantiate an `Omnibox` along with an * optional {@link SearchButton}. */ export class Omnibox extends Component { public static ID = 'Omnibox'; static doExport = () => { exportGlobally({ Omnibox: Omnibox, QueryboxQueryParameters: QueryboxQueryParameters }); }; /** * The options for the omnibox * @componentOptions */ static options: IOmniboxOptions = { /** * Specifies whether query completion suggestions appearing in the `Omnibox` should push the result list and facets * down, rather than rendering themselves over them (and partially hiding them). * * Set this option as well as {@link Omnibox.options.enableSearchAsYouType} and * {@link Omnibox.options.enableQuerySuggestAddon} to `true` for a cool effect! * * Default value is `false`. */ inline: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * Whether to automatically trigger a new query whenever the end user types additional text in the search box input. * * See also the [`searchAsYouTypeDelay`]{@link Omnibox.options.searchAsYouTypeDelay} option. * * **Note:** * > If you set this option and the [`enableQuerySuggestAddon`]{@link Omnibox.options.enableQuerySuggestAddon} * > option to `true`, the query suggestion feature returns the auto-completion of the currently typed keyword as * > its first suggestion. * * Default value is `false`. */ enableSearchAsYouType: ComponentOptions.buildBooleanOption({ defaultValue: false, section: 'Advanced Options' }), /** * If {@link Omnibox.options.enableSearchAsYouType} is `true`, specifies the delay (in milliseconds) before * triggering a new query when the end user types in the `Omnibox`. * * Default value is `2000`. Minimum value is `0`. */ searchAsYouTypeDelay: ComponentOptions.buildNumberOption({ defaultValue: 2000, min: 0, depend: 'enableSearchAsYouType', section: 'Advanced Options' }), /** * The `field` addon makes the `Omnibox` highlight and complete field syntax. Setting this option to `true` automatically sets * the [enableQuerySyntax]{@link Querybox.options.enableQuerySyntax} option to `true` as a side effect. * * **Example:** * > Suppose you want to search for PDF files. You start typing `@f` in the search box. The `Omnibox` provides * > you with several matching fields. You select the `@filetype` field. Then, you start typing `=p` in the input. * > This time, the `Omnibox` provides you with several matching values for the `@filetype` field. You select the * > `pdf` suggestion, and submit your search request. Since the `enableQuerySyntax` option is set to `true`, the * > Coveo Search API interprets the basic expression as query syntax and returns the items whose `@filetype` field * > matches the `pdf` value. * * Default value is `false`. */ enableFieldAddon: ComponentOptions.buildBooleanOption({ defaultValue: false, depend: 'enableQuerySyntax', postProcessing: (value, options: IOmniboxOptions) => { if (value) { options.enableQuerySyntax = true; } return value; }, section: 'Advanced Options' }), enableSimpleFieldAddon: ComponentOptions.buildBooleanOption({ defaultValue: false, depend: 'enableFieldAddon' }), listOfFields: ComponentOptions.buildFieldsOption({ depend: 'enableFieldAddon' }), /** * Whether to display Coveo Machine Learning (Coveo ML) query suggestions in the `Omnibox`. * * A Coveo ML query suggestions (QS) model must be properly configured in your Coveo Cloud organization, otherwise this * option has no effect (see * [Create a QS Model](https://docs.coveo.com/en/1832/#create-a-qs-model)). * * **Note:** * > When you set this option and the [`enableSearchAsYouType`]{@link Omnibox.options.enableSearchAsYouType} option * > to `true`, the query suggestion feature returns the auto-completion of the currently typed keyword as its first * > query suggestion. * * @availablesince [December 2015 Release (v1.0.273)](https://docs.coveo.com/en/289/#december-2015-release-v10273) */ enableQuerySuggestAddon: ComponentOptions.buildBooleanOption({ defaultValue: true, alias: ['enableTopQueryAddon', 'enableRevealQuerySuggestAddon'] }), /** * If {@link Querybox.options.enableQuerySyntax} is `true`, specifies whether to enable the `query extension` addon. * * The `query extension` addon allows the Omnibox to complete the syntax for query extensions. * * Default value is `false`. */ enableQueryExtensionAddon: ComponentOptions.buildBooleanOption({ defaultValue: false, depend: 'enableQuerySyntax', postProcessing: (value, options: IOmniboxOptions) => { if (value) { options.enableQuerySyntax = true; } return value; }, section: 'Advanced Options' }), /** * Specifies a placeholder for the input. * * @availablesince [July 2016 Release (v1.667.24)](https://docs.coveo.com/en/309/#july-2016-release-v166724) */ placeholder: ComponentOptions.buildLocalizedStringOption(), /** * Specifies a timeout (in milliseconds) before rejecting suggestions in the Omnibox. * * Default value is `2000`. Minimum value is `0`. */ omniboxTimeout: ComponentOptions.buildNumberOption({ defaultValue: 2000, min: 0 }), /** * Specifies whether the Coveo Platform should try to interpret special query syntax such as field references in the * query that the user enters in the Querybox (see * [Coveo Query Syntax Reference](https://docs.coveo.com/en/1552/searching-with-coveo/coveo-cloud-query-syntax)). * * Setting this option to `true` also causes the query syntax in the Querybox to highlight. * * Default value is `false`. */ enableQuerySyntax: ComponentOptions.buildBooleanOption({ defaultValue: false, section: 'Advanced Options' }), /** * Specifies the number of suggestions that should appear in standard ML-powered query suggestions. * * This option only affects the number of suggestions that appear with the {@link Omnibox.options.enableQuerySuggestAddon} option. * * This options does not affect the {@link AnalyticsSuggestions} or the {@link FieldSuggestions} component, which expose their own dedicated options for this parameter. * * Default value is `5`. * * Minimum value is `1`. */ numberOfSuggestions: ComponentOptions.buildNumberOption({ defaultValue: 5, min: 1 }), /** * The minimum number of characters required in the in the text input before displaying available query suggestions when focus is on the component. * * Note: Only effective when [enableQuerySuggestAddon]{@link Omnibox.options.enableQuerySuggestAddon} is true. * * depend: 'enableQuerySuggestAddon' * * Default (and minimum): 0, meaning that trending query suggestions are displayed when focus is on the component, even if its text input is empty. * * @availablesince [July 2019 Release (v2.6459)](https://docs.coveo.com/en/2938/) */ querySuggestCharacterThreshold: ComponentOptions.buildNumberOption({ defaultValue: 0, min: 0 }), /** * Whether to clear all active query filters when the end user submits a new query from the search box. * * Note: This does not include the filter expression enforced by the currently selected tab, if any. * * @availablesince [September 2019 Release (v2.7023)](https://docs.coveo.com/en/2990/) */ clearFiltersOnNewQuery: ComponentOptions.buildBooleanOption({ defaultValue: false }) }; public magicBox: MagicBoxInstance; private lastSuggestions: IOmniboxSuggestion[] = []; private lastQuery: string; private modifyEventTo: IAnalyticsActionCause; private movedOnce = false; private searchAsYouTypeTimeout: number; private skipAutoSuggest = false; private omniboxAnalytics: OmniboxAnalytics; public suggestionAddon: IQuerySuggestAddon; /** * Creates a new Omnibox component. Also enables necessary addons and binds events on various query events. * @param element The HTMLElement on which to instantiate the component. * @param options The options for the Omnibox component. * @param bindings The bindings that the component requires to function normally. If not set, these will be * automatically resolved (with a slower execution time). */ constructor(public element: HTMLElement, public options?: IOmniboxOptions, bindings?: IComponentBindings) { super(element, Omnibox.ID, bindings); this.options = ComponentOptions.initComponentOptions(element, Omnibox, options); const originalValueForQuerySyntax = this.options.enableQuerySyntax; new QueryboxOptionsProcessing(this).postProcess(); this.omniboxAnalytics = this.searchInterface.getOmniboxAnalytics(); $$(this.element).toggleClass('coveo-query-syntax-disabled', this.options.enableQuerySyntax == false); this.suggestionAddon = this.options.enableQuerySuggestAddon ? new QuerySuggestAddon(this) : new VoidQuerySuggestAddon(); new OldOmniboxAddon(this); this.createMagicBox(); this.bind.onRootElement(QueryEvents.newQuery, (args: INewQueryEventArgs) => this.handleNewQuery(args)); this.bind.onRootElement(QueryEvents.buildingQuery, (args: IBuildingQueryEventArgs) => this.handleBuildingQuery(args)); this.bind.onRootElement(StandaloneSearchInterfaceEvents.beforeRedirect, () => this.handleBeforeRedirect()); this.bind.onRootElement(QueryEvents.querySuccess, () => this.handleQuerySuccess()); this.bind.onQueryState(MODEL_EVENTS.CHANGE_ONE, QUERY_STATE_ATTRIBUTES.Q, (args: IAttributeChangedEventArg) => this.handleQueryStateChanged(args) ); if (this.isAutoSuggestion()) { this.bind.onRootElement(QueryEvents.duringQuery, (args: IDuringQueryEventArgs) => this.handleDuringQuery(args)); } this.bind.onComponentOptions(MODEL_EVENTS.CHANGE_ONE, COMPONENT_OPTIONS_ATTRIBUTES.SEARCH_BOX, (args: IAttributeChangedEventArg) => { if (args.value.enableQuerySyntax != null) { this.options.enableQuerySyntax = args.value.enableQuerySyntax; } else { this.options.enableQuerySyntax = originalValueForQuerySyntax; } this.updateGrammar(); }); this.bind.onRootElement(OmniboxEvents.querySuggestGetFocus, (args: IQuerySuggestSelection) => this.handleQuerySuggestGetFocus(args)); } /** * Adds the current content of the input to the query and triggers a query if the current content of the input has * changed since last submit. * * Also logs a `searchboxSubmit` event in the usage analytics. */ public submit() { this.magicBox.clearSuggestion(); this.updateQueryState(); this.triggerNewQuery(false, () => { logSearchBoxSubmitEvent(this.usageAnalytics); }); this.magicBox.blur(); } /** * Gets the current content of the input. * @returns {string} The current content of the input. */ public getText() { return this.magicBox.getText(); } /** * Sets the content of the input * @param text The string to set in the input. */ public setText(text: string) { this.magicBox.setText(text); this.updateQueryState(); } /** * Clears the content of the input. */ public clear() { this.magicBox.clear(); } /** * Gets the `HTMLInputElement` of the Omnibox. */ public getInput() { return <HTMLInputElement>this.magicBox.element.querySelector('input'); } public getResult() { return this.magicBox.getResult(); } public getDisplayedResult() { return this.magicBox.getDisplayedResult(); } public getCursor() { return this.magicBox.getCursor(); } public resultAtCursor(match?: string | { (result: Result): boolean }) { return this.magicBox.resultAtCursor(match); } private createGrammar() { let grammar = null; if (this.options.enableQuerySyntax) { grammar = Expressions(Complete); if (this.options.enableFieldAddon) { new FieldAddon(this); } if (this.options.fieldAlias != null) { this.options.listOfFields = this.options.listOfFields || []; this.options.listOfFields = this.options.listOfFields.concat(_.keys(this.options.fieldAlias)); } if (this.options.enableQueryExtensionAddon) { new QueryExtensionAddon(this); } } else { grammar = { start: 'Any', expressions: { Any: /.*/ } }; } if (this.options.grammar != null) { grammar = this.options.grammar(grammar); } return grammar; } private updateGrammar() { const grammar = this.createGrammar(); this.magicBox.grammar = new Grammar(grammar.start, grammar.expressions); this.magicBox.setText(this.magicBox.getText()); } private createMagicBox() { const grammar = this.createGrammar(); this.magicBox = createMagicBox(this.element, new Grammar(grammar.start, grammar.expressions), { inline: this.options.inline, selectableSuggestionClass: 'coveo-omnibox-selectable', selectedSuggestionClass: 'coveo-omnibox-selected', suggestionTimeout: this.options.omniboxTimeout }); const input = $$(this.magicBox.element).find('input'); if (input) { $$(input).setAttribute('aria-label', l('Search')); } this.setupMagicBox(); } private setupMagicBox() { this.magicBox.onmove = () => { // We assume that once the user has moved its selection, it becomes an explicit omnibox analytics event if (this.isAutoSuggestion()) { this.modifyEventTo = this.getOmniboxAnalyticsEventCause(); } this.movedOnce = true; }; this.magicBox.onfocus = () => { if (this.isAutoSuggestion()) { // This flag is used to block the automatic query when the UI is loaded with a query (#q=foo) // and then the input is focused. We want to block that query, even if it match the suggestion // Only when there is an actual change in the input (user typing something) is when we want the automatic query to kick in this.skipAutoSuggest = true; } }; this.magicBox.onSuggestions = (suggestions: IOmniboxSuggestion[]) => { // If text is empty, this can mean that user selected text from the search box // and hit "delete" : Reset the partial queries in this case if (Utils.isEmptyString(this.getText())) { this.omniboxAnalytics.partialQueries = []; } this.movedOnce = false; this.lastSuggestions = suggestions; if (this.isAutoSuggestion() && !this.skipAutoSuggest) { this.searchAsYouType(); } }; if (this.options.enableSearchAsYouType) { $$(this.element).addClass('coveo-magicbox-search-as-you-type'); } this.magicBox.onchange = () => { this.skipAutoSuggest = false; const text = this.getText(); if (text != undefined && text != '') { if (this.isAutoSuggestion()) { if (this.movedOnce) { this.searchAsYouType(true); } } else if (this.options.enableSearchAsYouType) { this.searchAsYouType(true); } } else { this.clear(); } }; if (this.options.placeholder) { (<HTMLInputElement>this.magicBox.element.querySelector('input')).placeholder = this.options.placeholder; } this.magicBox.onsubmit = () => this.submit(); this.magicBox.onselect = (suggestion: IOmniboxSuggestion) => { const index = _.indexOf(this.lastSuggestions, suggestion); const suggestions = _.compact(_.map(this.lastSuggestions, suggestion => suggestion.text)); this.magicBox.clearSuggestion(); this.updateQueryState(); // A bit tricky here : When it's machine learning auto suggestions // the mouse selection and keyboard selection acts differently : // keyboard selection will automatically do the query (which will log a search as you type event -> further modified by this.modifyEventTo if needed) // mouse selection will not "auto" send the query. // the movedOnce variable detect the keyboard movement, and is used to differentiate mouse vs keyboard if (!this.isAutoSuggestion()) { this.usageAnalytics.cancelAllPendingEvents(); this.triggerNewQuery(false, () => { this.usageAnalytics.logSearchEvent<IAnalyticsOmniboxSuggestionMeta>( this.getOmniboxAnalyticsEventCause(), this.buildCustomDataForPartialQueries(index, suggestions) ); }); } else if (this.isAutoSuggestion() && this.movedOnce) { this.handleAutoSuggestionWithKeyboard(index, suggestions); } else if (this.isAutoSuggestion() && !this.movedOnce) { this.handleAutoSuggestionsWithMouse(index, suggestions); } // Consider a selection like a reset of the partial queries (it's the end of a suggestion pattern) if (this.isAutoSuggestion()) { this.omniboxAnalytics.partialQueries = []; } }; this.magicBox.onblur = () => { if (this.isAutoSuggestion()) { this.setText(this.getQuery(true)); this.usageAnalytics.sendAllPendingEvents(); } }; this.magicBox.onclear = () => { this.updateQueryState(); if (this.options.triggerQueryOnClear || this.options.enableSearchAsYouType) { this.triggerNewQuery(false, () => { this.usageAnalytics.logSearchEvent<IAnalyticsNoMeta>(analyticsActionCauseList.searchboxClear, {}); }); } }; this.magicBox.ontabpress = () => { this.handleTabPress(); }; this.magicBox.getSuggestions = () => this.handleSuggestions(); } private handleAutoSuggestionWithKeyboard(index: number, suggestions: string[]) { if (this.searchAsYouTypeTimeout) { // Here, there is currently a search as you typed queued up : // Think : user typed very quickly, then very quickly selected a suggestion (without waiting for the search as you type) // Cancel the search as you type query, then immediately do the query with the selection (+analytics event with the selection) this.usageAnalytics.cancelAllPendingEvents(); clearTimeout(this.searchAsYouTypeTimeout); this.searchAsYouTypeTimeout = undefined; this.triggerNewQuery(false, () => { this.usageAnalytics.logSearchEvent<IAnalyticsOmniboxSuggestionMeta>( this.getOmniboxAnalyticsEventCause(), this.buildCustomDataForPartialQueries(index, suggestions) ); }); } else { // Here, the search as you type query has returned, but the analytics event has not ye been sent. // Think : user typed slowly, the query returned, and then the user selected a suggestion. // Since the analytics event has not yet been sent (search as you type event have a 5 sec delay) // modify the pending event, then send the newly modified analytics event immediately. this.modifyEventTo = this.getOmniboxAnalyticsEventCause(); this.modifyCustomDataOnPending(index, suggestions); this.modifyQueryContentOnPending(); this.usageAnalytics.sendAllPendingEvents(); } } private handleAutoSuggestionsWithMouse(index: number, suggestions: string[]) { if (this.searchAsYouTypeTimeout || index != 0) { // Here : the user either very quickly chose the first suggestion, and the search as you type is still queued up. // OR // the user chose something different then the first suggestion. // Remove the search as you type if it's there, and do the query with the suggestion directly. this.clearSearchAsYouType(); this.usageAnalytics.cancelAllPendingEvents(); this.triggerNewQuery(false, () => { this.usageAnalytics.logSearchEvent<IAnalyticsOmniboxSuggestionMeta>( this.getOmniboxAnalyticsEventCause(), this.buildCustomDataForPartialQueries(index, suggestions) ); }); } else { // Here : the user either very slowly chose a suggestion, and there is no search as you typed queued up // AND // the user chose the first suggestion. // this means the query is already returned, but the analytics event is still queued up. // modify the analytics event, and send it. this.modifyEventTo = this.getOmniboxAnalyticsEventCause(); this.modifyCustomDataOnPending(index, suggestions); this.modifyQueryContentOnPending(); this.usageAnalytics.sendAllPendingEvents(); // This should happen if : // users did a (short) query first, which does not match the first suggestion. (eg: typed "t", then search) // then, refocus the search box. The same suggestion(s) will re-appear. // By selecting the first one with the mouse, we need to retrigger a query because this means the search as you type could not // kick in and do the query automatically. if (this.lastQuery != this.getText()) { this.triggerNewQuery(false, () => { this.usageAnalytics.logSearchEvent<IAnalyticsOmniboxSuggestionMeta>( this.getOmniboxAnalyticsEventCause(), this.buildCustomDataForPartialQueries(index, suggestions) ); }); } } } private modifyCustomDataOnPending(index: number, suggestions: string[]) { const pendingEvt = this.usageAnalytics.getPendingSearchEvent(); if (pendingEvt instanceof PendingSearchAsYouTypeSearchEvent) { const newCustomData = this.buildCustomDataForPartialQueries(index, suggestions); _.each(_.keys(newCustomData), (k: string) => { (<PendingSearchAsYouTypeSearchEvent>pendingEvt).modifyCustomData(k, newCustomData[k]); }); } } private modifyQueryContentOnPending() { const pendingEvt = this.usageAnalytics.getPendingSearchEvent(); if (pendingEvt instanceof PendingSearchAsYouTypeSearchEvent) { const queryContent = this.getQuery(this.options.enableSearchAsYouType); pendingEvt.modifyQueryContent(queryContent); } } private buildCustomDataForPartialQueries(index: number, suggestions: string[]): IAnalyticsOmniboxSuggestionMeta { this.updateOmniboxAnalytics(suggestions, index); return this.omniboxAnalytics.buildCustomDataForPartialQueries(); } private handleQuerySuggestGetFocus({ suggestion }: IQuerySuggestSelection) { const suggestions = _.compact(_.map(this.lastSuggestions, suggestion => suggestion.text)); const ranking = this.lastSuggestions.indexOf(findWhere(this.lastSuggestions, { text: suggestion })); this.updateOmniboxAnalytics(suggestions, ranking); } private updateOmniboxAnalytics(suggestions: string[], suggestionRanking: number) { this.omniboxAnalytics.suggestions = suggestions; this.omniboxAnalytics.suggestionRanking = suggestionRanking; } private handleSuggestions() { const text = this.getText(); if (this.options.querySuggestCharacterThreshold <= text.length) { const suggestionsEventArgs: IPopulateOmniboxSuggestionsEventArgs = { suggestions: [], omnibox: this }; this.triggerOmniboxSuggestions(suggestionsEventArgs); if (!Utils.isNullOrEmptyString(text)) { this.omniboxAnalytics.partialQueries.push(text); } return _.compact(suggestionsEventArgs.suggestions); } else { return []; } } private triggerOmniboxSuggestions(args: IPopulateOmniboxSuggestionsEventArgs) { this.bind.trigger(this.element, OmniboxEvents.populateOmniboxSuggestions, args); if (!$$(this.element).isDescendant(this.root)) { this.bind.trigger(this.root, OmniboxEvents.populateOmniboxSuggestions, args); } } private handleBeforeRedirect() { this.updateQueryState(); } private handleBuildingQuery(data: IBuildingQueryEventArgs) { Assert.exists(data); Assert.exists(data.queryBuilder); this.updateQueryState(); this.lastQuery = this.getQuery(data.searchAsYouType); const result: Result = this.lastQuery == this.magicBox.getDisplayedResult().input ? this.magicBox.getDisplayedResult().clone() : this.magicBox.grammar.parse(this.lastQuery).clean(); const preprocessResultForQueryArgs: IOmniboxPreprocessResultForQueryEventArgs = { result: result }; if (this.options.enableQuerySyntax) { const notQuotedValues = preprocessResultForQueryArgs.result.findAll('FieldValueNotQuoted'); _.each(notQuotedValues, (value: Result) => (value.value = '"' + value.value.replace(/"|\u00A0/g, ' ') + '"')); if (this.options.fieldAlias) { const fieldNames = preprocessResultForQueryArgs.result.findAll( (result: Result) => result.expression.id == 'FieldName' && result.isSuccess() ); _.each(fieldNames, (result: Result) => { const alias = _.find(_.keys(this.options.fieldAlias), (alias: string) => alias.toLowerCase() == result.value.toLowerCase()); if (alias != null) { result.value = <string>this.options.fieldAlias[alias]; } }); } } this.triggerOmniboxPreprocessResultForQuery(preprocessResultForQueryArgs); const query = preprocessResultForQueryArgs.result.toString(); new QueryboxQueryParameters(this.options).addParameters(data.queryBuilder, query); } private triggerOmniboxPreprocessResultForQuery(args: IOmniboxPreprocessResultForQueryEventArgs) { this.bind.trigger(this.element, OmniboxEvents.omniboxPreprocessResultForQuery, args); if (!$$(this.element).isDescendant(this.root)) { this.bind.trigger(this.root, OmniboxEvents.omniboxPreprocessResultForQuery, args); } } private handleNewQuery(data: INewQueryEventArgs) { Assert.exists(data); this.options.clearFiltersOnNewQuery && this.clearFiltersIfNewQuery(data); } private clearFiltersIfNewQuery({ origin, searchAsYouType }: INewQueryEventArgs) { if (this.queryController.firstQuery) { return; } // Prevent queries triggered by unrelated components to clear the the filters // e.g., a facet selection const validOrigins = [Omnibox.ID, 'SearchButton']; if (!origin || validOrigins.indexOf(origin.type) === -1) { return; } const lastQuery = this.queryController.getLastQuery().q || ''; const newQuery = this.getQuery(searchAsYouType); if (lastQuery !== newQuery) { this.bind.trigger(this.root, BreadcrumbEvents.clearBreadcrumb); } } private handleTabPress() { if (this.options.enableQuerySuggestAddon) { this.handleTabPressForSuggestions(); } this.handleTabPressForOldOmniboxAddon(); } private handleTabPressForSuggestions() { if (!this.options.enableSearchAsYouType) { const suggestions = _.compact(_.map(this.lastSuggestions, suggestion => suggestion.text)); this.usageAnalytics.logCustomEvent( this.getOmniboxAnalyticsEventCause(), this.buildCustomDataForPartialQueries(0, suggestions), this.element ); } else { this.setText(this.getQuery(true)); } } private handleTabPressForOldOmniboxAddon() { const domSuggestions = this.lastSuggestions.filter(suggestions => suggestions.dom).map(suggestions => $$(suggestions.dom)); const selected = this.findAllElementsWithClass(domSuggestions, '.coveo-omnibox-selected'); if (selected.length > 0) { $$(selected[0]).trigger('tabSelect'); } else if (!this.options.enableQuerySuggestAddon) { const selectable = this.findAllElementsWithClass(domSuggestions, '.coveo-omnibox-selectable'); if (selectable.length > 0) { $$(selectable[0]).trigger('tabSelect'); } } } private findAllElementsWithClass(elements: Dom[], className: string): Dom[] { return elements .map(element => element.find(className)) .filter(s => s) .reduce((total, s) => total.concat(s), []); } private triggerNewQuery(searchAsYouType: boolean, analyticsEvent: () => void) { clearTimeout(this.searchAsYouTypeTimeout); const shouldExecuteQuery = this.shouldExecuteQuery(searchAsYouType); this.lastQuery = this.getQuery(searchAsYouType); shouldExecuteQuery && analyticsEvent(); this.queryController.executeQuery({ searchAsYouType: searchAsYouType, logInActionsHistory: true, cancel: !shouldExecuteQuery, origin: this }); } private getQuery(searchAsYouType: boolean) { if (this.lastQuery == this.magicBox.getText()) { return this.lastQuery; } if (!searchAsYouType) { return this.magicBox.getText(); } const wordCompletion = this.magicBox.getWordCompletion(); if (wordCompletion != null) { return wordCompletion; } const currentOmniboxSuggestion = this.magicBox.getWordCompletion() || this.getFirstSuggestion(); if (currentOmniboxSuggestion) { return currentOmniboxSuggestion; } if (this.isAutoSuggestion()) { return this.lastQuery || this.magicBox.getText(); } return this.magicBox.getText(); } private getFirstSuggestion() { if (this.lastSuggestions == null) { return ''; } if (this.lastSuggestions.length <= 0) { return ''; } const textSuggestion = _.find(this.lastSuggestions, (suggestion: IOmniboxSuggestion) => suggestion.text != null); if (textSuggestion == null) { return ''; } return textSuggestion.text; } public updateQueryState() { this.queryStateModel.set(QueryStateModel.attributesEnum.q, this.magicBox.getText()); } private handleQueryStateChanged(args: IAttributeChangedEventArg) { Assert.exists(args); const q = <string>args.value; if (q != this.magicBox.getText()) { this.magicBox.setText(q); } } private handleQuerySuccess() { if (!this.isAutoSuggestion()) { this.omniboxAnalytics.partialQueries = []; } } private handleDuringQuery(args: IDuringQueryEventArgs) { // When the query results returns ... (args.promise) args.promise.then(() => { // Get a handle on a pending search as you type (those events are delayed, not sent instantly) const pendingEvent = this.usageAnalytics.getPendingSearchEvent(); if (pendingEvent instanceof PendingSearchAsYouTypeSearchEvent) { (<PendingSearchAsYouTypeSearchEvent>pendingEvent).beforeResolve.then(evt => { // Check if we need to modify the event type beforeResolving it args.promise.then(() => { if (this.modifyEventTo) { evt.modifyEventCause(this.modifyEventTo); this.modifyEventTo = null; } }); }); } }); } private searchAsYouType(forceExecuteQuery = false) { this.clearSearchAsYouType(); if (this.shouldExecuteQuery(true)) { this.searchAsYouTypeTimeout = window.setTimeout(() => { if (this.suggestionShouldTriggerQuery() || forceExecuteQuery) { const suggestions = _.map(this.lastSuggestions, suggestion => suggestion.text); const index = _.indexOf(suggestions, this.magicBox.getWordCompletion()); this.triggerNewQuery(true, () => { this.usageAnalytics.logSearchAsYouType<IAnalyticsOmniboxSuggestionMeta>( analyticsActionCauseList.searchboxAsYouType, this.buildCustomDataForPartialQueries(index, suggestions) ); }); this.clearSearchAsYouType(); } }, this.options.searchAsYouTypeDelay); } } private isAutoSuggestion() { return this.options.enableSearchAsYouType && this.options.enableQuerySuggestAddon; } private shouldExecuteQuery(searchAsYouType: boolean) { const text = this.getQuery(searchAsYouType); if (this.searchInterface.options.allowQueriesWithoutKeywords === false) { return this.lastQuery != text && Utils.isNonEmptyString(text); } return this.lastQuery != text && text != null; } private suggestionShouldTriggerQuery(suggestions = this.lastSuggestions) { if (this.shouldExecuteQuery(true)) { if (suggestions && suggestions[0]) { const suggestion = suggestions[0]; // If we have access to a confidence level, return true if we are equal or above the minimum confidence level. if (suggestion && suggestion.executableConfidence != undefined) { return suggestion.executableConfidence >= MINIMUM_EXECUTABLE_CONFIDENCE; } // If we don't have access to a confidence level, return true only if it "starts with" the content of the search box if (suggestion.text && suggestion.text.indexOf(this.magicBox.getText()) == 0) { return true; } } } return false; } private clearSearchAsYouType() { clearTimeout(this.searchAsYouTypeTimeout); this.searchAsYouTypeTimeout = undefined; } private getOmniboxAnalyticsEventCause() { if (this.searchInterface instanceof StandaloneSearchInterface) { return analyticsActionCauseList.omniboxFromLink; } return analyticsActionCauseList.omniboxAnalytics; } } Omnibox.options = { ...Omnibox.options, ...Querybox.options }; Initialization.registerAutoCreateComponent(Omnibox);