UNPKG

coveo-search-ui

Version:

Coveo JavaScript Search Framework

634 lines (559 loc) • 19.9 kB
import { ComponentOptions } from '../Base/ComponentOptions'; import { LocalStorageUtils } from '../../utils/LocalStorageUtils'; import { ResultListEvents, IDisplayedNewResultEventArgs } from '../../events/ResultListEvents'; import { DebugEvents } from '../../events/DebugEvents'; import { IQueryResults } from '../../rest/QueryResults'; import { IQueryResult } from '../../rest/QueryResult'; import { $$, Dom } from '../../utils/Dom'; import { StringUtils } from '../../utils/StringUtils'; import { SearchEndpoint } from '../../rest/SearchEndpoint'; import { RootComponent } from '../Base/RootComponent'; import { BaseComponent } from '../Base/BaseComponent'; import { ModalBox as ModalBoxModule } from '../../ExternalModulesShim'; import Globalize = require('globalize'); import * as _ from 'underscore'; import 'styling/_Debug'; import { l } from '../../strings/Strings'; import { IComponentBindings } from '../Base/ComponentBindings'; import { DebugHeader } from './DebugHeader'; import { QueryEvents, IQuerySuccessEventArgs } from '../../events/QueryEvents'; import { DebugForResult } from './DebugForResult'; import { exportGlobally } from '../../GlobalExports'; import { Template } from '../Templates/Template'; export interface IDebugOptions { enableDebug?: boolean; } export class Debug extends RootComponent { static ID = 'Debug'; static doExport = () => { exportGlobally({ Debug: Debug }); }; static options: IDebugOptions = { enableDebug: ComponentOptions.buildBooleanOption({ defaultValue: false }) }; static customOrder = ['error', 'queryDuration', 'result', 'fields', 'rankingInfo', 'template', 'query', 'results', 'state']; static durationKeys = ['indexDuration', 'proxyDuration', 'clientDuration', 'duration']; static maxDepth = 10; public localStorageDebug: LocalStorageUtils<string[]>; public collapsedSections: string[]; private modalBox: Coveo.ModalBox.ModalBox; private opened = false; private stackDebug: any; private debugHeader: DebugHeader; public showDebugPanel: () => void; constructor( public element: HTMLElement, public bindings: IComponentBindings, public options?: IDebugOptions, public ModalBox = ModalBoxModule ) { super(element, Debug.ID); this.options = ComponentOptions.initComponentOptions(element, Debug, options); // This gets debounced so the following logic works correctly : // When you alt dbl click on a component, it's possible to add/merge multiple debug info source together // They will be merged together in this.addInfoToDebugPanel // Then, openModalBox, even if it's called from multiple different caller will be opened only once all the info has been merged together correctly this.showDebugPanel = _.debounce(() => this.openModalBox(), 100); $$(this.element).on(ResultListEvents.newResultDisplayed, (e, args: IDisplayedNewResultEventArgs) => this.handleNewResultDisplayed(args) ); $$(this.element).on(DebugEvents.showDebugPanel, (e, args) => this.handleShowDebugPanel(args)); $$(this.element).on(QueryEvents.querySuccess, (e, args: IQuerySuccessEventArgs) => this.handleQuerySuccess(args)); $$(this.element).on(QueryEvents.newQuery, () => this.handleNewQuery()); this.localStorageDebug = new LocalStorageUtils<string[]>('DebugPanel'); this.collapsedSections = this.localStorageDebug.load() || []; } public debugInfo() { return null; } public addInfoToDebugPanel(info: any) { if (this.stackDebug == null) { this.stackDebug = {}; } this.stackDebug = { ...this.stackDebug, ...info }; } private handleNewResultDisplayed(args: IDisplayedNewResultEventArgs) { $$(args.item).on('dblclick', (e: MouseEvent) => { this.handleResultDoubleClick(e, args); }); } private handleResultDoubleClick(e: MouseEvent, args: IDisplayedNewResultEventArgs) { if (e.altKey) { const index = args.result.index; const template = args.item['template']; const findResult = (results?: IQueryResults) => results != null ? _.find(results.results, (result: IQueryResult) => result.index == index) : args.result; const debugInfo = { ...new DebugForResult(this.bindings).generateDebugInfoForResult(args.result), findResult, template: this.templateToJson(template) }; this.addInfoToDebugPanel(debugInfo); this.showDebugPanel(); } } private handleQuerySuccess(args: IQuerySuccessEventArgs) { if (this.opened) { if (this.stackDebug && this.stackDebug.findResult) { this.addInfoToDebugPanel(new DebugForResult(this.bindings).generateDebugInfoForResult(this.stackDebug.findResult(args.results))); } this.redrawDebugPanel(); this.hideAnimationDuringQuery(); } } private handleNewQuery() { if (this.opened) { this.showAnimationDuringQuery(); } } private handleShowDebugPanel(args: any) { this.addInfoToDebugPanel(args); this.showDebugPanel(); } private buildStackPanel(): { body: HTMLElement; json: any } { const body = $$('div', { className: 'coveo-debug' }); const keys = _.chain(this.stackDebug) .omit('findResult') // findResult is a duplicate of the simpler "result" key used to retrieve the results only .keys() .value(); // TODO Can't chain this properly due to a bug in underscore js definition file. // Yep, A PR is opened to DefinitelyTyped. let keysPaired = _.pairs(keys); keysPaired = keysPaired.sort((a: any[], b: any[]) => { const indexA = _.indexOf(Debug.customOrder, a[1]); const indexB = _.indexOf(Debug.customOrder, b[1]); if (indexA != -1 && indexB != -1) { return indexA - indexB; } if (indexA != -1) { return -1; } if (indexB != -1) { return 1; } return a[0] - b[0]; }); const json = {}; _.forEach(keysPaired, (key: string[]) => { const section = this.buildSection(key[1]); const build = this.buildStackPanelSection(this.stackDebug[key[1]], this.stackDebug['result']); section.container.append(build.section); if (build.json != null) { json[key[1]] = build.json; } body.append(section.dom.el); }); return { body: body.el, json: json }; } private getModalBody() { if (this.modalBox && this.modalBox.content) { return $$(this.modalBox.content).find('.coveo-modal-body'); } return null; } private redrawDebugPanel() { const build = this.buildStackPanel(); const body = this.getModalBody(); if (body) { $$(body).empty(); $$(body).append(build.body); } this.updateSearchFunctionnality(build); } private openModalBox() { const build = this.buildStackPanel(); this.opened = true; this.modalBox = this.ModalBox.open(build.body, { title: l('Debug'), className: 'coveo-debug', titleClose: true, overlayClose: true, validation: () => { this.onCloseModalBox(); return true; }, sizeMod: 'big', body: this.bindings.root }); const title = $$(this.modalBox.wrapper).find('.coveo-modal-header'); if (title) { if (!this.debugHeader) { this.debugHeader = new DebugHeader(this, title, (value: string) => this.search(value, build.body), this.stackDebug); } else { this.debugHeader.moveTo(title); this.updateSearchFunctionnality(build); } } else { this.logger.warn('No title found in modal box.'); } } private updateSearchFunctionnality(build: { body: HTMLElement; json: any }) { if (this.debugHeader) { this.debugHeader.setNewInfoToDebug(this.stackDebug); this.debugHeader.setSearch((value: string) => this.search(value, build.body)); } } private onCloseModalBox() { this.stackDebug = null; this.opened = false; } private buildStackPanelSection(value: any, results: IQueryResults): { section: HTMLElement; json?: any } { if (value instanceof HTMLElement) { return { section: value }; } else if (_.isFunction(value)) { return this.buildStackPanelSection(value(results), results); } const json = this.toJson(value); return { section: this.buildProperty(json), json: json }; } private findInProperty(element: HTMLElement, value: string): boolean { const wrappedElement = $$(element); let match = element['label'].indexOf(value) != -1; if (match) { this.highlightSearch(element['labelDom'], value); } else { this.removeHighlightSearch(element['labelDom']); } if (wrappedElement.hasClass('coveo-property-object')) { wrappedElement.toggleClass('coveo-search-match', match); const children = element['buildKeys'](); let submatch = false; _.each(children, (child: HTMLElement) => { submatch = this.findInProperty(child, value) || submatch; }); wrappedElement.toggleClass('coveo-search-submatch', submatch); return match || submatch; } else { if (element['values'].indexOf(value) != -1) { this.highlightSearch(element['valueDom'], value); match = true; } else { this.removeHighlightSearch(element['valueDom']); } wrappedElement.toggleClass('coveo-search-match', match); } return match; } private buildSection(id: string) { const dom = $$('div', { className: `coveo-section coveo-${id}-section` }); const header = $$('div', { className: 'coveo-section-header' }); $$(header).text(id); dom.append(header.el); const container = $$('div', { className: 'coveo-section-container' }); dom.append(container.el); if (_.contains(this.collapsedSections, id)) { $$(dom).addClass('coveo-debug-collapsed'); } header.on('click', () => { $$(dom).toggleClass('coveo-debug-collapsed'); if (_.contains(this.collapsedSections, id)) { this.collapsedSections = _.without(this.collapsedSections, id); } else { this.collapsedSections.push(id); } this.localStorageDebug.save(this.collapsedSections); }); return { dom: dom, header: header, container: container }; } private buildProperty(value: any, label?: string): HTMLElement { if (value instanceof Promise) { return this.buildPromise(value, label); } else if ((_.isArray(value) || _.isObject(value)) && !_.isString(value)) { return this.buildObjectProperty(value, label); } else { return this.buildBasicProperty(value, label); } } private buildPromise(promise: Promise<any>, label?: string): HTMLElement { const dom = $$('div', { className: 'coveo-property coveo-property-promise' }); promise.then(value => { const resolvedDom = this.buildProperty(value, label); dom.replaceWith(resolvedDom); }); return dom.el; } private buildObjectProperty(object: any, label?: string): HTMLElement { const dom = $$('div', { className: 'coveo-property coveo-property-object' }); const valueContainer = $$('div', { className: 'coveo-property-value' }); const keys = _.keys(object); if (!_.isArray(object)) { keys.sort(); } let children: HTMLElement[]; const buildKeys = () => { if (children == null) { children = []; _.each(keys, (key: string) => { const property = this.buildProperty(object[key], key); if (property != null) { children.push(property); valueContainer.append(property); } }); } return children; }; dom.el['buildKeys'] = buildKeys; if (label != null) { const labelDom = $$('div', { className: 'coveo-property-label' }); labelDom.text(label); dom.el['labelDom'] = labelDom.el; dom.append(labelDom.el); if (keys.length != 0) { dom.addClass('coveo-collapsible'); labelDom.on('click', () => { buildKeys(); let className = dom.el.className.split(/\s+/); if (_.contains(className, 'coveo-expanded')) { className = _.without(className, 'coveo-expanded'); } else { className.push('coveo-expanded'); } dom.el.className = className.join(' '); }); } } else { buildKeys(); } if (keys.length == 0) { const className = _.without(dom.el.className.split(/\s+/), 'coveo-property-object'); className.push('coveo-property-basic'); dom.el.className = className.join(' '); if (_.isArray(object)) { valueContainer.setHtml('[]'); } else { valueContainer.setHtml('{}'); } dom.el['values'] = ''; } dom.el['label'] = label != null ? label.toLowerCase() : ''; dom.append(valueContainer.el); return dom.el; } private buildBasicProperty(value: string, label?: string): HTMLElement { const dom = $$('div', { className: 'coveo-property coveo-property-basic' }); if (label != null) { const labelDom = $$('div', { className: 'coveo-property-label' }); labelDom.text(label); dom.append(labelDom.el); dom.el['labelDom'] = labelDom.el; } const stringValue = value != null ? value.toString() : String(value); if (value != null && value['ref'] != null) { value = value['ref']; } const valueDom = $$('div'); valueDom.text(stringValue); valueDom.on('dblclick', () => { this.selectElementText(valueDom.el); }); dom.append(valueDom.el); dom.el['valueDom'] = valueDom; const className: string[] = ['coveo-property-value']; if (_.isString(value)) { className.push('coveo-property-value-string'); } if (_.isNull(value) || _.isUndefined(value)) { className.push('coveo-property-value-null'); } if (_.isNumber(value)) { className.push('coveo-property-value-number'); } if (_.isBoolean(value)) { className.push('coveo-property-value-boolean'); } if (_.isDate(value)) { className.push('coveo-property-value-date'); } if (_.isObject(value)) { className.push('coveo-property-value-object'); } if (_.isArray(value)) { className.push('coveo-property-value-array'); } valueDom.el.className = className.join(' '); dom.el['label'] = label != null ? label.toLowerCase() : ''; dom.el['values'] = stringValue.toLowerCase(); return dom.el; } private toJson(value: any, depth = 0, done: any[] = []) { if (value instanceof BaseComponent || value instanceof SearchEndpoint) { return this.componentToJson(value, depth); } if (value instanceof HTMLElement) { return this.htmlToJson(value); } if (value instanceof Template) { return this.templateToJson(value); } if (value instanceof Promise) { return value.then(value => { return this.toJson(value, depth, done); }); } if (value == window) { return this.toJsonRef(value); } if (_.isArray(value) || _.isObject(value)) { if (_.contains(done, value)) { return this.toJsonRef(value, '< RECURSIVE >'); } else if (depth >= Debug.maxDepth) { return this.toJsonRef(value); } else if (_.isArray(value)) { return _.map(value, (subValue, key) => this.toJson(subValue, depth + 1, done.concat([value]))); } else if (_.isDate(value)) { return this.toJsonRef(value, Globalize.format(value, 'F')); } else { const result = {}; _.each(value, (subValue, key) => { result[key] = this.toJson(subValue, depth + 1, done.concat([value])); }); result['ref']; return result; } } return value; } private toJsonRef(value: any, stringValue?: String): String { stringValue = new String(stringValue || value); stringValue['ref'] = value; return stringValue; } private componentToJson(value: BaseComponent | SearchEndpoint, depth = 0): any { const options = _.keys(value['options']); if (options.length > 0) { return this.toJson(value['options'], depth); } else { return this.toJsonRef(value['options'], new String('No options')); } } private htmlToJson(value: HTMLElement): any { if (value == null) { return undefined; } return { tagName: value.tagName, id: value.id, classList: value.className.split(/\s+/) }; } private templateToJson(template: Template) { if (template == null) { return null; } const element: HTMLElement = template['element']; const templateObject: any = { type: template.getType() }; if (element != null) { templateObject.id = element.id; templateObject.condition = element.attributes['data-condition']; templateObject.content = element.innerText; } return templateObject; } private selectElementText(el: HTMLElement) { if (window.getSelection && document.createRange) { const selection = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); selection.removeAllRanges(); selection.addRange(range); } else if ('createTextRange' in document.body) { const textRange = document.body['createTextRange'](); textRange.moveToElementText(el); textRange.select(); } } private search(value: string, body: HTMLElement) { if (_.isEmpty(value)) { $$(body) .findAll('.coveo-search-match, .coveo-search-submatch') .forEach(el => { $$(el).removeClass('coveo-search-match, coveo-search-submatch'); }); $$(body).removeClass('coveo-searching'); } else { $$(body).addClass('coveo-searching-loading'); setTimeout(() => { const rootProperties = $$(body).findAll('.coveo-section .coveo-section-container > .coveo-property'); _.each(rootProperties, (element: HTMLElement) => { this.findInProperty(element, value); }); $$(body).addClass('coveo-searching'); $$(body).removeClass('coveo-searching-loading'); }); } } private highlightSearch(elementToSearch: HTMLElement | Dom, search: string) { let asHTMLElement: HTMLElement; if (elementToSearch instanceof HTMLElement) { asHTMLElement = elementToSearch; } else if (elementToSearch instanceof Dom) { asHTMLElement = elementToSearch.el; } if (asHTMLElement != null && asHTMLElement.innerText != null) { const match = asHTMLElement.innerText.split(new RegExp('(?=' + StringUtils.regexEncode(search) + ')', 'gi')); asHTMLElement.innerHTML = ''; match.forEach(value => { const regex = new RegExp('(' + StringUtils.regexEncode(search) + ')', 'i'); const group = value.match(regex); let span; if (group != null) { span = $$('span', { className: 'coveo-debug-highlight' }); span.text(group[1]); asHTMLElement.appendChild(span.el); span = $$('span'); span.text(value.substr(group[1].length)); asHTMLElement.appendChild(span.el); } else { span = $$('span'); span.text(value); asHTMLElement.appendChild(span.el); } }); } } private removeHighlightSearch(element: HTMLElement) { if (element != null) { element.innerHTML = element.innerText; } } private showAnimationDuringQuery() { $$(this.modalBox.content).addClass('coveo-debug-loading'); } private hideAnimationDuringQuery() { $$(this.modalBox.content).removeClass('coveo-debug-loading'); } }