coveo-search-ui
Version:
Coveo JavaScript Search Framework
315 lines (265 loc) • 14.2 kB
text/typescript
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);