/**
* @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;
});