spandrel/jsx.js

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

/* eslint-env browser */

define([
  'bajaux/events',
  'bajaux/spandrel/symbols',
  'bajaux/spandrel/util',
  'Promise',
  'underscore' ], function (
  events,
  symbols,
  util,
  Promise,
  _) {

  'use strict';

  const { flatten, isFunction, once } = _;
  const { IS_SPANDREL_SYMBOL, KEY_SYMBOL, JSX_TYPE_SYMBOL, JSX_PROPS_SYMBOL, JSX_KIDS_SYMBOL,
    REIFY_SYMBOL, isSpandrelSymbol } = symbols;
  const { isDynamic, reify } = util;

  const JSX_NODE_SYMBOL = Symbol('jsxNode');
  /*
  a namespaces map will tag along as part of the "props" attribute of each jsx node. this keeps
  track of what namespaces are known by each JSX element so the corresponding *NS method can be
  called.
   */
  const NAMESPACES_SYMBOL = Symbol('namespaces');
  const ELEMENT_NAMESPACE_SYMBOL = Symbol('elementNamespace');

  const BAJAUX_NOT_DOM_ATTRIBUTES = {
    bind: true,
    bindKey: true,
    complex: true,
    enabled: true,
    formFactor: true,
    lax: true,
    properties: true,
    readonly: true,
    slot: true,
    stateBinding: true,
    tagName: true,
    validate: true,
    value: true
  };
  const BAJAUX_NOT_DOM_ATTRIBUTE_NAMES = Object.keys(BAJAUX_NOT_DOM_ATTRIBUTES);

  const isQuieted = (() => {
    const quiet = {};
    [
      'INITIALIZE_EVENT',
      'LOAD_EVENT',
      'SAVE_EVENT',
      'ENABLE_EVENT',
      'DISABLE_EVENT',
      'READONLY_EVENT',
      'WRITABLE_EVENT',
      'LAYOUT_EVENT',
      'DESTROY_EVENT'
    ].forEach((eventKey) => {
      quiet[events[eventKey]] = true;
    });
    return (eventName) => quiet[eventName];
  })();

  /**
   * API Status: **Development**
   * @exports bajaux/spandrel/jsx
   */
  const exports = {};

  /**
   * Return JSX instructions for creating one node (DOM element of Widget) in a tree of spandrel
   * data.
   *
   * These represent _instructions_ only. The spandrel data will not be created until it is
   * "reified" by calling `.then()` on the object returned by this function (or passing it to
   * `Promise.resolve()` etc.). spandrel itself will perform this reification as part of the
   * process of building out the widget.
   *
   * @param {string|Function} type HTML tag name, or a Widget constructor to instantiate
   * @param {object|null} [props]
   * @param {...module:bajaux/spandrel/jsx~JsxInstructions} kids
   * @returns {module:bajaux/spandrel/jsx~JsxInstructions}
   *
   * @example
   * <caption>Basic JSX->spandrel example</caption>
   * &#37;** @jsx spandrel.jsx *&#37;
   * class ComponentToHTML extends spandrel((comp) => {
   *   return (
   *     <table>
   *     {
   *       comp.getSlots().properties().toArray().map((prop) => {
   *         return <tr>
   *           <td>{ prop.getName() }</td>
   *           <td>{ prop.getType() }</td>
   *         </tr>;
   *       })
   *     }
   *     </table>
   *   );
   * }) {}
   *
   * @example
   * <caption>Continued configuration after creation</caption>
   *
   * // these two widgets are equivalent.
   *
   * spandrel((string) => <label className="hello">{ string }</label>);
   * spandrel((string) => {
   *   const label = <label>{string}</label>;
   *   label.className = 'hello';
   *   return label;
   * });
   *
   * // at the moment, spandrel JSX nodes are *write only* from your javascript code. this means you
   * // cannot do this:
   *
   * spandrel((string) => {
   *   const label = <label className="foo">{string}</label>;
   *   label.className += ' bar'; // can't read it!
   *   return label;
   * });
   */
  exports.jsxToSpandrel = function (type, props, ...kids) {
    return toJsxInstructions(type, props || {}, flatten(kids));
  };

  /**
   * Looks for event handlers on the properties of a JSX node, such as `onUxModify`, and normalizes
   * them into an `on` array as expected to be a member of `spandrel` data. This will **mutate** the
   * input properties object: `onUx*` members will be *removed* and placed in the resulting array.
   * If any of the events require unquieting (e.g. `bajaux:load`) in order to trigger event
   * handlers, the `$quiet` property will be set to false in `props.properties`.
   *
   * @private
   * @param {object} props the properties of a JSX node
   * @returns {Array} an array of event handlers as expected to be a member of
   * {@link module:bajaux/spandrel~WidgetDefinition}
   */
  exports.$normalizeJsxEventHandlers = function (props) {
    return normalizeEventHandlers(props);
  };

  /**
   * Applies DOM attributes like style and className from the JSX instructions to the given DOM
   * element. `className` and `style` will be merged together; atomic attributes like "readonly"
   * will be overwritten.
   *
   * @private
   * @param {module:bajaux/spandrel/jsx~JsxInstructions} jsx
   * @param {Element|JQuery} dom
   */
  exports.$applyJsxToDom = function (jsx, dom) {
    applyJsxToDom(jsx, dom);
  };

  /**
   * When defining a `spandrelSrc` in JSX, the implementor will often want to apply additional
   * configuration in the JSX that would override the generated spandrel data.
   *
   * Given actual spandrel build params (enabled/readonly/properties/formFactor/etc) as generated by
   * the spandrelSrc, this will take the configuration declared in the JSX and apply it to those
   * build params. `properties` will be merged; all other properties will simply overwrite if
   * present.
   *
   * @private
   * @param {module:bajaux/spandrel~SpandrelBuildParams} buildParams
   * @param {object} jsxProps
   * @returns {object}
   * @example
   * <caption>
   *   The "visible" property of the BorderPane's content widget will always be set to true,
   *   regardless of what the UxModel says. All other properties generated by the UxModel will still
   *   be respected.
   * </caption>
   * return (
   *   <BorderPane>
   *     <widget name="content" src={uxModel.get('content')} properties={{ visible: true }} />
   *   </BorderPane>
   * );
   */
  exports.$clobber = function (buildParams, jsxProps) {
    const newBuildParams = Object.assign({}, buildParams);
    BAJAUX_NOT_DOM_ATTRIBUTE_NAMES.forEach((bajauxAttributeName) => {
      if (bajauxAttributeName in jsxProps) {
        const bajauxAttributeValue = jsxProps[bajauxAttributeName];
        if (bajauxAttributeName === 'properties') {
          const properties = Object.assign({}, newBuildParams[bajauxAttributeName]);
          newBuildParams[bajauxAttributeName] = Object.assign(properties, bajauxAttributeValue);
        } else {
          newBuildParams[bajauxAttributeName] = bajauxAttributeValue;
        }
      }
    });
    return newBuildParams;
  };

  /**
   * @param {module:bajaux/spandrel/jsx~JsxInstructions} jsx
   * @returns {module:bajaux/spandrel~SpandrelData}
   */
  function jsxToSpandrel(jsx) {
    let { [JSX_TYPE_SYMBOL]: type, [JSX_PROPS_SYMBOL]: props, [JSX_KIDS_SYMBOL]: kids } = jsx;
    let textContent = '';
    let widgetType;
    props = props || {};

    if (typeof type !== 'string') {
      widgetType = type;
    }
    //convert all the props to lowercase
    props = mapPropertiesToLowerCase(props);
    if (!kids.length) {
      kids = undefined;
    } else {
      kids.forEach((kid, i) => {
        if (!kid) {
          return;
        }

        if (typeof kid === 'string') {
          textContent += kid;
          kids = undefined;
        } else {
          kid.key = kid.key || String(i);
        }
      });
    }

    const { spandrelkey } = props;
    const lazyReifier = makeDomReifier(jsx, textContent);

    let { bind, bindkey, complex, enabled, formfactor, lax, readonly, slot, validate, value } = props;
    if (typeof enabled === 'string') { enabled = enabled !== 'false'; }
    if (typeof readonly === 'string') { readonly = readonly !== 'false'; }
    if (typeof value === 'undefined' && isDynamic(type)) {
      // NiagaraWidgetManager specifically checks for undefined instead of falsy.
      // complex + slot + value: null will cause it to fail: "null is not compatible with this slot type."
      if (!(complex && slot)) { value = null; }
    }

    let on;
    const config = {
      [IS_SPANDREL_SYMBOL]: true,
      [JSX_NODE_SYMBOL]: jsx,
      [KEY_SYMBOL]: spandrelkey,
      get dom() { return lazyReifier.getDom(); }, // perform lazy reification before accessing the `dom` property.
      enabled,
      formFactor: formfactor,
      kids,
      get on() { return on || lazyReifier.getOn(); },
      set on(o) { on = o; }, // config.on = [] is a special case. after NCCB-48438 no longer needed or recommended.
      get properties() { return lazyReifier.getProperties(); },
      readonly,
      type: widgetType,
      validate,
      value
    };

    // TODO: there may be additional extra parameters that could be passed to
    // fe.buildFor than these. it doesn't feel safe to just do an
    // Object.assign() due to the possibility of collisions between DOM element
    // properties and fe.buildFor arguments. revisit if we need more.
    if (complex) { config.complex = complex; }
    if (slot) { config.slot = slot; }
    if (bind) { config.bind = bind; }
    if (bindkey) { config.bindKey = bindkey; }
    if (lax) { config.lax = true; }

    return config;
  }

  /**
   * JSX nodes are built from the bottom up. If the jsx reads:
   *
   * `<div><span></span></div>`
   *
   * then that inner span is constructed *before* the div is.
   *
   * But the construction of nodes is sometimes informed by their parents. If building an SVG:
   *
   * `<svg xmlns="http://www.w3.org/2000/svg"><circle/></svg>`
   *
   * then the construction of that `circle` *requires* knowledge of the namespace it inherits from
   * its parent `svg`. We *can't* correctly create it until we've got that information from the
   * parent node.
   *
   * `spandrel` addresses this by holding on to that tree of JSX nodes until it's actually
   * requested. The nodes sit in a data structure until a `spandrel` widget actually requests the
   * `dom` property to put a child widget into. At that moment, the tree will be "reified" into an
   * actual tree of Elements (e.g. HTMLElements and SVGElements) into which to build its structure.
   *
   * This also reifies the widget Properties and event handlers (because these are also constructed
   * with information from the JSX structure).
   *
   * @param {module:bajaux/spandrel/jsx~JsxInstructions} jsx
   * @param {string} [textContent] if there is any text content to assign to the created Element
   * @returns {{ getDom: (function(): Element), getProperties(): object, getOn(): object[] }}
   */
  function makeDomReifier(jsx, textContent) {
    let { [JSX_TYPE_SYMBOL]: type, [JSX_PROPS_SYMBOL]: props } = jsx;
    let makeDom;
    props = props || {};
    let on;

    if (typeof type === 'string') {
      if (type === 'any') {
        makeDom = () => document.createElement(props.tagName || 'div');
      } else {
        makeDom = () => createElement(type, jsx[NAMESPACES_SYMBOL]);
      }
    } else {
      makeDom = () => document.createElement(props.tagName || 'div');
    }

    /**
     * Before actually constructing the DOM element for this spandrel widget, perform reification:
     * that is, give spandrel.jsx calls higher up in the DOM hierarchy a chance to propagate their
     * namespaces down to descendant elements, so the correct (namespaced) element constructors are
     * called.
     */
    const reifyDom = once(() => {
      propagateNamespaces(jsx, jsx[NAMESPACES_SYMBOL]);
      const dom = makeDom();
      dom.textContent = textContent;
      on = normalizeEventHandlers(props);
      applyJsxToDom(jsx, dom);
      return dom;
    });

    return {
      getDom: () => reifyDom(),
      getProperties() {
        // configured DOM event handlers can affect widget properties, so reify first.
        reifyDom();
        return props.properties;
      },
      getOn() {
        reifyDom();
        return on;
      }
    };
  }

  function applyJsxToDom(jsx, dom) {
    dom = dom[0] || dom;
    const { [JSX_TYPE_SYMBOL]: type, [JSX_PROPS_SYMBOL]: props } = jsx;
    const isWidget = isFunction(type) || type === 'any';
    Object.keys(props).forEach((name) => {
      const prop = props[name];
      setDomElementProp(dom, name, prop, jsx[NAMESPACES_SYMBOL], isWidget);
    });
  }

  function normalizeEventHandlers(props) {
    let on = props.on || [];
    if (Array.isArray(on)) {
      if (on[0] && !Array.isArray(on[0])) {
        on = [ on ];
      }
    } else if (on.constructor === Object) {
      on = Object.keys(on).map((eventName) => [ eventName, on[eventName] ]);
    }

    let needsUnquiet;

    Object.keys(props).forEach((name) => {
      const prop = props[name];

      if (name.startsWith('onUx') && prop) {
        let func = prop;
        let handler;
        let eventName;

        const eventSubstring = name.substring(4).toLowerCase();
        if (eventSubstring === 'modifiedvalue') {
          handler = function (e, ed) {
            return ed.read().then((newValue) => {
              return func.call(this, newValue, e, ed);
            });
          };
          eventName = 'bajaux:modify';
        } else {
          handler = func;
          eventName = 'bajaux:' + eventSubstring;
          if (isQuieted(eventName)) {
            needsUnquiet = true;
          }
        }

        on.push([ eventName, handler ]);
        delete props[name];
      } else if (name.startsWith('on') && prop) {
        const eventName = name.substring(2).toLowerCase();
        if (eventName) {
          on.push([ eventName, prop ]);
          delete props[name];
        }
      }
    });

    if (needsUnquiet) {
      const properties = props.properties || (props.properties = {});
      properties.$quiet = false;
    }

    return on;
  }

  /**
   * @param {string} type
   * @param {object} namespaces
   * @returns {Element}
   */
  function createElement(type, namespaces) {
    if (namespaces) {
      const elementNamespace = namespaces[ELEMENT_NAMESPACE_SYMBOL];
      if (elementNamespace) {
        return document.createElementNS(elementNamespace, type);
      }
    }

    return document.createElement(type);
  }

  /**
   * @param {Element} el
   * @param {string} name
   * @param {string} value
   * @param {object} namespaces
   */
  function setDomElementAttribute(el, name, value, namespaces) {
    const [ nsName, nsProp ] = name.split(':');
    const ns = nsProp && namespaces[nsName];

    if (ns) {
      el.setAttributeNS(ns, name, value);
    } else {
      el.setAttribute(name, value);
    }
  }

  function setDomElementProp(el, name, value, namespaces, isWidget) {
    if (isWidget && BAJAUX_NOT_DOM_ATTRIBUTES[name]) {
      return;
    }

    if (value === null || value === undefined) {
      return;
    }

    if (name === '$init') { return value(el); }

    if (name === 'spandrelKey' || name === 'spandrelSrc' || name === 'on') { return; }

    if (name === 'className' || name ===  'class') {
      return value && el.classList.add(...value.trim().split(/\s+/));
    }

    // if needed, come back and add ability to parse a `style` attribute as string and merge it in.
    if (name === 'style' && typeof value === 'object') {
      const { style } = el;
      Object.keys(value).forEach((prop) => {
        const propValue = value[prop];
        if (propValue || (typeof propValue === 'number')) {
          style[prop] = propValue;
        }
      });
    } else if (typeof value === 'boolean') {
      if (value) { setDomElementAttribute(el, name, 'true', namespaces); }
      el[name] = value;
    } else {
      setDomElementAttribute(el, name, value, namespaces);
    }
  }

  /**
   * @param {module:bajaux/spandrel/jsx~JsxInstructions} jsx
   * @param {object} namespaces
   */
  function propagateNamespaces(jsx, namespaces = {}) {
    namespaces = Object.assign({}, namespaces);

    const { [JSX_PROPS_SYMBOL]: props, [JSX_KIDS_SYMBOL]: kids } = jsx;

    if (props) {
      Object.keys(props).forEach((prop) => {
        if (prop.startsWith('xmlns')) {
          const ns = props[prop];
          const [ , name ] = prop.split(':');
          if (name) {
            namespaces[name] = ns;
          } else {
            namespaces[ELEMENT_NAMESPACE_SYMBOL] = ns;
          }
        }
      });
    }

     jsx[NAMESPACES_SYMBOL] = namespaces;

    if (kids) {
      kids.forEach((kid) => kid && kid[JSX_NODE_SYMBOL] && propagateNamespaces(kid[JSX_NODE_SYMBOL], namespaces));
    }
  }

  /**
   * @param {string|Function} type
   * @param {object} props
   * @param {Array.<module:bajaux/spandrel/jsx~JsxInstructions|string>} kidInstructions
   * @returns {module:bajaux/spandrel/jsx~JsxInstructions}
   */
  function toJsxInstructions(type, props, kidInstructions) {
    const { spandrelSrc } = props;

    const instructions = {
      [JSX_TYPE_SYMBOL]: type,
      [JSX_PROPS_SYMBOL]: props,
      [JSX_KIDS_SYMBOL]: kidInstructions,
      [NAMESPACES_SYMBOL]: {},
      [IS_SPANDREL_SYMBOL]: true
    };

    if (spandrelSrc) {
      if (kidInstructions.length) {
        throw new Error('spandrelSrc cannot be combined with children');
      }
      if (typeof type !== 'string') {
        throw new Error('spandrelSrc can only be applied to a DOM element');
      }
      return spandrelSrcReifier(instructions);
    } else {
      return makeThenable(instructions, () => reifyJsxTree(instructions));
    }
  }

  /**
   * @param {object} object
   * @param {function} func
   * @returns {Thenable} a Thenable that will run the given function exactly once and then resolve
   */
  function makeThenable(object, func) {
    const runOnce = once(func);
    Object.defineProperty(object, 'then', {
      enumerable: false,
      writable: true,
      value: (resolve, reject) => Promise.resolve(runOnce()).then(resolve, reject)
    });
    return object;
  }

  /**
   * This JSX node specified `spandrelSrc`, so reifying this node consists of giving that
   * `spandrelSrc` the opportunity to programmatically generate its contents.
   *
   * @param {module:bajaux/spandrel/jsx~JsxInstructions} jsx
   * @returns {module:bajaux/spandrel/jsx~JsxInstructions}
   */
  function spandrelSrcReifier(jsx) {
    const { [JSX_PROPS_SYMBOL]: props } = jsx;
    const { spandrelKey, spandrelSrc } = props;
    const lazyReifier = makeDomReifier(jsx, '');

    jsx[IS_SPANDREL_SYMBOL] = true;
    jsx[REIFY_SYMBOL] = (ownerState) => {
      return reify(spandrelSrc.toSpandrel({ dom: lazyReifier.getDom() }), ownerState)
        .then((sp) => {
          if (typeof sp === 'object' && !Array.isArray(sp)) {
            const spOn = normalizeEventHandlers(sp);
            sp = exports.$clobber(sp, props);
            if (spandrelKey) {
              sp[KEY_SYMBOL] = spandrelKey;
            }

            // mark it as spandrel data, and not a key->widget map
            sp[IS_SPANDREL_SYMBOL] = true;
            sp.on = spOn.concat(lazyReifier.getOn());
          }

          return sp;
        });
    };

    return jsx;
  }

  /**
   * @param {module:bajaux/spandrel/jsx~JsxInstructions} jsxInstructions
   * @returns {Promise.<module:bajaux/spandrel~SpandrelData>}
   */
  function reifyJsxTree(jsxInstructions) {
    if (!jsxInstructions || typeof jsxInstructions === 'string') {
      return Promise.resolve(jsxInstructions);
    }

    const { [JSX_PROPS_SYMBOL]: props, [JSX_KIDS_SYMBOL]: kids } = jsxInstructions;

    Object.keys(jsxInstructions).forEach((name) => {
      if (!isSpandrelSymbol(name)) {
        props[name] = jsxInstructions[name];
      }
    });

    return Promise.all(kids)
      .then((reifiedKids) => {
        jsxInstructions[JSX_KIDS_SYMBOL] = reifiedKids;
        return jsxToSpandrel(jsxInstructions);
      });
  }

  /**
   * Converts object keys to lowercase
   * @param {object} props
   * @returns {object}
   */
  function mapPropertiesToLowerCase(props) {
    const newProps = {};
    Object.keys(props).forEach((prop) => {
      newProps[prop.toLowerCase()] = props[prop];
    });

    return newProps;
  }

  return exports;
});