coveo-search-ui
Version:
Coveo JavaScript Search Framework
634 lines (559 loc) • 19.9 kB
text/typescript
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');
}
}