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";

  const { BaseBajaObj, def: bajaDef, error: bajaError, objectify, strictArg, subclass } = baja;
  const { canUnsubscribe, setContextInFailCallback, setContextInOkCallback } = compUtil;
  
  /**
   * Component Subscriber used to subscribe to multiple `Component`s for events.
   *
   * @class
   * @alias baja.Subscriber
   * @extends baja.BaseBajaObj
   */
  const 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 are 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 a 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");
    
    let comps = obj.comps;
    const inpBatch = obj.batch;
    const cb = new Callback(obj.ok, obj.fail, inpBatch);
    const ordsBySpace = {};
    const spaces = [];
    const componentSubscriptions = [];
    let remoteCallRequired;
        
    // 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    
      const subscribePromises = comps.map((comp) => {
        // Make sure we have a Component
        strictArg(comp, baja.Component);
        
        if (!comp.isMounted()) {
          throw new Error("Cannot subscribe unmounted Component!");
        }

        const space = comp.getComponentSpace();
        const spaceOrd = space.getAbsoluteOrd().toString();
        if (!ordsBySpace[spaceOrd]) {
          spaces.push(space);
          ordsBySpace[spaceOrd] = [];
        }
                      
        // Add to this Subscribers Component array
        if (!this.$comps.includes(comp)) {
          this.$comps.push(comp);
        }
      
        // Make sure this Subscriber is listed in the Subscribed Component
        if (!comp.$subs.includes(this)) {
          comp.$subs.push(this);
        }

        // See if we need to make any network calls.
        if (!comp.$subDf || Promise.isRejected(comp.$subDf.promise())) {
          remoteCallRequired = true;
          ordsBySpace[spaceOrd].push("h:" + comp.getHandle());
          componentSubscriptions.push({
            spaceOrd,
            subscribedRemote: false,
            comp,
            df: (comp.$subDf = Promise.deferred())
          });  
        }

        return comp.$subDf.promise();
      });

      // When all of these promises are resolved then we're done.
      cb.addOk(function (ok, fail) {
        Promise.all(subscribePromises).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((ok, fail, handle, timestamp) => {
          componentSubscriptions.forEach(({ comp, df }) => {
            try {
              comp.$fw("fwSubscribed");
            } catch (err0) {
              bajaError(err0);
            } finally {
              try {
                comp.$lastSubTimestamp = timestamp;
                df.resolve();
              } catch (err1) {
                bajaError(err1);
              }
            }
          });

          ok();
        });

        cb.addFail((ok, fail, err) => {
          componentSubscriptions.forEach(({ comp, df, subscribedRemote }) => {
            if (subscribedRemote) {
              try {
                comp.$fw("fwSubscribed");
              } catch (err0) {
                bajaError(err0);
              } finally {
                try {
                  df.resolve();
                } catch (err1) {
                  bajaError(err1);
                }
              }
            } else {
              try {
                df.reject(err);
              } catch (err0) {
                bajaError(err0);
              }
            }
          });

          fail(err);
        });

        // Make the network call through the Spaces
        const batch = obj.batch || new baja.comm.Batch();

        const spacePromises = spaces.map((sp) => {
          const spaceAbsoluteOrd = sp.getAbsoluteOrd().toString();
          const ordsInSpace = ordsBySpace[spaceAbsoluteOrd];

          if (!ordsInSpace.length) { return; }

          const spaceCb = new Callback(function () {
            for (let i = 0; i < componentSubscriptions.length; ++i) {
              if (componentSubscriptions[i].spaceOrd === spaceAbsoluteOrd) {
                componentSubscriptions[i].subscribedRemote = true;
              }
            }
          }, baja.fail, batch);

          sp.getCallbacks().subscribe(ordsInSpace, spaceCb);
          return 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) {
    const { ok, fail, batch, ords, space } = obj;
    const cb = new Callback(ok, fail, batch);
    const { $comps } = this;

    try {     
      // Ensure these Components are all subscribed
      cb.addOk((ok, fail, handles, timestamp) => {
        // Remove references and see if we need a network call
        handles.forEach((handle) => {
          // Attempt to find the Component locally
          const c = space.findByHandle(handle);
          const { $subs } = c;
          
          if (c) {
            // Mark the Component as subscribed         
            const prevSub = c.isSubscribed();
                      
            // Add to this Subscribers Component array
            if (!$comps.includes(c)) {
              $comps.push(c);
            }
          
            // Make sure this Subscriber is listed in the Subscribed Component
            if (!$subs.includes(this)) {
              $subs.push(this);
            }
            
            // 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: " + handle);
          }
          
          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 can 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 a 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");


    const { $comps } = this;

    const { ok, fail, batch: inpBatch } = obj;
    const cb = new Callback(ok, fail, inpBatch);

    const spaces = [];
    const ordsBySpace = {};

    let { comps } = obj;
    let remoteCallRequired;
    
    // Ensure 'this' is Component in callbacks...
    setContextInOkCallback(this, cb);
    setContextInFailCallback(this, 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
      comps.forEach((c) => {
        // Make sure we have a Component
        strictArg(c, baja.Component);

        const { $subs } = c;

        // Attempt to remove Component from this Subscribers Component list
        for (let i = 0; i < $comps.length; ++i) {
          if ($comps[i] === c) {
            $comps.splice(i, 1);
            break;
          }
        }

        // Remove this Subscriber from the Component
        for (let i = 0; i < $subs.length; ++i) {
          if ($subs[i] === this) {
            $subs.splice(i, 1);
            break;
          }
        }
        
        // If the Component is not subscribed but was previously subscribed then make a network call
        if (canUnsubscribe(c)) {
          const space = c.getComponentSpace();
          const spaceOrd = space.getAbsoluteOrd().toString();
          if (!ordsBySpace[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
        const batch = inpBatch || new baja.comm.Batch();

        const unsubscribePromises = spaces.map((sp) => {
          if (sp.hasCallbacks()) {
            const ordsInSpace = ordsBySpace[sp.getAbsoluteOrd()];

            if (ordsInSpace.length > 0) {
              const spaceCb = new Callback(baja.ok, baja.fail, batch);
              sp.getCallbacks().unsubscribe(ordsInSpace, spaceCb);
              return spaceCb.promise();
            }
          }
        });

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

        if (!inpBatch) {
          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 can 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.indexOf(comp) >= 0;
  };
  
  return Subscriber;
});