model/UxModel.js

/**
 * @copyright 2020 Tridium, Inc. All Rights Reserved.
 * @author Logan Byam
 */

/* eslint-env browser */

/**
 * API Status: **Development**
 * @module nmodule/bajaui/rc/model/UxModel
 */
define([
  'bajaux/Widget',
  'Promise',
  'underscore',
  'nmodule/bajaui/rc/model/BindingList',
  'nmodule/bajaui/rc/model/jsxToUxModel' ], function (
  Widget,
  Promise,
  _,
  BindingList,
  jsxToUxModel) {

  'use strict';

  const { first, map, rest } = _;
  const { jsx } = jsxToUxModel;

  /**
   * we use this to cache the default properties
   * @type {symbol}
   */
  const CTOR_DEFAULT_PROPS_SYMBOL = Symbol('constructorProperties');
  const CTOR_SYMBOL = Symbol('constructor');

  /**
   * Represents all information needed to create one widget, and its children,
   * in a Ux Media graphic.
   *
   * 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.
   *
   * @todo this should eventually move to uxBuilder, but keeping it here for ease of development in conjunction with bajaui
   * @class
   * @alias module:nmodule/bajaui/rc/model/UxModel
   */
  class UxModel {
    /**
     * Don't call directly - use `make()` instead.
     * @private
     */
    constructor(obj = {}) {
      this.$obj = obj;
      obj.kids = processKids(obj.kids);
      this.$bindingList = new BindingList(obj.bindings);
    }

    /**
     * @param {object} [obj]
     * @param {string} [obj.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.
     * @param {Function} [obj.type] the Type of the widget to create
     * @param {object} [obj.properties] an object literal of the widget's
     * properties
     * @param {boolean} [obj.readonly] true if the widget should be readonly
     * @param {string} [obj.formFactor] the form factor this widget should be constructed with, if known
     * @param {Array.<object|module:nmodule/bajaui/rc/model/UxModel>} [obj.kids] objects
     * describing the widget's children
     * @param {Array.<module:nmodule/bajaui/rc/binding/IValueProvider>} [obj.bindings] bindings
     * to propagate data updates to the widget (these will be assigned to a
     * `BindingList`)
     * @param {*} [obj.value] can be specified if loading a value
     * @returns {Promise.<module:nmodule/bajaui/rc/model/UxModel>}
     */
    static make(obj) {
      if (obj instanceof UxModel) {
        obj = obj.$obj;
      }
      // TODO: resolve baja types in properties/bindings
      return Promise.resolve(new UxModel(obj));
    }

    /**
     * @private
     * @see module:nmodule/bajaui/rc/model/jsxToUxModel
     */
    static jsx(type, props, ...kids) {
      return jsx(type, props || {}, kids);
    }

    /**
     * @returns {string}
     */
    getName() {
      return this.$obj.name;
    }

    /**
     * @param {string|string[]} path
     * @returns {module:nmodule/bajaui/rc/model/UxModel|module:nmodule/bajaui/rc/baja/binding/Binding|undefined} the UxModel
     * kid by the given name. If an array of names are given, 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:nmodule/bajaui/rc/model/BindingList}
     */
    getBindingList() {
      return this.$bindingList;
    }

    /**
     * @returns {Array.<module:nmodule/bajaui/rc/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
     */
    getProperties() {
      const obj = this.$obj;
      let properties = obj.properties || {};

      if (!this.$widgetPropertiesApplied) {
        const Ctor = this.getType();
        if (Ctor) {
          let ctorProperties = Ctor[CTOR_DEFAULT_PROPS_SYMBOL];

          if (!ctorProperties || ctorProperties[CTOR_SYMBOL] !== Ctor) {
            ctorProperties = Ctor[CTOR_DEFAULT_PROPS_SYMBOL] = getDefaultProperties(Ctor);
            ctorProperties[CTOR_SYMBOL] = Ctor;
          }

          const newProps = Object.assign({}, ctorProperties, properties);
          properties = obj.properties = newProps;
        }
        this.$widgetPropertiesApplied = true;
      }
      return properties;
    }

    /**
     * @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.isReadonly(),
        formFactor: this.getFormFactor(),
        type: this.getType(),
        value: value === undefined ? this : value,
        data: { bindingList: this.getBindingList() }
      };
    }
  }

  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 getDefaultProperties(Ctor) {
    const ctorProperties = {};
    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;
        }
      }
    }
    return ctorProperties;
  }

  return UxModel;
});