UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

570 lines (517 loc) • 20.8 kB
import { Component } from '../Base/Component'; import { ComponentOptions } from '../Base/ComponentOptions'; import { ComponentOptionsModel } from '../../models/ComponentOptionsModel'; import { IResultsComponentBindings } from '../Base/ResultsComponentBindings'; import { analyticsActionCauseList } from '../Analytics/AnalyticsActionListMeta'; import { IResultLinkOptions } from './ResultLinkOptions'; import { ResultListEvents } from '../../events/ResultListEvents'; import { HighlightUtils } from '../../utils/HighlightUtils'; import { IQueryResult } from '../../rest/QueryResult'; import { DeviceUtils } from '../../utils/DeviceUtils'; import { OS_NAME, OSUtils } from '../../utils/OSUtils'; import { Initialization } from '../Base/Initialization'; import { QueryUtils } from '../../utils/QueryUtils'; import { Assert } from '../../misc/Assert'; import { Utils } from '../../utils/Utils'; import { Defer } from '../../misc/Defer'; import { $$ } from '../../utils/Dom'; import { StreamHighlightUtils } from '../../utils/StreamHighlightUtils'; import { StringUtils } from '../../utils/StringUtils'; import { debounce, extend, escape } from 'underscore'; import { exportGlobally } from '../../GlobalExports'; import 'styling/_ResultLink'; import { AccessibleButton } from '../../utils/AccessibleButton'; import { bindAnalyticsToLink } from './ResultLinkCommon'; /** * The `ResultLink` component automatically transform a search result title into a clickable link pointing to the * original item. * * This component is a result template component (see [Result Templates](https://docs.coveo.com/en/413/)). */ export class ResultLink extends Component { static ID = 'ResultLink'; static doExport = () => { exportGlobally({ ResultLink: ResultLink }); }; /** * The options for the ResultLink * @componentOptions */ static options: IResultLinkOptions = { /** * Specifies the field to use to output the component `href` attribute value. * * **Tip:** * > Instead of specifying a value for the `field` option, you can directly add an `href` attribute to the * > `ResultLink` HTML element. Then, you can use a custom script to generate the `href` value. * * **Examples:** * - With the following markup, the `ResultLink` outputs its `href` value using the `@uri` field (rather than the * default field): * * ```html * <a class="CoveoResultLink" data-field="@uri"></a> * ``` * * - In the following result template, the custom `getMyKBUri()` function provides the `href` value: * * ```html * <script id="KnowledgeArticle" type="text/underscore" class="result-template"> * <div class='CoveoIcon>'></div> * <a class="CoveoResultLink" href="<%= getMyKBUri(raw) %>"></a> * <div class="CoveoExcerpt"></div> * </script> * ``` * * See also [`hrefTemplate`]{@link ResultLink.options.hrefTemplate}, which can override this option. * * By default, the component uses the `@clickUri` field of the item to output the value of its `href` attribute. */ field: ComponentOptions.buildFieldOption(), /** * Specifies whether the component should try to open its link in Microsoft Outlook. * * Setting this option to `true` is normally useful for `ResultLink` instances related to Microsoft Exchange emails. * * If this option is `true`, clicking the `ResultLink` calls the * [`openLinkInOutlook`]{@link ResultLink.openLinkInOutlook} method instead of the * [`openLink`]{@link ResultLink.openLink} method. * * Default value is `false`. */ openInOutlook: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * Specifies whether the component should open its link in the [`Quickview`]{@link Quickview} component rather than * loading through the original URL. * * Default value is `false`. */ openQuickview: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * Specifies whether the component should open its link in a new window instead of opening it in the current * context. * * If this option is `true`, clicking the `ResultLink` calls the * [`openLinkInNewWindow`]{@link ResultLink.openLinkInNewWindow} method instead of the * [`openLink`]{@link ResultLink.openLink} method. * * **Note:** * > If a search page contains a [`ResultPreferences`]{@link ResultsPreferences} component whose * > [`enableOpenInNewWindow`]{@link ResultsPreferences.options.enableOpenInNewWindow} option is `true`, and the end * > user checks the <b>Always open results in new window</b> box, `ResultLink` components in this page will always * > open their links in a new window when the end user clicks them, no matter what the value of their * > `alwaysOpenInNewWindow` option is. * * Default value is `false`. */ alwaysOpenInNewWindow: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * Specifies a template literal from which to generate the `ResultLink` `href` attribute value (see * [Template literals](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Template_literals)). * * This option overrides the [`field`]{@link ResultLink.options.field} option value. * * The template literal can reference any number of fields from the parent result. It can also reference global * scope properties. * * **Examples:** * * - The following markup generates an `href` value such as `http://uri.com?id=itemTitle`: * * ```html * <a class='CoveoResultLink' data-href-template='${clickUri}?id=${raw.title}'></a> * ``` * * - The following markup generates an `href` value such as `localhost/fooBar`: * * ```html * <a class='CoveoResultLink' data-href-template='${window.location.hostname}/{Foo.Bar}'></a> * ``` * * Default value is `undefined`. */ hrefTemplate: ComponentOptions.buildStringOption(), /** * Specifies a template literal from which to generate the `ResultLink` display title (see * [Template literals](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Template_literals)). * * This option overrides the default `ResultLink` display title behavior. * * The template literal can reference any number of fields from the parent result. However, if the template literal * references a key whose value is undefined in the parent result fields, the `ResultLink` title displays the * name of this key instead. * * This option is ignored if the `ResultLink` innerHTML contains any value. * * **Examples:** * * - The following markup generates a `ResultLink` display title such as `Case number: 123456` if both the * `raw.objecttype` and `raw.objectnumber` keys are defined in the parent result fields: * * ```html * <a class="CoveoResultLink" data-title-template="${raw.objecttype} number: ${raw.objectnumber}"></a> * ``` * * - The following markup generates `${myField}` as a `ResultLink` display title if the `myField` key is undefined * in the parent result fields: * * ```html * <a class="CoveoResultLink" data-title-template="${myField}"></a> * ``` * * - The following markup generates `Foobar` as a `ResultLink` display title, because the `ResultLink` innterHTML is * not empty: * * ```html * <a class="CoveoResultLink" data-title-template="${will} ${be} ${ignored}">Foobar</a> * ``` * * Default value is `undefined`. * * @availablesince [January 2017 Release (v1.1865.9)](https://docs.coveo.com/en/396/#january-2017-release-v118659) */ titleTemplate: ComponentOptions.buildStringOption(), /** * Specifies an event handler function to execute when the user clicks the `ResultLink` component. * * The handler function takes a JavaScript [`Event`](https://developer.mozilla.org/en/docs/Web/API/Event) object and * an [`IQueryResult`]{@link IQueryResult} as its parameters. * * Overriding the default behavior of the `onClick` event can allow you to execute specific code instead. * * **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)), * > or before the `init` call, using the `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 * // You can set the option in the 'init' call: * Coveo.init(document.querySelector("#search"), { * ResultLink : { * onClick : function(e, result) { * e.preventDefault(); * // Custom code to execute with the item URI and title. * openUriInASpecialTab(result.clickUri, result.title); * } * } * }); * * // Or before the 'init' call, using the 'options' top-level function: * // Coveo.options(document.querySelector('#search'), { * // ResultLink : { * // onClick : function(e, result) { * // e.preventDefault(); * // // Custom code to execute with the item URI and title. * // openUriInASpecialTab(result.clickUri, result.title); * // } * // } * // }); * ``` */ onClick: ComponentOptions.buildCustomOption<(e: Event, result: IQueryResult) => any>(() => { return null; }), /** * Specify this option to log additional analytics when this result link is pressed. * * **Example:** * ```javascript * const resultLink = new Coveo.ResultLink( * linkElement, * { * logAnalytics: (href) => Coveo.logCustomEvent( * Coveo.analyticsActionCauseList.openSmartSnippetSource, * { * searchQueryUid: searchInterface.queryController.lastSearchUid, * documentTitle: result.title, * author: Utils.getFieldValue(result, 'author'), * documentURL: href * }, * element * ) * }, * searchInterface.getBindings(), * result * ) * ``` */ logAnalytics: ComponentOptions.buildCustomOption<(href: string) => void>(() => null) }; private toExecuteOnOpen: (e?: Event) => void; /** * Creates a new `ResultLink` component. * @param element The HTMLElement on which to instantiate the component. * @param options The options for the `ResultLink` 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 result The result to associate the component with. * @param os */ constructor( public element: HTMLElement, public options: IResultLinkOptions, public bindings?: IResultsComponentBindings, public result?: IQueryResult, public os?: OS_NAME ) { super(element, ResultLink.ID, bindings); const globalOptions = this.searchInterface.options.originalOptionsObject[ResultLink.ID] || {}; const initialOptions = ComponentOptions.initComponentOptions(element, ResultLink, { ...globalOptions, ...options }); const resultLinkOptions = this.componentOptionsModel.get(ComponentOptionsModel.attributesEnum.resultLink); this.options = extend({}, initialOptions, resultLinkOptions); this.result = result || this.resolveResult(); if (this.options.openQuickview == null) { this.options.openQuickview = result.raw['connectortype'] == 'ExchangeCrawler' && DeviceUtils.isMobileDevice(); } if (!this.element.hasAttribute('tabindex')) { this.element.setAttribute('tabindex', '0'); } Assert.exists(this.componentOptionsModel); Assert.exists(this.result); if (!this.quickviewShouldBeOpened()) { // Bind on multiple "click" or "mouse" events. // Create a function that will be executed only once, so as not to log multiple events // Once a result link has been opened, and that we log at least one analytics event, // it should not matter if the end user open the same link multiple times with different methods. // It's still only one "click" event as far as UA is concerned. // Also need to handle "longpress" on mobile (the contextual menu), which we assume to be 1 s long. bindAnalyticsToLink(element, () => this.logAnalytics()); } this.renderUri(element, result); this.bindEventToOpen(); } public renderUri(element: HTMLElement, result?: IQueryResult) { if (/^\s*$/.test(this.element.innerHTML)) { const title = this.getDisplayedTitle(); this.element.innerHTML = title; const titleAsText = this.getDisplayedTitleAsText(); if (!this.element.hasAttribute('aria-label')) { this.element.setAttribute('aria-label', titleAsText); } if (!this.element.title) { this.element.title = titleAsText; } } } /** * Opens the result in the same window, no matter how the actual component is configured for the end user. * @param logAnalytics Specifies whether the method should log an analytics event. */ public openLink(logAnalytics = true) { if (logAnalytics) { this.logAnalytics(); } window.location.href = this.getResultUri(); } /** * Opens the result in a new window, no matter how the actual component is configured for the end user. * @param logAnalytics Specifies whether the method should log an analytics event. */ public openLinkInNewWindow(logAnalytics = true) { if (logAnalytics) { this.logAnalytics(); } window.open(this.getResultUri(), '_blank'); } /** * Tries to open the result in Microsoft Outlook if the result has an `outlookformacuri` or `outlookuri` field. * * Normally, this implies the result should be a link to an email. * * If the needed fields are not present, this method does nothing. * @param logAnalytics Specifies whether the method should log an analytics event. */ public openLinkInOutlook(logAnalytics = true) { if (this.hasOutlookField()) { if (logAnalytics) { this.logAnalytics(); } this.openLink(); } } /** * Opens the link in the same manner the end user would. * * This essentially simulates a click on the result link. * * @param logAnalytics Specifies whether the method should log an analytics event. */ public openLinkAsConfigured(logAnalytics = true) { if (this.toExecuteOnOpen) { if (logAnalytics) { this.logAnalytics(); } this.toExecuteOnOpen(); } } protected bindEventToOpen(): boolean { return ( this.bindOnClickIfNotUndefined() || this.bindOpenQuickviewIfNotUndefined() || this.setHrefIfNotAlready() || this.openLinkThatIsNotAnAnchor() ); } private getDisplayedTitle() { if (!this.options.titleTemplate) { return this.result.title ? HighlightUtils.highlightString(this.result.title, this.result.titleHighlights, null, 'coveo-highlight') : this.escapedClickUri; } else { let newTitle = StringUtils.buildStringTemplateFromResult(this.options.titleTemplate, this.result); return newTitle ? StreamHighlightUtils.highlightStreamText(newTitle, this.result.termsToHighlight, this.result.phrasesToHighlight) : this.escapedClickUri; } } private getDisplayedTitleAsText() { const container = $$('div'); container.setHtml(this.getDisplayedTitle()); return container.text(); } private get escapedClickUri() { return escape(this.result.clickUri); } private bindOnClickIfNotUndefined() { if (this.options.onClick != undefined) { this.toExecuteOnOpen = (e: MouseEvent) => { this.options.onClick.call(this, e, this.result); }; new AccessibleButton() .withElement(this.element) .withLabel(this.result.title) .withSelectAction((e: Event) => this.toExecuteOnOpen(e)) .build(); return true; } else { return false; } } private bindOpenQuickviewIfNotUndefined() { if (this.quickviewShouldBeOpened()) { this.toExecuteOnOpen = () => { $$(this.bindings.resultElement).trigger(ResultListEvents.openQuickview); }; $$(this.element).on('click', (e: Event) => { e.preventDefault(); this.toExecuteOnOpen(); }); return true; } else { return false; } } private openLinkThatIsNotAnAnchor() { if (!this.elementIsAnAnchor()) { this.toExecuteOnOpen = () => { if (this.options.alwaysOpenInNewWindow) { if (this.options.openInOutlook) { this.openLinkInOutlook(); } else { this.openLinkInNewWindow(); } } else { this.openLink(); } }; $$(this.element).on('click', () => { this.toExecuteOnOpen(); }); return true; } return false; } private setHrefIfNotAlready() { // Do not erase any value put in href by the template, etc. Allows // using custom click urls while still keeping analytics recording // and other behavior brought by the component. if (this.elementIsAnAnchor() && !Utils.isNonEmptyString($$(this.element).getAttribute('href'))) { $$(this.element).setAttribute('href', this.getResultUri()); if (this.options.alwaysOpenInNewWindow && !(this.options.openInOutlook && this.hasOutlookField())) { $$(this.element).setAttribute('target', '_blank'); } return true; } else { return false; } } private logDocumentOpen(href: string) { this.usageAnalytics.logClickEvent( analyticsActionCauseList.documentOpen, { documentURL: href, documentTitle: this.result.title, author: Utils.getFieldValue(this.result, 'author') }, this.result, this.root ); } private logAnalytics = debounce( () => { this.queryController.saveLastQuery(); let documentURL = $$(this.element).getAttribute('href'); if (documentURL == undefined || documentURL == '') { documentURL = this.escapedClickUri; } if (this.options.logAnalytics) { this.options.logAnalytics(documentURL); } else { this.logDocumentOpen(documentURL); } Defer.flush(); }, 1500, true ); private filterProtocol(uri: string) { const isAbsolute = /^(https?|ftp|file|mailto|tel|sip):/i.test(uri); const isRelative = /^(\/|\.\/|\.\.\/)/.test(uri); return isAbsolute || isRelative ? uri : ''; } private getResultUri(): string { if (this.options.hrefTemplate) { const uri = StringUtils.buildStringTemplateFromResult(this.options.hrefTemplate, this.result); return this.filterProtocol(uri); } if (this.options.field == undefined && this.options.openInOutlook) { this.setField(); } if (this.options.field != undefined) { return this.filterProtocol(Utils.getFieldValue(this.result, <string>this.options.field)); } return this.filterProtocol(this.result.clickUri); } private elementIsAnAnchor() { return this.element.tagName == 'A'; } private setField() { let os = Utils.exists(this.os) ? this.os : OSUtils.get(); if (os == OS_NAME.MACOSX && this.hasOutlookField()) { this.options.field = '@outlookformacuri'; } else if (os == OS_NAME.WINDOWS && this.hasOutlookField()) { this.options.field = '@outlookuri'; } } private hasOutlookField() { let os = Utils.exists(this.os) ? this.os : OSUtils.get(); if (os == OS_NAME.MACOSX && this.result.raw['outlookformacuri'] != undefined) { return true; } else if (os == OS_NAME.WINDOWS && this.result.raw['outlookuri'] != undefined) { return true; } return false; } private isUriThatMustBeOpenedInQuickview(): boolean { return this.escapedClickUri.toLowerCase().indexOf('ldap://') == 0; } private quickviewShouldBeOpened() { return (this.options.openQuickview || this.isUriThatMustBeOpenedInQuickview()) && QueryUtils.hasHTMLVersion(this.result); } } Initialization.registerAutoCreateComponent(ResultLink);