(function($) {
// From https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys
if (!Object.keys) {
Object.keys = (function () {
'use strict';
var hasOwnProperty = Object.prototype.hasOwnProperty,
hasDontEnumBug = !({toString: null}).propertyIsEnumerable('toString'),
dontEnums = [
'toString',
'toLocaleString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'constructor'
],
dontEnumsLength = dontEnums.length;
return function (obj) {
if (typeof obj !== 'object' && (typeof obj !== 'function' || obj === null)) {
throw new TypeError('Object.keys called on non-object');
}
var result = [], prop, i;
for (prop in obj) {
if (hasOwnProperty.call(obj, prop)) {
result.push(prop);
}
}
if (hasDontEnumBug) {
for (i = 0; i < dontEnumsLength; i++) {
if (hasOwnProperty.call(obj, dontEnums[i])) {
result.push(dontEnums[i]);
}
}
}
return result;
};
}());
}
// The tree select control.
$.fn.treeselect = function(params) {
// Setup the default parameters for the tree select control.
params = $.extend({
colwidth: 18, /** The width of the columns. */
default_value: {}, /** An array of default values. */
selected: null, /** Callback when an item is selected. */
treeloaded: null, /** Called when the tree is loaded. */
load: null, /** Callback to load new tree's */
searcher: null, /** Callback to search a tree */
deepLoad: false, /** Performs a deep load */
onbuild: null, /** Called when each node is building. */
postbuild: null, /** Called when the node is done building. */
inputName: 'treeselect', /** The input name. */
autoSelectChildren: true, /** Select chldrn when parent is selected. */
showRoot: false, /** Show the root item with a checkbox. */
selectAll: false, /** If we wish to see a select all. */
selectAllText: 'Select All' /** The select all text. */
}, params);
/** Keep track of all loaded nodes */
var loadedNodes = {};
/** Variable for the busy states. */
var busyloading = 'treebusy-loading';
var busyloadingall = 'treebusy-loading-all';
var busyselecting = 'treebusy-selecting';
/**
* Constructor.
*/
var TreeNode = function(nodeparams, root) {
// Determine if this is a root item.
this.root = !!root;
// Setup the parameters.
nodeparams.title = nodeparams.title || 'anonymous';
$.extend(this, {
id: 0, /** The ID of this node. */
nodeloaded: false, /** Flag to see if this node is loaded. */
allLoaded: false, /** Flag to see if we have loaded all nodes. */
value: 0, /** The input value for this node. */
title: '', /** The title of this node. */
url: '', /** The URL to this node. */
has_children: true, /** Boolean if this node has children. */
children: [], /** Array of children. */
data: {}, /** Additional data to attach to the node. */
level: 0, /** The level of this node. */
odd: false, /** The odd/even state of this row. */
checked: false, /** If this node is checked. */
busy: false, /** If this node is busy. */
display: $(), /** The display of this node. */
input: $(), /** The input display. */
link: $(), /** The link display. */
span: $(), /** The span display. */
childlist: $(), /** The childlist display. */
exclude: {} /** An array of nodes to exclude for selection. */
}, nodeparams);
// Say that we are a TreeNode.
this.isTreeNode = true;
// Determine if a node is loading.
this.loading = false;
// The load callback queue.
this.loadqueue = [];
};
/**
* Set the busy cursor for this node.
*/
TreeNode.prototype.setBusy = function(state, type) {
// Make sure the state has changed.
if (state != this.span.hasClass(type)) {
this.busy = state;
if (state) {
// Set the busy type and treebusy.
this.span.addClass(type);
this.span.addClass('treebusy');
}
else {
// Remove the busy type.
this.span.removeClass(type);
// Only remove the busy if the busy flags are empty.
var othertype = (type == busyloading) ? busyselecting : busyloading;
if (!this.span.hasClass(othertype)) {
this.span.removeClass('treebusy');
}
}
}
};
/**
* Determines if this node is already loaded.
*/
TreeNode.prototype.isLoaded = function() {
var loaded = this.nodeloaded;
loaded |= loadedNodes.hasOwnProperty(this.id);
loaded |= !this.has_children;
loaded |= (this.has_children && this.children.length > 0);
return loaded;
};
/**
* Loads the current node.
*
* @param {function} callback - The callback when the node is loaded.
*/
TreeNode.prototype.loadNode = function(callback, hideBusy) {
// If we are loading, then just add this callback to the queue and return.
if (this.loading) {
if (callback) {
this.loadqueue.push(callback);
}
return;
}
// Trigger the callback when the node is done loading.
var triggerCallback = function() {
// Callback that we are loaded.
if (callback) {
callback(this);
}
// Process the loadqueue.
for (var i in this.loadqueue) {
this.loadqueue[i](this);
}
// Empty the loadqueue.
this.loadqueue.length = 0;
// Say we are not busy.
if (!hideBusy) {
this.setBusy(false, busyloading);
}
};
// Say we are loading.
this.loading = true;
// Only load if we have not loaded yet.
if (params.load && !this.isLoaded()) {
// Make this node busy.
if (!hideBusy) {
this.setBusy(true, busyloading);
}
// Call the load function.
params.load(this, (function(treenode) {
return function(node) {
// Only perform the merging and build if it hasn't loaded.
if (!treenode.nodeloaded) {
// Merge the result with this node.
treenode = jQuery.extend(treenode, node);
// Say this node is loaded.
treenode.nodeloaded = true;
// Add to the loaded nodes array.
loadedNodes[treenode.id] = treenode.id;
// Build the node.
treenode.build(function() {
// Callback that we are loaded.
triggerCallback.call(treenode);
});
}
else {
// Callback that we are loaded.
triggerCallback.call(treenode);
}
};
})(this));
}
else if (callback) {
// Just callback since we are already loaded.
triggerCallback.call(this);
}
// Say that we are not loading anymore.
this.loading = false;
};
/**
* Recursively loads and builds all nodes beneath this node.
*
* @param {function} callback Called when the tree has loaded.
* @param {function} operation Allow someone to perform an operation.
*/
TreeNode.prototype.loadAll = function(callback, operation, hideBusy, ids) {
ids = ids || {};
// Make sure we are loaded first.
this.loadNode(function(node) {
// See if an operation needs to be performed.
if (operation) {
operation(node);
}
// Get our children count.
var i = node.children.length, count = i;
// If no children, then just call the callback immediately.
if (!i || ids.hasOwnProperty(node.id)) {
if (callback) {
callback.call(node, node);
}
return;
}
// Add this to the ids to protect against recursion.
ids[node.id] = node.id;
// Make this node busy.
if (!hideBusy) {
node.setBusy(true, busyloadingall);
}
// Load children at a specific index.
var loadChildren = function(index) {
return function() {
// Load this childs children...
node.children[index].loadAll(function() {
// Decrement the child count.
count--;
// If all children are done loading, call the callback.
if (!count) {
// Callback that we are done loading this tree.
if (callback) {
callback.call(node, node);
}
// Make this node busy.
if (!hideBusy) {
node.setBusy(false, busyloadingall);
}
}
}, operation, hideBusy, ids);
};
};
// Iterate through each child.
while (i--) {
// Load recurssion on a separate thread.
setTimeout(loadChildren(i), 2);
}
});
};
/**
* Expands the node.
*/
TreeNode.prototype.expand = function(state) {
if (state) {
this.link.removeClass('collapsed').addClass('expanded');
this.span.removeClass('collapsed').addClass('expanded');
this.childlist.show('fast');
// If this node is checked as including children, go through and select
// all of it's children.
if (!params.deepLoad && this.checked && this.include_children) {
this.include_children = false;
this.selectChildren(true);
}
}
// Only collapse if they can open it back up.
else if (this.span.length > 0) {
this.link.removeClass('expanded').addClass('collapsed');
this.span.removeClass('expanded').addClass('collapsed');
this.childlist.hide('fast');
}
// If the state is expand, but the children have not been loaded.
if (state && !this.isLoaded()) {
// If there are no children, then we need to load them.
this.loadNode(function(node) {
if (node.checked) {
node.selectChildren(node.checked);
}
node.expand(true);
});
}
};
/**
* Selects all children of this node.
*
* @param {boolean} state The state of the selection or array of defaults.
* @param {function} done Called when we are done selecting.
*/
TreeNode.prototype.selectChildren = function(state, done, child) {
// See if the state is a boolean.
var defaults = (typeof state == 'object');
// Create a function to call when we are done selecting.
var doneSelecting = function() {
if (!child) {
// If they provided a selected parameter.
if (params.selected) {
params.selected(this, true);
}
// Say that we are done.
if (done) {
done.call(this);
}
}
};
if (params.deepLoad) {
// Load all nodes underneath this node.
this.loadAll(function() {
// Set this node not busy.
this.setBusy(false, busyselecting);
// We are done selecting.
doneSelecting.call(this);
}, function(node) {
var val = state;
if (defaults) {
val = state.hasOwnProperty(node.value);
val |= state.hasOwnProperty(node.id);
}
// Select this node.
node.select(val);
});
}
else {
// Select the current node.
this.select(state);
var name = params.inputName + '-' + this.value;
$('input[name="' + name + '-include-below"]').attr(
'name',
name
);
// We should load children if the current node is expanded, or the
// current node is being deselected and possibly has children selected
// below them.
if ((this.root === true) ||
(state === false && !this.include_children) ||
(this.link !== undefined && this.link[0] !== undefined &&
this.link[0].className.indexOf('expanded') !== -1)
) {
this.include_children = false;
this.expand(state);
var i = this.children.length;
while (i--) {
// Select all the children.
this.children[i].selectChildren(state, done, true);
}
}
else {
// Flag this noad as including all children below if it has children.
if (this.has_children > 0 && state) {
this.include_children = true;
$('input[name="' + name + '"]').attr(
'name',
name + '-include-below'
);
}
}
// We are done selecting.
doneSelecting.call(this);
}
};
/**
* Selects default values of the TreeNode.
*
* @param {boolean} defaults Array of defaults.
* @param {function} done Called when we are done selecting.
*/
TreeNode.prototype.selectDefaults = function(defaults, done) {
var defaultsLeft = Object.keys(defaults).length;
var defaultsQueue = [];
defaultsQueue.push(this);
// Loop through nodes depth first to find the defaults.
while (defaultsLeft > 0 && defaultsQueue.length > 0) {
var queueItem = defaultsQueue.shift();
var state = false;
// Check if the queued item is listed in the defaults.
if (defaults.hasOwnProperty(queueItem.value)) {
delete defaults[queueItem.value];
state = true;
defaultsLeft--;
}
if (defaults.hasOwnProperty(queueItem.id)) {
delete defaults[queueItem.id];
state = true;
defaultsLeft--;
}
// Check if the queued item is listed in the defaults and is flagged to
// include defaults.
if (defaults.hasOwnProperty(queueItem.value + '-include-below')) {
delete defaults[queueItem.value + '-include-below'];
queueItem.include_children = true;
state = true;
defaultsLeft--;
}
if (defaults.hasOwnProperty(queueItem.id + '-include-below')) {
delete defaults[queueItem.id + '-include-below'];
queueItem.include_children = true;
state = true;
defaultsLeft--;
}
// Select the queued item.
queueItem.select(state);
// Set the input name to the correct value.
var name = params.inputName + '-' + queueItem.value;
$('input[name="' + name + '-include-below"]').attr('name', name);
if (!queueItem.root && state && queueItem.include_children) {
$('input[name="' + name + '"]').attr('name', name + '-include-below');
}
else if (defaultsLeft > 0) {
// Add this node's children to the queue.
var i = queueItem.children.length;
while (i--) {
defaultsQueue.push(queueItem.children[i]);
}
}
else if (queueItem.root && queueItem.include_children) {
// Select the root node's children.
queueItem.selectChildren(true);
}
}
// Say this node is now fully selected.
if (params.selected) {
params.selected(this, true);
}
// Say we are now done.
if (done) {
done.call(this);
}
};
/**
* Sets the checked state for the input field depending on the state.
*
* @param {boolean} state
*/
TreeNode.prototype.setChecked = function(state) {
// Set the checked state.
this.checked = state;
// Set the checked state for this input.
this.input.eq(0)[0].checked = state;
// Trigger the change event.
this.input.change();
};
/**
* Selects a node.
*
* @param {boolean} state The state of the selection.
*/
TreeNode.prototype.select = function(state) {
// Only check this node if it is a selectable input.
if (!this.input.hasClass('treenode-no-select')) {
// Convert state to a boolean.
state = !!state;
// Select the element unless the state is false and we are on the root
// element which isn't unselectable.
if (state || !this.root || (this.showRoot && this.has_children)) {
// Set the checked state.
this.setChecked(state);
// Say that this node is selected.
if (params.selected) {
params.selected(this);
}
}
}
};
/**
* Build the treenode element.
*/
TreeNode.prototype.build_treenode = function() {
var treenode = $();
treenode = $(document.createElement(this.root ? 'div' : 'li'));
treenode.addClass('treenode');
treenode.addClass(this.odd ? 'odd' : 'even');
return treenode;
};
/**
* Build the input and return.
*/
TreeNode.prototype.build_input = function(left) {
// Only add an input if the input name is defined.
if (params.inputName) {
// If this node is excluded or has no roles enabled in the group finder,
// then add a dummy div tag.
if ((typeof this.exclude[this.id] !== 'undefined') ||
(params.inputName == 'group_finder' && !this.data.roles_enabled)) {
this.input = $(document.createElement('div'));
this.input.addClass('treenode-no-select');
}
else {
// Create the input element.
this.input = $(document.createElement('input'));
// Get the value for this input item.
var value = this.value || this.id;
// Create the attributes for this input item.
this.input.attr({
'type': 'checkbox',
'value': value,
'name': params.inputName + '-' + value,
'id': 'choice_' + this.id
}).addClass('treenode-input');
// Check the input.
this.setChecked(this.checked);
// Bind to the click on the input.
this.input.bind('click', (function(node) {
return function(event) {
// Set the checked state based on input.
node.checked = event.target.checked;
// Only expand/collapse and select children if auto select
// children is enabled.
if (params.autoSelectChildren) {
// Expand if deep loading. Collapse if unchecked.
if (!node.checked || params.deepLoad) {
node.expand(node.checked);
}
// Call the select method.
node.selectChildren(node.checked);
}
};
})(this));
// If this is a root item and we are not showing the root item, then
// just hide the input.
if (this.root && !params.showRoot) {
this.input.hide();
}
}
// Set the input left.
this.input.css('left', left + 'px');
}
return this.input;
};
/**
* Creates a node link.
*/
TreeNode.prototype.build_link = function(element) {
element.css('cursor', 'pointer').addClass('collapsed');
element.bind('click', {node: this}, function(event) {
event.preventDefault();
event.data.node.expand($(event.target).hasClass('collapsed'));
});
return element;
};
/**
* Build the span +/- symbol.
*/
TreeNode.prototype.build_span = function(left) {
// If we are showing the root item or we are not root, and we have
// children, show a +/- symbol.
if ((!this.root || this.showRoot) && this.has_children) {
this.span = this.build_link($(document.createElement('span')).attr({
'class': 'treeselect-expand'
}));
this.span.css('left', left + 'px');
}
return this.span;
};
/**
* Build the title link.
*/
TreeNode.prototype.build_title = function(left) {
// If there is a title, then build it.
if ((!this.root || this.showRoot) && this.title) {
// Create a node link.
this.nodeLink = $(document.createElement('a')).attr({
'class': 'treeselect-title',
'href': this.url,
'target': '_blank'
}).css('marginLeft', left + 'px').text(this.title);
// If this node has children, then it should be a link.
if (this.has_children) {
this.link = this.build_link(this.nodeLink.clone());
}
else {
this.link = $(document.createElement('div')).attr({
'class': 'treeselect-title'
}).css('marginLeft', left + 'px').text(this.title);
}
}
// Return the link.
return this.link;
};
/**
* Build the children.
*/
TreeNode.prototype.build_children = function(done) {
// Create the childlist element.
this.childlist = $();
// If this node has children.
if (this.children.length > 0) {
// Create the child list.
this.childlist = $(document.createElement('ul'));
// Set the odd state.
var odd = this.odd;
// Get the number of children.
var numChildren = this.children.length;
// Function to append children.
var appendChildren = function(treenode, index) {
return function() {
// Add the child tree to the list.
treenode.children[index].build(function(child) {
// Decrement the number of children loaded.
numChildren--;
// Append the child to the list.
treenode.childlist.append(child.display);
// If there are no more chlidren, then say we are done.
if (!numChildren) {
done.call(treenode, treenode.childlist);
}
});
};
};
// Now if there are children, iterate and build them.
for (var i in this.children) {
// Make sure the child is a valid object in the list.
if (this.children.hasOwnProperty(i)) {
// Set the child.
var child = this.children[i];
// Alternate the odd state.
odd = !odd;
// Get the checked value.
var isChecked = this.checked;
if (child.hasOwnProperty('checked')) {
isChecked = child.checked;
}
// Create a new TreeNode for this child.
this.children[i] = new TreeNode($.extend(child, {
level: this.level + 1,
odd: odd,
checked: isChecked,
exclude: this.exclude
}));
// Set timeout to help with recursion.
setTimeout(appendChildren(this, i), 2);
}
}
}
else {
// Call that we are done loading this child.
done.call(this, this.childlist);
}
};
/**
* Builds the DOM and the tree for this node.
*/
TreeNode.prototype.build = function(done) {
// Keep track of the left margin for each element.
var left = 5, elem = null;
// Create the list display.
if (this.display.length === 0) {
this.display = this.build_treenode();
}
else if (this.root) {
var treenode = this.build_treenode();
this.display.append(treenode);
this.display = treenode;
}
// Now append the input.
if ((this.input.length === 0) &&
(elem = this.build_input(left)) &&
(elem.length > 0)) {
// Add the input to the display.
this.display.append(elem);
left += params.colwidth;
}
// Now create the +/- sign if needed.
if (this.span.length === 0) {
this.display.append(this.build_span(left));
left += params.colwidth;
}
// Now append the node title.
if (this.link.length === 0) {
this.display.append(this.build_title(left));
}
// Called when the node is done building.
var onDone = function() {
// See if they wish to alter the build.
if (params.onbuild) {
params.onbuild(this);
}
// Create a search item.
this.searchItem = this.display.clone();
$('.treeselect-expand', this.searchItem).remove();
// If the search title is not a link, then make it one...
var searchTitle = $('div.treeselect-title', this.searchItem);
if (searchTitle.length > 0) {
searchTitle.replaceWith(this.nodeLink);
}
// See if they wish to hook into the postbuild process.
if (params.postbuild) {
params.postbuild(this);
}
// Check if this node is excluded, and hide if so.
if (typeof this.exclude[this.id] !== 'undefined') {
if ($('.treenode-input', this.display).length === 0) {
this.display.hide();
}
}
// If they wish to know when we are done building.
if (done) {
done.call(this, this);
}
};
// Append the children.
if (this.childlist.length === 0) {
this.build_children(function(children) {
if (children.length > 0) {
this.display.append(children);
}
onDone.call(this);
});
}
else {
onDone.call(this);
}
};
/**
* Returns the selectAll text if that applies to this node.
*/
TreeNode.prototype.getSelectAll = function() {
if (this.root && this.selectAll) {
return this.selectAllText;
}
return false;
};
/**
* Search this node for matching text.
*
* @param {string} text The text to search for.
* @param {function} callback Called with the results of this search.
*/
TreeNode.prototype.search = function(text, callback) {
// If no text was provided, then just return the root children.
if (!text) {
if (callback) {
callback(this.children, false);
}
}
else {
// Initialize our results.
var results = {};
// Convert the text to lowercase.
text = text.toLowerCase();
// See if they provided a search endpoint.
if (params.searcher) {
// Call the searcher for the new nodes.
params.searcher(this, text, function(nodes, getNode) {
// Get the number of nodes.
var numNodes = Object.keys(nodes).length;
// If no nodes were returned then return nothing.
if (numNodes === 0) {
callback(results, true);
}
// Called when the tree node is built.
var onBuilt = function(id) {
// Return the method to call when the node is built.
return function(treenode) {
// Decrement the counter.
numNodes--;
// Add the node to the results.
results[id] = treenode;
// If no more nodes are loading, then callback.
if (!numNodes) {
// Callback with the search results.
callback(results, true);
}
};
};
// Iterate through all the nodes.
for (var id in nodes) {
// Set the treenode.
var treenode = new TreeNode(getNode ? getNode(nodes[id]) : nodes[id]);
// Say this node is loaded.
treenode.nodeloaded = true;
// Add to the loaded nodes array.
loadedNodes[treenode.id] = treenode.id;
// Build the node.
treenode.build(onBuilt(id));
}
});
}
else {
// Load all nodes.
this.loadAll(function(node) {
// Callback with the results of this search.
if (callback) {
callback(results, true);
}
}, function(node) {
// If we are not the root node, and the text matches the title.
if (!node.root && node.title.toLowerCase().search(text) !== -1) {
// Add this to our search results.
results[node.id] = node;
}
}, true);
}
}
};
// Iterate through each instance.
return $(this).each(function() {
// Get the tree node parameters.
var treeParams = $.extend(params, {display: $(this)});
// Create a root tree node and load it.
var root = this.treenode = new TreeNode(treeParams, true);
// Add a select all link.
var selectAll = root.getSelectAll();
if (selectAll !== false && !root.showRoot) {
// See if the select all button should be checked.
var checked = false;
var default_value = params.default_value;
if (default_value.hasOwnProperty(root.value + '-include-below')) {
checked = true;
}
// Create an input element.
var inputElement = $(document.createElement('input')).attr({
'type': 'checkbox'
});
// Set the checked state.
inputElement.eq(0)[0].checked = checked;
// Bind to the click event.
inputElement.bind('click', function(event) {
root.selectChildren(event.target.checked);
});
// Add the input item to the root.
root.display.append(inputElement);
// If they provided select all text, add it here.
if (selectAll) {
var span = $(document.createElement('span')).attr({
'class': 'treeselect-select-all'
}).html(selectAll);
root.display.append(span);
}
}
// Create a loading span.
var initBusy = $(document.createElement('span')).addClass('treebusy');
root.display.append(initBusy.css('display', 'block'));
// Called when the root node is done loading.
var doneLoading = function() {
// Remove the init busy cursor.
initBusy.remove();
// Call the treeloaded params.
if (params.treeloaded) {
params.treeloaded(this);
}
};
// Load the node.
root.loadNode(function(node) {
// Check the length of children in this node.
if (node.children.length === 0) {
// If the root node does not have any children, then hide.
node.display.hide();
}
// Expand this root node.
node.expand(true);
// Select this node based on the default value.
node.select(node.checked);
// Set the defaults for all the children.
var defaults = node.checked;
if (!jQuery.isEmptyObject(params.default_value)) {
defaults = params.default_value;
}
// If there are defaults, then select the children with them.
if (defaults) {
// Select the children based on the defaults.
if (params.deepLoad) {
node.selectChildren(defaults, function() {
doneLoading.call(node);
});
}
else {
// When not deep loading, use selectDefaults to search for defaults
// using breadth first check.
node.selectDefaults(defaults, function() {
doneLoading.call(node);
});
}
}
else {
doneLoading.call(node);
}
});
// If the root element doesn't have children, then hide the treeselect.
if (!root.has_children) {
this.parentElement.style.display = 'none';
}
});
};
})(jQuery);