/**
* @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 } = 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>
* %** @jsx spandrel.jsx *%
* 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;
}
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,
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 (name === '$init') { return value(el); }
if (name === 'spandrelKey' || name === 'spandrelSrc' || name === 'on') { return; }
if (name === 'className' || name === 'class') {
return 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) => {
props[name] = jsxInstructions[name];
});
return Promise.all(kids)
.then((reifiedKids) => {
jsxInstructions[JSX_KIDS_SYMBOL] = reifiedKids;
return jsxToSpandrel(jsxInstructions);
});
}
return exports;
});