baja/comp/Subscriber.js

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

/**
 * Defines {@link baja.Subscriber}.
 * @module baja/comp/Subscriber
 */
define(["bajaScript/nav",
        "bajaScript/baja/comp/compUtil",
        "bajaScript/baja/comm/Callback",
        "bajaPromises"], function (
        baja,
        compUtil,
        Callback,
        Promise) {
  
  "use strict";
  
  var subclass = baja.subclass,
      strictArg = baja.strictArg,
      objectify = baja.objectify,
      bajaDef = baja.def,
      bajaError = baja.error,
      
      BaseBajaObj = baja.BaseBajaObj,
            
      setContextInOkCallback = compUtil.setContextInOkCallback,
      setContextInFailCallback = compUtil.setContextInFailCallback,
      canUnsubscribe = compUtil.canUnsubscribe;
  
  /**
   * Component Subscriber used to subscribe to multiple `Component`s for events.
   *
   * @class
   * @alias baja.Subscriber
   * @extends baja.BaseBajaObj
   */
  var Subscriber = function Subscriber() {
    this.$comps = [];
  };
  
  subclass(Subscriber, BaseBajaObj);
  
  // Mix-in the event handlers for baja.Subscriber
  baja.event.mixin(Subscriber.prototype);
  
  // The following comments have been added for the benefit of JsDoc Toolkit...
  
  /**
   * Attach an Event Handler to this Subscriber.
   * 
   * A Subscriber can be used to subscribe to multiple Components
   * in the Station and can be used to listen for Component Events.
   *  
   * An event handler consists of a name and a function. When the
   * function is called, 'this' will map to the target Component.
   * 
   * @function baja.Subscriber#attach
   *
   * @see baja.Component#attach
   * @see baja.Subscriber#detach
   * @see baja.Subscriber#getHandlers
   * @see baja.Subscriber#hasHandlers
   * @see baja.Slot
   *
   * @param {String} event handler name.
   * @param {Function} func the event handler function.
   * 
   * @example
   *   <caption>
   *     A common use case would be a Property changed event.
   *   </caption>
   *   
   *   // sub is a Subscriber and is subscribed to a Component
   *   sub.attach("changed", function (prop, cx) {
   *     if (prop.getName() === "out") {
   *       baja.outln("The output of the point is: " + this.getOutDisplay());
   *     }
   *   });
   * 
   * @example
   *   <caption>
   *     An object literal can be used to specify multiple handlers.
   *   </caption>
   *   
   *   var sub = new baja.Subscriber();
   *   sub.attach({
   *     changed: function (prop, cx) {
   *     },
   *     subscribed: function (cx) {
   *     }
   *   });
   * 
   * @example
   *   <caption>
   *     Spaces can be used in a name to specify a function for multiple events.
   *   </caption>
   * 
   *   var sub = new baja.Subscriber();
   *   sub.attach("subscribed changed", function () {
   *     updateGui(this);
   *   });
   *   
   * @example
   *   <caption>
   *     Here are some examples of the different event handlers that can be 
   *     attached to a Component.
   *   </caption>
   *   
   *   // Property Changed
   *   sub.attach("changed", function (prop, cx) {
   *     // prop: the Property that has changed
   *     // cx: the Context (used internally)
   *   });
   *   
   *   // Property Added
   *   sub.attach("added", function (prop, cx) {
   *     // prop: the Property that has been added
   *     // cx: the Context (used internally)
   *   });
   *   
   *   // Property Removed
   *   sub.attach("removed", function (prop, val, cx) {
   *     // prop: the Property that has been removed
   *     // val: the old value of the Property
   *     // cx: the Context (used internally)
   *   });
   *   
   *   // Property Renamed
   *   sub.attach("renamed", function (prop, oldName, cx) {
   *     // prop: the Property that has been renamed
   *     // oldName: the old slot name
   *     // cx: the Context (used internally)
   *   });
   *   
   *   // Dynamic Slots Reordered
   *   sub.attach("reordered", function (cx) {
   *     // cx: the Context (used internally)
   *   });
   *   
   *   // Topic Fired
   *   sub.attach("topicFired", function (topic, event, cx) {
   *     // topic: the Topic that has been fired
   *     // event: the Topic event data (can be null)
   *     // cx: the Context (used internally)
   *   });
   *   
   *   // Slot Flags Changed
   *   sub.attach("flagsChanged", function (slot, cx) {
   *     // slot: the slot whose flags have changed
   *     // cx: the Context (used internally)
   *   });
   *   
   *   // Slot Facets Changed
   *   sub.attach("facetsChanged", function (slot, cx) {
   *     // slot: the slot whose facets have changed
   *     // cx: the Context (used internally)
   *   });
   * 
   *   // Component subscribed
   *   sub.attach("subscribed", function (cx) {
   *     // cx: the Context (used internally)
   *   });
   * 
   *   // Component unsubscribed
   *   sub.attach("unsubscribed", function (cx) {
   *     // cx: the Context (used internally)
   *   });
   *
   *   // Component unmounted (called just before Component is removed from parent)
   *   sub.attach("unmount", function (cx) {
   *     // cx: the Context (used internally)
   *   });
   * 
   *   // Component renamed in parent
   *   sub.attach("componentRenamed", function (oldName, cx) {
   *     // cx: the Context (used internally)
   *   });
   * 
   *   // Component's flags changed in parent
   *   sub.attach("componentFlagsChanged", function (cx) {
   *     // cx: the Context (used internally)
   *   });
   * 
   *   // Component's facets changed in parent
   *   sub.attach("componentFacetsChanged", function (cx) {
   *     // cx: the Context (used internally)
   *   });
   * 
   *   // Component reordered in parent
   *   sub.attach("componentReordered", function (cx) {
   *     // cx: the Context (used internally)
   *   });
   */  
  
  /**
   * Detach an Event Handler from the Subscriber.
   * 
   * If no arguments are used with this method then all events are removed.
   * 
   * For a list of all the event handlers, please see {@link baja.Subscriber#attach}.
   * 
   * @function baja.Subscriber#detach
   *
   * @see baja.Component#attach
   * @see baja.Subscriber#attach
   * @see baja.Subscriber#getHandlers
   * @see baja.Subscriber#hasHandlers
   *
   * @param {String} [hName] the name of the handler to detach from the Subscriber.
   * @param {Function} [func] the function to remove from the Subscriber. It's recommended to supply this just in case
   *                          other scripts have added event handlers.
   * @example
   *   sub.detach("renamed"); // Remove a single handler
   *   sub.detach("subscribed changed"); // Remove multiple handlers at once
   */
    
  /**
   * Return an array of event handlers.
   * 
   * For a list of all the event handlers, please see {@link baja.Subscriber#attach}.
   * 
   * To access multiple handlers, insert a space between the handler names.
   *
   * @function baja.Subscriber#getHandlers
   *
   * @see baja.Component#attach
   * @see baja.Subscriber#detach
   * @see baja.Subscriber#attach
   * @see baja.Subscriber#hasHandlers
   *
   * @param {String} hName the name of the handler
   * @returns {Array.<Function>}
   */
  
  /**
   * Return true if there any handlers registered for the given handler name.
   * 
   * If no handler name is specified then test to see if there are any handlers 
   * registered at all.
   * 
   * For a list of all the event handlers, please see {@link baja.Subscriber#attach}.
   * 
   * Multiple handlers can be tested for by using a space character between the names.
   *
   * @function baja.Subscriber#hasHandlers
   *
   * @see baja.Component#attach
   * @see baja.Subscriber#detach
   * @see baja.Subscriber#attach
   * @see baja.Subscriber#getHandlers
   *
   * @param {String} [hName] the name of the handler. If undefined, then see if there are any 
   *                         handlers registered at all.
   * @returns {Boolean}
   */
    
  /**
   * Return an array of the `Component`s currently being 
   * subscribed to by this `Subscriber`.
   *
   * @returns {Array.<baja.Component>} a copy of the array of components 
   * subscribed to by this `Subscriber`.
   */
  Subscriber.prototype.getComponents = function () {
    return this.$comps.slice();
  };
  
  /**
   * Return true if the `Subscriber` is empty of subscriptions.
   *
   * @returns {Boolean} true if the `Subscriber` is empty.
   */
  Subscriber.prototype.isEmpty = function () {
    return this.$comps.length === 0;
  };
  
  /**
   * Subscribe a `Component` or a number of `Component`s.
   * 
   * This will put the `Component`s into subscription if they are not already 
   * subscribed and are mounted. The Subscription will last until the page is 
   * refreshed or `unsubscribe()` is called.
   * 
   * If the `Component`s are mounted and able to be subscribed, this will result in 
   * an **asynchronous** network call.
   *
   * If the `Component`s are mounted in multiple component spaces, there is a slight
   * chance for the promise to fail because a single component space subscription
   * failed, but `Component`s from other successful component spaces
   * could still be subscribed.  Therefore, in a failure condition, it is not safe to
   * assume that none of the `Component`s were successfully subscribed.  You can call
   * {@link baja.Subscriber#isSubscribed} in such a scenario to determine which
   * `Component`s were successfully subscribed and which were not. Furthermore, even
   * when a failure condition occurs, you may still need to remember to call
   * {@link baja.Subscriber#unsubscribe} due to the scenario just described.
   * 
   * For callbacks, the `this` keyword is set to the `comps` property.
   *
   * @name baja.Subscriber#subscribe
   * @function
   * 
   * @param {Object} [obj] the object literal used for the method's arguments.
   * @param {baja.Component|Array} obj.comps The `Component` or array of
   *   `Component`s to be subscribed.
   * @param {Function} [obj.ok] (Deprecated: use Promise) the ok callback.
   * Called once the `Component`s have been subscribed.
   * @param {Function} [obj.fail] (Deprecated: use Promise) the fail callback.
   * Called if the `Component`s fail to subscribe. Any errors will be passed to
   * this function.
   * @param {baja.comm.Batch} [obj.batch] if defined, any network calls will be
   * batched into this object.
   * @returns {Promise} a promise that will be resolved once component(s) have
   * been subscribed.
   * 
   * @example
   *   <caption>
   *     A <code>Component</code> instance, array of <code>Component</code>s 
   *     or an optional object literal can be used.
   *   </caption>
   *   
   *   // Subscribe a single Component
   *   sub.subscribe(aComp);
   *
   *   // ...or subscribe an array of Components...
   *   sub.subscribe([aComp1, aComp2]);
   *
   *   // ...or use an object literal for more arguments...
   *   sub.subscribe({
   *     comps: [aComp1, aComp2], // Can also just be an singular Component instance
   *     batch // if defined, any network calls will be batched into this object (optional)
   *   })
   *     .then(function () {
   *       baja.outln('components have been subscribed');
   *     })
   *     .catch(function (err) {
   *       baja.error('some components failed to subscribe: ' + err);
   *
   *       // If the components were mounted in different component spaces,
   *       // remember to check sub.isSubscribed(comp) to determine which
   *       // components successfully subscribed and which failed
   *     });
   */
  Subscriber.prototype.subscribe = function (obj) {  
    obj = objectify(obj, "comps");
    
    var comps = obj.comps,
        cb = new Callback(obj.ok, obj.fail, obj.batch),
        c,
        i,
        space,
        spaceOrd,
        spaces = [],
        ordsBySpace = {},
        remoteCallRequired = false,
        compsToSub = [],
        promises = [];
        
    // Ensure 'this' is Component in callbacks...
    setContextInOkCallback(comps, cb);
    setContextInFailCallback(comps, cb);
    
    try {    
      // Ensure we have an array of valid Components to subscribe too
      if (!(comps instanceof Array)) {
        comps = [comps];
      }

      // Remove references and see if we need a network call    
      for (i = 0; i < comps.length; ++i) {
        c = comps[i];

        // Make sure we have a Component
        strictArg(c, baja.Component); 
        
        if (!c.isMounted()) {
          throw new Error("Cannot subscribe unmounted Component!");
        }

        space = c.getComponentSpace();
        spaceOrd = space.getAbsoluteOrd().toString();
        if (!ordsBySpace.hasOwnProperty(spaceOrd)) {
          spaces.push(space);
          ordsBySpace[spaceOrd] = [];
        }
                      
        // Add to this Subscribers Component array
        if (!this.$comps.contains(c)) {
          this.$comps.push(c);
        }
      
        // Make sure this Subscriber is listed in the Subscribed Component
        if (!c.$subs.contains(this)) {
          c.$subs.push(this);
        }
        // See if we need to make any network calls.
        if (!c.$subDf || Promise.isRejected(c.$subDf.promise())) {
          remoteCallRequired = true;
          ordsBySpace[spaceOrd].push("h:" + c.getHandle());
          compsToSub.push({
            spaceOrd: spaceOrd,
            subscribedRemote: false,
            comp: c,
            df: (c.$subDf = Promise.deferred())
          });  
        }

        promises.push(c.$subDf.promise());
      }

      // When all of these promises are resolved then we're done.
      cb.addOk(function (ok, fail) {
        Promise.all(promises).then(ok, fail);
      });
          
      // If there is nothing to subscribe to at this point then just bail
      if (remoteCallRequired && bajaDef(obj.netCall, true)) {
      
        // Signal that each Component has been subscribed
        cb.addOk(function (ok, fail, handle, timestamp) {
          var i;
          for (i = 0; i < compsToSub.length; ++i) {
            try {
              compsToSub[i].comp.$fw("fwSubscribed");
            }
            catch (err0) {
              bajaError(err0);
            }
            finally {
              try {
                compsToSub[i].comp.$lastSubTimestamp = timestamp;
                compsToSub[i].df.resolve();
              }
              catch(err1) {
                bajaError(err1);
              }
            }
          }
          
          ok();
        });

        cb.addFail(function (ok, fail, err) {
          var i;
          for (i = 0; i < compsToSub.length; ++i) {
            if (compsToSub[i].subscribedRemote)
            {
              try {
                compsToSub[i].comp.$fw("fwSubscribed");
              }
              catch (err0) {
                bajaError(err0);
              }
              finally {
                try {
                  compsToSub[i].df.resolve();
                }
                catch(err1) {
                  bajaError(err1);
                }
              }
            } else {
              try {
                compsToSub[i].df.reject(err);
              }
              catch (err0) {
                bajaError(err0);
              }
            }
          }
          fail(err);
        });

        // Make the network call through the Spaces
        var batch = obj.batch || new baja.comm.Batch(),
            spacePromises = [];
        baja.iterate(spaces, function (sp) {
          var spaceAbsoluteOrd = sp.getAbsoluteOrd().toString(),
              ordsInSpace = ordsBySpace[spaceAbsoluteOrd];
          if (ordsInSpace.length > 0) {
            var spaceCb = new Callback(function() {
              for (i = 0; i < compsToSub.length; ++i) {
                if (compsToSub[i].spaceOrd === spaceAbsoluteOrd) {
                  compsToSub[i].subscribedRemote = true;
                }
              }
            }, baja.fail, batch);
            sp.getCallbacks().subscribe(ordsInSpace, spaceCb);
            spacePromises.push(spaceCb.promise());
          }
        });

        Promise.all(spacePromises)
          .then(function () { cb.ok(); })
          .catch(function (err) { cb.fail(err); });

        if (!obj.batch) {
          batch.commit();
        }
      }
      else {
        cb.ok();
      }
    }
    catch (err) {
      cb.fail(err);
    }
    return cb.promise();
  };
  
  /**
   * An internal private method for subscribing `Component`s via their ORDs. 
   * Please note, this method is strictly intended for Tridium developers only!
   * Also note that all Component ORDs subscribed using this method must live
   * in the same component space.
   *
   * @private
   * @internal
   *
   * @param {Object} [obj] the object literal used for the method's arguments.
   * @param {Array.<String>} obj.ords an Array of `String` ORDs that should resolve to 
   * `Component`s for subscription.
   * @param {baja.ComponentSpace} obj.space the Component Space used for ORD 
   * resolution. All component ORDs must live in this same space.
   * @param {Function} [obj.ok] (Deprecated: use Promise) the ok callback.
   * Called once the `Component`s have been subscribed.
   * @param {Function} [obj.fail] (Deprecated: use Promise) the fail callback.
   * Called if the `Component`s fail to subscribe. Any errors will be passed to
   * this function.
   * @param {baja.comm.Batch} [obj.batch] if defined, any network calls will 
   * be batched into this object.
   * @returns {Promise} a promise that will be resolved once the callbacks have
   * been invoked.
   */
  Subscriber.prototype.$ordSubscribe = function (obj) {      
    var ords = obj.ords,
        space = obj.space,
        cb = new Callback(obj.ok, obj.fail, obj.batch),
        that = this;

    try {     
      // Ensure these Components are all subscribed
      cb.addOk(function(ok, fail, handles, timestamp) {
        // Remove references and see if we need a network call    
        var i,
            c,
            prevSub;
            
        for (i = 0; i < handles.length; ++i) {
          // Attempt to find the Component locally
          c = space.findByHandle(handles[i]);     
          
          if (c) {
            // Mark the Component as subscribed         
            prevSub = c.isSubscribed();
                      
            // Add to this Subscribers Component array
            if (!that.$comps.contains(c)) {
              that.$comps.push(c);
            }
          
            // Make sure this Subscriber is listed in the Subscribed Component
            if (!c.$subs.contains(that)) {
              c.$subs.push(that);
            }
            
            // If this is now subscribed then fire the relevant callback
            if (!prevSub) {
              try {
                 c.$fw("fwSubscribed");
                 c.$lastSubTimestamp = timestamp;
                 c.$subDf = c.$subDf || Promise.deferred();
                 c.$subDf.resolve();
              }
              catch (err) {
                bajaError(err);
              }
            }
          }
          else {
            bajaError("Could not Batch Resolve Subscribe: " + handles[i]);
          }
          
          ok();
        }
      });
             
      // Make the network call through the Space    
      space.getCallbacks().subscribe(ords, cb);
    }
    catch (err) {
      cb.fail(err);
    }
    return cb.promise();
  };
  
  /**
   * Unsubscribe a `Component` or a number of `Component`s.
   * 
   * This will unsubscribe the mounted `Component`s if they are not already 
   * unsubscribed.
   * 
   * If the `Component`s are able to be unsubscribed, this will result in 
   * an **asynchronous** network call.
   *
   * If the `Component`s are mounted in multiple component spaces, there is a slight
   * chance for the promise to fail because a single component space
   * unsubscription failed, but `Component`s from other component spaces could
   * have been successfully unsubscribed.
   * 
   * For callbacks, the 'this' keyword is set to the comps property.
   *
   * @name baja.Subscriber#unsubscribe
   * @function
   *
   * @param {Object} [obj] the object literal used for the method's arguments.
   * @param {Function} [obj.ok] (Deprecated: use Promise) the ok callback.
   * Called once the Components have been unsubscribed.
   * @param {Function} [obj.fail] (Deprecated: use Promise) the fail callback.
   * Called if the Components fail to subscribe. Any errors will be passed to
   * this function.
   * @param {baja.comm.Batch} [obj.batch] if defined, any network calls will be
   * batched into this object.
   * @returns {Promise} a promise that will be resolved once the components have
   * been unsubscribed.
   * 
   * @example
   *   <caption>
   *     A `Component` instance, array of `Component`s or an optional object 
   *     literal can be used to specify the method's arguments.
   *   </caption>
   *   
   *   // Unsubscribe a single Component
   *   sub.unsubscribe(aComp);
   *
   *   // ...or unsubscribe an array of Components...
   *   sub.unsubscribe([aComp1, aComp2]);
   *
   *   // ...or use an object literal for more arguments...
   *   sub.unsubscribe({
   *     comps: [aComp1, aComp2], // Can also just be an singular Component instance
   *     batch // if defined, any network calls will be batched into this object (optional)
   *   })
   *     .then(function () {
   *       baja.outln('components have been unsubscribed');
   *     })
   *     .catch(function (err) {
   *       baja.error('components failed to unsubscribe: ' + err);
   *     });
   */
  Subscriber.prototype.unsubscribe = function (obj) {
    obj = objectify(obj, "comps");
  
    var that = this,
        comps = obj.comps,
        cb = new Callback(obj.ok, obj.fail, obj.batch),
        c,
        i,
        j,
        k,
        space,
        spaceOrd,
        spaces = [],
        ordsBySpace = {},
        remoteCallRequired = false;
    
    // Ensure 'this' is Component in callbacks...
    setContextInOkCallback(that, cb);
    setContextInFailCallback(that, cb);
          
    try {        
      // Ensure we have an array of valid Components to subscribe too
      if (!(comps instanceof Array)) {
        comps = [comps];
      }
            
      // Add references and see if we need a network call
      for (i = 0; i < comps.length; ++i) {
        c = comps[i];

        // Make sure we have a Component
        strictArg(c, baja.Component);

        // Attempt to remove Component from this Subscribers Component list
        for (j = 0; j < that.$comps.length; ++j) {
          if (that.$comps[j] === c) {
            that.$comps.splice(j, 1);
            break;
          }
        }
        
        // Remove this Subscriber from the Component
        for (k = 0; k < c.$subs.length; ++k) {
          if (c.$subs[k] === that) {
            c.$subs.splice(k, 1);
            break;
          }
        }
        
        // If the Component is not subscribed but was previously subscribed then make a network call
        if (canUnsubscribe(c)) {
          space = c.getComponentSpace();
          spaceOrd = space.getAbsoluteOrd().toString();
          if (!ordsBySpace.hasOwnProperty(spaceOrd)) {
            spaces.push(space);
            ordsBySpace[spaceOrd] = [];
          }

          delete c.$subDf;
          remoteCallRequired = true;
          ordsBySpace[spaceOrd].push("h:" + c.getHandle());
        }
      }
      
      // If there is nothing to unsubscribe at this point then just bail
      if (remoteCallRequired) {
        // Make the network call through the Spaces
        var batch = obj.batch || new baja.comm.Batch(),
            promises = [];
        baja.iterate(spaces, function (sp) {
          if (sp.hasCallbacks()) {
            var ordsInSpace = ordsBySpace[sp.getAbsoluteOrd()];
            if (ordsInSpace.length > 0) {
              var spaceCb = new Callback(baja.ok, baja.fail, batch);
              sp.getCallbacks().unsubscribe(ordsInSpace, spaceCb);
              promises.push(spaceCb.promise());
            }
          }
        });

        Promise.all(promises)
          .then(function () { cb.ok(); })
          .catch(function (err) { cb.fail(err); });

        if (!obj.batch) {
          batch.commit();
        }
      }
      else {
        cb.ok();
      }
    }
    catch (err) {
      cb.fail(err);
    }
    return cb.promise();
  };
  
  /**
   * Unsubscribe and unregister all `Component`s from a `Subscriber`.
   * 
   * If the `Component`s are able to be unsubscribed, this will result in 
   * an **asynchronous** network call.
   * 
   * For callbacks, the `this` keyword is set to the internal component array.
   *
   * @param {Object} [obj] the Object Literal used for the method's arguments.
   * @param {Function} [obj.ok] (Deprecated: use Promise) the ok callback.
   * Called once the Components have been unsubscribed.
   * @param {Function} [obj.fail] (Deprecated: use Promise) the fail callback.
   * Called if the Components fail to unsubscribe. Any errors will be passed to
   * this function.
   * @param {baja.comm.Batch} [obj.batch] if defined, any network calls will be
   * batched into this object.
   * @returns {Promise} a promise that will be resolved once all components are
   * unsubscribed.
   * 
   * @example
   *   sub.unsubscribeAll({
   *     batch // if defined, any network calls will be batched into this object (optional)
   *   })
   *     .then(function () {
   *       baja.outln('all components unsubscribed');
   *     })
   *     .catch(function (err) {
   *       baja.error('failed to unsubscribe components: ' + err);
   *     });
   */
  Subscriber.prototype.unsubscribeAll = function (obj) {
    obj = objectify(obj);
    obj.comps = this.$comps.slice();
    return this.unsubscribe(obj);
  };
    
  /**
   * Return true if the `Component` is subscribed in this `Subscriber`.
   *
   * @param {baja.Component} comp  the `Component` to be tested for subscription.
   * @returns {Boolean}
   */
  Subscriber.prototype.isSubscribed = function (comp) {
    strictArg(comp, baja.Component);
    return this.$comps.contains(comp);
  };
  
  return Subscriber;
});