UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

434 lines (389 loc) • 18.3 kB
import { SearchInterface, ISearchInterfaceOptions } from '../SearchInterface/SearchInterface'; import { ComponentOptions } from '../Base/ComponentOptions'; import { QueryEvents, IQuerySuccessEventArgs, IBuildingQueryEventArgs } from '../../events/QueryEvents'; import { OmniboxEvents } from '../../events/OmniboxEvents'; import { ResultListEvents } from '../../events/ResultListEvents'; import { SettingsEvents } from '../../events/SettingsEvents'; import { PreferencesPanelEvents } from '../../events/PreferencesPanelEvents'; import { AnalyticsEvents } from '../../events/AnalyticsEvents'; import { analyticsActionCauseList, IAnalyticsNoMeta } from '../Analytics/AnalyticsActionListMeta'; import { BreadcrumbEvents } from '../../events/BreadcrumbEvents'; import { QuickviewEvents } from '../../events/QuickviewEvents'; import { QUERY_STATE_ATTRIBUTES } from '../../models/QueryStateModel'; import { Model, MODEL_EVENTS } from '../../models/Model'; import { Utils } from '../../utils/Utils'; import { $$ } from '../../utils/Dom'; import { INoResultsEventArgs } from '../../events/QueryEvents'; import { IQueryErrorEventArgs } from '../../events/QueryEvents'; import { IComponentBindings } from '../Base/ComponentBindings'; import { ResponsiveRecommendation } from '../ResponsiveComponents/ResponsiveRecommendation'; import { get } from '../Base/RegisteredNamedMethods'; import { InitializationEvents } from '../../events/InitializationEvents'; import { ComponentOptionsModel } from '../../models/ComponentOptionsModel'; import * as _ from 'underscore'; import { exportGlobally } from '../../GlobalExports'; import { DefaultRecommendationTemplate } from '../Templates/DefaultRecommendationTemplate'; import { RecommendationQuery } from './RecommendationQuery'; import { RecommendationAnalyticsClient } from '../Analytics/RecommendationAnalyticsClient'; import 'styling/_Recommendation'; import { IStringMap } from '../../rest/GenericParam'; import { l } from '../../strings/Strings'; export interface IRecommendationOptions extends ISearchInterfaceOptions { mainSearchInterface?: HTMLElement; userContext?: IStringMap<any>; id?: string; optionsToUse?: string[]; sendActionsHistory?: boolean; hideIfNoResults?: boolean; enableResponsiveMode?: boolean; responsiveBreakpoint?: number; dropdownHeaderLabel?: string; } /** * The Recommendation component is a {@link SearchInterface} that displays recommendations typically based on user * history. * * This component usually listens to the main SearchInterface. When the main SearchInterface generates a query, the * Recommendation component generates another query to get the recommendations at the same time. * * To get history-based recommendations, you will likely want to include the `pageview` script in your page (see * [coveo.analytics.js](https://github.com/coveo/coveo.analytics.js)). However, including this script is not mandatory. * For instance, you could use the Recommendation component without the Coveo Machine Learning service to create a * simple "recommended people" interface. * * It is possible to include this component inside another SearchInterface, but it is also possible to instantiate it as * a "standalone" search interface, without even instantiating a main SearchInterface component. In any case, a * Recommendation component always acts as a full-fledged search interface. Therefore, you can include any component * inside the Recommendation component (Searchbox, Facet, Sort, etc.), just as you would inside the main SearchInterface * component. * * @availablesince [July 2016 Release (v1.667.24)](https://docs.coveo.com/en/309/#july-2016-release-v166724) */ export class Recommendation extends SearchInterface implements IComponentBindings { static ID = 'Recommendation'; private static NEXT_ID = 1; static doExport = () => { exportGlobally({ Recommendation: Recommendation, DefaultRecommendationTemplate: DefaultRecommendationTemplate, RecommendationQuery: RecommendationQuery, RecommendationAnalyticsClient: RecommendationAnalyticsClient }); }; /** * The options for the recommendation component * @componentOptions */ static options: IRecommendationOptions = { /** * Specifies the main {@link SearchInterface} to listen to. */ mainSearchInterface: ComponentOptions.buildSelectorOption(), /** * Specifies the user context to send to Coveo usage analytics. * The component sends this information with the query alongside the user history to get the recommendations. */ userContext: ComponentOptions.buildJsonOption(), /** * Specifies the ID of the interface. * The usage analytics use the interface ID to know which recommendation interface was selected. * * Default value is `Recommendation` for the first one and `Recommendation_{number}`, where {number} depends on the * number of Recommendation interfaces with default IDs in the page for the others. */ id: ComponentOptions.buildStringOption(), /** * Specifies which options from the main {@link QueryBuilder} to use in the triggered query. * * Possible values are: * - `expression` * - `advancedExpression` * - `constantExpression` * - `disjunctionExpression` * * **Example:** * * Adding the expression (`q`) and the advanced expression (`aq`) parts of the main query in the triggered query: * * `data-options-to-use="expression,advancedExpression"` * * Default value is `expression`. */ optionsToUse: ComponentOptions.buildListOption<'expression' | 'advancedExpression' | 'constantExpression' | 'disjunctionExpression'>({ defaultValue: ['expression'] }), /** * Specifies whether to send the actions history along with the triggered query. * * Setting this option to `false` makes it impossible for this component to get Coveo Machine Learning * recommendations. * * However, setting this option to `false` can be useful to display side results in a search page. * * Default value is `true`. * * @deprecated This option is now deprecated. The correct way to control this behavior is to configure an appropriate machine learning model in the administration interface (Recommendation, Relevance tuning, Query suggestions). */ sendActionsHistory: ComponentOptions.buildBooleanOption({ defaultValue: true, deprecated: 'This option is now deprecated. The correct way to control this behaviour is to configure an appropriate machine learning model in the administration interface (Recommendation, Relevance tuning, Query suggestions)' }), /** * Specifies whether to hide the Recommendations component if no result or recommendation is available. * * Default value is `true`. */ hideIfNoResults: ComponentOptions.buildBooleanOption({ defaultValue: true }), autoTriggerQuery: ComponentOptions.buildBooleanOption({ postProcessing: (value: boolean, options: IRecommendationOptions) => { if (options.mainSearchInterface) { return false; } return value; } }), /** * Specifies whether to enable *responsive mode* for Recommendation components. Setting this options to `false` on * any Recommendation component in a search interface disables responsive mode for all other Recommendation * components in the search interface. * * Responsive mode displays all Recommendation components under a single dropdown button whenever the width of the * HTML element which the search interface is bound to reaches or falls behind a certain threshold (see * {@link Recommendation.options.responsiveBreakpoint}). * * See also {@link Recommendation.options.dropdownHeaderLabel}. * * Default value is `true`. */ enableResponsiveMode: ComponentOptions.buildBooleanOption({ defaultValue: true }), /** * If {@link Recommendation.options.enableResponsiveMode} is `true` for all Recommendation components, specifies the * width threshold (in pixels) of the search interface at which Recommendation components go in responsive mode. * * Recommendation components go in responsive mode when the width of the search interface is equal to or lower than * this value. * * The `search interface` corresponds to the HTML element with the class `CoveoSearchInterface`. * * If more than one Recommendation component in the search interface specifies a value for this option, then the * framework uses the last occurrence of the option. * * Default value is `1000`. */ responsiveBreakpoint: ComponentOptions.buildNumberOption({ defaultValue: 1000 }), /** * If {@link Recommendation.options.enableResponsiveMode} is `true` for all Recommendation components, specifies the * label of the dropdown button that allows to display the Recommendation components when in responsive mode. * * If more than one Recommendation component in the search interface specifies a value for this option, then the * framework uses the first occurrence of the option. * * Default value is `Recommendations`. */ dropdownHeaderLabel: ComponentOptions.buildLocalizedStringOption({ localizedString: () => l('Recommendations') }) }; // These are used by the analytics client for recommendation // so that clicks event inside the recommendation component can be modified and attached to the main search interface. public mainQuerySearchUID: string; public mainQueryPipeline: string; private mainInterfaceQuery: IQuerySuccessEventArgs; /** * Creates a new Recommendation component. * @param element The HTMLElement on which to instantiate the component. * @param options The options for the Recommendation 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) * @param _window */ constructor(public element: HTMLElement, public options: IRecommendationOptions = {}, public analyticsOptions = {}, _window = window) { super(element, ComponentOptions.initComponentOptions(element, Recommendation, options), analyticsOptions, _window); this.element.style.display = ''; if (!this.options.id) { this.generateDefaultId(); } // This is done to allow the component to be included in another search interface without triggering the parent events. this.preventEventPropagation(); if (this.options.mainSearchInterface) { this.bindToMainSearchInterface(); } $$(this.element).on(QueryEvents.buildingQuery, (e: Event, args: IBuildingQueryEventArgs) => this.handleRecommendationBuildingQuery(args) ); $$(this.element).on(QueryEvents.querySuccess, (e: Event, args: IQuerySuccessEventArgs) => this.handleRecommendationQuerySuccess(args)); $$(this.element).on(QueryEvents.noResults, (e: Event, args: INoResultsEventArgs) => this.handleRecommendationNoResults()); $$(this.element).on(QueryEvents.queryError, (e: Event, args: IQueryErrorEventArgs) => this.handleRecommendationQueryError()); if (!this.options.mainSearchInterface) { // When the recommendation component is "standalone", we add additional safeguard against bad config. this.ensureCurrentPageViewExistsInStore(); } ResponsiveRecommendation.init(this.root, this, options); } public get historyStore() { return this.queryController.historyStore; } public getId(): string { return this.options.id; } public enable() { super.enable(); this.show(); } public disable() { super.disable(); this.hide(); } public hide(): void { $$(this.element).addClass('coveo-hidden'); } public show(): void { $$(this.element).removeClass('coveo-hidden'); } private ensureCurrentPageViewExistsInStore() { // It's not 100% sure that the element will actually be added in the store. // It's possible that an external script configured by the end user to log the page view already did that. // So we *could* end up with duplicate values in the store : // We rely on the fact that the coveo.analytics lib has defensive code against consecutive page view that are identical. // This is mainly done if the recommendation component is being initialized before the page view could be logged correctly by the coveo.analytics externa lib. const historyElement = { name: 'PageView', value: document.location.toString(), time: JSON.stringify(new Date()), title: _.escape(document.title) }; this.historyStore.addElement(historyElement); } private bindToMainSearchInterface() { this.bindComponentOptionsModelToMainSearchInterface(); this.bindQueryEventsToMainSearchInterface(); } private bindComponentOptionsModelToMainSearchInterface() { // Try to fetch the componentOptions from the main search interface. // Since we do not know which interface is init first (recommendation or full search interface) // add a mechanism that waits for the full search interface to be correctly initialized // then, set the needed values on the component options model. let searchInterfaceComponent = <SearchInterface>get(this.options.mainSearchInterface, SearchInterface); const alreadyInitialized = searchInterfaceComponent != null; const onceInitialized = () => { const mainSearchInterfaceOptionsModel = <ComponentOptionsModel>searchInterfaceComponent.getBindings().componentOptionsModel; this.componentOptionsModel.setMultiple(mainSearchInterfaceOptionsModel.getAttributes()); $$(this.options.mainSearchInterface).on(this.componentOptionsModel.getEventName(MODEL_EVENTS.ALL), () => { this.componentOptionsModel.setMultiple(mainSearchInterfaceOptionsModel.getAttributes()); }); }; if (alreadyInitialized) { onceInitialized(); } else { $$(this.options.mainSearchInterface).on(InitializationEvents.afterComponentsInitialization, () => { searchInterfaceComponent = <SearchInterface>get(this.options.mainSearchInterface, SearchInterface); onceInitialized(); }); } } private bindQueryEventsToMainSearchInterface() { // Whenever a query sucessfully returns on the full search interface, refresh the recommendation component. $$(this.options.mainSearchInterface).on(QueryEvents.querySuccess, (e: Event, args: IQuerySuccessEventArgs) => { this.mainInterfaceQuery = args; this.mainQuerySearchUID = args.results.searchUid; this.mainQueryPipeline = args.results.pipeline; if (args.results.results.length != 0) { this.usageAnalytics.logSearchEvent<IAnalyticsNoMeta>(analyticsActionCauseList.recommendation, {}); this.queryController.executeQuery({ closeModalBox: false }); } }); $$(this.options.mainSearchInterface).on(QueryEvents.queryError, () => this.hide()); $$(this.options.mainSearchInterface).on(QueryEvents.noResults, () => this.hide()); } private handleRecommendationBuildingQuery(data: IBuildingQueryEventArgs) { if (!this.disabled) { this.modifyQueryForRecommendation(data); this.addRecommendationInfoInQuery(data); } } private handleRecommendationQuerySuccess(data: IQuerySuccessEventArgs) { if (!this.disabled) { if (this.options.hideIfNoResults) { if (data.results.totalCount === 0) { this.hide(); } else { this.show(); } } } } private handleRecommendationNoResults() { if (!this.disabled) { if (this.options.hideIfNoResults) { this.hide(); } } } private handleRecommendationQueryError() { if (!this.disabled) { this.hide(); } } private modifyQueryForRecommendation(data: IBuildingQueryEventArgs) { if (this.mainInterfaceQuery) { Utils.copyObjectAttributes(data.queryBuilder, this.mainInterfaceQuery.queryBuilder, this.options.optionsToUse); } } private addRecommendationInfoInQuery(data: IBuildingQueryEventArgs) { if (!_.isEmpty(this.options.userContext)) { data.queryBuilder.addContext(this.options.userContext); } data.queryBuilder.recommendation = this.options.id; } private preventEventPropagation() { this.preventEventPropagationOn(QueryEvents); this.preventEventPropagationOn(OmniboxEvents); this.preventEventPropagationOn(ResultListEvents); this.preventEventPropagationOn(SettingsEvents); this.preventEventPropagationOn(PreferencesPanelEvents); this.preventEventPropagationOn(AnalyticsEvents); this.preventEventPropagationOn(BreadcrumbEvents); this.preventEventPropagationOn(QuickviewEvents); this.preventEventPropagationOn(InitializationEvents); this.preventEventPropagationOn(this.getAllModelEvents()); } private preventEventPropagationOn( eventType, eventName = (event: string) => { return event; } ) { for (const event in eventType) { $$(this.root).on(eventName(event), (e: Event) => e.stopPropagation()); } } private getAllModelEvents() { const events = {}; const queryStateModel = this.getBindings().queryStateModel; _.each(_.values(Model.eventTypes), event => { const eventName = queryStateModel.getEventName(event); events[eventName] = eventName; _.each(_.values(QUERY_STATE_ATTRIBUTES), attribute => { const eventName = this.queryStateModel.getEventName(event + attribute); events[eventName] = eventName; }); }); return events; } private generateDefaultId() { let id = 'Recommendation'; if (Recommendation.NEXT_ID !== 1) { this.logger.warn( 'Generating another recommendation default id', 'Consider configuring a human friendly / meaningful id for this interface' ); id = id + '_' + Recommendation.NEXT_ID; } Recommendation.NEXT_ID++; this.options.id = id; } } // We do not register the Recommendation component since it is done with .coveo('initRecommendation')