/**
* @copyright 2023 Tridium, Inc. All Rights Reserved.
*/
/* eslint-env browser */
/**
* API Status: **Development**
* @module bajaux/model/UxModel
*/
define([
'bajaux/Widget',
'Promise',
'underscore',
'bajaux/mixin/mixinUtils',
'bajaux/model/binding/BindingList',
'bajaux/model/jsxToUxModel' ], function (
Widget,
Promise,
_,
mixinUtils,
BindingList,
jsxToUxModel) {
'use strict';
const { first, map, rest } = _;
const { jsx } = jsxToUxModel;
const { hasMixin } = mixinUtils;
/**
* we use this to cache the default properties
* @type {symbol}
*/
const DEFAULT_PROPS_SYMBOL = Symbol('$defaultProperties');
/**
* we use this to cache the meta properties
* @type {symbol}
*/
const META_PROPS_SYMBOL = Symbol('$metaProperties');
/**
* Represents all information needed to create one widget, and its children.
*
* Note that this is not `spandrel` data. This is an intermediate, abstracted
* data model that is a representation of a tree of widgets and bindings that
* would make up a graphic or portion of a graphic. (In other words, a `.px`
* file would translate readily into a `UxModel`.) But `UxModel` is also
* intended to provide usable `spandrel` data to be used at rendering time.
*
* @class
* @alias module:bajaux/model/UxModel
*/
class UxModel {
/**
* Don't call directly - use `make()` instead.
* @private
*/
constructor(obj = {}) {
this.$obj = extendParams(obj);
delete this.$obj.bindings;
delete this.$obj.metaProperties;
this.$obj.kids = processKids(obj.kids);
this.$bindingList = new BindingList(obj.bindings);
this.$obj[META_PROPS_SYMBOL] = obj.metaProperties || {};
}
/**
* @private
* @param {module:bajaux/Widget} widget
* @returns {module:bajaux/model/UxModel|null} the UxModel loaded into this widget, whether it has
* UxModelSupport or not.
* @since Niagara 4.15
*/
static in(widget) {
let uxModel = null;
if (widget) {
if (hasMixin(widget, 'UxModelSupport')) {
uxModel = widget.getModel();
} else {
uxModel = widget.value();
}
}
return uxModel instanceof UxModel ? uxModel : null;
}
/**
* Creates a new UxModel from a configuration object. If an existing `UxModel` is given, this
* creates a clone of that model.
*
* @param {module:bajaux/model/UxModel~UxModelParams} obj
* @returns {Promise.<module:bajaux/model/UxModel>} a newly configured `UxModel` instance, or a
* clone
*/
static make(obj) {
if (obj instanceof UxModel) {
return obj.clone();
} else {
// TODO: resolve baja types in properties/bindings
return Promise.resolve(new UxModel(obj));
}
}
/**
* @private
* @see module:bajaux/model/jsxToUxModel
*/
static jsx(type, props, ...kids) {
return jsx(type, props || {}, kids);
}
/**
* Creates a new, equivalent copy of this UxModel, including cloning all of its kids.
*
* @param {module:bajaux/model/UxModel~UxModelParams} params if specified, these parameters
* will be applied to the clone (not including `kids`)
* @returns {Promise.<module:bajaux/model/UxModel>} to be resolved to a clone of this UxModel
* @since Niagara 4.15
*/
clone(params) {
const metaProperties = Object.assign({}, this.$obj[META_PROPS_SYMBOL]);
const obj = extendParams(this.$obj, { bindings: this.getBindingList().getBindings(), metaProperties }, params);
return Promise.all((obj.kids || []).map((kid) => kid.clone()))
.then((kidClones) => {
obj.kids = kidClones;
return new UxModel(obj);
});
}
/**
* @returns {string}
*/
getName() {
return this.$obj.name;
}
/**
* @param {string|string[]} path
* @returns {module:bajaux/model/UxModel|module:bajaux/model/binding/IBinding|undefined} the UxModel
* kid by the given name. If an array of names is given, this will follow the
* path down through the UxModel structure.
*/
get(path) {
if (!Array.isArray(path)) { path = [ path ]; }
return byName(this, path);
}
/**
* @returns {Function} constructor for the widget to create
*/
getType() {
return this.$obj.type;
}
/**
* @returns {module:bajaux/model/binding/BindingList}
*/
getBindingList() {
return this.$bindingList;
}
/**
* @returns {Array.<module:bajaux/model/UxModel>} UxModel
* instances for this widget's children
*/
getKids() {
const { kids = [] } = this.$obj;
return kids.slice();
}
/**
* @returns {object} object literal describing this widget's properties. This includes any
* default properties configured in the Widget's constructor.
*/
getProperties() {
const obj = this.$obj;
let properties = obj.properties || {};
if (!this.$widgetPropertiesApplied) {
const Ctor = this.getType();
if (Ctor) {
const newProps = this.$getDefaultProperties(properties);
properties = obj.properties = newProps;
}
this.$widgetPropertiesApplied = true;
}
return properties;
}
/**
* @returns {object} object literal describing this widget's default properties
* @since Niagara 4.15
*/
getDefaultProperties() {
return this.$getDefaultProperties();
}
/**
* @private
* @param [properties] Additional properties to add onto the Default Properties
* @returns {Object} properties
* @since Niagara 4.15
*/
$getDefaultProperties(properties = {}) {
return this.$getDefaultPropertiesInfo(properties).properties;
}
/**
* Get information about the default properties of a bajaux Widget. These are the properties
* that are set in the JS constructor of the bajaux Widget, typically passed as `defaults` to
* the Widget constructor.
*
* @private
* @param {Object} [properties] Additional properties to add onto the Default Properties
* @returns {Object} obj an object with properties and metaProperties
* @since Niagara 4.15
*/
$getDefaultPropertiesInfo(properties = {}) {
const Ctor = this.getType();
const currentMetaProperties = this.$obj[META_PROPS_SYMBOL] || {};
if (Ctor) {
const propertyInfo = getDefaultPropertiesInfo(Ctor);
const defaultProperties = this.$obj[DEFAULT_PROPS_SYMBOL] = propertyInfo.properties;
const metaProperties = this.$obj[META_PROPS_SYMBOL] = Object.assign(shallowClone(currentMetaProperties), propertyInfo.metaProperties);
return {
properties: Object.assign(shallowClone(defaultProperties), properties),
metaProperties
};
}
const metaProperties = this.$obj[META_PROPS_SYMBOL] || {};
return { properties, metaProperties: metaProperties };
}
/**
* adds information to the metaProperties of a UxModel for web widget properties
* @private
* @param {Object} webWidgetMetaProperties
* @returns {Object}
*/
$addWebWidgetMetaProperties(webWidgetMetaProperties) {
let currentMetaProperties = this.$obj[META_PROPS_SYMBOL] || {};
Object.keys(webWidgetMetaProperties).forEach((key) => {
const prop = webWidgetMetaProperties[key];
prop.webWidgetProperty = true;
});
currentMetaProperties = this.$obj[META_PROPS_SYMBOL] = Object.assign(shallowClone(currentMetaProperties), webWidgetMetaProperties);
return currentMetaProperties;
}
/**
* @private
* @param {function} superCtor
* @returns {Boolean} if the type of this UxModel is the given constructor, or a subclass of it
*/
$represents(superCtor) {
return isAssignableFrom(superCtor, this.getType());
}
/**
* returns the meta properties information for a property
* @private
* @param {String} propName the name of the property in the UxModel that you want the meta
* properties information for
* @returns {Object}
*/
$getMetaPropertyInfo(propName) {
return this.$getDefaultPropertiesInfo().metaProperties[propName];
}
/**
* Returns any metadata provided to the constructor.
*
* @since Niagara 4.15
* @returns {object}
*/
getMetadata() {
return this.$obj.metadata || {};
}
/**
* @returns {*|null} the value to be loaded into this widget
*/
getValue() {
return this.$obj.value;
}
/**
* @since Niagara 4.14
* @returns {boolean} true if this widget should be readonly
*/
isReadonly() {
return !!this.$obj.readonly;
}
/**
* @since Niagara 4.14
* @returns {string|undefined} the form factor this widget should be constructed with, if known
*/
getFormFactor() {
return this.$obj.formFactor;
}
/**
* Produce a `spandrel` config object that represents this Ux element as
* rendered in the DOM. The `value` property will always be `this`, as the
* `UxModel` will be loaded into the `spandrel` widget as the value.
*
* Remember that the `spandrel` data will contain any bindings present in
* the model as well! Beware of simply passing back `toSpandrel()` results
* from the `UxModel` passed to your render function - you may get duplicate
* bindings. `toSpandrel()` is typically more appropriate for calling on
* kids.
*
* @param {object|string|Function} params parameters used for generating the
* `spandrel` data; can also be `dom` passed directly as a string or
* function
* @param {string|Function} params.dom the DOM element into which to render
* this element. Can be a function that receives an object with
* `properties`, which are the properties of this Ux element, to be used to
* generate the DOM
* @param {Array.<object>|Function} [params.kids] You can specify the `kids`
* property of the `spandrel` config directly. Alternately, this can be a
* function that receives each `UxModel` in `getKids()`, and returns
* `kid.toSpandrel()` or a `spandrel` object of your choosing.
* @returns {object} an object fit to be passed as a `spandrel` argument
*/
toSpandrel(params = {}) {
if (isDom(params) || typeof params === 'function') {
params = { dom: params };
}
let { dom, kids } = params;
const properties = this.getProperties();
if (typeof dom === 'function') {
dom = dom({ properties });
}
if (typeof kids === 'function') {
kids = this.getKids().map(kids);
}
const value = this.getValue();
return {
dom,
enabled: properties.enabled !== false,
kids,
properties,
readonly: this.$obj.readonly,
formFactor: this.getFormFactor(),
type: this.getType(),
value: value === undefined ? this : value,
data: { bindingList: this.getBindingList() }
};
}
/**
* @returns {string}
* @since Niagara 4.15
*/
toString() {
const name = this.getName() || '{none}';
const type = this.getType();
const typeName = type ? type.name : '{none}';
const properties = this.getProperties();
const propsString = `properties={${
Object.keys(properties).map((key) => { return `${ key }=${ properties[key] }`; }).join(', ')
}}`;
return `UxModel[name=${ name }, type=${ typeName }, ${ propsString }]`;
}
/**
* Visit this model and all their kid models, calling the passed in function
* along the way.
* @param {Function} func called for every model and its kid models. The model
* itself will be passed as the first parameter.
* Visiting stops once the function returns false.
* @returns {Promise<*>}
* @since Niagara 4.15
*/
visit(func) {
return Promise.resolve(func(this))
.then((returnValue) => {
if (returnValue === false) {
return false;
}
return Promise.all(this.getKids().map((kid) => Promise.resolve(kid.visit(func))));
});
}
/**
* @private
* @param {...module:bajaux/model/UxModel~UxModelParams} [args]
* @returns {module:bajaux/model/UxModel~UxModelParams}
* @since Niagara 4.15
*/
static $extendParams(...args) {
return extendParams(...args);
}
}
function processKids(kids) {
if (!kids) { return []; }
return map(kids, (kid, name) => {
if (!(kid instanceof UxModel)) {
kid = new UxModel(kid);
}
const obj = kid.$obj;
obj.name = obj.name || String(name);
return kid;
});
}
function byName(model, path) {
if (!path.length) { return model; }
const obj = model.$obj;
const name = first(path);
const kids = obj.kids;
let kid;
if (Array.isArray(kids)) {
kid = kids.find((k) => k.getName() === String(name));
} else {
kid = kids[name];
}
if (!kid) {
const binding = model.$bindingList.getBindings().find((b) => b.getName() === name);
return binding || undefined;
}
return byName(kid, rest(path));
}
function isDom(dom) {
return typeof dom === 'string' || dom instanceof HTMLElement;
}
function getDefaultPropertiesInfo(Ctor) {
const ctorProperties = {};
const ctorMetaProperties = {};
const widget = new Ctor();
if (widget instanceof Widget) {
// accessing via private variables is bad - but this is a super
// hotspot so must be fast
const arr = widget.$properties.$array;
for (let i = 0, len = arr.length; i < len; ++i) {
const prop = arr[i];
const def = prop.defaultValue;
if (def !== null && def !== undefined) {
ctorProperties[prop.name] = def;
ctorMetaProperties[prop.name] = prop;
}
}
}
return {
properties: ctorProperties,
metaProperties: ctorMetaProperties
};
}
/**
* _.extend doesn't support Symbols.
* @param {...object} objs
* @returns {object}
*/
function extend(...objs) {
return Object.assign(...objs);
}
function shallowClone(obj) {
return extend({}, obj);
}
function extendParams() {
return [ ...arguments ].reduce((baseParams = {}, subParams = {}) => {
return extend(shallowClone(baseParams), subParams, {
properties: extend(shallowClone(baseParams.properties), subParams.properties),
metadata: extend(shallowClone(baseParams.metadata), subParams.metadata)
});
}, {});
}
/**
* Check to see if one constructor is a subclass of another constructor.
*
* @param {Function} superCtor constructor function
* @param {Function} subCtor constructor function
* @returns {Boolean} true if subCtor inherits from superCtor, or if they
* are the same constructor
*/
function isAssignableFrom(superCtor, subCtor) {
if (typeof superCtor !== 'function' || typeof subCtor !== 'function') {
return false;
}
return Object.create(subCtor.prototype) instanceof superCtor;
}
return UxModel;
});
/**
* @typedef {object} module:bajaux/model/UxModel~UxModelParams
* @property {string} [name] the name of the widget represented by this
* `UxModel`. This will be automatically set on child nodes; a parent-less
* root widget may have no name or a name arbitrarily chosen.
* @property {Function} [type] the Type of the widget to create
* @property {object} [properties] an object literal of the widget's
* properties
* @property {boolean} [readonly] true if the widget should be readonly
* @property {string} [formFactor] the form factor this widget should be constructed with, if known
* @property {Array.<object|module:bajaux/model/UxModel>} [kids] objects
* describing the widget's children
* @property {Array.<module:bajaux/model/binding/IBinding>} [bindings] bindings
* to propagate data updates to the widget (these will be assigned to a
* `BindingList`)
* @property {*} [value] can be specified if loading a value
* @property {object} [metadata] (since Niagara 4.15) append any special-purpose metadata to this
* UxModel. This data is for framework use and will not be applied to the actual Widget built by
* this UxModel.
*/