UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

307 lines (265 loc) 10.3 kB
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);