/**
* @copyright 2019 Tridium, Inc. All Rights Reserved.
* @author Logan Byam
*/
/**
* @module bajaux/lifecycle/WidgetManager
*/
define([
'bajaux/Widget',
'bajaux/lifecycle/JQueryElementTranslator',
'bajaux/registry/Registry',
'bajaux/widgets/ToStringWidget',
'Promise',
'nmodule/js/rc/asyncUtils/asyncUtils' ], function (
Widget,
JQueryElementTranslator,
Registry,
ToStringWidget,
Promise,
asyncUtils) {
'use strict';
const { doRequire } = asyncUtils;
/**
* WidgetManager's job is to manage the lifecycle of widgets, from the initial
* "what kind of widget do I need?" question through to the destruction of
* the unneeded widget.
*
* @class
* @alias module:bajaux/lifecycle/WidgetManager
* @param {object} params
* @param {module:bajaux/registry/Registry} params.registry the registry
* responsible for looking up Widget types
* @since Niagara 4.10
*/
class WidgetManager {
constructor({
registry = new Registry(),
elementTranslator = new JQueryElementTranslator()
} = {}) {
this.$registry = registry;
this.$elementTranslator = elementTranslator;
this.$hooks = {};
}
/**
* This method functions as the "starting point" for a Widget build. It
* receives the parameters as given by the user, and calculates a build
* context to be used during the rest of the initialize/load/destroy
* lifecycle.
*
* @param {module:bajaux/lifecycle/WidgetManager~BuildParams} params
* @returns {Promise.<module:bajaux/lifecycle/WidgetManager~BuildContext>}
*/
buildContext(params) {
if (!(params && typeof params === 'object')) {
return Promise.reject(new Error('params required'));
}
const { data, dom, enabled, formFactor, hooks, initializeParams, keyName,
layoutParams, loadParams, moduleName, properties, readonly, value } = params;
return this.resolveConstructor(params)
.then((widgetConstructor) => {
const constructorParams = Object.assign({}, params.$constructorParams);
if (formFactor) { constructorParams.formFactor = formFactor; }
if (moduleName) { constructorParams.moduleName = moduleName; }
if (keyName) { constructorParams.keyName = keyName; }
if (properties) { constructorParams.properties = properties; }
if (readonly) { constructorParams.readonly = true; }
if (enabled === false) { constructorParams.enabled = false; }
return {
widgetConstructor,
constructorParams,
dom,
initializeParams,
layoutParams,
loadParams,
value,
hooks: hooks,
data: data || {}
};
});
}
/**
* @param {module:bajaux/lifecycle/WidgetManager~BuildParams} params
* @returns {Promise.<Function|undefined>} resolves the constructor to be
* used to instantiate the widget, either as configured via params or as
* looked up from the registry.
*/
resolveConstructor(params) {
return Promise.resolve(this.deriveConfiguredConstructor(params))
.then((ctor) => ctor || this.resolveFromRegistry(params));
}
/**
* @param {module:bajaux/lifecycle/WidgetManager~BuildParams} params
* @returns {Function|Promise.<Function>} the constructor, as configured via
* the `type` parameter, if present; otherwise undefined. Override to
* define other methods of examining params to derive a directly-configured
* constructor.
*/
deriveConfiguredConstructor(params) {
const { type } = params;
if (typeof type === 'function') {
return isAssignableFrom(Widget, type) ?
Promise.resolve(type) :
Promise.reject(new Error('type as constructor must extend bajaux/Widget'));
}
if (typeof type === 'string') {
return doRequire(type);
}
}
/**
* @param {module:bajaux/lifecycle/WidgetManager~BuildParams} params
* @returns {Promise<Function>} the constructor resolved from the registry.
* By default, do a simple lookup by `params.value`; override to define how
* registry lookups are performed.
*/
resolveFromRegistry(params) {
return this.$registry.resolveFirst(params.value);
}
/**
* Create a new Widget instance from the build context. If no widget
* constructor could be determined, default to a
* {@link module:bajaux/widgets/ToStringWidget|ToStringWidget}.
*
* @param {module:bajaux/lifecycle/WidgetManager~BuildContext} buildContext
* @returns {module:bajaux/Widget|Promise.<module:bajaux/Widget>}
*/
instantiate(buildContext) {
const { widgetConstructor, constructorParams, hooks = {} } = buildContext;
const Ctor = widgetConstructor || ToStringWidget;
const widget = new Ctor(constructorParams);
const { instantiated } = this.$hooks;
const { instantiated: inpInstantiated } = hooks;
// For pre-spandrel widgets that try to set defaults after applying the Widget
// constructor, reapply the params so that the defaults do not overwrite them.
return Promise.resolve(
widget.$properties.$modified && widget.$reapplyParams(constructorParams)
)
.then(() => instantiated && instantiated(widget))
.then(() => inpInstantiated && inpInstantiated(widget))
.then(() => widget);
}
/**
* Initialize the widget into the DOM element as specified in the build
* context.
*
* @param {module:bajaux/Widget} widget
* @param {module:bajaux/lifecycle/WidgetManager~BuildContext} buildContext
* @returns {Promise}
*/
initialize(widget, buildContext) {
// here is where the hook would go: does this Widget want a jQuery object
// to pass to initialize? HTMLElement? virtual DOM? if dom is an
// HTMLElement and the Widget wants jQuery, can I just wrap it in $() and
// pass it in? or vice versa?
// in the future, plug in the appropriate hooks/services to provide a
// robust way of putting widgets in elements.
const { dom, initializeParams, layoutParams, hooks = {} } = buildContext;
const { preInitialize, postInitialize } = this.$hooks;
const { preInitialize: inpPreInitialize, postInitialize: inpPostInitialize } = hooks;
if (!dom) {
return Promise.reject(new Error('dom required'));
}
return Promise.resolve(this.$elementTranslator.translateElement(dom))
.then((dom) => {
const existing = Widget.in(dom);
return Promise.resolve(existing && this.destroy(existing))
.then(() => preInitialize && preInitialize(widget, buildContext))
.then(() => inpPreInitialize && inpPreInitialize(widget, buildContext))
.then(() => widget.initialize(dom, initializeParams, layoutParams))
.then(() => postInitialize && postInitialize(widget, buildContext))
.then(() => inpPostInitialize && inpPostInitialize(widget, buildContext));
});
}
/**
* Load the value from the build context into the widget.
*
* @param {module:bajaux/Widget} widget
* @param {module:bajaux/lifecycle/WidgetManager~BuildContext} buildContext
* @returns {Promise}
*/
load(widget, buildContext) {
const { loadParams, value, hooks = {} } = buildContext;
const { preLoad, postLoad } = this.$hooks;
const { preLoad: inpPreLoad, postLoad: inpPostLoad } = hooks;
if (value === undefined || widget.isDestroyed()) { return Promise.resolve(); }
return Promise.resolve(preLoad && preLoad(widget, buildContext))
.then(() => !widget.isDestroyed() && inpPreLoad && inpPreLoad(widget, buildContext))
.then(() => !widget.isDestroyed() && widget.load(buildContext.value, loadParams))
.then(() => !widget.isDestroyed() && postLoad && postLoad(widget, buildContext))
.then(() => !widget.isDestroyed() && inpPostLoad && inpPostLoad(widget, buildContext))
.catch((error) => {
if (!widget.isDestroyed()) {
throw error;
}
});
}
/**
* Destroy the widget.
* @param {module:bajaux/Widget} widget
* @returns {Promise}
*/
destroy(widget) {
return widget.destroy();
}
/**
* @private
* @param {module:bajaux/lifecycle/WidgetManager~BuildParams} params
* @param {module:bajaux/Widget} [widget]
* @returns {Promise}
*/
$doMakeFor(params, widget) {
return this.buildContext(params)
.then((buildContext) => {
return Promise.resolve(widget || this.instantiate(buildContext))
.then((widget) => ({ buildContext, widget }));
});
}
/**
* Resolves a new Widget instance as defined by the input parameters, but
* does not initialize or load it anywhere.
*
* @param {module:bajaux/lifecycle/WidgetManager~BuildParams} params
* @returns {Promise.<module:bajaux/Widget>}
*/
makeFor(params) {
return this.$doMakeFor(params)
.then(({ widget }) => widget);
}
/**
* Instantiates, initializes, and loads a value into a new Widget as defined
* by the input parameters.
* @param {module:bajaux/lifecycle/WidgetManager~BuildParams} params
* @param {module:bajaux/Widget} [widget] if present, skip the instantiation
* and just initialize/load the given widget instance.
* @returns {Promise.<module:bajaux/Widget>} resolves to the widget after it
* has been initialized and loaded.
*/
buildFor(params, widget) {
return this.$doMakeFor(params, widget)
.then(({ buildContext, widget }) => {
return this.initialize(widget, buildContext)
.then(() => this.load(widget, buildContext))
.then(() => widget);
});
}
/**
* Install hooks to be invoked at various stages of a widget lifecycle.
* @param {module:bajaux/lifecycle/WidgetManager~BuildHooks} hooks
*/
installHooks(hooks) {
const myHooks = this.$hooks;
[ 'preInitialize', 'postInitialize', 'preLoad', 'postLoad', 'instantiated', 'error' ]
.forEach((hookName) => {
const hook = hooks[hookName];
if (hook) { myHooks[hookName] = hook; }
});
}
/**
* This method is called when an error is encountered with a Widget.
*
* If there is an installed error hook on this manager, it will be invoked.
*
* @param {Error} err the error from the Widget
* @returns {Promise} If an error hook is installed, this will
* resolve once the error hook is finished. If no error hook is installed,
* this will reject with the provided error.
*/
error(err, widget) {
const { error } = this.$hooks;
if (!error) { return Promise.reject(err); }
return Promise.resolve(error(err, widget));
}
}
/**
* Object describing the parameters that can be passed to `WidgetManager` to
* define a build context. Subclasses of `WidgetManager` may support
* additional parameters.
*
* @typedef {Object} module:bajaux/lifecycle/WidgetManager~BuildParams
* @property {String|Function} [type] a `bajaux/Widget` subclass constructor
* function - if given, an instance of that Widget will *always* be
* instantiated instead of dynamically looked up from the `value`. You can
* also use a RequireJS module ID that resolves to a `Widget` subclass
* constructor.
* @property {*} [value] the value to be loaded into the new widget, if
* applicable.
* @property {string|HTMLElement|JQuery|*} [dom] the DOM element in which the
* new widget should be initialized, if applicable.
* @property {Object} [properties] the bajaux Properties the new widget
* should have.
* @property {Boolean} [enabled] set to `false` to cause the new widget to be
* disabled. Not used for lookups.
* @property {Boolean} [readonly] set to `true` to cause the new widget to be
* readonly. Not used for lookups.
* @property {String|Array.<String>} [formFactors] the possible form factors
* the new widget should have. The created widget could match any of these
* form factors depending on what is registered in the database. If no widget
* is found that supports any of these form factors, then no widget will be
* created (even if one is present that supports a different form factor). If
* no form factor is given, then the widget created could be of *any* form
* factor.
* @property {String} [formFactor] same as a `formFactors` array of length 1.
* @property {module:bajaux/lifecycle/WidgetManager~BuildHooks} [hooks] any
* hooks you wish to run at various stages in this widget's lifecycle. They
* will run immediately after any installed hooks.
*/
/**
* Object describing the configuration needed to construct, initialize, and
* load a Widget in a DOM element.
*
* @typedef {Object} module:bajaux/lifecycle/WidgetManager~BuildContext
* @property {Function} [widgetConstructor] Widget constructor to instantiate.
* If no constructor could be found it is up to the WidgetManager to decide
* whether to instantiate a default Widget type or to reject.
* @property {object} constructorParams params object to pass to the Widget
* constructor
* @property {Object} [initializeParams] params object to pass to the
* initialize() method
* @property {Object} [layoutParams] params object to pass to the layout()
* method
* @property {Object} [loadParams] params object to pass to the load() method
* @property {string|HTMLElement|JQuery|*} dom DOM element in which to build a
* Widget. Will be translated by the `WidgetManager` to an appropriate type
* for the Widget.
* @property {*} [value] the value to load into the Widget. `null` is an
* acceptable loadable value. If `undefined`, no loading should be performed.
* @property {object} data any additional data passed by the caller into the
* `WidgetManager` as the `data` property. This will be passed through the
* build lifecycle untouched. Most useful when using lifecycle hooks to add
* functionality to the `WidgetManager` instance.
*/
/**
* Object describing hooks to be invoked at various points in a widget's
* lifecycle. Each hook will be invoked with `widget` and `buildContext`
* arguments.
*
* @typedef {Object} module:bajaux/lifecycle/WidgetManager~BuildHooks
* @property {Function} [instantiated] called immediately after a widget is constructed
* @property {Function} [preInitialize] called before `initialize()` is called
* @property {Function} [postInitialize] called after `initialize()` completes
* @property {Function} [preLoad] called before `load()` is called
* @property {Function} [postLoad] called after `load()` completes
* @property {module:bajaux/lifecycle/WidgetManager~error} [error] called when
* a widget encounters an error
*/
/**
* A callback to handle the provided widget's error.
*
* @callback {Function} {module:bajaux/lifecycle/WidgetManager~error}
*
* @param {Error} the error the widget encountered
* @param {module:bajaux/Widget} the widget that encountered the error
* @returns {Promise}
*/
/**
* When building out a Widget, there are two DOM-related concerns.
*
* First, the `dom` parameter given to `WidgetManager` by the user could be: a
* string, an `HTMLElement`, a jQuery instance, a React virtual DOM node, or
* any other "DOM-element-like" object. The user is asking: please put a
* Widget in _here_.
*
* Second, the `dom` parameter given to the Widget's `initialize()` function
* must be one that the Widget knows how to initialize itself into. Since a
* vanilla Widget typically wants to initialize itself into a jQuery
* instance, if you hand it a virtual DOM node it won't know what to do.
*
* `ElementTranslator`'s job is to try to convert from the user-supplied `dom`
* to a `dom` that is usable by a Widget. This should increase the versatility
* of `WidgetManager` and allow Widgets to be used in different environments.
*
* @private
* @interface ElementTranslator
* @memberOf module:bajaux/lifecycle/WidgetManager
*/
//TODO: later, when we need to translate to non-jquery doms, create a composite
// translator to give multiple translator types a go
/**
* Translate a DOM element into one usable by the Widget.
*
* @function
* @name module:bajaux/lifecycle/WidgetManager~ElementTranslator#translateElement
* @param {*} dom the input `dom`, of unpredictable type, given to `WidgetManager`
* by the user
* @param {module:bajaux/Widget} widget the widget to be initialized into this
* `dom` element
* @returns {Promise.<*>} to be resolved to a DOM element usable by the
* Widget, or rejects if there is no way to turn the input `dom` into an
* element the Widget likes
*/
/**
* @param {Function} superCtor
* @param {Function} subCtor
* @returns {boolean}
*/
function isAssignableFrom(superCtor, subCtor) {
return Object.create(subCtor.prototype) instanceof superCtor;
}
return WidgetManager;
});