lifecycle/WidgetManager.js

/**
 * @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;
});