coveo-search-ui
Version:
Coveo JavaScript Search Framework
484 lines (436 loc) • 15.8 kB
text/typescript
import 'styling/_ResultTagging';
import * as _ from 'underscore';
import { exportGlobally } from '../../GlobalExports';
import { Assert } from '../../misc/Assert';
import { QueryStateModel } from '../../models/QueryStateModel';
import { IFieldDescription } from '../../rest/FieldDescription';
import { IIndexFieldValue } from '../../rest/FieldValue';
import { IQueryResult } from '../../rest/QueryResult';
import { ITaggingRequest } from '../../rest/TaggingRequest';
import { l } from '../../strings/Strings';
import { $$ } from '../../utils/Dom';
import { KEYBOARD, KeyboardUtils } from '../../utils/KeyboardUtils';
import { StringUtils } from '../../utils/StringUtils';
import { SVGDom } from '../../utils/SVGDom';
import { SVGIcons } from '../../utils/SVGIcons';
import { Utils } from '../../utils/Utils';
import { analyticsActionCauseList } from '../Analytics/AnalyticsActionListMeta';
import { Component } from '../Base/Component';
import { IComponentBindings } from '../Base/ComponentBindings';
import { ComponentOptions } from '../Base/ComponentOptions';
import { IFieldOption } from '../Base/IComponentOptions';
import { Initialization } from '../Base/Initialization';
export interface IResultTaggingOptions {
field: IFieldOption;
suggestBoxSize?: number;
autoCompleteTimer?: number;
}
export interface IAnalyticsResultTaggingMeta {
facetId: string;
facetField: string;
facetValue?: string;
facetTitle?: string;
}
/**
* The ResultTagging component lists the current tag field values of its associated result and renders a control that
* allows the end user to add values to a tag field.
*
* This component is a result template component (see [Result Templates](https://docs.coveo.com/en/413/)).
*
* **Note:**
* > The ResultTagging component is not supported with Coveo Cloud V2. To implement the ResultTagging component in Coveo Cloud V1, contact [Coveo Support](https://support.coveo.com/s/).
*
* @notSupportedIn salesforcefree
*/
export class ResultTagging extends Component {
static ID = 'ResultTagging';
static autoCompleteClass = 'coveo-result-tagging-auto-complete';
static doExport = () => {
exportGlobally({
ResultTagging: ResultTagging
});
};
/**
* @componentOptions
*/
static options: IResultTaggingOptions = {
/**
* Specifies the tag field that the component will use.
*
* Specifying a value for this options is necessary for this component to work.
*/
field: ComponentOptions.buildFieldOption({
match: (field: IFieldDescription) => field.type == 'Tag',
required: true
}),
/**
* Specifies the number of items to show in the list of suggested items.
*
* Default value is `5`. Minimum value is `0 `.
*/
suggestBoxSize: ComponentOptions.buildNumberOption({ defaultValue: 5, min: 0 }),
/**
* Specifies how much time (in milliseconds) it takes for the list of suggested items to disappear when it loses
* focus.
*
* Default value is `2000`. Minimum value is `0`.
*/
autoCompleteTimer: ComponentOptions.buildNumberOption({ defaultValue: 2000, min: 0 })
};
static AUTO_COMPLETE_CLASS = 'coveo-result-tagging-auto-complete';
private autoCompleteZone: HTMLElement;
private textBox: HTMLInputElement;
private autoCompletePopup: HTMLElement;
private tagZone: HTMLElement;
private tags: string[];
/**
* Creates a new ResultTagging component.
* @param element The HTMLElement on which to instantiate the component.
* @param options The options for the ResultTagging component.
* @param bindings The bindings that the component requires to function normally. If not set, these will be
* automatically resolved (with a slower execution time).
* @param result The result to associate the component with.
*/
constructor(
public element: HTMLElement,
public options?: IResultTaggingOptions,
bindings?: IComponentBindings,
public result?: IQueryResult
) {
super(element, ResultTagging.ID, bindings);
this.options = ComponentOptions.initComponentOptions(element, ResultTagging, options);
this.result = result || this.resolveResult();
Assert.exists(this.componentOptionsModel);
Assert.exists(this.result);
if (!this.options.field) {
this.logger.error('You must specify a field to the ResultTagging component');
return;
}
let fieldValue = Utils.getFieldValue(this.result, <string>this.options.field);
if (fieldValue && Utils.isNonEmptyString(fieldValue)) {
this.tags = fieldValue.split(';');
} else if (fieldValue && Utils.isNonEmptyArray(fieldValue)) {
this.tags = fieldValue;
} else {
this.tags = [];
}
this.tags = _.map(this.tags, t => {
return t.trim();
});
this.tagZone = $$('div', {
className: 'coveo-result-tagging-tag-zone'
}).el;
element.appendChild(this.tagZone);
element.appendChild(this.buildTagIcon());
this.autoCompleteZone = $$('div', {
className: 'coveo-result-tagging-auto-complete-zone'
}).el;
element.appendChild(this.autoCompleteZone);
this.autoCompleteZone.appendChild(this.buildTextBox());
this.autoCompleteZone.appendChild(this.buildAddIcon());
this.autoCompleteZone.appendChild(this.buildClearIcon());
this.buildExistingTags();
}
private buildExistingTags() {
if (this.tags) {
_.each(this.tags, (tag: string) => {
this.tagZone.appendChild(this.buildTagValue(tag));
});
}
}
private buildTagIcon(): HTMLElement {
let tagZone = $$('div', {
className: 'coveo-result-tagging-add-tag'
});
let tagTextBox = $$('span', {
className: 'coveo-result-tagging-add-tag-text'
});
tagTextBox.text(l('EnterTag'));
let tagIcon = $$('span', {
className: 'coveo-result-tagging-add-tag-icon'
});
tagIcon.on('click', () => {
_.defer(() => {
this.focusOnTextBox();
}, 20);
});
tagZone.el.appendChild(tagIcon.el);
tagZone.append(tagTextBox.el);
tagZone.setAttribute('title', l('EnterTag'));
return tagZone.el;
}
private focusOnTextBox() {
this.textBox.focus();
}
private buildTagValue(tagValue: string): HTMLElement {
let tag = $$('div', {
className: 'coveo-result-tagging-coveo-tag'
});
tag.el.appendChild(this.buildShortenedTagWithTitle(tagValue));
let deleteIcon = $$(
'span',
{
className: 'coveo-result-tagging-delete-icon'
},
SVGIcons.icons.delete
);
SVGDom.addClassToSVGInContainer(deleteIcon.el, 'coveo-result-tagging-delete-icon-svg');
tag.el.appendChild(deleteIcon.el);
deleteIcon.on('click', () => {
this.doRemoveTag(tag.el, tagValue.toLowerCase());
});
return tag.el;
}
private buildShortenedTagWithTitle(tagValue: string): HTMLElement {
let shortenedTag = StringUtils.removeMiddle(tagValue, 16, '...');
let clickableValue = $$('a', {
title: tagValue,
href: 'javascript:void(0);'
});
clickableValue.text(shortenedTag);
this.bindFacetEventOnValue(clickableValue.el, tagValue);
return clickableValue.el;
}
private buildTextBox(): HTMLInputElement {
this.textBox = <HTMLInputElement>$$('input', {
type: 'text',
className: 'coveo-add-tag-textbox',
placeholder: l('EnterTag')
}).el;
this.autoCompletePopup = $$('div', {
className: ResultTagging.autoCompleteClass
}).el;
this.autoCompleteZone.appendChild(this.autoCompletePopup);
this.manageAutocompleteAutoHide();
$$(this.textBox).on('keyup', (e: KeyboardEvent) => {
if (e.keyCode == KEYBOARD.UP_ARROW || e.keyCode == KEYBOARD.DOWN_ARROW || e.keyCode == KEYBOARD.ENTER) {
this.manageUpDownEnter(e.keyCode);
} else if (!KeyboardUtils.isArrowKeyPushed(e.keyCode)) {
this.populateSuggestions();
}
$$(this.element).removeClass('coveo-error');
});
$$(this.textBox).on('click', () => {
this.populateSuggestions();
});
return this.textBox;
}
private buildAddIcon(): HTMLElement {
let icon = $$(
'div',
{
className: 'coveo-result-tagging-add-tag-tick-icon'
},
SVGIcons.icons.taggingOk
);
SVGDom.addClassToSVGInContainer(icon.el, 'coveo-result-tagging-add-tag-tick-icon-svg');
let clickable = $$('span');
clickable.on('click', () => {
this.doAddTag();
});
icon.el.appendChild(clickable.el);
return icon.el;
}
private buildClearIcon(): HTMLElement {
let icon = $$(
'div',
{
className: 'coveo-result-tagging-clear-icon'
},
SVGIcons.icons.clear
);
SVGDom.addClassToSVGInContainer(icon.el, 'coveo-result-tagging-clear-icon-svg');
let clickable = $$('span');
clickable.on('click', () => {
this.textBox.value = '';
});
icon.el.appendChild(clickable.el);
return icon.el;
}
private bindFacetEventOnValue(element: HTMLElement, value: string) {
let facetAttributeName = QueryStateModel.getFacetId(<string>this.options.field);
let facetModel: string[] = this.queryStateModel.get(facetAttributeName);
let facets: Component[] = this.componentStateModel.get(facetAttributeName);
let atLeastOneFacetIsEnabled = _.filter(facets, (value: Component) => !value.disabled).length > 0;
if (facetModel != null && atLeastOneFacetIsEnabled) {
$$(element).on('click', () => {
if (_.contains(facetModel, value)) {
this.queryStateModel.set(facetAttributeName, _.without(facetModel, value));
} else {
this.queryStateModel.set(facetAttributeName, _.union(facetModel, [value]));
}
this.queryController.deferExecuteQuery({
beforeExecuteQuery: () =>
this.usageAnalytics.logSearchEvent<IAnalyticsResultTaggingMeta>(analyticsActionCauseList.documentTag, {
facetId: <string>this.options.field,
facetField: <string>this.options.field,
facetValue: value
})
});
});
if (_.contains(facetModel, value)) {
$$(element).addClass('coveo-selected');
}
$$(element).addClass('coveo-clickable');
}
}
private clearPopup() {
$$(this.autoCompletePopup).hide();
$$(this.autoCompletePopup).empty();
}
private showPopup() {
$$(this.autoCompletePopup).show();
}
private populateSuggestions() {
let endpoint = this.queryController.getEndpoint();
let searchText = this.textBox.value;
let searchOptions = {
field: <string>this.options.field,
ignoreAccents: true,
sortCriteria: 'occurences',
maximumNumberOfValues: this.options.suggestBoxSize,
queryOverride: '@uri',
pattern: this.buildRegEx(searchText),
patternType: 'RegularExpression'
};
endpoint.listFieldValues(searchOptions).then((fieldValues: IIndexFieldValue[]) => {
this.clearPopup();
_.each(fieldValues, (fieldValue: IIndexFieldValue) => {
this.autoCompletePopup.appendChild(this.buildSelectableValue(fieldValue.lookupValue));
});
this.showPopup();
this.autoCompletePopup.style.width = this.textBox.offsetWidth + ' px';
});
}
private manageAutocompleteAutoHide() {
let timeout: any;
$$(this.textBox).on('mouseover', () => {
clearTimeout(timeout);
});
$$(this.autoCompletePopup).on('mouseout', e => {
if ($$(<HTMLElement>e.target).hasClass(ResultTagging.autoCompleteClass)) {
timeout = setTimeout(() => {
this.clearPopup();
}, this.options.autoCompleteTimer);
}
});
$$(this.autoCompletePopup).on('mouseenter', () => {
clearTimeout(timeout);
});
$$(this.element).on('mouseenter', () => {
this.clearPopup();
$$(this.element).addClass('coveo-opened');
});
$$($$(this.element).closest('.CoveoResult')).on('mouseleave', () => {
this.clearPopup();
if (this.textBox.value == '') {
$$(this.element).removeClass('coveo-opened');
}
});
$$($$(this.element).closest('.CoveoResult')).on('focusout', e => {
if (this.textBox.value != '' && $$(<HTMLElement>e.target).closest('.CoveoResult') != $$(this.element).closest('.CoveoResult')) {
$$(this.element).addClass('coveo-error');
}
});
$$($$(this.element).closest('.CoveoResult')).on('focusin', () => {
$$(this.element).removeClass('coveo-error');
});
}
// Exclude tags that are already on the result (Since we can tag with the same value twice.
private buildRegEx(searchTerm: string) {
return '(?=.*' + searchTerm + ')' + _.map(this.tags, (tag: string) => this.buildTermToExclude(tag)).join('') + '.*';
}
private buildTermToExclude(term: string) {
return '(?!^' + term + '$)';
}
private manageUpDownEnter(code: number) {
let selectableArray = $$(this.element).findAll('.coveo-selectable');
if (code == KEYBOARD.ENTER) {
this.doAddTag();
return;
}
if (selectableArray.length > 0) {
let newIndex = this.computeNextIndex(code, selectableArray);
newIndex = Math.max(0, newIndex);
newIndex = Math.min(selectableArray.length - 1, newIndex);
let selected = $$(selectableArray[newIndex]);
selected.addClass('coveo-selected');
this.textBox.value = selected.text();
}
}
private computeNextIndex(code: number, selectableArray: HTMLElement[]): number {
let nextIndex = 0;
_.each(selectableArray, (selectable: HTMLElement, index) => {
if ($$(selectable).hasClass('coveo-selected')) {
if (code == KEYBOARD.UP_ARROW) {
nextIndex = index - 1;
} else if (code == KEYBOARD.DOWN_ARROW) {
nextIndex = index + 1;
}
$$(selectable).removeClass('coveo-selected');
}
});
return nextIndex;
}
private buildSelectableValue(lookupValue: string): HTMLElement {
let line = $$('div', {
className: 'coveo-selectable'
});
line.el.appendChild(this.buildShortenedTagWithTitle(lookupValue));
line.on('click', () => {
this.doAddTagWithValue(lookupValue);
});
return line.el;
}
private doRemoveTag(element: HTMLElement, tagValue: string) {
let request: ITaggingRequest = {
fieldName: <string>this.options.field,
fieldValue: tagValue,
doAdd: false,
uniqueId: this.result.uniqueId
};
this.queryController
.getEndpoint()
.tagDocument(request)
.then(() => {
this.tags.splice(_.indexOf(this.tags, tagValue), 1);
$$(element).detach();
});
}
private doAddTagWithValue(tagValue: string) {
_.each(tagValue.split(','), (tag: string) => {
this.doAddSingleTagValue(tag);
});
}
private doAddSingleTagValue(tagValue: string) {
this.clearPopup();
if (_.indexOf(this.tags, tagValue) > -1) {
$$(this.element).addClass('coveo-error');
return;
}
this.tags.push(tagValue);
let request: ITaggingRequest = {
fieldName: <string>this.options.field,
fieldValue: tagValue,
doAdd: true,
uniqueId: this.result.uniqueId
};
this.queryController
.getEndpoint()
.tagDocument(request)
.then(() => {
this.tagZone.appendChild(this.buildTagValue(tagValue));
this.textBox.value = '';
$$(this.element).removeClass('coveo-error');
})
.catch(() => {
// We do this otherwise it's possible to add the same tag while we wait for the server's response
this.tags = _.without(this.tags, _.findWhere(this.tags, tagValue));
});
}
private doAddTag() {
let tagValue = Utils.trim(this.textBox.value.toLowerCase());
this.doAddTagWithValue(tagValue);
}
}
Initialization.registerAutoCreateComponent(ResultTagging);