coveo-search-ui
Version:
Coveo JavaScript Search Framework
490 lines (430 loc) • 19.5 kB
text/typescript
import { contains, each, escape, extend, filter, find, isArray, isObject, isString, keys, map, omit } from 'underscore';
import { exportGlobally } from '../../GlobalExports';
import { Assert } from '../../misc/Assert';
import { IQueryResult } from '../../rest/QueryResult';
import { l } from '../../strings/Strings';
import { AccessibleButton } from '../../utils/AccessibleButton';
import { DateUtils, IDateToStringOptions } from '../../utils/DateUtils';
import { $$ } from '../../utils/Dom';
import { StringUtils } from '../../utils/StringUtils';
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 { IComponentOptionsObjectOptionArgs, IFieldConditionOption, IFieldOption } from '../Base/IComponentOptions';
import { Initialization } from '../Base/Initialization';
import { FacetUtils } from '../Facet/FacetUtils';
import { TemplateFieldsEvaluator } from '../Templates/TemplateFieldsEvaluator';
import { TemplateHelpers } from '../Templates/TemplateHelpers';
import { IFieldValueCompatibleFacet, isFacetFieldValueCompatible } from './IFieldValueCompatibleFacet';
import { ComponentsTypes } from '../../utils/ComponentsTypes';
export interface IFieldValueOptions {
field?: IFieldOption;
facet?: string;
dynamicFacet?: string;
htmlValue?: boolean;
helper?: string;
helperOptions?: { [key: string]: any };
splitValues?: boolean;
separator?: string;
displaySeparator?: string;
textCaption?: string;
conditions?: IFieldConditionOption[];
}
export interface IAnalyticsFieldValueMeta {
facetId: string;
facetField: string;
facetValue?: string;
facetTitle?: string;
}
function showOnlyWithHelper<T>(helpers: string[], options?: T): T {
if (options == null) {
options = <any>{};
}
(<any>options).helpers = helpers;
return options;
}
/**
* The FieldValue component displays the value of a field associated to its parent search result. It is normally usable
* within a {@link FieldTable}.
*
* This component is a result template component (see [Result Templates](https://docs.coveo.com/en/413/)).
*
* A common use of this component is to display a specific field value which also happens to be an existing
* {@link Facet.options.field}. When the user clicks on the FieldValue component, it activates the corresponding Facet.
*/
export class FieldValue extends Component {
static ID = 'FieldValue';
static doExport = () => {
exportGlobally({
FieldValue: FieldValue
});
};
/**
* The options for the component
* @componentOptions
*/
static options: IFieldValueOptions = {
/**
* Specifies the field that the FieldValue should display.
*
* Specifying a value for this parameter is required in order for the FieldValue component to work.
*/
field: ComponentOptions.buildFieldOption({ defaultValue: '@field', required: true }),
/**
* Specifies the {@link Facet} component to toggle when the end user clicks the FieldValue.
*
* Default value is the value of {@link FieldValue.options.field}.
*
* **Note:**
* > If the target {@link Facet.options.id} is is not the same as its {@link Facet.options.field}), you must specify
* > this option manually in order to link to the correct Facet.
*/
facet: ComponentOptions.buildStringOption({ postProcessing: (value, options) => value || options.field }),
/**
* Specifies whether the content to display is an HTML element.
*
* Default value is `false`.
*/
htmlValue: ComponentOptions.buildBooleanOption({ defaultValue: false }),
/**
* Specifies whether to split the FieldValue at each {@link FieldValue.options.separator}.
*
* This is useful for splitting groups using a {@link Facet.options.field}.
*
* When this option is `true`, the displayed values are split by the {@link FieldValue.options.displaySeparator}.
*
* Default value is `false`.
*/
splitValues: ComponentOptions.buildBooleanOption({ defaultValue: false }),
/**
* If {@link FieldValue.options.splitValues} is `true`, specifies the separator string which separates multi-value
* fields in the index.
*
* See {@link FieldValue.options.displaySeparator}.
*
* Default value is `";"`.
*/
separator: ComponentOptions.buildStringOption({ depend: 'splitValues', defaultValue: ';' }),
/**
* If {@link FieldValue.options.splitValues} is `true`, specifies the string to use when displaying multi-value
* fields in the UI.
*
* The component will insert this string between each value it displays from a multi-value field.
*
* See also {@link FieldValue.options.separator}.
*
* Default value is `", "`.
*/
displaySeparator: ComponentOptions.buildStringOption({ depend: 'splitValues', defaultValue: ', ' }),
/**
* Specifies the helper that the FieldValue should use to display its content.
*
* While several helpers exist by default (see {@link ICoreHelpers}), it is also possible for you to create your own
* custom helpers (see {@link TemplateHelpers}).
*/
helper: ComponentOptions.buildHelperOption(),
/**
* Specifies the options to call on the specified helper.
*/
helperOptions: ComponentOptions.buildObjectOption(<IComponentOptionsObjectOptionArgs>{
subOptions: {
text: ComponentOptions.buildStringOption(showOnlyWithHelper(['anchor'])),
target: ComponentOptions.buildStringOption(showOnlyWithHelper(['anchor'])),
class: ComponentOptions.buildStringOption(showOnlyWithHelper(['anchor'])),
format: ComponentOptions.buildStringOption(showOnlyWithHelper(['number'])),
decimals: ComponentOptions.buildNumberOption(showOnlyWithHelper(['currency'], { min: 0 })),
symbol: ComponentOptions.buildStringOption(showOnlyWithHelper(['currency'])),
useTodayYesterdayAndTomorrow: ComponentOptions.buildBooleanOption(
showOnlyWithHelper(['date', 'dateTime', 'emailDateTime', 'time'], { defaultValue: true })
),
useWeekdayIfThisWeek: ComponentOptions.buildBooleanOption(
showOnlyWithHelper(['date', 'dateTime', 'emailDateTime', 'time'], { defaultValue: true })
),
omitYearIfCurrentOne: ComponentOptions.buildBooleanOption(
showOnlyWithHelper(['date', 'dateTime', 'emailDateTime', 'time'], { defaultValue: true })
),
useLongDateFormat: ComponentOptions.buildBooleanOption(
showOnlyWithHelper(['date', 'dateTime', 'emailDateTime', 'time'], { defaultValue: false })
),
includeTimeIfToday: ComponentOptions.buildBooleanOption(
showOnlyWithHelper(['date', 'dateTime', 'emailDateTime', 'time'], { defaultValue: true })
),
includeTimeIfThisWeek: ComponentOptions.buildBooleanOption(
showOnlyWithHelper(['date', 'dateTime', 'emailDateTime', 'time'], { defaultValue: true })
),
alwaysIncludeTime: ComponentOptions.buildBooleanOption(
showOnlyWithHelper(['date', 'dateTime', 'emailDateTime', 'time'], { defaultValue: false })
),
predefinedFormat: ComponentOptions.buildStringOption(showOnlyWithHelper(['date', 'dateTime', 'emailDateTime', 'time'])),
companyDomain: ComponentOptions.buildStringOption(showOnlyWithHelper(['email'])),
me: ComponentOptions.buildStringOption(showOnlyWithHelper(['email'])),
lengthLimit: ComponentOptions.buildNumberOption(showOnlyWithHelper(['email'], { min: 1 })),
truncateName: ComponentOptions.buildBooleanOption(showOnlyWithHelper(['email'])),
alt: ComponentOptions.buildStringOption(showOnlyWithHelper(['image'])),
height: ComponentOptions.buildStringOption(showOnlyWithHelper(['image'])),
width: ComponentOptions.buildStringOption(showOnlyWithHelper(['image'])),
srcTemplate: ComponentOptions.buildStringOption(showOnlyWithHelper(['image'])),
precision: ComponentOptions.buildNumberOption(showOnlyWithHelper(['size'], { min: 0, defaultValue: 2 })),
base: ComponentOptions.buildNumberOption(showOnlyWithHelper(['size'], { min: 0, defaultValue: 0 })),
isMilliseconds: ComponentOptions.buildBooleanOption(showOnlyWithHelper(['timeSpan'])),
length: ComponentOptions.buildNumberOption(showOnlyWithHelper(['shorten', 'shortenPath', 'shortenUri'], { defaultValue: 200 }))
}
}),
/**
* Specifies a caption to display before the value.
*
* Default value is `undefined`.
*
* @availablesince [January 2017 Release (v1.1865.9)](https://docs.coveo.com/en/396/#january-2017-release-v118659)
*/
textCaption: ComponentOptions.buildLocalizedStringOption(),
/**
* A field-based condition that must be satisfied by the query result item for the component to be rendered.
*
* Note: This option uses a distinctive markup configuration syntax allowing multiple conditions to be expressed. Its underlying logic is the same as that of the field value conditions mechanism used by result templates.
*
* **Examples:**
* Render the component if the query result item's @documenttype field value is Article or Documentation.
* ```html
* <div class="CoveoFieldValue" data-field="@author" data-condition-field-documenttype="Article, Documentation"></div>
* ```
* Render the component if the query result item's @documenttype field value is anything but Case.
* ```html
* <div class="CoveoFieldValue" data-field="@author" data-condition-field-not-documenttype="Case"></div>
* ```
* Render the component if the query result item's @documenttype field value is Article, and if its @author field value is anything but Anonymous.
* ```html
* <div class="CoveoFieldValue" data-field="@author" data-condition-field-documenttype="Article" data-condition-field-not-author="Anonymous"></div>
* ```
* Default value is `undefined`.
*/
conditions: ComponentOptions.buildFieldConditionOption()
};
static simpleOptions = omit(FieldValue.options, 'helperOptions');
static helperOptions = <any>{
helperOptions: FieldValue.options.helperOptions
};
/**
* Creates a new FieldValue.
* @param element The HTMLElement on which to instantiate the component.
* @param options The options for the FieldValue 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: IFieldValueOptions,
bindings?: IComponentBindings,
public result?: IQueryResult,
fieldValueClassId: string = FieldValue.ID
) {
super(element, fieldValueClassId, bindings);
this.options = ComponentOptions.initOptions(element, FieldValue.simpleOptions, options, FieldValue.ID);
if (this.options.helper != null) {
this.normalizeHelperAndOptions();
}
this.result = this.result || this.resolveResult();
Assert.exists(this.result);
if (TemplateFieldsEvaluator.evaluateFieldsToMatch(this.options.conditions, this.result) && !Utils.isNullOrUndefined(this.getValue())) {
this.initialize();
} else if (this.element.parentElement != null) {
this.element.parentElement.removeChild(this.element);
}
}
private initialize() {
let loadedValueFromComponent = this.getValue();
let values: string[];
if (isArray(loadedValueFromComponent)) {
values = loadedValueFromComponent;
} else if (this.options.splitValues) {
if (isString(loadedValueFromComponent)) {
values = map(loadedValueFromComponent.split(this.options.separator), (v: string) => {
return v.trim();
});
}
} else {
loadedValueFromComponent = loadedValueFromComponent.toString();
values = [loadedValueFromComponent];
}
if (values.length > 1 && this.isValueHierarchical) {
values = values.slice(-1);
}
this.appendValuesToDom(values);
if (this.options.textCaption != null) {
this.prependTextCaptionToDom();
}
}
/**
* Gets the current FieldValue from the current {@link IQueryResult}.
*
* @returns {any} The current FieldValue or `null` if value is and `Object`.
*/
public getValue() {
let value = Utils.getFieldValue(this.result, <string>this.options.field);
if (!isArray(value) && isObject(value)) {
value = null;
}
return value;
}
/**
* Renders a value to HTML using all of the current FieldValue component options.
* @param value The value to render.
* @returns {HTMLElement} The element containing the rendered value.
*/
public renderOneValue(value: string): HTMLElement {
const element = $$('span').el;
let toRender = this.getCaption(value);
if (this.options.helper) {
// Try to resolve and execute version 2 of each helper function if available
const helper = TemplateHelpers.getHelper(`${this.options.helper}v2`) || TemplateHelpers.getHelper(`${this.options.helper}`);
if (Utils.exists(helper)) {
toRender = helper.call(this, value, this.getHelperOptions(), this.result);
} else {
this.logger.warn(
`Helper ${this.options.helper} is not found in available helpers. The list of supported helpers is :`,
keys(TemplateHelpers.getHelpers())
);
}
const fullDateStr = this.getFullDate(value, this.options.helper);
if (fullDateStr) {
element.setAttribute('title', fullDateStr);
}
if (this.options.helper == 'date' || this.options.helper == 'dateTime' || this.options.helper == 'emailDateTime') {
toRender = StringUtils.capitalizeFirstLetter(toRender);
}
}
if (this.options.htmlValue) {
element.innerHTML = toRender;
} else {
element.appendChild(document.createTextNode(toRender));
}
this.bindEventOnValue(element, value, toRender);
return element;
}
protected getValueContainer() {
return this.element;
}
private normalizeHelperAndOptions() {
this.options = ComponentOptions.initOptions(this.element, FieldValue.helperOptions, this.options, FieldValue.ID);
const toFilter = keys(FieldValue.options.helperOptions['subOptions']);
const toKeep = filter(toFilter, optionKey => {
const optionDefinition = FieldValue.options.helperOptions['subOptions'][optionKey];
if (optionDefinition) {
const helpers = optionDefinition.helpers;
return helpers != null && contains(helpers, this.options.helper);
}
return false;
});
this.options.helperOptions = omit(this.options.helperOptions, (value, key) => {
return !contains(toKeep, key);
});
}
private getHelperOptions() {
const inlineOptions = ComponentOptions.loadStringOption(this.element, 'helperOptions', {});
if (Utils.isNonEmptyString(inlineOptions)) {
return extend({}, this.options.helperOptions, eval('(' + inlineOptions + ')'));
}
return this.options.helperOptions;
}
private getFullDate(date: string, helper: string) {
const fullDateOptions: IDateToStringOptions = {
useLongDateFormat: true,
useTodayYesterdayAndTomorrow: false,
useWeekdayIfThisWeek: false,
omitYearIfCurrentOne: false
};
if (helper == 'date') {
return DateUtils.dateToString(new Date(parseInt(date)), fullDateOptions);
} else if (helper == 'dateTime' || helper == 'emailDateTime') {
return DateUtils.dateTimeToString(new Date(parseInt(date)), fullDateOptions);
}
return '';
}
private appendValuesToDom(values: string[]): void {
each(values, (value, index) => {
if (value != undefined) {
this.getValueContainer().appendChild(this.renderOneValue(value));
if (index !== values.length - 1) {
this.getValueContainer().appendChild(document.createTextNode(this.options.displaySeparator));
}
}
});
}
private renderTextCaption(): HTMLElement {
const element = $$('span', { className: 'coveo-field-caption' }, escape(this.options.textCaption));
return element.el;
}
protected prependTextCaptionToDom(): void {
const elem = this.getValueContainer();
$$(elem).prepend(this.renderTextCaption());
// Add a class to the container so the value and the caption wrap together.
$$(elem).addClass('coveo-with-label');
}
private bindEventOnValue(element: HTMLElement, originalFacetValue: string, renderedFacetValue: string) {
this.bindFacets(element, originalFacetValue, renderedFacetValue);
}
private getCaption(value: string) {
for (let facet of this.getFacets()) {
const caption = facet.getCaptionForStringValue(value);
if (caption) {
return caption;
}
}
return FacetUtils.tryToGetTranslatedCaption(this.options.field as string, value);
}
private getFacets() {
const facets = ComponentsTypes.getAllFacetsFromSearchInterface(this.searchInterface)
.filter(isFacetFieldValueCompatible)
.filter(facet => !facet.disabled);
const facetsWithMatchingId = facets.filter(facet => facet.options.id === this.options.facet);
if (facetsWithMatchingId.length) {
return facetsWithMatchingId;
}
return facets.filter(facet => facet.options.field === this.options.field);
}
private get isValueHierarchical() {
for (let facet of this.getFacets()) {
if (facet.isFieldValueHierarchical) {
return true;
}
}
return false;
}
private bindFacets(element: HTMLElement, originalFacetValue: string, renderedFacetValue: string) {
const facets = this.getFacets();
if (!facets.length) {
return;
}
const isValueSelected = !!find(facets, facet => facet.hasSelectedValue(originalFacetValue));
const selectAction = () => this.handleFacetSelection(isValueSelected, facets, originalFacetValue);
this.buildClickableElement(element, isValueSelected, renderedFacetValue, selectAction);
}
private buildClickableElement(element: HTMLElement, isSelected: boolean, value: string, selectAction: () => void) {
const label = isSelected ? l('RemoveFilterOn', value) : l('FilterOn', value);
new AccessibleButton().withTitle(label).withElement(element).withSelectAction(selectAction).build();
if (isSelected) {
$$(element).addClass('coveo-selected');
}
$$(element).addClass('coveo-clickable');
}
private handleFacetSelection(isValueSelected: boolean, facets: IFieldValueCompatibleFacet[], value: string) {
facets.forEach(facet => {
isValueSelected ? facet.deselectValue(value) : facet.selectValue(value);
});
this.executeQuery(value);
}
private executeQuery(value: string) {
this.queryController.deferExecuteQuery({
beforeExecuteQuery: () =>
this.usageAnalytics.logSearchEvent<IAnalyticsFieldValueMeta>(analyticsActionCauseList.documentField, {
facetId: this.options.facet,
facetField: this.options.field.toString(),
facetValue: value.toLowerCase()
})
});
}
}
Initialization.registerAutoCreateComponent(FieldValue);