commands/CommandGroup.js

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

/**
 * @module bajaux/commands/CommandGroup
 */
define([ 'lex!',
         'Promise',
         'bajaux/commands/Command',
         'bajaux/events' ], function (
          lex,
          Promise,
          Command,
          events) {
  
  "use strict";
  
  var COMMANDGROUP_CHANGE_EVENT = events.command.GROUP_CHANGE_EVENT;
  
  
  /**
   * A CommandGroup is a container for Commands and other CommandGroups.
   * 
   * @class
   * @alias module:bajaux/commands/CommandGroup
   * @param {object} params parameters - or just pass the `displayName` directly
   * @param {String} params.displayName A format display name that will be used
   * when `toDisplayName` is called.
   * @param {String} [params.icon] The Command Group Icon.
   * @param {Array.<module:bajaux/commands/CommandGroup|module:bajaux/commands/Command>} [params.commands] specify
   * the initial set of children of this command group
   */
  var CommandGroup = function CommandGroup(params) {    
    var that = this;
    params = params && params.constructor === Object ? params : { displayName: params };
        
    that.$kids = params.commands || [];
    that.$displayName = params.displayName || "";
    that.$flags = params.flags === undefined ? Command.flags.ALL : params.flags;
    that.$jq = params.jq || null;

    that.setIcon(params.icon || "");
  };
    
  /**
   * Add a command to this group. Triggers a 
   * `bajaux:changecommandgroup` event. Multiple arguments can be specified to
   * add many commands at once.
   * 
   * @param {module:bajaux/commands/Command} command The command to add.
   * @returns {module:bajaux/commands/CommandGroup} Return the CommandGroup instance.
   */
  CommandGroup.prototype.add = function add(command) {
    //TODO: support objects / arrays and convert to Commands/CommandGroups
    var that = this,
        jq = that.$jq,
        visit = function (c) {
          c.jq(jq);
        },
        kids = that.$kids,
        i;
        
    for (i = 0; i < arguments.length; ++i) {
      if (!contains(kids, arguments[i])) {
        kids.push(arguments[i]);
      }
    }

    if (jq) {
      for (i = 0; i < arguments.length; ++i) {
        arguments[i].visit(visit);
      }
    }
    
    that.trigger(COMMANDGROUP_CHANGE_EVENT);
    return that;
  };
  
  /**
   * Remove a command from this group. Triggers a 
   * `bajaux:changecommandgroup` event. 
   * Multiple arguments
   * can be specified so multiple commands can be removed at once.
   * @example
   *  // Passing an id/index (Number)
   *  commandGroup.remove(0); // Removes the 0 index kid from the group
   *  // Passing a command (module:bajaux/commands/Command)
   *  commandGroup.remove(command); // Removes the matching 'command' from the group
   * // Passing multiple commands (module:bajaux/commands/Command)
   *  commandGroup.remove(command1, command2); // Removes the matching commands from the group 
   * 
   * @param {module:bajaux/commands/Command|Number} command The command or index 
   * of the Command to remove.
   * @returns {module:bajaux/commands/CommandGroup} Return the CommandGroup instance.
   */
  CommandGroup.prototype.remove = function remove(command) {
    var that = this,
        kids = that.$kids,
        indicesToRemove = [],
        i, j;
       
    // Mark elements we want to remove  
    for (i = 0; i < arguments.length; ++i) {
      if (typeof arguments[i] === "number" && kids[arguments[i]]) {
        indicesToRemove.push(arguments[i]);
      } else {
        for (j = 0; j < kids.length; ++j) {
          if (kids[j] === arguments[i]) {
            indicesToRemove.push(j);
            break;
          }
          // If the command is in a sub-group try there
          if (!kids[j].isCommand()) {
            kids[j].remove.apply(kids[j], arguments);
          }
        }
      }
    }    
         
    if (indicesToRemove.length) {
      indicesToRemove.sort(function (a, b) { return a - b; });

      // Remove marked elements
      for (i = indicesToRemove.length; i >= 0; --i) {
        if (kids[indicesToRemove[i]]) {
          kids.splice(indicesToRemove[i], 1);
        }
      }
      
      that.trigger(COMMANDGROUP_CHANGE_EVENT);
    }
    
    return that;
  };
  
  /**
   * Remove all children of this command group that match the input flag,
   * optionally emptying out child groups as well. Triggers a
   * `bajaux:changecommandgroup` event.
   * 
   * @param {Object} options An options object
   * @param {Number} [options.flags] Flags to use to filter commands. Only
   * children that match one of these flags will be removed.
   * If omitted, defaults to `Command.flags.ALL` meaning all
   * children will be removed.
   * @param {Boolean} [options.recurse] `true` if should also empty out any
   * sub-groups.
   * 
   * @example
   *   cmdGroup.removeAll();
   *   cmdGroup.removeAll(Command.flags.MENU_BAR);
   *   cmdGroup.removeAll({ flags: Command.flags.MENU_BAR, recurse: true });
   */
  CommandGroup.prototype.removeAll = function removeAll(options) {
    options = options && options.constructor === Object ? options : { flags: options };
    
    var that = this,
        kids = that.$kids,
        flags = options.flags === undefined ? Command.flags.ALL : options.flags,
        recurse = options.recurse,
        kid,
        i = 0;
        
    while (i < kids.length) {
      kid = kids[i];
      
      if (recurse && !kid.isCommand()) {
        kid.removeAll(options);
      }
      
      if (kid.hasFlags(flags)) {
        kids.splice(i, 1);
      } else {
        i++;
      }
    }
    
    that.trigger(COMMANDGROUP_CHANGE_EVENT);
  };
  
  /**
   * Sorts this command group. Typically will not be overridden; override
   * `doSort` instead. Triggers a `bajaux:changecommandgroup` event.
   * 
   * @returns {Promise} A promise that will be invoked once the CommandGroup
   * has been sorted.
   */
  CommandGroup.prototype.sort = function sort() {
    var that = this;
    
    return Promise.resolve(that.doSort(that.$kids.slice()))
      .then(function (sorted) {
        that.$kids = sorted;
        that.trigger(COMMANDGROUP_CHANGE_EVENT);
        return sorted;
      });
  };
  
  /**
   * Does the work of sorting this command group. By default, does nothing.
   * 
   * When overriding this method, be sure to return the sorted array as a
   * parameter to `deferred.resolve`.
   * 
   * @param {Array} kids An array of the command group's children - this may
   * include commands or sub-CommandGroups.
   * @returns {Array|Promise} an array containing the sorted children, or
   * a promise to be resolved with same
   */
  CommandGroup.prototype.doSort = function doSort(kids) {
    return kids;
  };
  
  /**
   * Return a listing of the commands (not command groups) contained within
   * this group. Can optionally recursively visit down through any 
   * sub-CommandGroups contained in this group (depth first) to return all
   * commands. Can optionally filter commands by flags as well.
   * 
   * @param {Object} options An options object.
   * @param {Number} [options.flags] flags Used to filter commands. If omitted,
   * includes all commands regardless of flags.
   * @param {Boolean} [options.recurse] `true` if should recurse down through any
   * sub-groups.
   * @returns {Array} An array of Commands.
   * 
   * @example
   *   cmdGroup.flatten(); // all child commands
   *   cmdGroup.flatten(Command.flags.MENU); // only children with MENU flag
   *   cmdGroup.flatten({ flags: Command.flags.MENU, recurse: true });
   */
  CommandGroup.prototype.flatten = function flatten(options) {
    options = options && options.constructor === Object ? options : { flags: options };
    
    var flags = options.flags,
        recurse = options.recurse,
        commands = [];
                
    this.$kids.forEach(function (kid) {
      if (kid instanceof Command && flagsMatch(kid, flags)) {
        commands.push(kid);
      } else if (kid instanceof CommandGroup && recurse && flagsMatch(kid, flags)) {
        commands = commands.concat(kid.flatten(options));
      }
    });
        
    return commands;
  };

  function flagsMatch(command, flags) {
    if (typeof flags === 'number') {
      return command.hasFlags(flags);
    }
    return true;
  }

  /**
   * Return promise that will be resolved once all the Commands in this
   * group have been loaded. 
   *
   * @returns {Promise} A promise that will be resolved once all of the 
   * child Commands have loaded.
   */
  CommandGroup.prototype.loading = function loading() {
    var promises = this.$kids.map(function (kid) { return kid.loading(); });
    return Promise.all(promises);
  };

  /**
   * @returns {boolean} true if any of the Commands are still loading.
   */
  CommandGroup.prototype.isLoading = function isLoading() {
    var kids = this.$kids,
        i;

    for (i = 0; i < kids.length; ++i) {
      if (kids[i].isLoading()) {
        return true;
      }
    }

    return false;
  };
 
  /**
   * Filters the commands in this command group based on the results of
   * a test function, and returns a new, filtered CommandGroup instance.
   * 
   * @param {Object} options An options object literal
   * @param {Function} [options.include] A function that will receive a
   * command object and return true or false depending on whether to include
   * that command in the filtered results. If omitted, defaults to always 
   * return true.
   * @param {Number} [options.flags] A number containing flag bits to test on
   * each command. If omitted, defaults to Command.flags.ALL.
   * @returns {module:bajaux/commands/CommandGroup} A filtered command group.
   */
  CommandGroup.prototype.filter = function filter(options) {  
    options = options && options.constructor === Object ? options : { include: options };
        
    var that = this,
        newKids = [],
        include = options.include === undefined ? function () { return true; } : options.include,
        flags = options.flags === undefined ? Command.flags.ALL : options.flags,
        newGroup = new CommandGroup(that.$displayName);
     
    newGroup.jq(that.$jq);
     
    that.$kids.forEach(function (kid) {
      if (kid instanceof Command && kid.hasFlags(flags) && include(kid)) {
        newKids.push(kid);
      } else if (kid instanceof CommandGroup && kid.hasFlags(flags)) {
        newKids.push(kid.filter(options));
      }
    });
        
    newGroup.$kids = newKids;
    newGroup.$flags = that.$flags;
            
    return newGroup;
  };
  
  /**
   * Visit through all of the Commands and CommandGroups.
   * 
   * The function passed it will be called for every Command and CommandGroup
   * found (including this CommandGroup).
   * 
   * @param func Called for every CommandGroup and Command found. Each
   *             Command/CommandGroup is passed in as the first argument to the function.
   *             If the function returns a false value then iteration stops.
   */
  CommandGroup.prototype.visit = function visit(func) {  
    if (func(this) === false) {
      return false;
    }
    
    this.$kids.forEach(function (kid) {      
      return kid.visit(func);
    });
  };
  
  /**
   * Find a Command in this CommandGroup (or sub CommandGroup) via its id and return it.
   *
   * @param {Number|Function} id an id, filter function, or Command constructor. A filter
   * function receives a Command instance and returns a boolean result - the first Command passing
   * the filter will be returned. If passing a Command constructor, the first Command that is an
   * instance of that constructor will be returned.
   *
   * @example
   * group.findCommand(DeleteCommand); // returns the first instance of DeleteCommand in the group if present.
   *
   * @returns {module:bajaux/commands/Command|null} return the found Command (or null if nothing found).
   */
  CommandGroup.prototype.findCommand = function findCommand(id) {
    var c = null;

    const isFunction = typeof id === 'function';
    if (isFunction && id.prototype instanceof Command) {
      const ctor = id;
      id = (cmd) => cmd instanceof ctor;
    }

    function filterFunc(cmd) {
      var result = isFunction ? id(cmd) : cmd.isCommand() && cmd.getId() === id;
      if (result) {
        c = cmd;
        return false;
      }
    }

    this.visit(filterFunc);

    return c;
  };
  
  /**
   * Merges an input command group with this one, and returns a new
   * command group with the merged results.
   * 
   * @param {module:bajaux/commands/CommandGroup} group The group to merge with
   * this one
   * @param {object} [params]
   * @param {boolean} [params.mergeCommands=true] set to false to cause commands
   * to be merged by a simple instanceof check, and all child commands of this
   * group will be included; otherwise, only commands whose `merge()` function
   * returns a new Command will be included
   * @returns {module:bajaux/commands/CommandGroup|null} the merged group, or
   * null if the merge could not be completed
   */
  CommandGroup.prototype.merge = function merge(group, params) {
    if (!(group instanceof CommandGroup)) { return null; }
    
    var that = this,
        mergeCommands = (params && params.mergeCommands) !== false,
        newGroup = new CommandGroup(that.$displayName),
        newKids,
        otherKids = group.$kids;
    
    if (mergeCommands) {
      newKids = [];
      that.$kids.forEach(function (myKid) {
        for (var i = 0; i < otherKids.length; ++i) {
          var merged = myKid.merge(otherKids[i]);
          if (merged) {
            return newKids.push(merged);
          }
        }
      });
      if (!newKids.length) {
        return null;
      }
    } else {
      newKids = that.$kids.slice();
      otherKids.forEach(function (otherKid) {
        if (!contains(newKids, otherKid)) {
          newKids.push(otherKid);
        }
      });
    }
    
    
    newGroup.$kids = newKids;
    newGroup.$flags = that.$flags;
    newGroup.visit(function (c) {
      c.jq(that.$jq);
    });
    
    return newGroup;
  };
  
  /**
   * Returns a defensive copy of this group's array of children.
   * 
   * @returns {Array}
   */
  CommandGroup.prototype.getChildren = function getChildren() {
    return this.$kids.slice();
  };
  
  /**
   * Sets this group's array of commands/groups wholesale. Triggers a
   * `bajaux:changecommandgroup` event.
   * 
   * @param {Array} children
   */
  CommandGroup.prototype.setChildren = function setChildren(children) {
    var that = this;
    
    that.$kids = children;
    that.visit(function (c) {
      c.jq(that.$jq);
    });
    that.trigger(COMMANDGROUP_CHANGE_EVENT);
  };
  
  /**
   * Return a Command/CommandGroup based upon the index or
   * null if nothing can be found.
   *
   * @param {Number} index
   * @returns {module:bajaux/commands/Command|module:bajaux/commands/CommandGroup|null}
   */
  CommandGroup.prototype.get = function get(index) {
    return this.$kids[index] || null;
  };
  
  /**
   * Return the number of children the CommandGroup has
   * (this covers both Commands and CommandGroups).
   *
   * @returns {Number}
   */
  CommandGroup.prototype.size = function size() {
    return this.$kids.length; 
  };
  
  /**
   * Return true if this CommandGroup doesn't contain any children.
   *
   * @returns true if empty.
   */
  CommandGroup.prototype.isEmpty = function isEmpty() {
    return this.$kids.length === 0;
  };
  
  /**
   * Return the format display name of this command group.
   * 
   * @returns {String}
   */
  CommandGroup.prototype.getDisplayNameFormat = function getDisplayNameFormat() {
    return this.$displayName;
  };
  
  /**
   * Set the display name format of the command group. Triggers a
   * `bajaux:changecommandgroup` event.
   * 
   * @param {String} displayName display name - supports baja Format syntax
   */
  CommandGroup.prototype.setDisplayNameFormat = function setDisplayNameFormat(displayName) {
    this.$displayName = displayName;
    this.trigger(COMMANDGROUP_CHANGE_EVENT);
  };
  
  /**
   * Formats the command's display name.
   *
   * @returns {Promise.<String>} Promise to be resolved with the display name
   */   
  CommandGroup.prototype.toDisplayName = function toDisplayName() {
    var that = this;
    
    return lex.format(that.$displayName)
      .then(function (s) {
        // If the format doesn't resolve then use the default Commands display name
        return s || lex.format("%lexicon(bajaux:commands)%");
      });
  };
  
  /**
   * Returns this group's flags.
   * 
   * @returns {Number}
   */
  CommandGroup.prototype.getFlags = function getFlags() {
    return this.$flags;
  };
  
  /**
   * Sets this group's flags. Triggers a `bajaux:changecommandgroup` event.
   * 
   * @param {Number} flags
   */
  CommandGroup.prototype.setFlags = function setFlags(flags) {
    this.$flags = flags;
    this.trigger(COMMANDGROUP_CHANGE_EVENT);
  };
  
  /**
   * Check to see if this group's flags match any of the bits of the
   * input flags.
   * 
   * @param {Number} flags The flags to check against
   */
  CommandGroup.prototype.hasFlags = function hasFlags(flags) {
    return (this.$flags & flags) > 0;
  };
    
  /**
   * Always returns false.
   */
  CommandGroup.prototype.isCommand = function isCommand() {
    return false;
  };
  
  /**
   * Always returns false.
   */
  CommandGroup.prototype.isToggleCommand = function isToggleCommand() {
    return false;
  };
  
  /**
   * Triggers an event from this CommandGroup.
   *
   * @function
   * @param {String} name
   */
  CommandGroup.prototype.trigger = Command.prototype.trigger;
  
  /**
   * If a jQuery DOM argument is specified, this will set the DOM.
   * If not specified then no DOM will be set.
   * This method will always return the jQuery DOM associated with this CommandGroup.
   *
   * @function
   * @param {JQuery} [jqDom] If specified, this will set the jQuery DOM.
   * @returns {JQuery} A jQuery DOM object for firing events on.
   */
  CommandGroup.prototype.jq = Command.prototype.jq;

  /**
   * Register a function callback handler for the specified event.
   *
   * @param  {String} event The event id to register the function for.
   * @param  {Function} handler The event handler to be called when the event is fired.
   * @since Niagara 4.12
   */
  CommandGroup.prototype.on = Command.prototype.on;

  /**
   * Unregister a function callback handler for the specified event.
   *
   * @param  {String} [event] The name of the event to unregister.
   * If name isn't specified, all events for the Command will be unregistered.
   * @param  {Function} [handler] The function to unregister. If
   * not specified, all handlers for the event will be unregistered.
   * @since Niagara 4.12
   */
  CommandGroup.prototype.off = Command.prototype.off;

  /**
   * Return the CommandGroup's icon URI
   * @since Niagara 4.12
   *
   * @returns {String}
   */
  CommandGroup.prototype.getIcon = function getIcon() {
    return this.$icon;
  };

  /**
   * Sets the icon for this CommandGroup. Triggers a `bajaux:changecommandgroup` event.
   *
   * @param {String} icon The CommandButton's icon (either a URI or a module:// ORD string)
   * @since Niagara 4.12
   */
  CommandGroup.prototype.setIcon = function setIcon(icon) {
    this.$icon = icon.replace(/^module:\/\//, "/module/");
    this.trigger(COMMANDGROUP_CHANGE_EVENT);
  };
  
  function contains(arr, obj) {
    for (var i = 0; i < arr.length; ++i) {
      if (arr[i] === obj) { return true; }
    }
  }
      
  return CommandGroup;
});