(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);