UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

520 lines (470 loc) • 20.1 kB
import { Assert } from '../../misc/Assert'; import { IQueryResult } from '../../rest/QueryResult'; import { Utils } from '../../utils/Utils'; import { JQueryUtils } from '../../utils/JQueryutils'; import { $$, Dom } from '../../utils/Dom'; import { QueryStateModel } from '../../models/QueryStateModel'; import { ComponentStateModel } from '../../models/ComponentStateModel'; import { ComponentOptionsModel } from '../../models/ComponentOptionsModel'; import { QueryController } from '../../controllers/QueryController'; import { SearchInterface } from '../../ui/SearchInterface/SearchInterface'; import { IAnalyticsClient } from '../../ui/Analytics/AnalyticsClient'; import { NoopAnalyticsClient } from '../../ui/Analytics/NoopAnalyticsClient'; import { BaseComponent } from './BaseComponent'; import { IComponentBindings } from './ComponentBindings'; import { DebugEvents } from '../../events/DebugEvents'; import * as _ from 'underscore'; import { Model } from '../../models/Model'; import { exportGlobally } from '../../GlobalExports'; /** * Definition for a Component. */ export interface IComponentDefinition { /** * The static ID that each component need to be identified.<br/> * For example, SearchButton -> static ID : SearchButton -> className : CoveoSearchButton */ ID: string; aliases?: string[]; /** * The generated `className` for this component.<br/> * For example, SearchButton -> static ID : SearchButton -> className : CoveoSearchButton */ className?: string; /** * Function that can be called to export one or multiple module in the global scope. */ doExport?: () => void; /** * Constructor for each component * @param element The HTMLElement on which the component will instantiate. * @param options The available options for the component. * @param bindings The bindings (or environment) for the component.For exemple, the {@link QueryController} or {@link SearchInterface}. Optional, if not provided, the component will resolve those automatically. This has a cost on performance, though, since it has to traverses it's parents to find the correct elements. * @param args Optional arguments, depending on the component type. For example, ResultComponent will receive the result there. */ new (element: HTMLElement, options: any, bindings: IComponentBindings, ...args: any[]): Component; /** * The available options for the component. */ options?: any; /** * The optional parent of the component, which will be a component itself. */ parent?: IComponentDefinition; /** * The optional index fields that the component possess or display. */ fields?: string[]; } /** * The `ComponentEvents` class is used by the various Coveo Component to trigger events and bind event handlers. It adds * logic to execute handlers or triggers only when a component is "enabled", which serves as a way to avoid executing * handlers on components that are invisible and inactive in the query. * * Typically, a component is disabled when it is not active in the current [`Tab`]{@link Tab}. It can also be disabled * by external code, however. * * To manually enable or disable a component, simply use its [`enable`]{@link Component.enable} or * [`disable`]{@link Component.disable} method. */ export class ComponentEvents { static doExport() { exportGlobally({ ComponentEvents: ComponentEvents }); } /** * Creates a new `ComponentEvents` instance for a [`Component`]{@link Component}. * @param owner The [`Component`]{@link Component} that owns the event handlers and triggers. */ constructor(public owner: Component) { Assert.exists(owner); } /** * Executes the handler for an event on a target element. * * Executes only if the component is enabled (see the [`enable`]{@link Component.enable} method). * @param el The element from which the event originates. * @param event The event for which to register a handler. * @param handler The function to execute when the event is triggered. */ public on(el: HTMLElement | Window | Document, event: string, handler: Function); public on(el: Dom, event: string, handler: Function); public on(arg: any, event: string, handler: Function) { if (this.shouldTreatElementAsJQuery(arg)) { arg.on(event, this.wrapToCallIfEnabled(handler)); } else { $$(arg).on(event, this.wrapToCallIfEnabled(handler)); } } /** * Executes the handler for the given event on the given target element.<br/> * Execute only if the component is "enabled" (see {@link Component.enable}).<br/> * Execute the handler only ONE time. * @param el The target on which the event will originate. * @param event The event for which to register a handler. * @param handler The function to execute when the event is triggered. */ public one(el: HTMLElement, event: string, handler: Function); public one(el: Dom, event: string, handler: Function); public one(arg: any, event: string, handler: Function) { if (this.shouldTreatElementAsJQuery(arg)) { arg.one(event, this.wrapToCallIfEnabled(handler)); } else { $$(arg).one(event, this.wrapToCallIfEnabled(handler)); } } /** * Bind on the "root" of the Component. The root is typically the {@link SearchInterface}.<br/> * Bind an event using native javascript code. * @param event The event for which to register a handler. * @param handler The function to execute when the event is triggered. */ public onRootElement<T>(event: string, handler: (args: T) => any) { this.on(this.owner.root, event, handler); } /** * Bind on the "root" of the Component. The root is typically the {@link SearchInterface}.<br/> * Bind an event using native javascript code. * The handler will execute only ONE time. * @param event The event for which to register a handler. * @param handler The function to execute when the event is triggered. */ public oneRootElement<T>(event: string, handler: (args: T) => any) { this.one(this.owner.root, event, handler); } /** * Bind an event related specially to the query state model.<br/> * This will build the correct string event and execute the handler only if the component is activated. * @param eventType The event type for which to register an event. * @param attribute The attribute for which to register an event. * @param handler The handler to execute when the query state event is triggered. */ public onQueryState<T>(eventType: string, attribute?: string, handler?: (args: T) => any) { this.onRootElement(this.getQueryStateEventName(eventType, attribute), handler); } /** * Bind an event related specially to the component option model. * This will build the correct string event and execute the handler only if the component is activated. * @param eventType The event type for which to register an event. * @param attribute The attribute for which to register an event. * @param handler The handler to execute when the query state event is triggered. */ public onComponentOptions<T>(eventType: string, attribute?: string, handler?: (args: T) => any) { this.onRootElement(this.getComponentOptionEventName(eventType, attribute), handler); } /** * Bind an event related specially to the query state model.<br/> * This will build the correct string event and execute the handler only if the component is activated.<br/> * Will execute only once. * @param eventType The event type for which to register an event. * @param attribute The attribute for which to register an event. * @param handler The handler to execute when the query state event is triggered. */ public oneQueryState<T>(eventType: string, attribute?: string, handler?: (args: T) => any) { this.oneRootElement(this.getQueryStateEventName(eventType, attribute), handler); } /** * Trigger an event on the target element, with optional arguments. * @param el The target HTMLElement on which to trigger the event. * @param event The event to trigger. * @param args The optional argument to pass to the handlers. */ public trigger(el: HTMLElement, event: string, args?: Object); public trigger(el: Dom, event: string, args?: Object); public trigger(arg: any, event: string, args?: Object) { this.wrapToCallIfEnabled(() => { if (this.shouldTreatElementAsJQuery(arg)) { arg.trigger(event, args); } else { $$(arg).trigger(event, args); } })(args); } /** * Execute the function only if the component is enabled. * @param func The function to execute if the component is enabled. * @returns {function(...[any]): *} */ protected wrapToCallIfEnabled(func: Function) { return (...args: any[]) => { if (!this.owner.disabled) { if (args && args[0] instanceof CustomEvent) { if (args[0].detail) { args = [args[0].detail]; } } else if (args && this.shouldTreatEventAsJQuery(args[0])) { if (args[1] != undefined) { args = [args[1]]; } else if (args[0].hasOwnProperty('originalEvent')) { // If the event is a jQuery Event but no args are found, let's use the ones of the originalEvent, if any. args = [args[0].originalEvent]; } else { args = []; } } return func.apply(this.owner, args); } }; } private shouldTreatElementAsJQuery(arg: any) { if (Dom.useNativeJavaScriptEvents === true) { return false; } if (JQueryUtils.getJQuery() && JQueryUtils.isInstanceOfJQuery(arg)) { return true; } return false; } private shouldTreatEventAsJQuery(arg: any) { if (Dom.useNativeJavaScriptEvents === true) { return false; } return JQueryUtils.getJQuery() && JQueryUtils.isInstanceOfJqueryEvent(arg); } private getQueryStateEventName(eventType: string, attribute?: string): string { return this.getModelEvent(this.owner.queryStateModel, eventType, attribute); } private getComponentOptionEventName(eventType: string, attribute?: string): string { return this.getModelEvent(this.owner.componentOptionsModel, eventType, attribute); } private getModelEvent(model: Model, eventType: string, attribute?: string) { var evtName; if (eventType && attribute) { evtName = model.getEventName(eventType + attribute); } else { evtName = model.getEventName(eventType); } return evtName; } } /** * The base class for every component in the framework. */ export class Component extends BaseComponent { static ComponentEventClass: typeof ComponentEvents = ComponentEvents; /** * Allows the component to bind events and execute them only when it is enabled. * @type {Coveo.ComponentEvents} */ public bind: ComponentEvents; /** * A reference to the root HTMLElement (the {@link SearchInterface}). */ public root: HTMLElement; /** * Contains the state of the query. Allows to get/set values. Trigger query state event when modified. Each component can listen to those events. */ public queryStateModel: QueryStateModel; /** * Contains the state of different components (enabled vs disabled). Allows to get/set values. Triggers component state event when modified. Each component can listen to those events. */ public componentStateModel: ComponentStateModel; /** * Contains the singleton that allows to trigger queries. */ public queryController: QueryController; /** * A reference to the root of every component, the {@link SearchInterface}. */ public searchInterface: SearchInterface; /** * Contains the state of options for different components. Mainly used by {@link ResultLink}. */ public componentOptionsModel: ComponentOptionsModel; public ensureDom: Function; public options?: any; /** * Create a new Component. Resolve all {@link IComponentBindings} if not provided.<br/> * Create a new Logger for this component. * Attach the component to the {@link SearchInterface}.<br/> * @param element The HTMLElement on which to create the component. Used to bind data on the element. * @param type The unique identifier for this component. See: {@link IComponentDefinition.ID}. Used to generate the unique Coveo CSS class associated with every component. * @param bindings The environment for every component. Optional, but omitting to provide one will impact performance. */ constructor(public element: HTMLElement, public type: string, bindings: IComponentBindings = {}) { super(element, type); this.bind = new Component.ComponentEventClass(this); this.root = bindings.root || Component.resolveRoot(element); this.queryStateModel = bindings.queryStateModel || this.resolveQueryStateModel(); this.componentStateModel = bindings.componentStateModel || this.resolveComponentStateModel(); this.queryController = bindings.queryController || this.resolveQueryController(); this.searchInterface = bindings.searchInterface || this.resolveSearchInterface(); this.componentOptionsModel = bindings.componentOptionsModel || this.resolveComponentOptionsModel(); this.ensureDom = _.once(() => this.createDom()); if (this.searchInterface != null) { this.searchInterface.attachComponent(type, this); } this.initDebugInfo(); } /** * Return the bindings, or environment, for the current component. * @returns {IComponentBindings} */ public getBindings(): IComponentBindings { return <IComponentBindings>{ root: this.root, queryStateModel: this.queryStateModel, queryController: this.queryController, searchInterface: this.searchInterface, componentStateModel: this.componentStateModel, componentOptionsModel: this.componentOptionsModel, usageAnalytics: this.usageAnalytics }; } /** * A reference to the {@link Analytics.client}. */ public get usageAnalytics() { return this.resolveUA(); } public createDom() { // By default we do nothing } public resolveSearchInterface(): SearchInterface { return <SearchInterface>Component.resolveBinding(this.element, SearchInterface); } public resolveQueryController(): QueryController { return <QueryController>Component.resolveBinding(this.element, QueryController); } public resolveComponentStateModel(): ComponentStateModel { return <ComponentStateModel>Component.resolveBinding(this.element, ComponentStateModel); } public resolveQueryStateModel(): QueryStateModel { return <QueryStateModel>Component.resolveBinding(this.element, QueryStateModel); } public resolveComponentOptionsModel(): ComponentOptionsModel { return <ComponentOptionsModel>Component.resolveBinding(this.element, ComponentOptionsModel); } public resolveUA(): IAnalyticsClient { return this.searchInterface && this.searchInterface.usageAnalytics ? this.searchInterface.usageAnalytics : new NoopAnalyticsClient(); } public resolveResult(): IQueryResult { return Component.getResult(this.element); } protected removeTabSupport() { if (this.element.hasAttribute('data-tab')) { this.logger.warn('The "data-tab" attribute is not supported for this component and was removed.'); this.element.removeAttribute('data-tab'); } if (this.element.hasAttribute('data-tab-not')) { this.logger.warn('The "data-tab-not" attribute is not supported for this component and was removed.'); this.element.removeAttribute('data-tab-not'); } } private initDebugInfo() { $$(this.element).on('dblclick', (e: MouseEvent) => { if (e.altKey) { var debugInfo = this.debugInfo(); if (debugInfo != null) { $$(this.root).trigger(DebugEvents.showDebugPanel, this.debugInfo()); } } }); } /** * Get the bound component to the given HTMLElement. Throws an assert if the HTMLElement has no component bound, unless using the noThrow argument.<br/> * If there is multiple component bound to the current HTMLElement, you must specify the component class. * @param element HTMLElement for which to get the bound component. * @param componentClass Optional component class. If the HTMLElement has multiple components bound, you must specify which one you are targeting. * @param noThrow Boolean option to tell the method to not throw on error. * @returns {Component} */ static get(element: HTMLElement, componentClass?: any, noThrow?: boolean): BaseComponent { Assert.exists(element); if (_.isString(componentClass)) { return <Component>element[Component.computeCssClassNameForType(componentClass)]; } else if (Utils.exists(componentClass)) { Assert.exists(componentClass.ID); return <Component>element[Component.computeCssClassNameForType(componentClass.ID)]; } else { // No class specified, but we support returning the bound component // if there is exactly one. var boundComponents = BaseComponent.getBoundComponentsForElement(element); if (!noThrow) { Assert.check( boundComponents.length <= 1, 'More than one component is bound to this element. You need to specify the component type.' ); } return boundComponents[0]; } } static getResult(element: HTMLElement, noThrow: boolean = false): IQueryResult { var resultElement = $$(element).closest('.CoveoResult'); Assert.check(noThrow || resultElement != undefined); return resultElement['CoveoResult']; } static bindResultToElement(element: HTMLElement, result: IQueryResult) { Assert.exists(element); Assert.exists(result); $$(element).addClass('CoveoResult'); element['CoveoResult'] = result; let jQuery = JQueryUtils.getJQuery(); if (jQuery) { jQuery(element).data(result); } } static bindFoldedResultToElement(element: HTMLElement) { Assert.exists(element); $$(element).addClass('coveo-result-folding-child-result'); } static resolveRoot(element: HTMLElement): HTMLElement { Assert.exists(element); const resolvedSearchInterface = Component.resolveBinding(element, SearchInterface); if (resolvedSearchInterface) { return resolvedSearchInterface.element; } const resolvedRootFromParent = Component.findRootInParents(element); if (resolvedRootFromParent) { return resolvedRootFromParent; } return document.body; } static resolveBinding(element: HTMLElement, componentClass: any): BaseComponent { Assert.exists(element); Assert.exists(componentClass); Assert.exists(componentClass.ID); const targetClassName = Component.computeCssClassNameForType(componentClass.ID); let found: HTMLElement; if ($$(element).is('.' + targetClassName)) { found = element; } else { // first, look down const findDown = $$(element).findClass(targetClassName); if (findDown && findDown.length !== 0) { found = findDown[0]; } else { const findUp = $$(element).closest(targetClassName); if (findUp) { found = findUp; } } } if (found) { return <BaseComponent>found[targetClassName]; } else { return undefined; } } static pointElementsToDummyForm(element: HTMLElement) { let inputs = $$(element).is('input') ? [element] : []; inputs = inputs.concat($$(element).findAll('input')); _.each(_.compact(inputs), input => { input.setAttribute('form', 'coveo-dummy-form'); }); } private static findRootInParents(element: HTMLElement): HTMLElement | null { const boundComponents = Component.getBoundComponentsForElement(element); for (let i = 0; i < boundComponents.length; i++) { const component = boundComponents[i]; if (component instanceof Component) { return component.root; } } if (!element.parentElement) { return null; } return Component.findRootInParents(element.parentElement); } }