wb/tree/TreeNode.js

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

/**
 * @module nmodule/webEditors/rc/wb/tree/TreeNode
 */
define([ 'Promise',
        'underscore',
        'nmodule/js/rc/tinyevents/tinyevents',
        'nmodule/webEditors/rc/util/Switchboard' ], function (
         Promise,
         _,
         tinyevents,
         Switchboard) {

  'use strict';

  function resolve(val) { return Promise.resolve(val); }
  /** @param {String} val */
  function reject(val) { return Promise.reject(new Error(val)); }

  function notifyThenResolve(value, progressCallback) {
    if (progressCallback) {
      progressCallback('commitReady');
    }
    return Promise.resolve(value);
  }


  /**
   * Find the index in the array of kids of the kid that matches the given name.
   * @param {Array.<module:nmodule/webEditors/rc/wb/tree/TreeNode>} kids
   * @param {String} name
   * @returns {Number}
   */
  function getIndexByName(kids, name) {
    for (var i = 0; i < kids.length; i++) {
      if (kids[i].getName() === name) {
        return i;
      }
    }
    return -1;
  }

  /**
   * Find the kid in the array of kids that matches the given name.
   * @private
   * @inner
   * @param {Array.<module:nmodule/webEditors/rc/wb/tree/TreeNode>} kids
   * @param {String} name
   * @returns {module:nmodule/webEditors/rc/wb/tree/TreeNode}
   */
  function getByName(kids, name) {
    return kids[getIndexByName(kids, name)];
  }

  /**
   * Get the kid name from the input value.
   * @private
   * @inner
   * @param {String|module:nmodule/webEditors/rc/wb/tree/TreeNode} kid either
   * a kid name (returned directly) or a `TreeNode` (returns kid name).
   * @returns {String}
   */
  function getKidName(kid) {
    return typeof kid === 'string' ? kid : kid.getName();
  }


  /**
   * Make sure that all the old kids are present and accounted for in the list
   * of reordered kids, and that the list of reordered kids contains no extras.
   * @private
   * @inner
   * @param {Array.<module:nmodule/webEditors/rc/wb/tree/TreeNode>} newKids
   * @param {Array.<module:nmodule/webEditors/rc/wb/tree/TreeNode>} oldKids
   */
  function verifyAllAccountedFor(newKids, oldKids) {
    var newlen = newKids.length,
        oldlen = oldKids.length;

    if (newlen !== oldlen) {
      throw new Error('expected ' + oldlen + ' kids but got ' + newlen);
    }

    _.each(oldKids, function (oldKid) {
      if (!getByName(newKids, oldKid.getName())) {
        throw new Error('"' + oldKid.getName() + '" not accounted for in new ' +
          'array');
      }
    });
  }


  /**
   * API Status: **Development**
   *
   * Represents a single node in a tree.
   *
   * One node has a number of different properties, as well as a reference to
   * a backing value this node represents. This backing value could be a
   * nav node on a station, a file or folder on the file system, etc.
   *
   * It also maintains a list of children. Note that this list of children will
   * be lazily, asynchronously requested the first time it is loaded. After
   * that, the list of children must be kept up to date using the parent
   * node's mutators (added/removed/etc.).
   *
   * Please note that any child nodes added to a parent node effectively become
   * the parent node's "property" and are subject to alteration by the parent.
   * If the parent node is activated, the child nodes will likewise be
   * activated, and same for destroying.
   *
   * @class
   * @alias module:nmodule/webEditors/rc/wb/tree/TreeNode
   * @mixes tinyevents
   * @param {String} name the node name
   * @param {String} display the node display
   * @param {Array.<module:nmodule/webEditors/rc/wb/tree/TreeNode>} [kids] an
   * array of child nodes
   */
  var TreeNode = function TreeNode(name, display, kids) {
    if (typeof name !== 'string') {
      throw new Error('name required');
    }

    if (kids && !Array.isArray(kids)) {
      throw new Error('kids must be array');
    }

    tinyevents(this);
    new Switchboard(this).allow('$loadKids').oneAtATime();
    this.$name = name;
    this.$display = display;
    this.$kids = kids ? Array.prototype.slice.call(kids) : []; //safe copy
    this.$kidsLoaded = false;
    this.$parent = null;
    this.$preLoad = true;
  };

  /**
   * Pass this to `#reorder` to sort all tree nodes by name.
   */
  TreeNode.BY_NODE_NAME = function (node1, node2) {
    return node1.getName() < node2.getName() ? -1 : 1;
  };

  /**
   * The name of this node. If this node has siblings, note that names must
   * be unique among all sibling nodes.
   *
   * @returns {String}
   */
  TreeNode.prototype.getName = function () {
    return this.$name;
  };

  /**
   * The display name of this node, to be shown in user interfaces. May make
   * asynchronous calls to format the display name.
   *
   * @returns {Promise} promise to be resolved with the display name
   */
  TreeNode.prototype.toDisplay = function () {
    return Promise.resolve(this.$display || this.$name);
  };

  /**
   * The parent node. If the node is unparented, will return `null`.
   *
   * @returns {module:nmodule/webEditors/rc/wb/tree/TreeNode}
   */
  TreeNode.prototype.getParent = function () {
    return this.$parent;
  };

  /**
   * The full path of names leading to this node, beginning from the parent
   * node. Since names must be unique among siblings, each node in a tree will
   * therefore have a unique full path.
   *
   * @returns {Array.<String>} an array of node names, with the name of the
   * root node first and this node last
   */
  TreeNode.prototype.getFullPath = function () {
    var parent = this.getParent(),
        path = parent ? parent.getFullPath() : [];

    path.push(this.getName());

    return path;
  };

  /**
   * Returns the backing value represented by this node. By default, this will
   * return `undefined`, since a vanilla `TreeNode` is really just a
   * name/display pair. Subclasses of `TreeNode` intended to represent real-life
   * values should override this method to return the appropriate value.
   *
   * @returns {*}
   */
  TreeNode.prototype.value = function () {
    return undefined;
  };

  /**
   * Return a list of URIs to image files that represent a display icon for this
   * node. Typically, this will only return zero or one URI, but may return
   * several if the node's icon should be layered or have a "badge" applied. By
   * default, this just returns an empty array.
   *
   * @returns {Array.<String>} an array of URIs to image files
   */
  TreeNode.prototype.getIcon = function () {
    return [];
  };

  /**
   * Retrieves a child node by name. If child nodes are not yet loaded, they
   * will be upon calling this method.
   *
   * @param {String} name
   * @returns {Promise} promise to be resolved with the child node
   * with the given name, or `undefined` if not found
   */
  TreeNode.prototype.getKid = function (name) {
    return this.getKids()
      .then(function (kids) {
        return getByName(kids, name);
      });
  };

  /**
   * Retrieves a child by traversing the tree using the names provided. Each name
   * will traverse a level deeper into the tree.
   *
   * @param {Array.<string>} names
   * @returns {Promise} promise to be resolved with the descendent node, or
   * `undefined` if not found
   */
  TreeNode.prototype.getDescendent = function (names) {
    if (names.length === 0) {
      return resolve(this);
    } else {
      var childDescendent = names.shift();
      return this.getKid(childDescendent).then(function (kid) {
        if (kid) {
          return kid.getDescendent(names);
        } else {
          return resolve(kid);
        }
      });
    }
  };

  /**
   * Return false if you know for a fact that this node has no child nodes.
   *
   * Why is this different from the `bajaui` implementation which declares a
   * `getChildCount()` method? Remember that retrieving child nodes is
   * asynchronous, so it's not always possible to count them synchronously.
   * This function will mainly serve as a hint to UI widgets whether to show
   * an expander for this node, with the understanding that `getKids()` may
   * still resolve zero nodes, even if this function returned true.
   *
   * @abstract
   * @returns {boolean}
   */
  TreeNode.prototype.mayHaveKids = function () {
    return !this.$kidsLoaded || (this.$kids.length > 0);
  };

  /**
   * Performs a one-time, asynchronous load of child nodes. On a vanilla
   * `TreeNode`, this does nothing but resolve the array of child nodes passed
   * into the constructor. In subclasses, this should be overridden to perform
   * any network calls or other asynchronous behavior to load child nodes.
   *
   * This method is intended to be overridden by subclasses, but not called
   * directly. It will automatically be used the first time `getKids()` is
   * called.
   *
   * After `getKids()` is called for the first time, any updates or changes to
   * the list of nodes should only be done through the `add()`, `remove()`,
   * and other mutator methods.
   *
   * Do not set the parent of the child nodes created by this method - they
   * will automatically be parented when `getKids()` is called.
   *
   * *Important contractual note:* in some cases, the async operation to load
   * kids can be batched together if loading a number of nodes at once. If
   * `$loadKids` receives a `Batch` object, it is obligated to ensure that any
   * `progressCallback` param passed in will be called with a `commitReady`
   * progress event to notify the caller that the batch is ready to be committed.
   *
   * @abstract
   * @param {Object} [params]
   * @param {baja.comm.Batch} [params.batch] optional Batch that may be used
   * when loading multiple tree nodes. See method description for contract.
   * @param {Function} [params.progressCallback] optional function that will
   * receive progress notifications during the load process.
   * @returns {Promise} promise to be resolved when all child nodes
   * have been loaded. It should be resolved with an array of `TreeNode`
   * instances.
   */
  TreeNode.prototype.$loadKids = function (params) {
    return notifyThenResolve(this.$kids, params && params.progressCallback);
  };


  /**
   * Resolves all child nodes of this node. If they have already been loaded,
   * they will be resolved immediately, otherwise they will be asynchronously
   * loaded in a one-time operation. (The children will not be loaded if the
   * node was destroyed first.)
   *
   * After `getKids()` is called for the first time, any updates or changes to
   * the list of nodes should only be done through the `add()`, `remove()`,
   * and other mutator methods.
   *
   * @param {Object} [params] params object to be passed to `$loadKids`. This
   * should be provided if you are calling `getKids` without being sure you have
   * called `$loadKids` first.
   * @returns {Promise} promise to be resolved with an array of child
   * `TreeNode`s
   */
  TreeNode.prototype.getKids = function (params) {
    var that = this;

    if (that.$kidsLoaded || that.$destroyed) {
      return Promise.resolve(that.$kids.slice());
    } else {
      //TODO: support batching properly
      return that.$loadKids(params)
        .then(function (kids) {
          for (var i = 0; i < kids.length; i++) {
            kids[i].$parent = that;
          }
          that.$kids = kids;
          that.$kidsLoaded = true;
          return kids.slice();
        });
    }
  };

  /**
   * Make sure that the kid to add to the current list is a valid TreeNode,
   * not parented, and isn't a duplicate.
   * @private
   * @inner
   * @param {Array.<module:nmodule/webEditors/rc/wb/tree/TreeNode>} kids
   * @param {module:nmodule/webEditors/rc/wb/tree/TreeNode} kid
   * @returns {Promise} to be resolved if the kid can be added, or
   * rejected if not
   */
  function validateAdd(kids, kid) {
    if (!(kid instanceof TreeNode)) {
      return reject('TreeNode required');
    }

    if (kid.$parent) {
      return reject('already parented');
    }

    if (getByName(kids, kid.getName())) {
      return reject('duplicate name "' + kid.getName() + '"');
    }

    return resolve(kids);
  }

  /**
   * Adds a child node to the end of this parent's list of child nodes. The
   * child will automatically be parented when it is set. If this node has
   * been activated, the child node will likewise be activated when it is
   * added.
   *
   * @param {module:nmodule/webEditors/rc/wb/tree/TreeNode} kid
   * @returns {Promise} promise to be resolved when the child node is
   * added, or rejected if the child node is already parented, if the list
   * of children is not yet loaded (`getKids()` not yet called), or an existing
   * child with a duplicate name is found
   */
  TreeNode.prototype.add = function (kid) {
    var that = this,
        kids = that.$kids;

    if (!that.$kidsLoaded) {
      return reject('cannot add to a node not yet loaded ' +
        '(call getKids() first)');
    }

    return validateAdd(kids, kid)
      .then(function () {
        kids.push(kid);
        kid.$parent = that;
        that.emit('added', kid);
        return that.$activated && kid.activate();
      });
  };


  /**
   * Removes a child node from this parent's list of child nodes. Note that
   * child's `destroy()` will be called when it is removed.
   *
   * @param {module:nmodule/webEditors/rc/wb/tree/TreeNode|String} kid the
   * node to remove, or the name of the node to remove
   * @returns {Promise} promise to be resolved with the
   * removed/destroyed child, or rejected if the given node or node name is not
   * found in the existing list of children, or if the list of children is not
   * yet loaded (`getKids()` not yet called)
   */
  TreeNode.prototype.remove = function (kid) {
    var that = this,
        name = typeof kid === 'string' ? kid : kid.getName();

    if (!that.$kidsLoaded) {
      //TODO: this is causing console spam. why not just resolve?
      return reject('cannot remove from a node not yet loaded ' +
        '(call getKids() first)');
    }

    return that.getKids()
      .then(function () {
        // for this we need to operate on the real backing array to avoid
        // async issues.
        var kids = that.$kids,
            i = getIndexByName(kids, name);

        if (i >= 0) {
          kid = kids[i];
          kids.splice(i, 1);
          that.emit('removed', kid);
          return kid.destroy()
            .then(function () {
              return kid;
            });
        } else {
          return reject('kid "' + name + '" not found');
        }
      });
  };

  /**
   * Renames one child node.
   *
   * @param {String} name the name of the existing child node to rename
   * @param {String} newName the new name of the child node
   * @returns {Promise} promise to be resolved when the child is renamed,
   * or rejected if the child was not found, if the node already has a
   * sibling by the new name, or if the list of children is not
   * yet loaded (`getKids()` not yet called)
   */
  TreeNode.prototype.rename = function (name, newName) {
    var that = this;

    if (!that.$kidsLoaded) {
      return reject('cannot remove from a node not yet loaded ' +
        '(call getKids() first)');
    }

    return Promise.all([ that.getKid(name), that.getKid(newName) ])
      .then(([ kid, existingKid ]) => {

        // If the re-name is done on the server we will not have a kid and will not need to do
        // the re-name here
        if (!kid && existingKid) {
          return;
        }

        if (existingKid && name !== newName) {
          return reject('cannot rename: "' + newName + '" already exists');
        }

        if (kid) {
          kid.$name = newName;
          that.emit('renamed', newName, name);
          return;
        }

        return reject('cannot rename: "' + name + '" not found');
      });
  };

  /**
   * Sets the order of this node's children. The input array must contain the
   * exact same set of children as this node has, but in any order.
   *
   * @param {Array.<module:nmodule/webEditors/rc/wb/tree/TreeNode>|Array.<String>|Function} newKids
   * the children of this node, in the desired new order. This can be
   * an array of the actual nodes rearranged, or an array of node names. It can
   * also be a sort function that takes two tree nodes; the existing nodes will
   * be reordered according to this function.
   * @returns {Promise} promise to be resolved when the child nodes are
   * reordered, or rejected if the input array contains a different number of
   * nodes than this node has children, if it contains a node that does not
   * exist as a child node, or if the list of children is not
   * yet loaded (`getKids()` not yet called)
   */
  TreeNode.prototype.reorder = function (newKids) {
    var that = this;

    if (!that.$kidsLoaded) {
      return reject('cannot reorder kids of a node not yet loaded ' +
        '(call getKids() first)');
    }

    function getNewAndOldKids() {
      return that.getKids()
        .then(function (kids) {
          if (Array.isArray(newKids)) {
            return [ newKids, kids ];
          } else {
            var slice = kids.slice();
            slice.sort(newKids);
            return [ slice, kids ];
          }
        });
    }

    return getNewAndOldKids()
      .then(([ newKids, kids ]) => {
        //make sure the input kid actually exists in my kid list.
        function findExisting(kid) {
          return getByName(kids, getKidName(kid));
        }

        var reorderedKids = _.map(newKids, findExisting);
        verifyAllAccountedFor(reorderedKids, kids);
        that.$kids = reorderedKids;
        that.emit('reordered');
      });
  };

  TreeNode.prototype.replace = function (kid, newKid) {
    //TODO
  };

  /**
   * Activates the node and all of its child nodes. This method works very
   * similarly to `Widget#initialize()` in that it delegates the implementation
   * of the destruction of each individual node to `doActivate()`.
   *
   * Note that child nodes will *not* be activated if they are not yet loaded.
   *
   * @returns {Promise} promise to be resolved when this node and all
   * child nodes (if loaded) have been activated
   */
  TreeNode.prototype.activate = function () {
    var that = this;

    return Promise.resolve(!that.$activated && that.doActivate())
      .then(function () {
        that.$activated = true;
        return that.$kidsLoaded && that.getKids();
      })
      .then(function (kids) {
        return kids && Promise.all(_.invoke(kids, 'activate'));
      });
  };

  /**
   * Implementation of `activate()`. This method should acquire any resources
   * the node needs to function properly - registering event handlers,
   * subscribing components, etc. Ensure that all resources acquired are
   * properly released in `doDestroy()`. By default, does nothing.
   *
   * @returns {Promise} promise to be resolved when activation is
   * complete - or return undefined if no asynchronous work needs to be done
   */
  TreeNode.prototype.doActivate = function () {

  };

  /**
   * Destroys the node and all of its child nodes. This method works very
   * similarly to `Widget#destroy()` in that it delegates the implementation
   * of the destruction of each individual node to `doDestroy()`.
   *
   * Note that child nodes will *not* be destroyed if they are not yet loaded.
   *
   * @returns {Promise} promise to be resolved when this node and all
   * child nodes (if loaded) have been destroyed
   */
  TreeNode.prototype.destroy = function () {
    var that = this;

    //TODO: isn't this backwards? shouldn't kids destroy first?
    return Promise.resolve(!that.$destroyed && that.doDestroy())
      .then(function () {
        that.$destroyed = true;
        return that.$kidsLoaded && that.getKids();
      })
      .then(function (kids) {
        return kids && Promise.all(_.invoke(kids, 'destroy'));
      });
  };

  /**
   * Implementation of `destroy()`. This method should release any resources
   * acquired by the node during `doActivate()`. By default, does nothing.
   *
   * @returns {Promise} promise to be resolved when destruction is
   * complete - or return undefined if no asynchronous work needs to be done
   */
  TreeNode.prototype.doDestroy = function () {

  };

  /**
   * Test to see if this node is equivalent to some value. By default, a node
   * is equivalent only to itself.
   *
   * @param {*} value
   * @returns {boolean}
   */
  TreeNode.prototype.equals = function (value) {
    return value === this;
  };

  /**
   * Returns a string representation of this node. By default, just returns
   * the name.
   *
   * @returns {String}
   */
  TreeNode.prototype.toString = function () {
    return this.getName();
  };

  /**
   * Return true if this tree node is eligible to begin a drag operation.
   *
   * @returns {boolean} false by default
   */
  TreeNode.prototype.isDraggable = function () { return false; };

  /**
   * When activated, many tree nodes will instigate a page change. Override
   * this function to specify the hyperlink target.
   *
   * @returns {Promise} promise to be resolved with the hyperlink target.
   * Bu default, resolves `undefined`.
   */
  TreeNode.prototype.toHyperlinkUri = function () {
    return Promise.resolve();
  };

  /**
   * A tree node has the option of accepting data from a drag and drop
   * operation. If a node is to accept drag and drop, this function should be
   * overridden to examine the currently loaded value (if appropriate) and
   * determine if it can accept a drop operation.
   *
   * It is up to the `NavTree` that holds this node to `return false` from the
   * event handler, apply any CSS styles, etc.
   *
   * Naturally, any node that implements this function should also implement
   * `doDrop` to perform the requested drop operation.
   *
   * @returns {boolean} `false` by default
   */
  TreeNode.prototype.isDropTarget = function () {
    return false;
  };

  /**
   * Override this method to return `false` to prevent this node from being
   * selected in the tree.
   *
   * @returns {Boolean} `true` by default.
   */
  TreeNode.prototype.isSelectable = function () {
    return true;
  };

  /**
   * A tree node that returned `true` from `isDropTarget` can then take an array
   * of values to perform the drop action.
   *
   * By default, this function does nothing.
   *
   * @param {Array} values the values being dropped onto this node
   * @returns {Promise} promise to be resolved when the drop operation
   * completes, or rejected if the given array does not hold valid data
   * to perform a drop operation.
   */
  TreeNode.prototype.doDrop = function (values) {
    return Promise.resolve();
  };

  return TreeNode;
});