coveo-search-ui
Version:
Coveo JavaScript Search Framework
307 lines (265 loc) • 10.3 kB
text/typescript
import 'styling/_MissingTerms';
import { $$, Initialization, l } from '../../Core';
import { exportGlobally } from '../../GlobalExports';
import { IQueryResult } from '../../rest/QueryResult';
import { Dom } from '../../utils/Dom';
import { analyticsActionCauseList, IAnalyticsMissingTerm } from '../Analytics/AnalyticsActionListMeta';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { MissingTermManager } from './MissingTermManager';
import XRegExp = require('xregexp');
import { intersection } from 'underscore';
import { Utils } from '../../UtilsModules';
export interface IMissingTermsOptions {
caption?: string;
clickable?: boolean;
numberOfTerms?: number;
}
/**
* This [result template component](https://docs.coveo.com/en/513/#using-result-template-components) renders a list of query terms
* that were not matched by the associated result item.
*
* @availablesince [July 2019 Release (v2.6459)](https://docs.coveo.com/en/2938/)
*/
export class MissingTerms extends Component {
static ID = 'MissingTerms';
/**
* @componentOptions
*/
static options: IMissingTermsOptions = {
/**
* Whether to allow the end-user to click a missing term to filter out items that do not match this term.
*
* **Default:** `true`
*/
clickable: ComponentOptions.buildBooleanOption({ defaultValue: true }),
/**
* The text to display before missing terms.
*
* **Default:** The localized string for `Missing`.
*/
caption: ComponentOptions.buildLocalizedStringOption({
localizedString: () => l('Missing')
}),
/**
* The maximum number of missing term to be displayed
*
* **Default:** `5`
* **Minimum value:** `1`
*/
numberOfTerms: ComponentOptions.buildNumberOption({
defaultValue: 5,
min: 1
})
};
static doExport = () => {
exportGlobally({
MissingTerms: MissingTerms
});
};
private termForcedToAppear: string[];
/**
* Creates a new `MissingTerms` component instance.
* @param element The element on which to instantiate the component.
* @param options The configuration options for the component.
* @param bindings The bindings required by the component to function normally. If not set, these will be automatically resolved (with a slower execution time).
* @param result The query result item to associate the component with.
*/
constructor(
public element: HTMLElement,
public options?: IMissingTermsOptions,
bindings?: IComponentBindings,
public result?: IQueryResult
) {
super(element, MissingTerms.ID, bindings);
this.options = ComponentOptions.initComponentOptions(element, MissingTerms, options);
this.addMissingTerms();
}
private get allResultTerms() {
let absentTerms = this.result.absentTerms;
if (this.result.attachments) {
absentTerms = this.intersectAbsentTerms(absentTerms, this.result.attachments);
}
if (this.result.childResults) {
absentTerms = this.intersectAbsentTerms(absentTerms, this.result.childResults);
}
return absentTerms;
}
private intersectAbsentTerms(absentTerms: string[], results: IQueryResult[]) {
return intersection(absentTerms, ...results.map(result => result.absentTerms));
}
/**
* Returns all original basic query expression terms and phrases that were not matched by the result item the component instance is associated with.
*/
public get missingTerms() {
return [...this.absentTerms, ...this.absentPhrases];
}
private get absentTerms() {
const absentTerms: string[] = [];
const terms = this.allResultTerms.filter(value => !this.isMissingPhrase(value));
for (const term of terms) {
const termMatch = this.queryMatch(term);
termMatch && absentTerms.push(termMatch);
}
return absentTerms;
}
private get absentPhrases() {
const absentPhrases: string[] = [];
const phrases = this.allResultTerms.filter(value => this.isMissingPhrase(value));
for (const phrase of phrases) {
const withoutQuotes = phrase.slice(1, -1);
const phraseMatch = this.queryMatch(withoutQuotes, true);
phraseMatch && absentPhrases.push(phraseMatch);
}
return absentPhrases;
}
private isMissingPhrase(value: string) {
return Utils.stringStartsWith(value, '"') && Utils.stringEndsWith(value, '"');
}
private queryMatch(term: string, stripBreakingCharacters = false): string | null {
const regex = this.createWordBoundaryDelimitedRegex(term);
const query: string = this.queryStateModel.get('q');
// Mimics the query received by the index
const queryToExec = stripBreakingCharacters ? query.replace(XRegExp(this.breakingCharacters, 'gi'), ' ') : query;
const result = regex.exec(queryToExec);
if (result) {
const originalKeywordInQuery = result[4];
return originalKeywordInQuery;
}
return null;
}
/**
* Injects a term in the advanced part of the query expression (aq) to filter out items that do not match the term.
* @param term The term to add to the advanced query expression.
*/
public addTermForcedToAppear(term: string) {
if (this.missingTerms.indexOf(term) === -1) {
this.logger.warn(
`Method execution aborted because the term to inject in aq ("${term}") is not a missing term.`,
`Allowed missing terms: ${this.missingTerms.toString()}.`
);
return;
}
this.updateTermForcedToAppear();
this.termForcedToAppear.push(term);
this.queryStateModel.set('missingTerms', [...this.termForcedToAppear]);
}
private updateTermForcedToAppear() {
this.termForcedToAppear = [...this.queryStateModel.get('missingTerms')];
}
private addMissingTerms() {
if (this.missingTerms.length === 0) {
return;
}
const missingTermElement = this.buildContainer();
if (missingTermElement.length > 1) {
this.hideMissingTermsOverTheNumberOfResults(missingTermElement);
missingTermElement.map(element => {
$$(this.element).append(element);
});
}
}
private buildContainer(): HTMLElement[] {
const elements: HTMLElement[] = [];
elements.push(this.buildCaption().el);
this.buildMissingTerms().forEach(term => {
if (term) {
elements.push(term.el);
}
});
return elements;
}
private buildCaption(): Dom {
return $$('span', { className: 'coveo-field-caption' }, this.options.caption);
}
private buildMissingTerms(): Dom[] {
const validTerms = this.absentTerms.filter(term => this.isValidTerm(term));
const validPhrases = this.absentPhrases.filter(phrase => this.isValidPhrase(phrase, validTerms));
return [...validTerms, ...validPhrases].map(term => {
return this.makeTermClickableIfEnabled(term);
});
}
private executeNewQuery(missingTerm: string = this.queryStateModel.get('q')) {
this.queryController.executeQuery();
}
private makeTermClickableIfEnabled(term: string): Dom {
if (this.options.clickable) {
const termElement = $$('button', { className: 'coveo-missing-term coveo-clickable', type: 'button' }, term);
termElement.on('click', () => {
this.addTermForcedToAppear(term);
this.logAnalyticsAddMissingTerm(term);
this.executeNewQuery(term);
});
return termElement;
} else {
return $$('span', { className: 'coveo-missing-term' }, term);
}
}
private createWordBoundaryDelimitedRegex(term: string): RegExp {
return XRegExp(`${MissingTermManager.wordBoundary}(${term})${MissingTermManager.wordBoundary}`, 'gi');
}
private containsFeaturedResults(term: string): boolean {
this.updateTermForcedToAppear();
return this.termForcedToAppear.indexOf(term) !== -1;
}
private hideMissingTermsOverTheNumberOfResults(elements: HTMLElement[]) {
const allMissingTerms = elements.filter(element => {
return element.tagName === 'BUTTON';
});
if (allMissingTerms.length <= this.options.numberOfTerms) {
return;
}
for (let index = this.options.numberOfTerms; index < allMissingTerms.length; index++) {
$$(allMissingTerms[index]).hide();
}
const nbMoreResults = allMissingTerms.length - this.options.numberOfTerms;
const showMore = $$(
'button',
{ className: 'coveo-missing-term-show-more coveo-clickable', type: 'button' },
l('NMore', [nbMoreResults])
);
showMore.on('click', () => {
this.showAllHiddenMissingTerms();
});
elements.push(showMore.el);
}
private showAllHiddenMissingTerms() {
const showMore = $$(this.element).find('.coveo-missing-term-show-more');
showMore.parentNode.removeChild(showMore);
const allMissingTerms = $$(this.element).findAll('.coveo-missing-term');
for (let index = this.options.numberOfTerms; index < allMissingTerms.length; index++) {
$$(allMissingTerms[index]).show();
allMissingTerms[index].removeAttribute('style');
}
}
private isValidTerm(term: string) {
return this.isNonBoundaryTerm(term) && !this.containsFeaturedResults(term);
}
private isValidPhrase(phrase: string, terms: string[]) {
return terms.every(term => {
const regex = this.createWordBoundaryDelimitedRegex(term);
const termInPhrase = regex.exec(phrase);
return !termInPhrase;
});
}
private get breakingCharacters() {
return `[-'?\*’.~=,\/\\\\:\`;_!&\(\)]+`;
}
private isNonBoundaryTerm(term: string) {
//p{L} is a Unicode script that matches any character in any language.
const wordWithBreakpoints = `\\p{L}*${this.breakingCharacters}\\p{L}*`;
const regex = XRegExp(wordWithBreakpoints, 'gi');
const query = this.queryStateModel.get('q');
const matches = query.match(regex) || [];
return matches.every((word: string) => {
return word.indexOf(term) === -1;
});
}
private logAnalyticsAddMissingTerm(term: string) {
this.usageAnalytics.logSearchEvent<IAnalyticsMissingTerm>(analyticsActionCauseList.addMissingTerm, {
missingTerm: term
});
}
}
Initialization.registerAutoCreateComponent(MissingTerms);