mixin/subscriberMixIn.js

/**
 * @copyright 2015 Tridium, Inc. All Rights Reserved.
 * @author Logan Byam and Gareth Johnson
 */

/**
 * A widget that implements this MixIn will be able to
 * load and subscribe to Components.  The MixIn name is 'subscriber'.
 * 
 * @module bajaux/mixin/subscriberMixIn
 * @requires baja
 * @requires jquery
 * @requires bajaux/events
 * @requires bajaux/Widget
 */
define([ 'baja!',
         'jquery',
         'Promise',
         'bajaux/events',
         'bajaux/Widget' ], function (
          baja,
          $,
          Promise,
          events,
          Widget) {
  
  "use strict";

  var unknownErr = Widget.unknownErr;

////////////////////////////////////////////////////////////////
// Nav Event Listening
////////////////////////////////////////////////////////////////

  function detachNavEventListener(widget) {
    // If we have a function monitoring whether the Component in question is removed
    // then detach it from the BajaScript NavEvent architecture.
    if (widget.$subNavFunc) {
      baja.nav.detach("unmount", widget.$subNavFunc);
      delete widget.$subNavFunc;
    }
  }

  //TODO: memory leak here.
  /*
  assigning the function to $subNavFunc seems to set up a circular reference
  situation that chrome can't GC (even though widgetNavComp has *no* retaining
  tree).
  possible workaround: keep a registry of nav event listeners in a local object
  rather than sticking them directly onto widgets.
  possible workaround: only have one nav event listener, but a registry of
  widgets. look up registered widget by handle.
   */
  function attachNavEventListener(widget, value) {
    // Invoked whenever the loaded Component is removed  
    widget.$subNavFunc = function widgetNavComp() {
      // If the Component for this widget has been removed then show
      // the widget's error message.
      if (this.getHandle() === value.getHandle() &&
        typeof widget.showError === "function") {

        var navOrdStr = this.getNavOrd().toString();
        baja.lex({
          module: "bajaux",
          ok: function (lex) {
            widget.showError(lex.get("widget.error.title"), lex.get({
              key: "widget.error.componentRemoved",
              args: [ navOrdStr ]
            }));
          }
        });
      }
    }; 

    baja.nav.attach("unmount", widget.$subNavFunc);
  }

////////////////////////////////////////////////////////////////
// Support functions
////////////////////////////////////////////////////////////////

  function isBajaComponent(value) {
    return baja.hasType(value, 'baja:Component');
  }

  function removeFromArray(arr, obj) {
    var i;
    if (arr) {
      for (i = 0; i < arr.length; i++) {
        if (arr[i] === obj) {
          return arr.splice(i, 1);
        }
      }
    }
  }
  
  function addMixin(widget, mixin) {
    var mixins = widget.$mixins,
        i = mixins.indexOf(mixin);
    if (i === -1) {
      mixins.push(mixin);
    }
  }
  
////////////////////////////////////////////////////////////////
// Subscriber Mix-In
////////////////////////////////////////////////////////////////
  
  /**
   * @alias module:bajaux/mixin/subscriberMixIn
   * 
   * @param {module:bajaux/Widget} target target widget to have the Subscriber mixin
   * applied to it.
   * @param {Object} [params]
   * @param {Boolean} [params.autoSubscribe=true] By default, any component
   * loaded into the widget will be automatically subscribed. Set this to false
   * to skip the subscription; manually subscribe when necessary by accessing
   * `this.getSubscriber()`. Unsubscription for cleanup purposes will still
   * be performed.
   */
  var exports = function exports(target, params) {

    if (!(target instanceof Widget)) {
      throw new Error("Subscriber MixIn only applies to instances or sub-classes of Widget");
    }

    var superLoad = target.load,
        superDestroy = target.destroy,
        superEnabled = target.setEnabled,
        superResolve = target.resolve,
      
        autoSubscribe = !params || params.autoSubscribe !== false;
    
    addMixin(target, 'subscriber');
    addMixin(target, 'batchLoad');

    /**
     * Overrides resolve and injects a Subscriber into the ORD
     * resolution so it's subscribed.
     * 
     * @memberOf module:bajaux/mixin/subscriberMixIn
     * @param data Specifies some data used to resolve a load value 
     * so `load(value)` can be called on the widget.
     * @param {Object} [resolveParams] An Object Literal used for ORD
     * resolution. This parameter is designed to be used internally by bajaux
     * and shouldn't be used by developers.
     * @returns {Promise}
     */
    target.resolve = function resolve(data, resolveParams) {
      var that = this;
      resolveParams = resolveParams || {};

      if (that.isEnabled() && autoSubscribe) {
        resolveParams.subscriber = that.getSubscriber();
      }

      return superResolve.apply(that, [ data, resolveParams ]);
    };

    /**
     * Override the default widget load method. Loads a value into the widget.
     * If the value is a Component, then it will be subscribed (unless
     * `autoSubscribe` is false).
     * 
     * This function supports the contract defined in `batchLoadMixin`.
     *
     * @see module:bajaux/Widget#load
     * @see module:bajaux/mixin/batchLoadMixin
     *
     * @memberOf module:bajaux/mixin/subscriberMixIn
     * @param {*} value The value for the Widget to load.
     * @param {Object} [params]
     * @param {baja.comm.Batch} [params.batch] component subscription will
     * use this batch, if provided
     * @param {Function} [params.progressCallback] a function to be called when
     * subscription progress occurs
     * @returns {Promise} A promise that's resolved once the value has been
     * fully loaded.
     */     
    target.load = function load(value, params) {
      var that = this,
          args = arguments,
          oldValue = that.$value,
          progressCallback = params && params.progressCallback;
         
      that.$loading = true;
      that.$value = value;

      function resolve() {
        return value;
      }

      function callSuper() {
        that.$value = oldValue;
        return superLoad.apply(that, args);
      }

      if (!that.isInitialized()) {
        return callSuper().then(resolve);
      }

      function unsubscribeOldValue() {
        //if the widget was disabled while it had the old value loaded, then
        //the old value will be cached for re-subscription. uncache the old
        //value so that it won't be re-subscribed when we re-enable the widget.
        removeFromArray(that.$subComps, oldValue);

        if (isBajaComponent(oldValue) &&
            oldValue !== value &&
            that.getSubscriber().isSubscribed(oldValue)) {

          return that.getSubscriber().unsubscribe(oldValue);
        } else {
          return Promise.resolve();
        }
      }

      function subscribeNewValue() {
        var promise;

        // Start subscription process unless the Widget doesn't want automatic subscription.
        if (that.isEnabled() &&
            isBajaComponent(value) &&
            value.isMounted() &&
            autoSubscribe) {

          promise = that.getSubscriber().subscribe({
            comps: value, 
            batch: params && params.batch ? params.batch : undefined 
          });
        }

        if (progressCallback) {
          progressCallback("commitReady");
        }

        return promise;
      }

      function fail(err) {
        that.$value = oldValue;
        that.$loading = false;

        err = err || unknownErr;
          
        // We don't want to fire this event twice.
        that.trigger(events.LOAD_FAIL_EVENT, err);
        throw err;
      }

      if (isBajaComponent(oldValue)) {
        detachNavEventListener(that);
      }

      if (isBajaComponent(value)) {
        attachNavEventListener(that, value);
      }
      
      return unsubscribeOldValue()
        .then(subscribeNewValue)
        .then(callSuper)
        .then(resolve, fail);
    };
    
    /**
     * Overrides the default widget setEnabled method. If a widget is enabled
     * then a subscription for the value is started (unless `autoSubscribe`
     * returns false). If the value is unsubscribed then an unsubscription for
     * the value is attempted.
     *
     * @see module:bajaux/Widget#setEnabled
     * 
     * @memberOf module:bajaux/mixin/subscriberMixIn
     * @param {Boolean} enabled
     * @returns {Promise} A promise resolved once the widget is enabled and the
     * loaded component is subscribed
     */
    target.setEnabled = function setEnabled(enabled) {
      var that = this,
          args = arguments,
          val = that.value(),
          sub = that.getSubscriber(),
          comps;

      enabled = !!enabled;
      that.$enabled = enabled;

      function resolve() {
        return enabled;
      }

      function callSuper() {
        return superEnabled.apply(that, args);
      }

      if (enabled) {
        comps = that.$subComps || [];

        // Handle when a Component may have been loaded while the
        // widget was disabled.
        if (isBajaComponent(val) && val.isMounted() && $.inArray(val, comps) === -1) {
          comps.push(val);
        }
      } else {
        comps = that.$subComps = sub.getComponents() || [];
      }

      if (comps.length === 0) {
        return callSuper().then(resolve);
      }

      function handleSubscription() {
        //if unsubscribing, just kick it off but don't wait for it to finish.
        return Promise.resolve(enabled ? 
          sub.subscribe(comps) : 
          (sub.unsubscribe(comps) || null));
      }

      return handleSubscription()
        .catch(function (err) {
          err = err || unknownErr;
          that.trigger(enabled ? events.ENABLE_FAIL_EVENT : events.DISABLE_FAIL_EVENT, err);
          throw err;
        })
        .then(callSuper)
        .then(resolve);
    };
    
    /**
     * Overrides the default widget destroy method. This method ensures
     * all handlers are removed by the Subscriber when the widget is destroyed.
     *
     * @see module:bajaux/Widget#destroy
     * @memberOf module:bajaux/mixin/subscriberMixIn
     * @returns {Promise} A promise resolved once everything has been destroyed.
     */
    target.destroy = function destroy() {
      var that = this;

      detachNavEventListener(that);

      // Detach all event handlers from the Subscriber.    
      that.getSubscriber().detach();

      // Remove any references to Components that are cached.
      delete that.$subComps;

      // Call the super destroy to delete everything else.
      return superDestroy.apply(that, arguments)
        .then(function () {
          that.getSubscriber().unsubscribeAll().catch(baja.error);
          return null;
        });
    };
    
    /**
     * Returns this widget's subscriber.
     * 
     * @memberOf module:bajaux/mixin/subscriberMixIn
     * @returns {baja.Subscriber} The widget's subscriber.
     */
    target.getSubscriber = target.getSubscriber || function getSubscriber() {
      var that = this;
      if (!that.$subscriber) {
        that.$subscriber = new baja.Subscriber();
      }
      return that.$subscriber;
    };
  };
  
  return exports;
});