UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

315 lines (265 loc) • 14.2 kB
import 'styling/_FieldSuggestions'; import * as _ from 'underscore'; import { IPopulateOmniboxSuggestionsEventArgs, OmniboxEvents } from '../../events/OmniboxEvents'; import { exportGlobally } from '../../GlobalExports'; import { l } from '../../MiscModules'; import { QueryStateModel } from '../../ModelsModules'; import { $$ } from '../../utils/Dom'; import { DomUtils } from '../../UtilsModules'; import { analyticsActionCauseList, IAnalyticsNoMeta } from '../Analytics/AnalyticsActionListMeta'; 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 { IOmniboxSuggestion, Omnibox } from '../Omnibox/Omnibox'; import { FacetValueSuggestionsProvider, IFacetValueSuggestionRow, IFacetValueSuggestionsProvider } from './FacetValueSuggestionsProvider'; import { Suggestion } from '../../magicbox/SuggestionsManager'; export interface IFacetValueSuggestionsOptions { numberOfSuggestions: number; field?: IFieldOption; isCategoryField?: boolean; categoryFieldDelimitingCharacter?: string; useQuerySuggestions?: boolean; useValueFromSearchbox?: boolean; displayEstimateNumberOfResults?: boolean; expression?: string; templateHelper?: (row: IFacetValueSuggestionRow, omnibox: Omnibox) => string; } export interface IQuerySuggestionKeyword { text: string; html: string; } /** * This component provides [`Omnibox`]{@link Omnibox} query suggestions scoped to distinct categories based on the values of a * specific [`field`]{@link FacetValueSuggestions.options.field} whose [Facet](https://docs.coveo.com/en/1982/#facet) option is enabled. * * @externaldocs [Providing Facet Value Suggestions](https://docs.coveo.com/en/340/#providing-facet-value-suggestions) * * @availablesince [May 2018 Release (v2.4094.8)](https://docs.coveo.com/410/#may-2018-release-v240948) */ export class FacetValueSuggestions extends Component { static ID = 'FacetValueSuggestions'; static doExport = () => { exportGlobally({ FacetValueSuggestions: FacetValueSuggestions }); }; /** * @componentOptions */ static options: IFacetValueSuggestionsOptions = { /** * The field on whose values the scoped query suggestions should be based. * * @examples @productcategory */ field: ComponentOptions.buildFieldOption({ required: true }), /** * The maximum number of suggestions to render in the [`Omnibox`]{@link Omnibox}. */ numberOfSuggestions: ComponentOptions.buildNumberOption({ defaultValue: 5, min: 1 }), /** * Whether to get scoped query suggestions from the current Coveo ML query suggestions. * * **Notes:** * - If this option is set to `true`, the [`enableQuerySuggestAddon`]{@link Omnibox.options.enableQuerySuggestAddon} option of the [`Omnibox`]{@link Omnibox} component must be set to `true` as well. * - If this option is set to `true`, an additional `POST` HTTP request is sent to `https://platform.cloud.coveo.com/rest/search/v2/querySuggest`. */ useQuerySuggestions: ComponentOptions.buildBooleanOption({ defaultValue: true }), /** * Whether to get scoped query suggestions from the current user query entered in the search box. * * **Default:** `true` if [`useQuerySuggestions`]{@link FacetValueSuggestions.options.useQuerySuggestions} is `false`; `false` otherwise */ useValueFromSearchbox: ComponentOptions.buildBooleanOption({ postProcessing: (value, options: IFacetValueSuggestionsOptions) => { return value || !options.useQuerySuggestions; } }), /** * Whether to display an estimate of the number of results for each scoped query suggestions. * * **Notes:** * - Setting this option to `true` has no effect when the [`templateHelper`]{@link FacetValueSuggestions.options.templateHelper} options is set. * - When displaying scoped query suggestions for a standalone search box whose queries are redirected to a search interface enforcing other filters, the number of results will be inaccurate. */ displayEstimateNumberOfResults: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * The template helper function to execute when rendering each scoped query suggestion. * * If specified, the function must have the following signature: (row: [IFacetValueSuggestionRow]{@link IFacetValueSuggestionRow}, omnibox: Omnibox) => string * * If not specified, a default function will be used. * * **Note:** You cannot set this option directly in the component markup as an HTML attribute. You must either set it: * - In the [`init`]{@link init} call of your search interface (see [Passing Component Options in the init Call](https://docs.coveo.com/en/346/#passing-component-options-in-the-init-call) * - Before the `init` call, using the [`options`](@link options) top-level function (see [Passing Component Options Before the init Call](https://docs.coveo.com/en/346/#passing-component-options-before-the-init-call)). * * **Example:** * * ```javascript * Coveo.init(document.getElementById('search'), { * FacetValueSuggestions: { * templateHelper: (row, omnibox) => { return `Searching for <strong>${row.keyword}</strong> in category <em>${row.value}</em>`; } * } * }) * ``` */ templateHelper: ComponentOptions.buildCustomOption<(row: IFacetValueSuggestionRow, omnibox: Omnibox) => string>(() => { return null; }), /** * Whether the [`field`]{@link FacetValueSuggestions.options.field} option references a multi-value field. * * Setting this option to `true` if appropriate will allow the corresponding [`CategoryFacet`]{@link CategoryFacet} or [`DynamicHierarchicalFacet`]{@link DynamicHierarchicalFacet} component (if present) to properly handle the filter format. * * See also the [`categoryFieldDelimitingCharacter`]{@link FacetValueSuggestions.options.categoryFieldDelimitingCharacter} option. */ isCategoryField: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * The delimiting character used for the multi-value field referenced by the [`field`]{@link FacetValueSuggestions.options.field} option. * * @examples ;, # */ categoryFieldDelimitingCharacter: ComponentOptions.buildStringOption({ defaultValue: '|', depend: 'isCategoryField' }), /** * An advanced query expression to add when requesting facet value suggestions. * * Set this option to ensure that the suggestions are properly scoped when using the component with a standalone search box. For instance, if a certain [tab]{@link Tab} is automatically selected in the search interface the standalone search box is redirecting its queries to, you should set this option to that tab's [`expression`]{@link Tab.options.expression}. * * @examples @objecttype==Message */ expression: ComponentOptions.buildQueryExpressionOption() }; public facetValueSuggestionsProvider: IFacetValueSuggestionsProvider; private queryStateFieldFacetId; static defaultTemplate(this: FacetValueSuggestions, row: IFacetValueSuggestionRow, omnibox: Omnibox): string { const keyword = row.keyword.html; const facetValue = DomUtils.highlight(row.value, 'coveo-omnibox-hightlight'); const details = this.options.displayEstimateNumberOfResults ? DomUtils.highlight( ` (${l('ResultCount', row.numberOfResults.toString(), row.numberOfResults)})`, 'coveo-omnibox-suggestion-results-count', true ) : ''; return `${l('KeywordInCategory', keyword, facetValue)}${details}`; } private static getQuerySuggestionKeywordFromText(text: string): IQuerySuggestionKeyword { return { text, html: DomUtils.highlight(text, 'coveo-omnibox-hightlight') }; } /** * Creates a new `FacetValueSuggestions` component. * @param element The HTMLElement on which to instantiate the component. * @param options The options for the `FacetValueSuggestions` 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(element: HTMLElement, public options: IFacetValueSuggestionsOptions, bindings?: IComponentBindings) { super(element, FacetValueSuggestions.ID, bindings); this.options = ComponentOptions.initComponentOptions(element, FacetValueSuggestions, options); this.facetValueSuggestionsProvider = new FacetValueSuggestionsProvider(this.queryController, { field: <string>this.options.field, expression: this.options.expression }); this.queryStateFieldFacetId = `f:${this.options.field}`; if (!this.options.templateHelper) { this.options.templateHelper = FacetValueSuggestions.defaultTemplate; } $$(this.root).on(OmniboxEvents.populateOmniboxSuggestions, (e: Event, args: IPopulateOmniboxSuggestionsEventArgs) => { args.suggestions.push(this.getSuggestions(args.omnibox)); }); } public async getSuggestions(omnibox: Omnibox): Promise<IOmniboxSuggestion[]> { if (this.options.numberOfSuggestions == 0) { return []; } const text = omnibox.getText(); const suggestions: IOmniboxSuggestion[] = await this.getFacetValueSuggestions(text, omnibox); return suggestions || []; } private async getQuerySuggestionsKeywords(omnibox: Omnibox): Promise<IQuerySuggestionKeyword[]> { if (this.options.useQuerySuggestions && omnibox.suggestionAddon) { const suggestions = await omnibox.suggestionAddon.getSuggestion(); return suggestions.map(({ text, html }) => <IQuerySuggestionKeyword>{ text: text || '', html }); } else { return []; } } private async getFacetValueSuggestions(text: string, omnibox: Omnibox): Promise<IOmniboxSuggestion[]> { const wordsToQuery = this.options.useValueFromSearchbox ? [FacetValueSuggestions.getQuerySuggestionKeywordFromText(text)] : []; const suggestionsKeywords: IQuerySuggestionKeyword[] = await this.getQuerySuggestionsKeywords(omnibox); const allKeywordsToQuery = _.unique( wordsToQuery.concat(suggestionsKeywords).filter(keyword => keyword.text != ''), keyword => keyword.text ); if (allKeywordsToQuery.length === 0) { return []; } return this.getSuggestionsForWords(allKeywordsToQuery, omnibox); } private async getSuggestionsForWords(keywordToQuery: IQuerySuggestionKeyword[], omnibox: Omnibox): Promise<IOmniboxSuggestion[]> { try { const suggestions = await this.facetValueSuggestionsProvider.getSuggestions(keywordToQuery); this.logger.debug('FacetValue Suggestions Results', suggestions); const currentSelectedValues: string[] = this.queryStateModel.get(this.queryStateFieldFacetId) || []; const filteredSuggestions = suggestions.filter(suggestion => this.isSuggestionRowAlreadyCheckedInFacet(suggestion, currentSelectedValues) ); return this.rankSuggestionRows(filteredSuggestions).map(result => this.mapFacetValueSuggestion(result, omnibox)); } catch (error) { this.logger.error(error); return []; } } private isSuggestionRowAlreadyCheckedInFacet(suggestion: IFacetValueSuggestionRow, currentlySelectedValues: string[]): boolean { return !currentlySelectedValues.some(value => value == suggestion.value); } private rankSuggestionRows(suggestions: IFacetValueSuggestionRow[]): IFacetValueSuggestionRow[] { const rankedResults = [...suggestions.sort((a, b) => b.score.distanceFromTotalForField - a.score.distanceFromTotalForField)]; const firstSlice = Math.ceil(this.options.numberOfSuggestions / 2); const lastSlice = -Math.floor(this.options.numberOfSuggestions / 2); const firstResultsToReturn = rankedResults.splice(0, firstSlice); if (lastSlice != 0) { const lastResultsToReturn = rankedResults.slice(lastSlice); return [...firstResultsToReturn, ...lastResultsToReturn]; } return firstResultsToReturn; } private mapFacetValueSuggestion(resultToShow: IFacetValueSuggestionRow, omnibox: Omnibox) { const suggestion: IOmniboxSuggestion = { html: this.buildDisplayNameForRow(resultToShow, omnibox), text: resultToShow.keyword.text }; const fieldValues = this.options.isCategoryField ? resultToShow.value.split(this.options.categoryFieldDelimitingCharacter) : [resultToShow.value]; suggestion.advancedQuery = fieldValues.map(value => `${this.options.field}=="${value}"`).join(' AND '); suggestion.onSelect = () => this.onSuggestionSelected(suggestion, fieldValues, omnibox); return suggestion; } private buildDisplayNameForRow(row: IFacetValueSuggestionRow, omnibox: Omnibox): string { try { return this.options.templateHelper.call(this, row, omnibox); } catch (ex) { this.logger.error('Could not apply template from options for the given row. Will use default template.', ex, row, omnibox); return FacetValueSuggestions.defaultTemplate.call(this, row, omnibox); } } private onSuggestionSelected(suggestion: Suggestion, fieldValues: string[], omnibox: Omnibox): void { omnibox.setText(suggestion.text); // Copy the state here, else it will directly modify queryStateModel.defaultAttributes.fv. const fvState: { [key: string]: string[] } = { ...this.queryStateModel.get(QueryStateModel.attributesEnum.fv) }; const existingValues: string[] = fvState[this.options.field.toString()] || []; fvState[this.options.field.toString()] = existingValues.concat(fieldValues); this.queryStateModel.set(QueryStateModel.attributesEnum.fv, fvState); omnibox.magicBox.blur(); this.usageAnalytics.logSearchEvent<IAnalyticsNoMeta>(analyticsActionCauseList.omniboxField, {}); this.queryController.executeQuery(); } } Initialization.registerAutoCreateComponent(FacetValueSuggestions);