/**
* @copyright 2019 Tridium, Inc. All Rights Reserved.
* @author Logan Byam
*/
/* eslint-env browser */
/**
* API Status: **Development**
* @module bajaux/spandrel
*/
define([
'log!bajaux.spandrel',
'bajaux/Widget',
'bajaux/lifecycle/WidgetManager',
'jquery',
'Promise',
'underscore',
'nmodule/js/rc/switchboard/switchboard',
'bajaux/spandrel/buildConfig',
'bajaux/spandrel/diff',
'bajaux/spandrel/jsx',
'bajaux/spandrel/RequestLayoutMixin',
'bajaux/spandrel/SpandrelWidget',
'bajaux/spandrel/util' ], function (
log,
Widget,
WidgetManager,
$,
Promise,
_,
switchboard,
buildConfig,
diff,
jsx,
RequestLayoutMixin,
SpandrelWidget,
util) {
'use strict';
const TRACE = log.isLoggable('FINEST');
const logFinest = log.finest.bind(log);
let defaultManager = new WidgetManager();
const { $DEPTH_SYMBOL, $IS_DYNAMIC_SYMBOL, $ROOT_SYMBOL } = buildConfig;
const { diffBuildContexts } = diff;
const { any, difference, extend, isArray, last, lastIndexOf } = _;
const {
getContainingSpandrelWidget, getInlineStyles, getPathToKid, kidsMatching,
pathMatches, requiresRebuild, updateElement
} = util;
const PRIORITY_UPDATE = 1;
const PRIORITY_REBUILD = 2;
const PATH_TO_KID_CONTEXTS = [ 'config', 'kids', 'members' ];
/**
* The purpose of `spandrel` is to provide a reasonably pure-functional,
* diffable method of defining a nested structure of bajaux Widgets and
* supporting HTML. Rather than require Widget implementors to manually code
* calls to `initialize()` or `buildFor()`, `spandrel` allows you to provide
* your desired structure of HTML elements and their associated Widget
* instances, and handle the work of updating the document as that structure
* may change over time.
*
* See {@tutorial spandrel} for in-depth information.
*
* @alias module:bajaux/spandrel
* @param {module:bajaux/spandrel~SpandrelArg} arg
* @param {object} [params={}] params
* @param {function(new:module:bajaux/Widget)} [params.extends] optionally specify a Widget superclass to extend
* @param {module:bajaux/lifecycle/WidgetManager} [params.manager] optionally provide your own WidgetManager to manage Widget lifecycle
* @returns {Function} a Widget constructor
* @since Niagara 4.10
*
* @example
* <caption>Generate a static widget</caption>
* const StaticWidget = spandrel([
* '<label>Name: </label>',
* '<input type="text" value="{{ props.name }}">',
* {
* dom: '<span></span>',
* value: false,
* properties: 'inherit'
* }
* ]);
* return fe.buildFor({
* dom: $('#myStaticWidget'),
* type: StaticWidget,
* properties: { name: 'Logan', trueText: 'Good', falseText: 'Not So Good' }
* });
*
* @example
* <caption>Generate a dynamic widget with a field editor for each slot</caption>
* const DynamicWidget = spandrel(comp => comp.getSlots().toArray().map(slot => ({
* dom: '<div class="componentSlot"/>',
* kids: [
* `<label>${ slot.getName() }: </label>`,
* { dom: '<span/>', complex: comp, slot: slot }
* ]
* })));
*
* return fe.buildFor({
* dom: $('#myDynamicWidget'),
* type: DynamicWidget,
* value: myComponent
* });
*
* @example
* <caption>Subclass an existing dynamic spandrel widget, making changes
* before rendering.</caption>
*
* // our superclass will render a <label> element, with a background
* // determined by a widget property.
* const LabelWidget = spandrel((value, { properties }) => {
* const label = document.createElement('label');
* label.innerText = value;
* label.style.background = properties.background || '';
* return label;
* });
*
* const RedLabelWidget = spandrel((value, { renderSuper }) => {
*
* // renderSuper will call back to the superclass, allowing your subclass
* // to edit the data before spandrel renders it to the page.
* //
* // you can optionally pass a function to renderSuper that will tweak the
* // widget state before the superclass renders its data. if no tweaking is
* // desired, just renderSuper() is fine.
* //
* return renderSuper((state) => {
* state.properties.background = 'lightpink';
*
* // remember to return the new state.
* return state;
* })
* .then((label) => {
* // renderSuper will resolve the data exactly as rendered by the
* // superclass.
* label.style.color = 'red';
* return label;
* });
* }, { extends: LabelWidget });
*/
function spandrel(arg, params) {
if (typeof arg === 'function') {
//dynamically redefine the nested widget structure based on whatever
//value is being loaded.
return makeDynamic(arg, params);
} else {
// define a static nested widget structure.
return makeStatic(arg, params);
}
}
/**
* Given spandrel input (potentially dynamically generated), spit out a build
* context, where each member may potentially contain more nested data, that
* will map to one or more fe.buildFor calls.
*
* @private
* @param {module:bajaux/spandrel~SpandrelArg} arg
* @param {module:bajaux/spandrel~WidgetState} widgetState configuration data derived
* from the parent widget to contain all these spandrel-generated widgets
* @returns {Promise.<module:bajaux/spandrel~BuildContext>}
*/
spandrel.build = function (arg, widgetState) {
return buildConfig(arg, widgetState);
};
/**
* Use `spandrel.jsx` as your JSX pragma to convert your JSX into spandrel
* config.
*
* @see module:bajaux/spandrel/jsx
*/
spandrel.jsx = jsx.jsxToSpandrel;
/**
* @private
* @param {module:bajaux/lifecycle/WidgetManager} manager
*/
spandrel.$installDefaultWidgetManager = function (manager) {
defaultManager = manager;
};
/**
* @private
* @returns {module:bajaux/lifecycle/WidgetManager}
*/
spandrel.$getDefaultWidgetManager = function () {
return defaultManager;
};
/**
* @param {function(*): module:bajaux/spandrel~SpandrelArg} func
* @param {Object} [params]
* @param {Function} [params.extends]
* @param {module:bajaux/lifecycle/WidgetManager} [params.manager]
* @returns {function(new:module:bajaux/spandrel/SpandrelWidget)}
*/
function makeDynamic(func, { extends: Super, manager } = {}) {
class DynamicSpandrelWidget extends (Super || SpandrelWidget) {
constructor(params) {
super(...arguments);
this.$manager = extractManager(params) || manager;
switchboard(this, {
render: { allow: 'oneAtATime', onRepeat: 'preempt' }
});
RequestLayoutMixin(this);
this[$IS_DYNAMIC_SYMBOL] = true;
}
initialize(dom, params, layoutParams) {
return super.initialize(dom, params, Object.assign({ quick: true }, layoutParams));
}
doLoad(value) {
return this.render(value);
}
/**
* @private
* @param {*} value the value being loaded
* @param {module:bajaux/spandrel~WidgetState} state the widget state
* @returns {Promise.<*>} data produced by the widget, ready to be
* rendered by spandrel
*/
$render(value, state) {
const { rootElement } = state;
const oldClass = rootElement.getAttribute('class');
const oldStyle = rootElement.getAttribute('style');
const oldStyles = getInlineStyles(rootElement);
// here, we make a copy because:
// - we need to keep track of precisely what classes the widget adds to
// its classList, therefore we have to start with a blank classList.
// - if we just set the classList to empty on the real live element,
// class-based selectors will stop working if the selection happens
// while the element is in the 'in-between' state while
// $renderSpandrelData is being called.
const copyClassList = document.createElement(rootElement.tagName).classList;
const origClassList = rootElement.classList;
setClassList(rootElement, copyClassList);
return this.$renderSpandrelData(value, state)
.then((spandrelArg) => {
const addedClasses = Array.prototype.slice.call(copyClassList);
const addedStyles = getInlineStyles(rootElement);
// record styles explicitly removed
const oldProps = Object.keys(oldStyles);
for (let i = 0, len = oldProps.length; i < len; i++) {
const prop = oldProps[i];
if (oldStyles[prop] && !addedStyles[prop]) {
addedStyles[prop] = '';
}
}
setClassList(rootElement, origClassList);
attr(rootElement, 'class', oldClass);
attr(rootElement, 'style', oldStyle);
rootElement[$IS_DYNAMIC_SYMBOL] = true;
return { spandrelArg, addedClasses, addedStyles };
});
}
/**
* Calls the render function that defines the class (the argument to
* `spandrel` itself).
* @param {*} value the value being loaded
* @param {module:bajaux/spandrel~WidgetState} state the widget state
* @returns {Promise}
*/
$renderSpandrelData(value, state) {
const widgetState = extend({}, state, {
renderSuper: (func) => {
if (typeof func === 'function') {
return Promise.resolve(func(state))
.then((newState) => super.$renderSpandrelData(value, newState));
} else {
return super.$renderSpandrelData(value, state);
}
}
});
return Promise.resolve(func.call(this, value, widgetState));
}
/**
* Pass the given value back through the spandrel processing function,
* regenerating the widget's structure as appropriate. This differs from
* `load()` in that it changes the DOM structure only - it does not fire
* load events or change the result of `this.value()`.
*
* @param {*} value
* @param {module:bajaux/spandrel~WidgetState} [state] widget
* state to use; if omitted the widget's current state will be used
* @returns {Promise}
*/
render(value, state) {
return Promise.try(() => {
return this.$render(value, state || this.state())
.then((result) => this.$doBuild(result))
.then(() => {
this.$loadFinished = true;
return this.layout({ quick: this !== this[$ROOT_SYMBOL] });
});
}).catch((err) => {
if (!this.$manager) { throw err; }
return this.$manager.error(err, this);
});
}
// TODO: rerender() needs to be smart about not wiping modified editors.
// if called in response to a MODIFY_EVENT, it rebuilds the editor you
// were typing in and you lose focus. c.f. LayoutEditor: i should be able
// to just [ MODIFY_EVENT, () => this.rerender() ]
// TODO: rerender() needs to handle when read() resolves a type that is
// not compatible with load().
/**
* Re-renders the widget based on the result of `this.read()`.
*
* @param {module:bajaux/spandrel~WidgetState} [state]
* @returns {Promise}
*/
rerender(state) {
if (!this.isInitialized()) { return Promise.resolve(); }
//TODO: switchboard onlyAfter
return Promise.resolve(
this.$loadFinished && this.read().then((v) => this.render(v, state)));
}
layout() {
if (!this.$loadFinished) {
return Promise.resolve();
}
return super.layout(...arguments);
}
doReadonly() {
return this.rerender();
}
doEnabled() {
return this.rerender();
}
//TODO: add lex support
$doBuild({ spandrelArg, addedClasses, addedStyles }) {
const dom = this.jq();
const widgetState = this.state();
const mgr = this.$manager || spandrel.$getDefaultWidgetManager();
let prevClasses;
let prevStyles;
return spandrel.build(spandrelArg, widgetState)
.then((curr) => {
const prev = this.$spandrel;
let differences;
if (prev) {
differences = diffBuildContexts(prev.spandrelData, curr);
prevClasses = prev.addedClasses;
prevStyles = prev.addedStyles;
}
this.$spandrel = {
spandrelData: curr,
addedClasses,
addedStyles
};
if (!prev) {
return buildWidgetsFromSpandrelData(this, curr, dom, mgr);
}
return differences &&
applyDiffs(this, curr, prev.spandrelData, differences, this.jq()[0], mgr);
})
.then(() => {
const { rootElement } = widgetState;
const { classList, style } = rootElement;
if (prevClasses && prevClasses.length) {
classList.remove.apply(classList, difference(prevClasses, addedClasses));
}
if (prevStyles) {
const removedStyles = difference(Object.keys(prevStyles),
Object.keys(addedStyles));
for (let i = 0, len = removedStyles.length; i < len; ++i) {
style.removeProperty(removedStyles[i]);
}
}
if (addedClasses.length) {
classList.add.apply(classList, addedClasses);
}
Object.assign(style, addedStyles);
});
}
}
//we need SpandrelWidget functionality even if we didn't inherit from it
if (Super) { SpandrelWidget.mixin(Super); }
DynamicSpandrelWidget[$IS_DYNAMIC_SYMBOL] = true;
return DynamicSpandrelWidget;
}
function makeStatic(spandrelArg, { manager = defaultManager } = {}) {
return class extends SpandrelWidget {
constructor() {
super(...arguments);
RequestLayoutMixin(this);
}
doInitialize(dom) {
return spandrel.build(spandrelArg, this.state())
.then((data) => buildWidgetsFromSpandrelData(this, data, dom, manager))
.then(() => super.doInitialize(...arguments));
}
};
}
/**
* @param {module:bajaux/Widget} widget
* @param {module:bajaux/spandrel~BuildContext} buildConfig
* @param {JQuery} dom
* @param {module:bajaux/lifecycle/WidgetManager} manager
* @returns {Promise}
*/
function buildWidgetsFromSpandrelData(widget, buildConfig, dom, manager) {
const { members, on } = buildConfig;
if (on.length) { armHandlers(widget, dom, on); }
return Promise.all(members.map((member) => {
return doFeBuild(member, $(member.config.dom).appendTo(dom), manager,
widget[$ROOT_SYMBOL], widget[$DEPTH_SYMBOL] + 1);
}));
}
/**
* @param {module:bajaux/Widget} owner
* @param {module:bajaux/spandrel~BuildContext} curr current spandrel build context
* @param {module:bajaux/spandrel~BuildContext} prev previous spandrel build context
* @param {Array.<object>} differences deep-diff differences between the two
* @param {HTMLElement} rootElement root element of the spandrel Widget
* @param {module:bajaux/lifecycle/WidgetManager} manager
* @returns {Promise} to be resolved when differences are applied
*/
function applyDiffs(owner, curr, prev, differences, rootElement, manager) {
const findNodePath = (path = []) => {
let currMember = null,
prevMember,
currMembers = curr && curr.members,
prevMembers = prev && prev.members;
const keys = extractKeys(path)
.map((key) => {
currMember = currMembers[key] || {};
prevMember = prevMembers[key] || {};
currMembers = resolveObjectPath(currMember, PATH_TO_KID_CONTEXTS);
prevMembers = resolveObjectPath(prevMember, PATH_TO_KID_CONTEXTS);
return prevMember.key || key; //if the key changed, the old key is still in the dom
});
const node = keys.reduce(
(node, key) => kidsMatching(node, key)[0], rootElement);
return { node, keys, member: currMember };
};
const edits = {};
const root = owner[$ROOT_SYMBOL];
const depth = owner[$DEPTH_SYMBOL] + 1;
const doEdit = (key, priority, doEdit) => {
const existingEdit = edits[key];
if (existingEdit && existingEdit.$priority === PRIORITY_REBUILD) {
return existingEdit;
} else {
const promise = edits[key] = Promise.resolve(existingEdit).then(doEdit);
promise.$priority = priority;
return promise;
}
};
/*
TODO: do we need to optimize this?
if we got a diff for the top-level widget and the event handler on the
kid widget at the same time, spandrel was destroying the parent (which
also destroys the kid) and re-arming the event handler on the (destroyed)
kid at the same time.
for now, we'll run the promises in serial since deep-diff should return the
diffs in top-to-bottom order. but maybe we could eke out some more
performance if we ran promises in distinct branches (never same branch) in
parallel.
*/
const setupEditFromDiff = (diff) => {
let { kind, index, item, path } = diff;
//additions/deletions only count if they're to lists of child widgets.
//e.g. i might have added a new member to an array as in an OrdList.
if (path && path.length && last(path) !== 'members') {
kind = 'E';
}
switch (kind) {
case 'E': {
const { node, keys, member } = findNodePath(path);
const editPath = keys.join('/');
const changedProp = last(path);
if (changedProp === 'on') {
const widget = Widget.in(rootElement).queryWidget(keys.join('/'));
// TODO: optimize this
// the differing value could be just one member of `on` but we have
// to wipe and re-arm all events at once
const on = getValueFromBuildContext(curr, path, 'on');
return armHandlers(widget, widget.jq(), on);
}
//special case for dom: don't do a full wipe and rebuild if dom is
//reusable
if (changedProp === 'dom') {
const oldElement = node;
const newElement = $(member.config.dom)[0];
if (!requiresRebuild(oldElement, newElement)) {
if (TRACE) {
trace(owner, 'updating dom in-place at path {}', editPath);
}
return doEdit(editPath, PRIORITY_UPDATE,
() => updateElement(oldElement, newElement, member.config.properties));
} else if (TRACE) {
trace(owner, 'rebuilding dom at path {} because it could not be diffed', editPath);
}
}
if (changedProp === 'value') {
const existing = Widget.in(node);
const config = getValueFromBuildContext(curr, path, 'config');
return Promise.resolve(manager.deriveConfiguredConstructor(config))
.then((Ctor) => {
if (Ctor && (existing instanceof Ctor)) {
if (TRACE) {
trace(owner, 'loading new value "{}" into widget {} at path {}',
member.config.value, existing.constructor.name, editPath);
}
return doEdit(editPath, PRIORITY_UPDATE, () => manager.load(existing, member.config));
} else {
if (TRACE) {
trace(owner, 'rebuilding widget at {} because new value to load ' +
'"{}" is not compatible with existing widget {}', editPath,
member.config.value, existing && existing.constructor.name);
}
return doEdit(editPath, PRIORITY_REBUILD, () => rebuild($(node), member, manager, root, depth));
}
});
}
if (isUpdatableDiff(path)) {
const widget = Widget.in($(node));
if (widget instanceof SpandrelWidget && typeof widget.rerender === 'function') {
if (TRACE) {
trace(owner, 'updating "{}" widget property at path {}', changedProp, editPath);
}
return doEdit(editPath, PRIORITY_UPDATE, () => update(widget, member));
} else if (TRACE) {
trace(owner, 'could not update "{}" widget property at path {} ' +
'because it was not a dynamic spandrel widget', changedProp, editPath);
}
}
if (TRACE) {
trace(owner, 'falling back to a full rebuild at path {} because ' +
'widget property "{}" could not be updated in-place', editPath, changedProp);
}
return doEdit(editPath, PRIORITY_REBUILD, () => rebuild($(node), member, manager, root, depth));
}
case 'A':
switch (item.kind) {
case 'N': {
const { keys, node } = findNodePath(path),
addedObj = item.rhs;
if (TRACE) {
trace(owner, 'creating new widget at {}/{}', keys.join('/'), addedObj.key);
}
return doFeBuild(addedObj, $(addedObj.config.dom).appendTo(node), manager, root, depth);
}
case 'D': {
const { keys, node } = findNodePath((path || []).concat(index));
if (TRACE) {
trace(owner, 'destroying {} at path {}', Widget.in(node).constructor.name, keys.join('/'));
}
return Widget.in(node).destroy().then(() => $(node).remove());
}
}
}
};
return differences.reduce((prom, diff) => prom.then(() => setupEditFromDiff(diff)), Promise.resolve());
}
function isUpdatableDiff(path) {
const lastInPath = last(path);
return lastInPath === 'properties' ||
lastInPath === 'readonly' ||
lastInPath === 'enabled' ||
lastInPath === 'writable';
}
/**
* @param {JQuery} dom
* @param {module:bajaux/spandrel~Member} member
* @param {module:bajaux/lifecycle/WidgetManager} manager
* @returns {Promise}
*/
function rebuild(dom, member, manager, root, depth) {
//TODO: support a RebuildStrategy
const oldWidget = Widget.in(dom);
if (oldWidget && !(oldWidget instanceof SpandrelWidget) && oldWidget.isModified()) {
return Promise.resolve();
}
return Promise.resolve(oldWidget && oldWidget.destroy())
.then(() => {
const newDom = $(member.config.dom);
dom.replaceWith(newDom);
return doFeBuild(member, newDom, manager, root, depth);
});
}
/**
* @param {module:bajaux/spandrel/SpandrelWidget} widget
* @param {module:bajaux/spandrel~Member} member
* @returns {Promise}
*/
function update(widget, member) {
//TODO: support an UpdateStrategy
return widget.applyParams(member.config)
.then(() => widget.rerender());
}
/**
* Given a config from a Spandrel member, determine what kind of widget
* Spandrel should show for it.
* @param {module:bajaux/lifecycle/WidgetManager~BuildParams} config the
* `config` property from a Spandrel member
* @param {module:bajaux/lifecycle/WidgetManager} manager
* @returns {Promise.<Function>} promise to be resolved with the kind of
* widget to show. If none is specified or it can't figure it out, default to
* Widget which will do nothing but show some raw HTML.
*/
function tryToDetermineType(config, manager) {
const { kids } = config;
if (kids) {
const { members, on } = kids;
return Promise.resolve(spandrel({ kids: members, on }, { manager }));
}
return manager.buildContext(extend({ formFactor: 'mini' }, config))
.then((buildContext) => buildContext.widgetConstructor || Widget);
}
/**
* Build a widget in this element, as configured.
* @param {module:bajaux/spandrel~Member} member
* @param {JQuery} dom
* @param {module:bajaux/lifecycle/WidgetManager} manager
* @returns {Promise.<module:bajaux/Widget>} the built widget
*/
function doFeBuild({ key, config }, dom, manager, root, depth) {
return tryToDetermineType(config, manager)
.then((type) => {
dom[0].spandrelKey = key;
const data = extend({ manager }, config.data);
const $quiet = !(config.properties && config.properties.$quiet === false);
const params = extend({
$constructorParams: { $quiet },
layoutParams: { quick: true }
}, config, { data, dom, type });
return manager.makeFor(params)
.then((ed) => {
RequestLayoutMixin(ed, root || ed, depth, (err, widget) => manager.error(err, widget));
return manager.buildFor(params, ed);
});
});
}
function armHandlers(widget, dom, on) {
const prev = widget.$spandrelHandlers;
if (prev) { prev.forEach(([ event, , handler ]) => dom.off(event, handler)); }
//allow 1-dimensional array handler
if (!isArray(on[0])) { on = [ on ]; }
const spandrelHandlers = widget.$spandrelHandlers = [];
on.forEach((arr) => {
let [ event, selectorString, handler ] = arr;
if (arr.length === 2) {
handler = selectorString;
selectorString = null;
}
const selectors = selectorString ? selectorString.split(',').map((s) => s.trim()) : null;
const eventHandler = function (e) {
const spandrelWidget = getContainingSpandrelWidget(e.target);
let args = Array.prototype.slice.call(arguments, 1);
if (spandrelWidget) {
//Widget#trigger already passes itself as first argument - don't double up
if (spandrelWidget === args[0]) { args = args.slice(1); }
const path = getPathToKid(widget, spandrelWidget);
if (!selectors || anyPathMatches(selectors, path.join('/'))) {
return handler.apply(null, [ e, spandrelWidget ].concat(args));
}
}
};
spandrelHandlers.push([ event, selectorString, eventHandler ]);
dom.on(event, eventHandler);
});
}
/**
* @param {string[]} path a property path through a nested spandrel config
* @returns {string[]} the widget keys extracted from the path
*/
function extractKeys(path) {
// in the spandrel config, the path to the diff if nested is going
// to follow the pattern:
// members, key, config, kids, members, key, config, kids, members, key...
return path.filter((p, i) => path[i - 1] === 'members');
}
function getValueFromBuildContext(ctx, path, name) {
return resolveObjectPath(ctx, path.slice(0, lastIndexOf(path, name) + 1));
}
/**
* Does a deep-get of a property path from an object. Works like the array
* form of `_.property`.
* @param {object} obj
* @param {string[]} path
* @returns {*}
*/
function resolveObjectPath(obj, path) {
return path.reduce((curr, prop) => curr[prop] || {}, obj);
}
/**
* @param {string[]} selectors array of widget selectors
* @param {string} path actual path to a queried widget
* @returns {boolean}
*/
function anyPathMatches(selectors, path) {
return any(selectors, (selector) => pathMatches(path, selector));
}
function attr(el, name, attr) {
if (attr) {
el.setAttribute(name, attr);
} else {
el.removeAttribute(name);
}
}
/**
* @param {object} params
* @returns {module:bajaux/lifecycle/WidgetManager} the manager passed either
* as `params.data.manager` or `params.params.data.manager`
*/
function extractManager(params = {}) {
//TODO: does Widget itself need an API for this?
let data = params.data;
let manager = data && data.manager;
if (!manager) {
params = params.params;
data = params && params.data;
manager = data && data.manager;
}
return manager;
}
function trace(owner, msg, ...args) {
logFinest(owner.constructor.name + ': ' + msg, ...args);
}
function setClassList(el, classList) {
Object.defineProperty(el, 'classList', {
configurable: true,
enumerable: true,
writable: true,
value: classList
});
}
return spandrel;
});