UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

460 lines (415 loc) • 15.7 kB
import { ModalBox as ModalBoxModule } from '../../ExternalModulesShim'; import { exportGlobally } from '../../GlobalExports'; import { Component } from '../Base/Component'; import { IComponentBindings } from '../Base/ComponentBindings'; import { QueryEvents, Initialization, $$ } from '../../Core'; import { IQuerySuccessEventArgs } from '../../events/QueryEvents'; import { IQuestionAnswerResponse } from '../../rest/QuestionAnswerResponse'; import 'styling/_SmartSnippet'; import { find, map, flatten, compact } from 'underscore'; import { IQueryResult } from '../../rest/QueryResult'; import { UserFeedbackBanner } from './UserFeedbackBanner'; import { analyticsActionCauseList, IAnalyticsNoMeta, IAnalyticsSmartSnippetFeedbackMeta, AnalyticsSmartSnippetFeedbackReason, IAnalyticsSmartSnippetOpenSourceMeta, IAnalyticsSmartSnippetOpenSnippetInlineLinkMeta } from '../Analytics/AnalyticsActionListMeta'; import { HeightLimiter } from './HeightLimiter'; import { ExplanationModal, IReason } from './ExplanationModal'; import { l } from '../../strings/Strings'; import { attachShadow } from '../../misc/AttachShadowPolyfill'; import { Utils } from '../../utils/Utils'; import { ComponentOptions } from '../Base/ComponentOptions'; import { transformSnippetLinks, getDefaultSnippetStyle, getSanitizedAnswerSnippet } from './SmartSnippetCommon'; import { ResultLink } from '../ResultLink/ResultLink'; import { IFieldOption } from '../Base/IComponentOptions'; interface ISmartSnippetReason { analytics: AnalyticsSmartSnippetFeedbackReason; localeKey: string; hasDetails?: boolean; } const reasons: ISmartSnippetReason[] = [ { analytics: AnalyticsSmartSnippetFeedbackReason.DoesNotAnswer, localeKey: 'UsefulnessFeedbackDoesNotAnswer' }, { analytics: AnalyticsSmartSnippetFeedbackReason.PartiallyAnswers, localeKey: 'UsefulnessFeedbackPartiallyAnswers' }, { analytics: AnalyticsSmartSnippetFeedbackReason.WasNotAQuestion, localeKey: 'UsefulnessFeedbackWasNotAQuestion' }, { analytics: AnalyticsSmartSnippetFeedbackReason.Other, localeKey: 'Other', hasDetails: true } ]; const BASE_CLASSNAME = 'coveo-smart-snippet'; const QUESTION_CLASSNAME = `${BASE_CLASSNAME}-question`; const ANSWER_CONTAINER_CLASSNAME = `${BASE_CLASSNAME}-answer`; const HAS_ANSWER_CLASSNAME = `${BASE_CLASSNAME}-has-answer`; const SHADOW_CLASSNAME = `${BASE_CLASSNAME}-content`; const CONTENT_CLASSNAME = `${BASE_CLASSNAME}-content-wrapper`; const SOURCE_CLASSNAME = `${BASE_CLASSNAME}-source`; const SOURCE_TITLE_CLASSNAME = `${SOURCE_CLASSNAME}-title`; const SOURCE_URL_CLASSNAME = `${SOURCE_CLASSNAME}-url`; export const SmartSnippetClassNames = { QUESTION_CLASSNAME, ANSWER_CONTAINER_CLASSNAME, HAS_ANSWER_CLASSNAME, SHADOW_CLASSNAME, CONTENT_CLASSNAME, SOURCE_CLASSNAME, SOURCE_TITLE_CLASSNAME, SOURCE_URL_CLASSNAME }; export interface ISmartSnippetOptions { maximumSnippetHeight: number; titleField: IFieldOption; hrefTemplate?: string; alwaysOpenInNewWindow?: boolean; useIFrame?: boolean; } /** * The SmartSnippet component displays the excerpt of a document that would be most likely to answer a particular query. * * This excerpt can be visually customized using inline styling. */ export class SmartSnippet extends Component { static ID = 'SmartSnippet'; static doExport = () => { exportGlobally({ SmartSnippet }); }; /** * The options for the SmartSnippet * @componentOptions */ static options: ISmartSnippetOptions = { /** * The maximum height an answer can have in pixels. * Any part of an answer exceeding this height will be hidden by default and expendable via a "show more" button. * Default value is `250`. */ maximumSnippetHeight: ComponentOptions.buildNumberOption({ defaultValue: 250, min: 0 }), /** * The field to display for the title. */ titleField: ComponentOptions.buildFieldOption({ defaultValue: '@title' }), /** * Specifies a template literal from which to generate the title and URI's `href` attribute value (see * [Template literals](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Template_literals)). * * This option overrides the [`field`]{@link SmartSnippet.options.uriField} 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='CoveoSmartSnippet' data-href-template='${clickUri}?id=${raw.title}'></a> * ``` * * - The following markup generates an `href` value such as `localhost/fooBar`: * * ```html * <a class='CoveoSmartSnippet' data-href-template='${window.location.hostname}/{Foo.Bar}'></a> * ``` * * Default value is `undefined`. */ hrefTemplate: ComponentOptions.buildStringOption(), /** * Specifies whether the component should open its links in a new window instead of opening them in the current * context. * * Default value is `false`. */ alwaysOpenInNewWindow: ComponentOptions.buildBooleanOption({ defaultValue: false }), /** * Specify if the SmartSnippet should be displayed inside an iframe or not. * * Use this option in specific cases where your environment has limitations around iframe usage. * * **Examples:** * * ```html * <div class='CoveoSmartSnippet' data-use-i-frame='true'></div> * ``` * * Default value is `true`. */ useIFrame: ComponentOptions.buildBooleanOption({ defaultValue: true }) }; private lastRenderedResult: IQueryResult = null; private searchUid: string; private questionContainer: HTMLElement; private shadowContainer: HTMLElement; private sourceContainer: HTMLElement; private snippetContainer: HTMLElement; private heightLimiter: HeightLimiter; private explanationModal: ExplanationModal; private feedbackBanner: UserFeedbackBanner; private shadowLoading: Promise<HTMLElement>; constructor( public element: HTMLElement, public options?: ISmartSnippetOptions, bindings?: IComponentBindings, private ModalBox = ModalBoxModule ) { super(element, SmartSnippet.ID, bindings); this.options = ComponentOptions.initComponentOptions(element, SmartSnippet, options); this.bind.onRootElement(QueryEvents.deferredQuerySuccess, (data: IQuerySuccessEventArgs) => this.handleQuerySuccess(data)); } public get loading() { return this.shadowLoading; } private get style() { const styles = $$(this.element) .children() .filter(element => element instanceof HTMLScriptElement && element.type.toLowerCase() === 'text/css') .map(element => element.innerHTML); return styles.length ? styles.join('\n') : null; } private set hasAnswer(hasAnswer: boolean) { $$(this.element).toggleClass(HAS_ANSWER_CLASSNAME, hasAnswer); } public createDom() { this.element.appendChild(this.buildAnswerContainer()); this.feedbackBanner = new UserFeedbackBanner( isUseful => (isUseful ? this.sendLikeSmartSnippetAnalytics() : this.sendDislikeSmartSnippetAnalytics()), () => this.openExplanationModal() ); this.element.appendChild(this.feedbackBanner.build()); this.explanationModal = new ExplanationModal({ reasons: reasons.map( reason => <IReason>{ label: l(reason.localeKey), id: reason.analytics.replace(/_/g, '-'), onSelect: () => this.sendExplanationAnalytics(reason.analytics, this.explanationModal.details), hasDetails: reason.hasDetails } ), onClosed: () => this.sendCloseFeedbackModalAnalytics(), ownerElement: this.searchInterface.options.modalContainer, modalBoxModule: this.ModalBox }); } private buildAnswerContainer() { return $$( 'div', { className: ANSWER_CONTAINER_CLASSNAME }, this.buildQuestion(), this.buildShadow(), this.buildHeightLimiter(), this.buildSourceContainer() ).el; } private buildQuestion() { return (this.questionContainer = $$('div', { className: QUESTION_CLASSNAME }).el); } private buildShadow() { this.shadowContainer = $$('div', { className: SHADOW_CLASSNAME }).el; this.snippetContainer = $$('section', { className: CONTENT_CLASSNAME }).el; this.shadowLoading = attachShadow(this.shadowContainer, { mode: 'open', title: l('AnswerSnippet'), onSizeChanged: () => this.handleAnswerSizeChanged(), useIFrame: this.options.useIFrame }).then(shadow => { shadow.appendChild(this.snippetContainer); const style = this.buildStyle(); shadow.appendChild(style); return shadow; }); return this.shadowContainer; } private buildHeightLimiter() { return (this.heightLimiter = new HeightLimiter( this.shadowContainer, this.shadowContainer.childNodes.item(0) as HTMLElement, this.options.maximumSnippetHeight, isExpanded => (isExpanded ? this.sendExpandSmartSnippetAnalytics() : this.sendCollapseSmartSnippetAnalytics()) )).toggleButton; } private buildSourceContainer() { return (this.sourceContainer = $$('div', { className: SOURCE_CLASSNAME }).el); } private buildStyle() { const style = Utils.isNullOrUndefined(this.style) ? getDefaultSnippetStyle(CONTENT_CLASSNAME) : this.style; const styleTag = document.createElement('style'); styleTag.innerHTML = style; return styleTag; } private handleAnswerSizeChanged() { this.heightLimiter.onContentHeightChanged(); } /** * @warning This method only works for the demo. In practice, the source of the answer will not always be part of the results. */ private getCorrespondingResult(questionAnswer: IQuestionAnswerResponse) { const lastResults = this.queryController.getLastResults().results; const childResults = flatten(map(lastResults, lastResult => lastResult.childResults)) as IQueryResult[]; const attachments = flatten(map(lastResults, lastResult => lastResult.attachments)) as IQueryResult[]; return find( compact(lastResults.concat(childResults, attachments)), result => result.raw[questionAnswer.documentId.contentIdKey] === questionAnswer.documentId.contentIdValue ); } private async handleQuerySuccess(data: IQuerySuccessEventArgs) { const { questionAnswer } = data.results; if (!this.containsQuestionAnswer(questionAnswer)) { this.hasAnswer = false; return; } this.hasAnswer = true; this.searchUid = data.results.searchUid; await this.render(questionAnswer); } private containsQuestionAnswer(questionAnswer: IQuestionAnswerResponse) { return questionAnswer && questionAnswer.question && questionAnswer.answerSnippet; } private async render(questionAnswer: IQuestionAnswerResponse) { this.ensureDom(); this.feedbackBanner.reset(); this.questionContainer.innerText = questionAnswer.question; this.renderSnippet(questionAnswer); this.lastRenderedResult = this.getCorrespondingResult(questionAnswer); if (this.lastRenderedResult) { this.renderSource(); } else { this.lastRenderedResult = null; } } private renderSnippet(questionAnswer: IQuestionAnswerResponse) { this.snippetContainer.innerHTML = getSanitizedAnswerSnippet(questionAnswer); transformSnippetLinks(this.snippetContainer, this.options.alwaysOpenInNewWindow, link => this.sendClickSnippetLinkAnalytics(link)); } private renderSource() { const container = $$(this.sourceContainer); container.empty(); container.append(this.renderSourceUrl().el); container.append(this.renderSourceTitle().el); } private renderSourceTitle() { const link = this.buildLink(SOURCE_TITLE_CLASSNAME); link.text(Utils.getFieldValue(this.lastRenderedResult, <string>this.options.titleField)); return link; } private renderSourceUrl() { const link = this.buildLink(SOURCE_URL_CLASSNAME); link.text((link.el as HTMLAnchorElement).href); return link; } private buildLink(className: string) { const element = $$('a', { className: 'CoveoResultLink' }); element.addClass(className); new ResultLink( element.el, { hrefTemplate: this.options.hrefTemplate, logAnalytics: href => this.sendClickSourceAnalytics(element.el, href), alwaysOpenInNewWindow: this.options.alwaysOpenInNewWindow }, { ...this.getBindings(), resultElement: this.element }, this.lastRenderedResult ); return element; } private openExplanationModal() { this.sendOpenFeedbackModalAnalytics(); this.explanationModal.open(this.feedbackBanner.explainWhy); } private sendLikeSmartSnippetAnalytics() { return this.usageAnalytics.logCustomEvent<IAnalyticsNoMeta>( analyticsActionCauseList.likeSmartSnippet, { searchQueryUid: this.searchUid }, this.element ); } private sendDislikeSmartSnippetAnalytics() { return this.usageAnalytics.logCustomEvent<IAnalyticsNoMeta>( analyticsActionCauseList.dislikeSmartSnippet, { searchQueryUid: this.searchUid }, this.element ); } private sendExpandSmartSnippetAnalytics() { return this.usageAnalytics.logCustomEvent<IAnalyticsNoMeta>( analyticsActionCauseList.expandSmartSnippet, { searchQueryUid: this.searchUid }, this.element ); } private sendCollapseSmartSnippetAnalytics() { return this.usageAnalytics.logCustomEvent<IAnalyticsNoMeta>( analyticsActionCauseList.collapseSmartSnippet, { searchQueryUid: this.searchUid }, this.element ); } private sendOpenFeedbackModalAnalytics() { return this.usageAnalytics.logCustomEvent<IAnalyticsNoMeta>( analyticsActionCauseList.openSmartSnippetFeedbackModal, { searchQueryUid: this.searchUid }, this.element ); } private sendCloseFeedbackModalAnalytics() { return this.usageAnalytics.logCustomEvent<IAnalyticsNoMeta>( analyticsActionCauseList.closeSmartSnippetFeedbackModal, { searchQueryUid: this.searchUid }, this.element ); } private sendExplanationAnalytics(reason: AnalyticsSmartSnippetFeedbackReason, details?: string) { return this.usageAnalytics.logCustomEvent<IAnalyticsSmartSnippetFeedbackMeta>( analyticsActionCauseList.sendSmartSnippetReason, { searchQueryUid: this.searchUid, reason, details }, this.element ); } private sendClickSourceAnalytics(element: HTMLElement, href: string) { return this.usageAnalytics.logClickEvent<IAnalyticsSmartSnippetOpenSourceMeta>( analyticsActionCauseList.openSmartSnippetSource, { searchQueryUid: this.searchUid, documentTitle: this.lastRenderedResult.title, author: Utils.getFieldValue(this.lastRenderedResult, 'author'), documentURL: href }, this.lastRenderedResult, element ); } private sendClickSnippetLinkAnalytics(link: HTMLAnchorElement) { return this.usageAnalytics.logClickEvent<IAnalyticsSmartSnippetOpenSnippetInlineLinkMeta>( analyticsActionCauseList.openSmartSnippetInlineLink, { searchQueryUid: this.searchUid, linkText: link.innerText, linkURL: link.href }, this.lastRenderedResult, link ); } } Initialization.registerAutoCreateComponent(SmartSnippet);