coveo-search-ui
Version:
Coveo JavaScript Search Framework
351 lines (314 loc) • 12.2 kB
text/typescript
import { $$ } from '../utils/Dom';
import { Assert } from '../misc/Assert';
import { Utils } from '../utils/Utils';
import { BaseComponent } from '../ui/Base/BaseComponent';
import * as _ from 'underscore';
import { IStringMap } from '../rest/GenericParam';
export const MODEL_EVENTS = {
PREPROCESS: 'preprocess',
CHANGE_ONE: 'change:',
CHANGE: 'change',
RESET: 'reset',
ALL: 'all'
};
export interface IModelSetOptions {
silent?: boolean;
customAttribute?: boolean;
validateType?: boolean;
}
export interface IAttributeChangedEventArg {
attribute: string;
value: any;
}
export interface IAttributesChangedEventArg {
attributes: {};
}
export interface IModelChangedEventArg {
model: Model;
}
/**
* A *model* is a key-value store that triggers various JavaScript events when any value associated to one of its key changes.<br/>
* This class is meant to be extended, one of the most important extension being the {@link QueryStateModel} class.<br/>
* Components set values in this key-value store and listen to triggered events in order to update themselves accordingly.<br/>
*/
export class Model extends BaseComponent {
/**
* The attributes contained in this model.</br>
* Normally, you should not set attributes directly on this property, as this would prevent required events from being triggered.
*/
public attributes: IStringMap<any>;
public defaultAttributes: IStringMap<any>;
private eventNameSpace;
/**
* The event types that can be triggered:<br/>
* • `preprocess`: triggered before a value is set on an attribute. This allows the value to be modified before it is set.<br/>
* • `changeOne`: triggered when a single value changes.</br>
* • `change`: triggered when one or many values change.</br>
* • `reset`: triggered when all attributes are reset to their default values. </br>
* • `all`: triggered after the `change` event.</br>
* @type {{preprocess: string, changeOne: string, change: string, reset: string, all: string}}
*/
public static eventTypes = {
preprocess: 'preprocess',
changeOne: 'change:',
change: 'change',
reset: 'reset',
all: 'all'
};
constructor(element: HTMLElement, id: string, attributes: { [key: string]: any }) {
super(element, id);
this.eventNameSpace = id;
this.defaultAttributes = Utils.extendDeep(this.defaultAttributes, attributes);
this.attributes = attributes;
this.logger.debug('Creating model');
}
/**
* Sets the value of a single specific attribute.</br>
* Note: this method calls the `setMultiple` method.
* @param attribute
* the specific attribute whose value is to be set.
* @param value
* the value to set the attribute to.
* @param options
* the options (see {@link setMultiple}).
*/
public set(attribute: string, value: any, options?: IModelSetOptions) {
const toSet: { [key: string]: any } = {};
toSet[attribute] = value;
this.setMultiple(toSet, options);
}
/**
* Gets an object containing all *active* registered attribute key-values.</br>
* An attribute is considered active when its value is not in its default state.
* @returns {{object}}
*/
public getAttributes() {
const attributes: { [key: string]: any } = {};
_.each(this.attributes, (attribute, key) => {
if (_.isObject(attribute)) {
if (!Utils.objectEqual(attribute, this.defaultAttributes[key])) {
attributes[key] = Utils.extendDeep({}, attribute);
}
} else if (attribute != this.defaultAttributes[key]) {
attributes[key] = attribute;
}
});
return attributes;
}
/**
* Sets the values of one or many attributes.</br>
* This method may trigger the following events (in order):</br>
* • `preprocess`</br>
* • `changeOne`</br>
* • `change`</br>
* • `all`
* @param toSet
* the key-value list of attributes with their new intended values.
* @param options
* if the `customAttribute` option is set to `true`, the method will not validate whether an attribute is registered or not.</br>
* If the `validateType` option is set to `true`, the method will ensure that each value type is correct.</br>
* If the `silent` option is set to `true`, then the `changeOne`, `change` and `all` events will not be triggered.
*/
public setMultiple(toSet: { [key: string]: any }, options?: IModelSetOptions) {
let anythingChanged = false;
this.preprocessEvent(toSet);
_.each(<_.Dictionary<any>>toSet, (value, attribute?) => {
if (!options || !options.customAttribute) {
this.checkIfAttributeExists(attribute);
}
value = this.parseToCorrectType(attribute, value);
if (!options || options.validateType) {
if (!this.typeIsValid(attribute, value)) {
return;
}
}
if (this.checkIfAttributeChanged(attribute, value)) {
this.attributes[attribute] = value;
anythingChanged = true;
if (options == null || !options.silent) {
this.attributeHasChangedEvent(attribute);
}
}
});
if (anythingChanged && (options == null || !options.silent)) {
this.attributesHasChangedEvent();
this.anyEvent();
}
}
/**
* Sets a new default value to a single specific attribute.</br>
* Note: specifying a new attribute default value does not set the attribute to that value. This can be done using the {@link setDefault} method.
* @param attribute
* the specific attribute whose default value is to be changed.
* @param value
* the new intended default value.
* @param options
* if the `customAttribute` option is set to `true`, the method will not validate whether the attribute is registered or not.
*/
public setNewDefault(attribute: string, value: any, options?: IModelSetOptions) {
if (!options || !options.customAttribute) {
this.checkIfAttributeExists(attribute);
}
this.defaultAttributes[attribute] = value;
}
/**
* Sets a single specific attribute to its default value.</br>
* Note: this method calls the {@link setMultiple} method without specifying any option.
* @param attribute
* the specific attribute whose value is to be set to its default value.
*/
public setDefault(attribute: string) {
this.set(attribute, this.defaultAttributes[attribute]);
}
/**
* Gets the value of a single specific attribute.</br>
* If no attribute is specified, the method instead returns an object containing all registered attribute key-values.
* @param attribute
* the specific attribute whose value should be returned.
* @returns {any}
*/
public get(attribute?: string): any {
if (attribute == undefined) {
return this.attributes;
} else {
return this.attributes[attribute];
}
}
/**
* Gets the default value of a single specific attribute.</br>
* If no attribute is specified, the method instead returns an object containing all registered attribute key-default values.
* @param attribute
* the specific attribute whose default value should be returned.
* @returns {any}
*/
public getDefault(attribute?: string): any {
if (attribute == undefined) {
return this.defaultAttributes;
} else {
return this.defaultAttributes[attribute];
}
}
/**
* Resets each registered attribute to its default value.</br>
* Note: this method calls the {@link setMultiple} method without specifying any options.</br>
* After the `setMultiple` call has returned, this method triggers the `reset` event.
*/
public reset() {
this.setMultiple(this.defaultAttributes);
this.modelWasResetEvent();
}
/**
* Registers a new attribute key-value.
* @param attribute
* the name of the new attribute to register.
* @param defaultValue
* the newly registered attribute default value.
*/
public registerNewAttribute(attribute: string, defaultValue: any) {
this.defaultAttributes[attribute] = defaultValue;
this.attributes[attribute] = defaultValue;
}
/**
* Gets a string displaying the event namespace followed by the specific event name. The returned string is formatted thus:</br>
* `[eventNameSpace]:[eventName]`
* @example `getEventName("reset");` could return `"state:reset"`.
* @param event
* the event name.
* @returns {string}
*/
public getEventName(event: string) {
return this.eventNameSpace + ':' + event;
}
private attributesHasChangedEvent() {
$$(this.element).trigger(this.getEventName(Model.eventTypes.change), this.createAttributesChangedArgument());
}
private attributeHasChangedEvent(attr: string) {
$$(this.element).trigger(this.getEventName(Model.eventTypes.changeOne) + attr, this.createAttributeChangedArgument(attr));
}
private preprocessEvent(attributes: { [key: string]: any }) {
$$(this.element).trigger(this.getEventName(Model.eventTypes.preprocess), attributes);
}
private modelWasResetEvent() {
$$(this.element).trigger(this.getEventName(Model.eventTypes.reset), this.createModelChangedArgument());
}
private anyEvent() {
$$(this.element).trigger(this.getEventName(Model.eventTypes.all), this.createModelChangedArgument());
}
private createAttributeChangedArgument(attribute: string): IAttributeChangedEventArg {
return { attribute: attribute, value: this.attributes[attribute] };
}
private createAttributesChangedArgument(): IAttributesChangedEventArg {
return { attributes: this.attributes };
}
private createModelChangedArgument(): IModelChangedEventArg {
return { model: this };
}
private checkIfAttributeExists(attribute: string) {
Assert.check(_.has(this.attributes, attribute), `The attribute ${attribute} is not registered.`);
}
private typeIsValid(attribute: string, value: any): boolean {
if (!Utils.isNullOrUndefined(this.attributes[attribute]) && !Utils.isUndefined(value)) {
if (_.isNumber(this.attributes[attribute])) {
return this.validateNumber(attribute, value);
} else if (_.isBoolean(this.attributes[attribute])) {
return this.validateBoolean(attribute, value);
} else {
return this.validateOther(attribute, value);
}
}
return true;
}
private validateNumber(attribute: string, value: any): boolean {
if (!_.isNumber(value) || isNaN(value)) {
this.logger.error(`Non-matching type for ${attribute}. Expected number and got ${value}`);
return false;
}
return true;
}
private validateBoolean(attribute: string, value: any) {
if (!_.isBoolean(value) && !Utils.parseBooleanIfNotUndefined(value) !== undefined) {
this.logger.error(`Non matching type for ${attribute}. Expected boolean and got ${value}`);
return false;
}
return true;
}
private validateOther(attribute: string, value: any) {
if (!Utils.isNullOrUndefined(this.defaultAttributes[attribute])) {
const valueType = typeof value;
const attributeType = typeof this.defaultAttributes[attribute];
if (valueType !== attributeType) {
this.logger.error(`Non-matching type for ${attribute}. Expected ${attributeType} and got ${valueType}`);
return false;
}
}
return true;
}
private parseToCorrectType(attribute: string, value: any): any {
if (_.isNumber(this.attributes[attribute])) {
return parseInt(value, 10);
} else if (_.isBoolean(this.attributes[attribute])) {
if (_.isBoolean(value)) {
return value;
} else {
return Utils.parseBooleanIfNotUndefined(value);
}
}
return value;
}
private checkIfAttributeChanged(attribute: string, newValue: any): boolean {
let oldValue = this.attributes[attribute];
if (_.isNumber(oldValue) || _.isString(oldValue) || _.isBoolean(oldValue)) {
return oldValue !== newValue;
}
if (_.isArray(oldValue)) {
return !Utils.arrayEqual(oldValue, newValue);
}
if (_.isObject(oldValue)) {
return !Utils.objectEqual(oldValue, newValue);
}
return true;
}
public debugInfo() {
return null;
}
}