baja/binding/Binding.js

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

/**
 * API Status: **Development**
 * @module nmodule/bajaui/rc/baja/binding/Binding
 */
define([ 
  'baja!',
  'log!nmodule.bajaui.rc.baja.binding.Binding',
  'Promise',
  'nmodule/bajaui/rc/binding/impl/widgetEvents' ], function (
   baja,
   log,
   Promise,
   widgetEvents) {

  'use strict';

  const logWarning = log.warning.bind(log);

  const SETTLED_ORD_SYMBOL = Symbol('settledOrd');

  /**
   * BajaScript representation of a `bajaui:Binding`. An instance of this class
   * knows how to propagate data values from an `OrdTarget` to a `Widget`.
   *
   * @abstract
   * @class
   * @alias module:nmodule/bajaui/rc/baja/binding/Binding
   * @extends baja.Component
   * @implements module:bajaux/model/binding/IBinding
   * @implements module:nmodule/bajaui/rc/binding/IValueProvider
   * @implements module:nmodule/bajaui/rc/binding/IWidgetEventListener
   */
  return class Binding extends baja.Component {

    /**
     * @returns {module:baja/ord/OrdTarget|undefined} the OrdTarget this Binding is bound to, or
     * undefined if unbound
     */
    getOrdTarget() {
      return this.$ordTarget;
    }

    /**
     * Only to be called by the UxMedia framework itself.
     *
     * @private
     * @param {module:baja/ord/OrdTarget|undefined} ordTarget the OrdTarget this Binding is bound to
     */
    setOrdTarget(ordTarget) {
      this.$ordTarget = ordTarget;
    }

    /**
     * Given a certain property name, typically a Widget property, provide the
     * corresponding value for that name. This is roughly analogous to
     * `BBinding#getOnWidget`.
     *
     * @param {string} name the name of the property to retrieve a value for
     * @param {object} cx user context
     * @returns {*|null|Promise.<*|null>} by default, returns `null` which
     * indicates there is no value to be provided by this name. Override in
     * subclasses.
     */
    provide(name, cx) {
      return null;
    }

    /**
     * Every Binding belongs to a BindingList which contains all the other
     * Bindings that are also bound to its target.
     *
     * @returns {module:bajaux/model/binding/BindingList}
     */
    getBindingList() {
      return this.$bindingList;
    }

    /**
     * This is a callback that will be run whenever the Binding is updated to
     * point to a new OrdTarget. This may happen when the binding's ORD changes,
     * or when it is re-resolved.
     *
     * @returns {Promise}
     */
    targetChanged() {
      return null;
    }

    /**
     * @returns {module:bajaux/Widget} the Widget this Binding is bound to
     */
    getWidget() {
      return this.$widget;
    }

    /**
     * @param {module:bajaux/Widget} widget the Widget this Binding is bound to
     */
    setWidget(widget) {
      this.$widget = widget;
    }

    /**
     * @private
     * @param {module:bajaux/Widget} widget
     * @since Niagara 4.15
     */
    $init(widget) {
      this.setWidget(widget);
      this[SETTLED_ORD_SYMBOL] = this.get('ord');
    }

    /**
     * When the Binding is bound to a Widget, it can start listening for events
     * from that Widget. Override this method as needed.
     *
     * You may find it helpful to call `addWidgetEvents` from your override of this
     * method.
     *
     * @param {module:bajaux/Widget} widget
     * @see module:nmodule/bajaui/rc/binding/impl/widgetEvents
     */
    addListeners(widget) {}

    /**
     * Registers the provided events on the widget and keeps track of these
     * events which are disarmed with the default implementation of
     * Binding#removeListeners.
     *
     * The added listeners will fire in the order they were added to the widget.
     * The handler for a listener may optionally return false which will cause
     * the later event handlers to no longer fire. The provided handler for an
     * event also executes in order, asynchronously via promises. This means
     * it will wait for the async operation to finish prior to moving on to the
     * next event of the same type. Valid event types include both JQuery and
     * bajaux events.
     *
     * Note that this is a _convenience_ method and overriding this will not gain
     * your Binding subclass any functionality.
     *
     * @example
     * binding.addWidgetEvents(widget, {
     *   click: () => {
     *     // do something when click happens
     *   }
     * });
     *
     * @example
     * binding.addWidgetEvents(widget, {
     *   click: () => {
     *    // This promise will resolve prior to executing the second click event
     *    return this.fetchDataAsync()
     *      .then(() => {
     *        // do something with the fetched data and continue to next click
     *      });
     *   }
     * });
     *
     * @example
     * binding.addWidgetEvents(widget, {
     *   loaded: () => {
     *    return false; // prevent firing events that were armed later.
     *   }
     * });
     *
     * @param {module:bajaux/Widget} widget
     * @param {object} events
     */
    addWidgetEvents(widget, events) {
      this.$widgetEventDisarms = this.$widgetEventDisarms || [];
      this.$widgetEventDisarms.push(widgetEvents(widget, events).disarm);
    }

    /**
     * Any event handlers that would not automatically be cleaned up by
     * `widget.destroy()` can be explicitly cleaned up here.
     *
     * By default, this will clean up any events that were added via
     * `addWidgetEvents()`.
     *
     * @param {module:bajaux/Widget} widget
     */
    removeListeners(widget) {
      if (this.$widgetEventDisarms) {
        this.$widgetEventDisarms.forEach((disarm) => disarm());
        this.$widgetEventDisarms = [];
      }
    }

    /**
     * Hide or disable the widget based on whether this binding is currently
     * degraded.
     * @returns {Promise}
     */
    applyDegradeBehavior() {
      const widget = this.$widget;
      const degraded = this.isDegraded();

      switch (this.get('degradeBehavior').getTag()) {
        case 'disable':
          return widget.setEnabled(!degraded);
        case 'hide':
          widget.properties().add('visible', !degraded);
          return Promise.resolve();
        default:
          return Promise.resolve();
      }
    }

    /**
     * @returns {boolean} if the binding is successfully bound to an
     * `OrdTarget`.
     */
    isBound() {
      return !!this.$ordTarget;
    }

    /**
     * @returns {boolean} if the binding is unusable for any reason. The default
     * implementation returns `!this.isBound()`.
     */
    isDegraded() {
      return !this.isBound();
    }

    /**
     * Callback that will be called when the Px page containing this Binding is
     * saved. Each Binding will have a chance to save any user-entered data (it
     * is the Binding's responsibility to check if the user has made any changes
     * or not).
     *
     * @returns {*|Promise} to be resolved when the binding has saved any
     * user-entered data
     */
    save() {}

    /**
     * Fires an event to signal to the framework that the binding is now bound to
     * a different target ORD. The framework should resolve the Binding's new
     * ORD, set a brand-new OrdTarget, and refresh its bound Widget so the UI is
     * brought up to date.
     *
     * Should not be overridden without calling `super()`.
     */
    requestRebind() {
      this.fireHandlers('rebind', logWarning, this);
    }

    /**
     * Fires an event to signal that the Binding has new values to provide. The
     * framework should propagate all the Binding's properties to its bound
     * Widget so the UI is brought up to date.
     *
     * Should not be overridden without calling `super()`.
     */
    requestRefresh() {
      this.fireHandlers('refresh', logWarning, this);
    }

    /**
     * Fires an event to signal that this Binding, and all of its peers, should be unbound.
     *
     * @private
     * @since Niagara 4.15
     */
    $requestUnbindAll() {
      this.fireHandlers('unbindAll', logWarning, this);
    }

    /**
     * Helper method to return a string representation of the binding of the form
     * <moduleName>:<bindingTypeDisplayName>[boundOrd]
     *
     * @private
     * @returns {String} Returns the String representation of the binding's ord.
     */
    $toDisplayString() {
      const type = this.getType().getModuleName() + ':' + this.getTypeDisplayName();
      const ord = String(this.getOrd());
      return `${ type } [${ ord }]`;
    }

    /**
     * @private
     * @param {baja.Ord} baseOrd
     * @param {baja.Ord} ord
     * @returns {baja.Ord}
     */
    $relativizeToBase(baseOrd, ord) {
      let relativizedOrd;

      if (!baseOrd.isNull()) {
        relativizedOrd = ord.isNull() ? ord : baja.Ord.make({ base: baseOrd, child: ord });
      } else {
        relativizedOrd = ord;
      }

      try {
        relativizedOrd = relativizedOrd.normalize();
      } catch (ignore) {}

      return relativizedOrd;
    }

    /**
     * @private
     * @returns {baja.Ord} the actual ORD to resolve against the station - may be different from the
     * value of the `ord` slot if optimized.
     * @since Niagara 4.15
     */
    $getSettledOrd() {
      return this[SETTLED_ORD_SYMBOL] || this.get('ord');
    }

    /**
     * Get the ord associated with this Binding, resolve it, and update the binding's OrdTarget.
     *
     * @private
     * @param {object} params
     * @param {function(baja.Ord)} params.resolveOrd use this to resolve an ORD to an OrdTarget,
     * optimized for best performance in the UxMedia page this Binding is used in
     * @returns {Promise} to be resolved after the work is done and the OrdTarget is updated. Will
     * reject if resolution fails, or no base ORD set.
     * @since Niagara 4.15
     */
    $resolve({ baseOrd, resolveOrd }) {
      let ord = this.$getSettledOrd();

      return Promise.resolve()
        .then(() => this.$doResolve(ord, { baseOrd, resolveOrd }))
        .then((ordTarget) => {
          if (!ordTarget) {
            return;
          }
          const optimizedOrd = ordTarget.getFacets().get('optimizedOrd');
          if (optimizedOrd) {
            this[SETTLED_ORD_SYMBOL] = optimizedOrd;
          }
          this.setOrdTarget(ordTarget);
        })
        .catch(() => {
          const ordStr = ord.toString();
          let msg = 'unresolved: ' + ordStr;
          if (ordStr.startsWith('sys:|') || ordStr.indexOf('|sys:|') >= 0) {
            // Provide a better clue of why an ORD containing a query to the
            // SystemDb might not resolve
            msg = msg + " (ensure the SystemDb is available and/or the base is indexed into the SystemDb)";
          }

          throw new Error(msg);
        });
    }

    /**
     * Private, but intended for internal subclasses to override. Lets the binding do the work of
     * "resolving my ord" - if the subclass does something special other than just
     * this.get('ord').resolve(), it can implement that here.
     *
     * @private
     * @param {baja.Ord} ord the ord to resolve, _not_ relativized against the base ORD yet
     * @param {object} params
     * @param {baja.Ord} params.baseOrd
     * @param {function(baja.Ord)} params.resolveOrd use this to resolve an ORD to an OrdTarget,
     * optimized for best performance in the UxMedia page this Binding is used in
     * @returns {Promise.<module:baja/ord/OrdTarget|*>} resolves the given ORD to an OrdTarget, or
     * falsy if resolution was intentionally not performed (e.g. ord is null)
     * @since Niagara 4.15
     */
    $doResolve(ord, { baseOrd, resolveOrd }) {
      if (ord.isNull()) {
        return Promise.resolve(null);
      }
      return resolveOrd(this.$relativizeToBase(baseOrd, ord));
    }
  };
});