model/UxModel.js

/**
 * @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.
 */