coveo-search-ui
Version:
Coveo JavaScript Search Framework
697 lines (642 loc) • 26.3 kB
text/typescript
import { contains, each, isArray, keys, map } from 'underscore';
import { Assert } from '../../misc/Assert';
import { Logger } from '../../misc/Logger';
import { IStringMap } from '../../rest/GenericParam';
import { $$, Dom } from '../../utils/Dom';
import { SVGIcons } from '../../utils/SVGIcons';
import { Utils } from '../../utils/Utils';
import { Template } from '../Templates/Template';
import { ComponentOptionLoader } from './ComponentOptionsLoader';
import { ComponentOptionsMerger } from './ComponentOptionsMerger';
import { ComponentOptionsPostProcessor } from './ComponentOptionsPostProcessor';
import { ComponentOptionsValidator } from './ComponentOptionsValidator';
import {
ComponentOptionsType,
IComponentJsonObjectOption,
IComponentLocalizedStringOptionArgs,
IComponentOptions,
IComponentOptionsChildHtmlElementOption,
IComponentOptionsChildHtmlElementOptionArgs,
IComponentOptionsCustomListOptionArgs,
IComponentOptionsFieldOptionArgs,
IComponentOptionsFieldsOptionArgs,
IComponentOptionsListOption,
IComponentOptionsListOptionArgs,
IComponentOptionsLoadOption,
IComponentOptionsNumberOption,
IComponentOptionsNumberOptionArgs,
IComponentOptionsObjectOptionArgs,
IComponentOptionsOption,
IFieldConditionOption,
IFieldOption,
IQueryExpression
} from './IComponentOptions';
import { IComponentOptionsTemplateOptionArgs, TemplateComponentOptions } from './TemplateComponentOptions';
const camelCaseToHyphenRegex = /([A-Z])|\W+(\w)/g;
const fieldsSeperator = /\s*,\s*/;
const localizer = /([a-zA-Z\-]+)\s*:\s*(([^,]|,\s*(?!([a-zA-Z\-]+)\s*:))+)/g;
/**
* The `ComponentOptions` static class contains methods allowing the Coveo JavaScript Search Framework to initialize
* component options.
*
* Typically, each [`Component`]{@link Component} that exposes a set of options contains a static `options` property.
*
* This property "builds" each option using the `ComponentOptions` method corresponding to its type (e.g.,
* [`buildBooleanOption`]{@link ComponentOptions.buildBooleanOption},
* [`buildFieldOption`]{@link ComponentOptions.buildFieldOption},
* [`buildStringOption`]{@link ComponentOptions.buildStringOption}, etc.)
*/
export class ComponentOptions {
static buildTemplateOption(optionArgs?: IComponentOptionsTemplateOptionArgs): Template {
return TemplateComponentOptions.buildTemplateOption(optionArgs);
}
/**
* Builds a boolean option.
*
* **Markup Examples:**
*
* > `data-foo="true"`
*
* > `data-foo="false"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {boolean} The resulting option value.
*/
static buildBooleanOption(optionArgs?: IComponentOptions<boolean>): boolean {
return ComponentOptions.buildOption<boolean>(ComponentOptionsType.BOOLEAN, ComponentOptions.loadBooleanOption, optionArgs);
}
/**
* Builds a number option.
*
* A number option can be an integer or a float in the markup.
*
* **Note:**
*
* > To build a float option, you need to set the `float` property in the [`IComponentOptionsNumberOptionArgs`]{@link IComponentOptionsNumberOptionArgs} to `true`.
*
* **Markup Examples:**
*
* > `data-foo="3"`
*
* > `data-foo="1.5"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {number} The resulting option value.
*/
static buildNumberOption(optionArgs?: IComponentOptionsNumberOptionArgs): number {
return ComponentOptions.buildOption<number>(ComponentOptionsType.NUMBER, ComponentOptions.loadNumberOption, optionArgs);
}
/**
* Builds a string option.
*
* A string option can be any arbitrary string in the markup.
*
* **Markup Example:**
*
* > `data-foo="bar"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {string} The resulting option value.
*/
static buildStringOption<T extends string>(optionArgs?: IComponentOptions<T>): T {
return ComponentOptions.buildOption<T>(ComponentOptionsType.STRING, ComponentOptions.loadStringOption, optionArgs);
}
/**
* Builds an icon option.
*
* This takes an SVG icon name, validates it and returns the name of the icon.
* **Markup Examples:**
*
* > `data-foo="search"`
*
* > `data-foo="facet-expand"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {string} The resulting option value.
*/
static buildIconOption(optionArgs?: IComponentOptions<string>): string {
return ComponentOptions.buildOption<string>(ComponentOptionsType.ICON, ComponentOptions.loadIconOption, optionArgs);
}
/**
* Builds a color option.
*
* Normally, this simply builds a string that matches a CSS color.
*
* **Note:**
*
* > In the markup, this offers no advantage over using a plain string. This is mostly useful for the Coveo JavaScript
* > Interface Editor.
*
* **Markup Examples:**
*
* > `data-foo="coveo-sprites-user"`
*
* > `data-foo="coveo-sprites-database"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {string} The resulting option value.
*/
static buildColorOption(optionArgs?: IComponentOptions<string>): string {
return ComponentOptions.buildOption<string>(ComponentOptionsType.COLOR, ComponentOptions.loadStringOption, optionArgs);
}
/**
* Builds a helper option.
*
* Normally, this simply builds a string that matches the name of a template helper.
*
* **Note:**
*
* > In the markup, this offers no advantage over using a plain string. This is mostly useful for the Coveo JavaScript
* > Interface Editor.
*
* **Markup Examples:**
*
* > `data-foo="date"`
*
* > `data-foo="dateTime"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {string} The resulting option value.
*/
static buildHelperOption(optionArgs?: IComponentOptions<string>): string {
return ComponentOptions.buildOption<string>(ComponentOptionsType.HELPER, ComponentOptions.loadStringOption, optionArgs);
}
/**
* Tries to parse a stringified JSON option.
*
* If unsuccessful (because of invalid syntax), the JSON option is ignored altogether, and the console displays a warning message.
*
* **Markup Example:**
*
* > `data-foo='{"bar" : "baz"}'`
*
* **Note:**
*
* A JSON option can always be set as a property in the `init` call of the framework rather than as a `data-` property in the corresponding HTMLElement markup.
*
* **Initialization Example:**
*
* ```
* Coveo.init(root, {
* Facet : {
* foo : {
* "bar" : "baz"
* }
* }
* })
* ```
* @param optionArgs The arguments to apply when building the option.
* @returns {T} The resulting option value.
*/
static buildJsonOption<T extends IStringMap<any>>(optionArgs?: IComponentJsonObjectOption<T>): T {
return ComponentOptions.buildOption(ComponentOptionsType.JSON, ComponentOptions.loadJsonObjectOption, optionArgs) as T;
}
/**
* @deprecated Use buildJsonOption instead
*
* @deprecatedsince [2017 Javascript Search Framework Releases](https://docs.coveo.com/en/373/#december-2017-release-v236794)
*/
static buildJsonObjectOption<T>(optionArgs?: IComponentJsonObjectOption<T>): T {
return ComponentOptions.buildJsonOption(optionArgs);
}
/**
* Builds a localized string option.
*
* A localized string option can be any arbitrary string.
*
* When parsing the value, the Coveo JavaScript Search Framework tries to load the localization for that string, if it
* is available.
*
* If it is not available, it returns the non-localized value.
*
* This should be used for options that will be rendered directly to the end users.
*
* **Markup Example:**
*
* > `data-foo="bar"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {string} The resulting option value.
*/
static buildLocalizedStringOption(optionArgs?: IComponentLocalizedStringOptionArgs): string {
return ComponentOptions.buildOption<string>(
ComponentOptionsType.LOCALIZED_STRING,
ComponentOptions.loadLocalizedStringOption,
optionArgs
);
}
/**
* Builds a field option.
*
* A field option validates whether the field has a valid name. This means that the string must start with the `@`
* character.
*
* **Markup Example:**
*
* > `data-foo="@bar"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {string} The resulting option value.
*/
static buildFieldOption(optionArgs?: IComponentOptionsFieldOptionArgs): IFieldOption {
return ComponentOptions.buildOption<string>(ComponentOptionsType.FIELD, ComponentOptions.loadFieldOption, optionArgs);
}
/**
* Builds an array of fields option.
*
* As with all options that expect an array, you should use commas to delimit the different values.
*
* **Markup Example:**
*
* > `data-foo="@bar,@baz"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {string[]} The resulting option value.
*/
static buildFieldsOption(optionArgs?: IComponentOptionsFieldsOptionArgs): IFieldOption[] {
return ComponentOptions.buildOption<string[]>(ComponentOptionsType.FIELDS, ComponentOptions.loadFieldsOption, optionArgs);
}
/**
* Builds a query expression option.
*
* The query expression option should follow the [Coveo Cloud Query Syntax Reference](https://docs.coveo.com/en/1552/).
*
* **Markup Example:**
*
* > `data-foo="@bar==baz"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {IQueryExpression} The resulting option value.
*/
static buildQueryExpressionOption(optionArgs?: IComponentOptions<string>): IQueryExpression {
return ComponentOptions.buildOption<string>(ComponentOptionsType.QUERY_EXPRESSION, ComponentOptions.loadStringOption, optionArgs);
}
/**
* Builds an array of strings option.
*
* As with all options that expect an array, you should use commas to delimit the different values.
*
* **Markup Example:**
*
* > `data-foo="bar,baz"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {string[]} The resulting option value.
*/
static buildListOption<T>(optionArgs?: IComponentOptionsListOptionArgs): T[] | string[] {
return ComponentOptions.buildOption<string[]>(ComponentOptionsType.LIST, ComponentOptions.loadListOption, optionArgs);
}
/**
* Builds an option that allow to select an HTMLElement.
*
* The option accepts a CSS selector matching the required HTMLElement. This selector can either be a class, or an ID
* selector.
*
* **Markup Examples:**
*
* > `data-foo-selector=".bar"`
*
* > `data-foo-selector="#bar"`
*
* @param optionArgs The arguments to apply when building the option.
* @returns {HTMLElement} The resulting option value.
*/
static buildSelectorOption(optionArgs?: IComponentOptions<HTMLElement>): HTMLElement {
return ComponentOptions.buildOption<HTMLElement>(ComponentOptionsType.SELECTOR, ComponentOptions.loadSelectorOption, optionArgs);
}
static buildChildHtmlElementOption(optionArgs?: IComponentOptionsChildHtmlElementOptionArgs): HTMLElement {
return ComponentOptions.buildOption<HTMLElement>(
ComponentOptionsType.CHILD_HTML_ELEMENT,
ComponentOptions.loadChildHtmlElementOption,
optionArgs
);
}
static buildCustomOption<T>(converter: (value: string) => T, optionArgs?: IComponentOptions<T>): T {
const loadOption: IComponentOptionsLoadOption<T> = (element: HTMLElement, name: string, option: IComponentOptionsOption<T>) => {
const stringvalue = ComponentOptions.loadStringOption(element, name, option);
if (!Utils.isNullOrEmptyString(stringvalue)) {
return converter(stringvalue);
}
};
return ComponentOptions.buildOption<T>(ComponentOptionsType.STRING, loadOption, optionArgs);
}
static buildCustomListOption<T>(converter: (value: string[]) => T, optionArgs?: IComponentOptionsCustomListOptionArgs<T>): T {
const loadOption: IComponentOptionsLoadOption<T> = (element: HTMLElement, name: string, option: any) => {
const stringvalue = ComponentOptions.loadListOption(element, name, option);
return converter(stringvalue);
};
return ComponentOptions.buildOption<any>(ComponentOptionsType.LIST, loadOption, optionArgs);
}
static buildObjectOption(optionArgs?: IComponentOptionsObjectOptionArgs): any {
const loadOption: IComponentOptionsLoadOption<{
[key: string]: any;
}> = (element: HTMLElement, name: string, option: IComponentOptionsOption<any>) => {
const extractedKeys = keys(optionArgs.subOptions);
const scopedOptions: {
[name: string]: IComponentOptionsOption<any>;
} = {};
const scopedValues: {
[name: string]: any;
} = {};
for (let i = 0; i < extractedKeys.length; i++) {
const key = extractedKeys[i];
const scopedkey = ComponentOptions.mergeCamelCase(name, key);
scopedOptions[scopedkey] = optionArgs.subOptions[key];
}
ComponentOptions.initOptions(element, scopedOptions, scopedValues, '');
const resultValues: {
[name: string]: any;
} = {};
let resultFound = false;
for (let i = 0; i < extractedKeys.length; i++) {
const key = extractedKeys[i];
const scopedkey = ComponentOptions.mergeCamelCase(name, key);
if (scopedValues[scopedkey] != null) {
resultValues[key] = scopedValues[scopedkey];
resultFound = true;
}
}
return resultFound ? resultValues : null;
};
return ComponentOptions.buildOption<{
[key: string]: any;
}>(ComponentOptionsType.OBJECT, loadOption, optionArgs);
}
/**
* Builds a field condition option.
*
* A field condition option defines a field-based condition that must be dynamically evaluated against,
* and satisfied by a query result item in order to initialize a result template component.
*
* **Markup Example:**
*
* ```html
* data-condition-field-author="Alice Smith, Bob Jones"
* data-condition-field-not-filetype="pdf"`
* ```
*
* @returns {string} The resulting option value.
*/
static buildFieldConditionOption(): IFieldConditionOption[] {
return ComponentOptions.buildOption<IFieldConditionOption[]>(ComponentOptionsType.FIELD, ComponentOptions.loadFieldConditionOption);
}
static buildOption<T>(type: ComponentOptionsType, load: IComponentOptionsLoadOption<T>, optionArg: IComponentOptions<T> = {}): T {
const option: IComponentOptionsOption<T> = <any>optionArg;
option.type = type;
option.load = load;
return <any>option;
}
static attrNameFromName(name: string, optionArgs?: IComponentOptions<any>) {
if (optionArgs && optionArgs.attrName) {
return optionArgs.attrName;
}
if (name) {
return 'data-' + ComponentOptions.camelCaseToHyphen(name);
}
return name;
}
static camelCaseToHyphen(name: string) {
return name.replace(camelCaseToHyphenRegex, '-$1$2').toLowerCase();
}
static mergeCamelCase(parent: string, name: string) {
return parent + name.substr(0, 1).toUpperCase() + name.substr(1);
}
/**
* Loads and parses the options of the current element.
*
* Each component calls this method in its constructor.
*
* @param element The element whose markup options the method should load and parse.
* @param component The class of the component whose options the method should load and parse (e.g., `Searchbox`,
* `Facet`, etc.)
* @param values The additional options which the method should merge with the specified markup option values.
*/
static initComponentOptions(element: HTMLElement, component: any, values?: any) {
return ComponentOptions.initOptions(element, component.options, values, component.ID);
}
static initOptions(
element: HTMLElement,
options: {
[name: string]: IComponentOptionsOption<any>;
},
values: any = {},
componentID: string
) {
if (Utils.isNullOrUndefined(values)) {
values = {};
}
each(options, (optionDefinition, name) => {
const value = new ComponentOptionLoader(element, values, name, optionDefinition).load();
new ComponentOptionsMerger(optionDefinition, { value, name }, values).merge();
new ComponentOptionsValidator(optionDefinition, { componentID, name, value }, values).validate();
});
new ComponentOptionsPostProcessor(options, values, componentID).postProcess();
return values;
}
static tryLoadFromAttribute(element: HTMLElement, name: string, optionDefinition: IComponentOptionsOption<any>) {
const loadFromAttribute = optionDefinition.load;
if (!loadFromAttribute) {
return null;
}
return loadFromAttribute(element, name, optionDefinition);
}
static loadStringOption<T extends string>(element: HTMLElement, name: string, option: IComponentOptions<any>): T {
return element.getAttribute(ComponentOptions.attrNameFromName(name, option)) || ComponentOptions.getAttributeFromAlias(element, option);
}
static loadIconOption(element: HTMLElement, name: string, option: IComponentOptions<any>): string {
let svgIconName = ComponentOptions.loadStringOption(element, name, option);
if (svgIconName == null) {
return null;
}
// Old card templates icons used these values as the icon option. These names have changed since we moved to SVG.
// This avoids breaking old default templates that people may still have after moving to 2.0.
svgIconName = svgIconName.replace('coveo-sprites-replies', 'replies');
svgIconName = svgIconName.replace('coveo-sprites-main-search-active', 'search');
if (Utils.isNullOrUndefined(SVGIcons.icons[svgIconName])) {
new Logger(element).warn(`Icon with name ${svgIconName} not found.`);
return null;
}
svgIconName = Utils.toCamelCase(svgIconName);
return svgIconName;
}
static loadFieldOption(element: HTMLElement, name: string, option: IComponentOptionsOption<any>): string {
const field = ComponentOptions.loadStringOption(element, name, option);
Assert.check(!Utils.isNonEmptyString(field) || Utils.isCoveoField(field), field + ' is not a valid field');
return field;
}
static loadFieldConditionOption(element: HTMLElement, name: string, option: IComponentOptionsOption<any>): IFieldConditionOption[] {
var attrs = Dom.nodeListToArray(element.attributes).filter(attribute =>
Utils.stringStartsWith(attribute.nodeName, 'data-condition-field-')
);
return attrs.length != 0
? attrs.map(attribute => ({
field: attribute.nodeName.replace('data-condition-field-not-', '').replace('data-condition-field-', ''),
values: Utils.isNonEmptyString(attribute.nodeValue) ? attribute.nodeValue.split(/\s*,\s*/) : null,
reverseCondition: attribute.nodeName.indexOf('data-condition-field-not-') == 0
}))
: undefined;
}
static loadFieldsOption(element: HTMLElement, name: string, option: IComponentOptionsOption<any>): string[] {
const fieldsAttr = ComponentOptions.loadStringOption(element, name, option);
if (fieldsAttr == null) {
return null;
}
const fields = fieldsAttr.split(fieldsSeperator);
each(fields, (field: string) => {
Assert.check(Utils.isCoveoField(field), field + ' is not a valid field');
});
return fields;
}
static loadLocalizedStringOption(element: HTMLElement, name: string, option: IComponentOptionsOption<any>): string {
const attributeValue = ComponentOptions.loadStringOption(element, name, option);
const locale: string = String['locale'] || String['defaultLocale'];
if (locale != null && attributeValue != null) {
const localeParts = locale.toLowerCase().split('-');
const locales = map(localeParts, (part, i) => localeParts.slice(0, i + 1).join('-'));
const localizers = attributeValue.match(localizer);
if (localizers != null) {
for (let i = 0; i < localizers.length; i++) {
const groups = localizer.exec(localizers[i]);
if (groups != null) {
const lang = groups[1].toLowerCase();
if (contains(locales, lang)) {
return groups[2].replace(/^\s+|\s+$/g, '');
}
}
}
}
return attributeValue != null ? attributeValue.toLocaleString() : null;
}
return attributeValue;
}
static loadNumberOption(element: HTMLElement, name: string, option: IComponentOptionsNumberOption): number {
const attributeValue = ComponentOptions.loadStringOption(element, name, option);
if (attributeValue == null) {
return null;
}
let numberValue = option.float === true ? Utils.parseFloatIfNotUndefined(attributeValue) : Utils.parseIntIfNotUndefined(attributeValue);
if (option.min != null && option.min > numberValue) {
new Logger(element).info(
`Value for option ${name} is less than the possible minimum (Value is ${numberValue}, minimum is ${
option.min
}). It has been forced to its minimum value.`,
option
);
numberValue = option.min;
}
if (option.max != null && option.max < numberValue) {
new Logger(element).info(
`Value for option ${name} is higher than the possible maximum (Value is ${numberValue}, maximum is ${
option.max
}). It has been forced to its maximum value.`,
option
);
numberValue = option.max;
}
return numberValue;
}
static loadBooleanOption(element: HTMLElement, name: string, option: IComponentOptionsOption<any>): boolean {
return Utils.parseBooleanIfNotUndefined(ComponentOptions.loadStringOption(element, name, option));
}
static loadListOption(element: HTMLElement, name: string, option: IComponentOptionsListOption): string[] {
const separator = option.separator || /\s*,\s*/;
const attributeValue = ComponentOptions.loadStringOption(element, name, option);
return Utils.isNonEmptyString(attributeValue) ? attributeValue.split(separator) : null;
}
static loadEnumOption(element: HTMLElement, name: string, option: IComponentOptionsOption<any>, _enum: any): number {
const enumAsString = ComponentOptions.loadStringOption(element, name, option);
return enumAsString != null ? _enum[enumAsString] : null;
}
static loadJsonObjectOption<T>(element: HTMLElement, name: string, option: IComponentOptions<any>): T {
const jsonAsString = ComponentOptions.loadStringOption(element, name, option);
if (jsonAsString == null) {
return null;
}
try {
return JSON.parse(jsonAsString) as T;
} catch (exception) {
new Logger(element).info(
`Value for option ${name} is not a valid JSON string (Value is ${jsonAsString}). It has been disabled.`,
exception
);
return null;
}
}
static loadSelectorOption(
element: HTMLElement,
name: string,
option: IComponentOptionsOption<any>,
doc: Document = document
): HTMLElement {
const attributeValue = ComponentOptions.loadStringOption(element, name, option);
return Utils.isNonEmptyString(attributeValue) ? <HTMLElement>doc.querySelector(attributeValue) : null;
}
static loadChildHtmlElementOption(
element: HTMLElement,
name: string,
option: IComponentOptionsChildHtmlElementOption,
doc: Document = document
): HTMLElement {
let htmlElement: HTMLElement;
// Attribute: selector
const selectorAttr = option.selectorAttr || ComponentOptions.attrNameFromName(name, option) + '-selector';
const selector = element.getAttribute(selectorAttr) || ComponentOptions.getAttributeFromAlias(element, option);
if (selector != null) {
htmlElement = <HTMLElement>doc.body.querySelector(selector);
}
// Child
if (htmlElement == null) {
let childSelector = option.childSelector;
if (childSelector == null) {
childSelector = '.' + name;
}
htmlElement = ComponentOptions.loadChildHtmlElementFromSelector(element, childSelector);
}
return htmlElement;
}
static loadChildHtmlElementFromSelector(element: HTMLElement, selector: string): HTMLElement {
Assert.isNonEmptyString(selector);
if ($$(element).is(selector)) {
return element;
}
return <HTMLElement>$$(element).find(selector);
}
static loadChildrenHtmlElementFromSelector(element: HTMLElement, selector: string): HTMLElement[] {
Assert.isNonEmptyString(selector);
return $$(element).findAll(selector);
}
static findParentScrolling(element: HTMLElement, doc: Document = document): HTMLElement {
element = this.findParentScrollLockable(element, doc);
return element instanceof HTMLBodyElement || !ComponentOptions.isElementScrollable(element) ? <any>window : element;
}
static findParentScrollLockable(element: HTMLElement, doc: Document = document): HTMLElement {
if (!element) {
return doc.body;
}
if (ComponentOptions.isElementScrollable(element) || element instanceof HTMLBodyElement || !element.parentElement) {
return element;
}
return ComponentOptions.findParentScrollLockable(element.parentElement, doc);
}
static isElementScrollable(element: HTMLElement) {
const overflowProperty = $$(element).css('overflow-y');
return overflowProperty == 'scroll' || overflowProperty == 'auto';
}
static getAttributeFromAlias(element: HTMLElement, option: IComponentOptions<any>) {
if (isArray(option.alias)) {
let attributeFound;
each(option.alias, alias => {
const attributeFoundWithThisAlias = element.getAttribute(ComponentOptions.attrNameFromName(alias));
if (attributeFoundWithThisAlias) {
attributeFound = attributeFoundWithThisAlias;
}
});
return attributeFound;
}
if (option.alias) {
return element.getAttribute(ComponentOptions.attrNameFromName(option.alias));
} else {
return undefined;
}
}
}