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