diff --git a/assets/controllers/elements/tree_controller.js b/assets/controllers/elements/tree_controller.js index 44a4ee34..6ed34d51 100644 --- a/assets/controllers/elements/tree_controller.js +++ b/assets/controllers/elements/tree_controller.js @@ -1,7 +1,9 @@ import {Controller} from "@hotwired/stimulus"; -import "patternfly-bootstrap-treeview/src/css/bootstrap-treeview.css" -import "patternfly-bootstrap-treeview"; +import "../../js/lib/bootstrap-treeview/src/css/bootstrap-treeview.css" +//import "../../js/lib/bootstrap-treeview/src/js/bootstrap-treeview.js" + +import BSTreeView from "../../ts_src/BSTreeView"; export default class extends Controller { static targets = [ "tree" ]; @@ -27,9 +29,9 @@ export default class extends Controller { //Fetch data and initialize tree this._getData() .then(this._fillTree.bind(this)) - .catch((err) => { + /*.catch((err) => { console.error("Could not load the tree data: " + err); - }); + });*/ } setData(data) { @@ -46,9 +48,7 @@ export default class extends Controller { //Get primary color from css variable const primary_color = getComputedStyle(document.documentElement).getPropertyValue('--bs-warning'); - const tree = this.treeTarget; - - $(tree).treeview({ + const tree = new BSTreeView(this.treeTarget, { data: data, enableLinks: true, showIcon: false, @@ -62,7 +62,7 @@ export default class extends Controller { let a = document.createElement('a'); a.setAttribute('href', data.href); a.innerHTML = ""; - $(tree).append(a); + document.body.appendChild(a); a.click(); a.remove(); } @@ -71,13 +71,13 @@ export default class extends Controller { expandIcon: "fas fa-plus fa-fw fa-treeview", collapseIcon: "fas fa-minus fa-fw fa-treeview" }) - .on('initialized', function () { + /*.on('initialized', function () { //Collapse all nodes after init $(this).treeview('collapseAll', {silent: true}); //Reveal the selected ones $(this).treeview('revealNode', [$(this).treeview('getSelected')]); - }); + });*/ } collapseAll() { diff --git a/assets/js/lib/bootstrap-treeview/src/css/bootstrap-treeview.css b/assets/js/lib/bootstrap-treeview/src/css/bootstrap-treeview.css new file mode 100644 index 00000000..35a92849 --- /dev/null +++ b/assets/js/lib/bootstrap-treeview/src/css/bootstrap-treeview.css @@ -0,0 +1,52 @@ +/* ========================================================= + * patternfly-bootstrap-treeview.css v2.1.0 + * ========================================================= + * Copyright 2013 Jonathan Miles + * Project URL : http://www.jondmiles.com/bootstrap-treeview + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= */ + +.treeview .list-group-item { + cursor: pointer; +} + +.treeview span.indent { + margin-left: 10px; + margin-right: 10px; +} + +.treeview span.icon { + width: 12px; + margin-right: 5px; +} + +.treeview .node-disabled { + color: silver; + cursor: not-allowed; +} + +.treeview .node-hidden { + display: none; +} + +.treeview span.image { + display: inline-block; + width: 12px; + height: 1.19em; + vertical-align: middle; + background-size: contain; + background-repeat: no-repeat; + margin-right: 5px; + line-height: 1em; +} diff --git a/assets/js/lib/bootstrap-treeview/src/js/bootstrap-treeview.js b/assets/js/lib/bootstrap-treeview/src/js/bootstrap-treeview.js new file mode 100644 index 00000000..048a80b9 --- /dev/null +++ b/assets/js/lib/bootstrap-treeview/src/js/bootstrap-treeview.js @@ -0,0 +1,1948 @@ +/* ========================================================= + * patternfly-bootstrap-treeview.js v2.1.0 + * ========================================================= + * Copyright 2013 Jonathan Miles + * Project URL : http://www.jondmiles.com/bootstrap-treeview + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ========================================================= */ + +;(function ($, window, document, undefined) { + + /*global jQuery, console*/ + + 'use strict'; + + var pluginName = 'treeview'; + + var _default = {}; + + _default.settings = { + + injectStyle: true, + + levels: 2, + + expandIcon: 'glyphicon glyphicon-plus', + collapseIcon: 'glyphicon glyphicon-minus', + loadingIcon: 'glyphicon glyphicon-hourglass', + emptyIcon: 'glyphicon', + nodeIcon: '', + selectedIcon: '', + checkedIcon: 'glyphicon glyphicon-check', + partiallyCheckedIcon: 'glyphicon glyphicon-expand', + uncheckedIcon: 'glyphicon glyphicon-unchecked', + tagsClass: 'badge', + + color: undefined, + backColor: undefined, + borderColor: undefined, + changedNodeColor: '#39A5DC', + onhoverColor: '#F5F5F5', + selectedColor: '#FFFFFF', + selectedBackColor: '#428bca', + searchResultColor: '#D9534F', + searchResultBackColor: undefined, + + highlightSelected: true, + highlightSearchResults: true, + showBorder: true, + showIcon: true, + showImage: false, + showCheckbox: false, + checkboxFirst: false, + highlightChanges: false, + showTags: false, + multiSelect: false, + preventUnselect: false, + allowReselect: false, + hierarchicalCheck: false, + propagateCheckEvent: false, + wrapNodeText: false, + + // Event handlers + onLoading: undefined, + onLoadingFailed: undefined, + onInitialized: undefined, + onNodeRendered: undefined, + onRendered: undefined, + onDestroyed: undefined, + + onNodeChecked: undefined, + onNodeCollapsed: undefined, + onNodeDisabled: undefined, + onNodeEnabled: undefined, + onNodeExpanded: undefined, + onNodeSelected: undefined, + onNodeUnchecked: undefined, + onNodeUnselected: undefined, + + onSearchComplete: undefined, + onSearchCleared: undefined + }; + + _default.options = { + silent: false, + ignoreChildren: false + }; + + _default.searchOptions = { + ignoreCase: true, + exactMatch: false, + revealResults: true + }; + + _default.dataUrl = { + method: 'GET', + dataType: 'json', + cache: false + }; + + var Tree = function (element, options) { + this.$element = $(element); + this._elementId = element.id; + this._styleId = this._elementId + '-style'; + + this._init(options); + + return { + + // Options (public access) + options: this._options, + + // Initialize / destroy methods + init: $.proxy(this._init, this), + remove: $.proxy(this._remove, this), + + // Query methods + findNodes: $.proxy(this.findNodes, this), + getNodes: $.proxy(this.getNodes, this), // todo document + test + getParents: $.proxy(this.getParents, this), + getSiblings: $.proxy(this.getSiblings, this), + getSelected: $.proxy(this.getSelected, this), + getUnselected: $.proxy(this.getUnselected, this), + getExpanded: $.proxy(this.getExpanded, this), + getCollapsed: $.proxy(this.getCollapsed, this), + getChecked: $.proxy(this.getChecked, this), + getUnchecked: $.proxy(this.getUnchecked, this), + getDisabled: $.proxy(this.getDisabled, this), + getEnabled: $.proxy(this.getEnabled, this), + + // Tree manipulation methods + addNode: $.proxy(this.addNode, this), + addNodeAfter: $.proxy(this.addNodeAfter, this), + addNodeBefore: $.proxy(this.addNodeBefore, this), + removeNode: $.proxy(this.removeNode, this), + updateNode: $.proxy(this.updateNode, this), + + // Select methods + selectNode: $.proxy(this.selectNode, this), + unselectNode: $.proxy(this.unselectNode, this), + toggleNodeSelected: $.proxy(this.toggleNodeSelected, this), + + // Expand / collapse methods + collapseAll: $.proxy(this.collapseAll, this), + collapseNode: $.proxy(this.collapseNode, this), + expandAll: $.proxy(this.expandAll, this), + expandNode: $.proxy(this.expandNode, this), + toggleNodeExpanded: $.proxy(this.toggleNodeExpanded, this), + revealNode: $.proxy(this.revealNode, this), + + // Check / uncheck methods + checkAll: $.proxy(this.checkAll, this), + checkNode: $.proxy(this.checkNode, this), + uncheckAll: $.proxy(this.uncheckAll, this), + uncheckNode: $.proxy(this.uncheckNode, this), + toggleNodeChecked: $.proxy(this.toggleNodeChecked, this), + unmarkCheckboxChanges: $.proxy(this.unmarkCheckboxChanges, this), + + // Disable / enable methods + disableAll: $.proxy(this.disableAll, this), + disableNode: $.proxy(this.disableNode, this), + enableAll: $.proxy(this.enableAll, this), + enableNode: $.proxy(this.enableNode, this), + toggleNodeDisabled: $.proxy(this.toggleNodeDisabled, this), + + // Search methods + search: $.proxy(this.search, this), + clearSearch: $.proxy(this.clearSearch, this) + }; + }; + + Tree.prototype._init = function (options) { + this._tree = []; + this._initialized = false; + + this._options = $.extend({}, _default.settings, options); + + // Cache empty icon DOM template + this._template.icon.empty.addClass(this._options.emptyIcon); + + this._destroy(); + this._subscribeEvents(); + + this._triggerEvent('loading', null, _default.options); + this._load(options) + .then($.proxy(function (data) { + // load done + return this._tree = $.extend(true, [], data); + }, this), $.proxy(function (error) { + // load fail + this._triggerEvent('loadingFailed', error, _default.options); + }, this)) + .then($.proxy(function (treeData) { + // initialize data + return this._setInitialStates({ nodes: treeData }, 0); + }, this)) + .then($.proxy(function () { + // render to DOM + this._render(); + }, this)); + }; + + Tree.prototype._load = function (options) { + var done = new $.Deferred(); + if (options.data) { + this._loadLocalData(options, done); + } else if (options.dataUrl) { + this._loadRemoteData(options, done); + } + return done.promise(); + }; + + Tree.prototype._loadRemoteData = function (options, done) { + $.ajax($.extend(true, {}, _default.dataUrl, options.dataUrl)) + .done(function (data) { + done.resolve(data); + }) + .fail(function (xhr, status, error) { + done.reject(error); + }); + }; + + Tree.prototype._loadLocalData = function (options, done) { + done.resolve((typeof options.data === 'string') ? + JSON.parse(options.data) : + $.extend(true, [], options.data)); + }; + + Tree.prototype._remove = function () { + this._destroy(); + $.removeData(this, pluginName); + $('#' + this._styleId).remove(); + }; + + Tree.prototype._destroy = function () { + if (!this._initialized) return; + this._initialized = false; + + this._triggerEvent('destroyed', null, _default.options); + + // Switch off events + this._unsubscribeEvents(); + + // Tear down + this.$wrapper.remove(); + this.$wrapper = null; + }; + + Tree.prototype._unsubscribeEvents = function () { + this.$element.off('loading'); + this.$element.off('loadingFailed'); + this.$element.off('initialized'); + this.$element.off('nodeRendered'); + this.$element.off('rendered'); + this.$element.off('destroyed'); + this.$element.off('click'); + this.$element.off('nodeChecked'); + this.$element.off('nodeCollapsed'); + this.$element.off('nodeDisabled'); + this.$element.off('nodeEnabled'); + this.$element.off('nodeExpanded'); + this.$element.off('nodeSelected'); + this.$element.off('nodeUnchecked'); + this.$element.off('nodeUnselected'); + this.$element.off('searchComplete'); + this.$element.off('searchCleared'); + }; + + Tree.prototype._subscribeEvents = function () { + this._unsubscribeEvents(); + + if (typeof (this._options.onLoading) === 'function') { + this.$element.on('loading', this._options.onLoading); + } + + if (typeof (this._options.onLoadingFailed) === 'function') { + this.$element.on('loadingFailed', this._options.onLoadingFailed); + } + + if (typeof (this._options.onInitialized) === 'function') { + this.$element.on('initialized', this._options.onInitialized); + } + + if (typeof (this._options.onNodeRendered) === 'function') { + this.$element.on('nodeRendered', this._options.onNodeRendered); + } + + if (typeof (this._options.onRendered) === 'function') { + this.$element.on('rendered', this._options.onRendered); + } + + if (typeof (this._options.onDestroyed) === 'function') { + this.$element.on('destroyed', this._options.onDestroyed); + } + + this.$element.on('click', $.proxy(this._clickHandler, this)); + + if (typeof (this._options.onNodeChecked) === 'function') { + this.$element.on('nodeChecked', this._options.onNodeChecked); + } + + if (typeof (this._options.onNodeCollapsed) === 'function') { + this.$element.on('nodeCollapsed', this._options.onNodeCollapsed); + } + + if (typeof (this._options.onNodeDisabled) === 'function') { + this.$element.on('nodeDisabled', this._options.onNodeDisabled); + } + + if (typeof (this._options.onNodeEnabled) === 'function') { + this.$element.on('nodeEnabled', this._options.onNodeEnabled); + } + + if (typeof (this._options.onNodeExpanded) === 'function') { + this.$element.on('nodeExpanded', this._options.onNodeExpanded); + } + + if (typeof (this._options.onNodeSelected) === 'function') { + this.$element.on('nodeSelected', this._options.onNodeSelected); + } + + if (typeof (this._options.onNodeUnchecked) === 'function') { + this.$element.on('nodeUnchecked', this._options.onNodeUnchecked); + } + + if (typeof (this._options.onNodeUnselected) === 'function') { + this.$element.on('nodeUnselected', this._options.onNodeUnselected); + } + + if (typeof (this._options.onSearchComplete) === 'function') { + this.$element.on('searchComplete', this._options.onSearchComplete); + } + + if (typeof (this._options.onSearchCleared) === 'function') { + this.$element.on('searchCleared', this._options.onSearchCleared); + } + }; + + Tree.prototype._triggerEvent = function (event, data, options) { + if (options && !options.silent) { + this.$element.trigger(event, $.extend(true, {}, data)); + } + } + + /* + Recurse the tree structure and ensure all nodes have + valid initial states. User defined states will be preserved. + For performance we also take this opportunity to + index nodes in a flattened ordered structure + */ + Tree.prototype._setInitialStates = function (node, level) { + this._nodes = {}; + return $.when.apply(this, this._setInitialState(node, level)) + .done($.proxy(function () { + this._orderedNodes = this._sortNodes(); + this._inheritCheckboxChanges(); + this._triggerEvent('initialized', this._orderedNodes, _default.options); + return; + }, this)); + }; + + Tree.prototype._setInitialState = function (node, level, done) { + if (!node.nodes) return; + level += 1; + done = done || []; + + var parent = node; + $.each(node.nodes, $.proxy(function (index, node) { + var deferred = new $.Deferred(); + done.push(deferred.promise()); + + // level : hierarchical tree level, starts at 1 + node.level = level; + + // index : relative to siblings + node.index = index; + + // nodeId : unique, hierarchical identifier + node.nodeId = (parent && parent.nodeId) ? + parent.nodeId + '.' + node.index : + (level - 1) + '.' + node.index; + + // parentId : transversing up the tree + node.parentId = parent.nodeId; + + // if not provided set selectable default value + if (!node.hasOwnProperty('selectable')) { + node.selectable = true; + } + + // if not provided set checkable default value + if (!node.hasOwnProperty('checkable')) { + node.checkable = true; + } + + // where provided we should preserve states + node.state = node.state || {}; + + // set checked state; unless set always false + if (!node.state.hasOwnProperty('checked')) { + node.state.checked = false; + } + + // convert the undefined string if hierarchical checks are enabled + if (this._options.hierarchicalCheck && node.state.checked === 'undefined') { + node.state.checked = undefined; + } + + // set enabled state; unless set always false + if (!node.state.hasOwnProperty('disabled')) { + node.state.disabled = false; + } + + // set expanded state; if not provided based on levels + if (!node.state.hasOwnProperty('expanded')) { + if (!node.state.disabled && + (level < this._options.levels) && + (node.nodes && node.nodes.length > 0)) { + node.state.expanded = true; + } + else { + node.state.expanded = false; + } + } + + // set selected state; unless set always false + if (!node.state.hasOwnProperty('selected')) { + node.state.selected = false; + } + + // set visible state; based parent state plus levels + if ((parent && parent.state && parent.state.expanded) || + (level <= this._options.levels)) { + node.state.visible = true; + } + else { + node.state.visible = false; + } + + // recurse child nodes and transverse the tree, depth-first + if (node.nodes) { + if (node.nodes.length > 0) { + this._setInitialState(node, level, done); + } + else { + delete node.nodes; + } + } + + // add / update indexed collection + this._nodes[node.nodeId] = node; + + // mark task as complete + deferred.resolve(); + }, this)); + + return done; + }; + + Tree.prototype._sortNodes = function () { + return $.map(Object.keys(this._nodes).sort(function (a, b) { + if (a === b) return 0; + var a = a.split('.').map(function (level) { return parseInt(level); }); + var b = b.split('.').map(function (level) { return parseInt(level); }); + + var c = Math.max(a.length, b.length); + for (var i=0; i 0) return +1; + if (a[i] - b[i] < 0) return -1; + }; + + }), $.proxy(function (value, index) { + return this._nodes[value]; + }, this)); + }; + + Tree.prototype._clickHandler = function (event) { + + var target = $(event.target); + var node = this.targetNode(target); + if (!node || node.state.disabled) return; + + var classList = target.attr('class') ? target.attr('class').split(' ') : []; + if ((classList.indexOf('expand-icon') !== -1)) { + this._toggleExpanded(node, $.extend({}, _default.options)); + } + else if ((classList.indexOf('check-icon') !== -1)) { + if (node.checkable) { + this._toggleChecked(node, $.extend({}, _default.options)); + } + } + else { + if (node.selectable) { + this._toggleSelected(node, $.extend({}, _default.options)); + } else { + this._toggleExpanded(node, $.extend({}, _default.options)); + } + } + }; + + // Looks up the DOM for the closest parent list item to retrieve the + // data attribute nodeid, which is used to lookup the node in the flattened structure. + Tree.prototype.targetNode = function (target) { + var nodeId = target.closest('li.list-group-item').attr('data-nodeId'); + var node = this._nodes[nodeId]; + if (!node) { + console.log('Error: node does not exist'); + } + return node; + }; + + Tree.prototype._toggleExpanded = function (node, options) { + if (!node) return; + + // Lazy-load the child nodes if possible + if (typeof(this._options.lazyLoad) === 'function' && node.lazyLoad) { + this._lazyLoad(node); + } else { + this._setExpanded(node, !node.state.expanded, options); + } + }; + + Tree.prototype._lazyLoad = function (node) { + // Show a different icon while loading the child nodes + node.$el.children('span.expand-icon') + .removeClass(this._options.expandIcon) + .addClass(this._options.loadingIcon); + + var _this = this; + this._options.lazyLoad(node, function (nodes) { + // Adding the node will expand its parent automatically + _this.addNode(nodes, node); + }); + // Only the first expand should do a lazy-load + delete node.lazyLoad; + }; + + Tree.prototype._setExpanded = function (node, state, options) { + + // We never pass options when rendering, so the only time + // we need to validate state is from user interaction + if (options && state === node.state.expanded) return; + + if (state && node.nodes) { + + // Set node state + node.state.expanded = true; + + // Set element + if (node.$el) { + node.$el.children('span.expand-icon') + .removeClass(this._options.expandIcon) + .removeClass(this._options.loadingIcon) + .addClass(this._options.collapseIcon); + } + + // Expand children + if (node.nodes && options) { + $.each(node.nodes, $.proxy(function (index, node) { + this._setVisible(node, true, options); + }, this)); + } + + // Optionally trigger event + this._triggerEvent('nodeExpanded', node, options); + } + else if (!state) { + + // Set node state + node.state.expanded = false; + + // Set element + if (node.$el) { + node.$el.children('span.expand-icon') + .removeClass(this._options.collapseIcon) + .addClass(this._options.expandIcon); + } + + // Collapse children + if (node.nodes && options) { + $.each(node.nodes, $.proxy(function (index, node) { + this._setVisible(node, false, options); + this._setExpanded(node, false, options); + }, this)); + } + + // Optionally trigger event + this._triggerEvent('nodeCollapsed', node, options); + } + }; + + Tree.prototype._setVisible = function (node, state, options) { + + if (options && state === node.state.visible) return; + + if (state) { + + // Set node state + node.state.visible = true; + + // Set element + if (node.$el) { + node.$el.removeClass('node-hidden'); + } + } + else { + + // Set node state to unchecked + node.state.visible = false; + + // Set element + if (node.$el) { + node.$el.addClass('node-hidden'); + } + } + }; + + Tree.prototype._toggleSelected = function (node, options) { + if (!node) return; + this._setSelected(node, !node.state.selected, options); + return this; + }; + + Tree.prototype._setSelected = function (node, state, options) { + + // We never pass options when rendering, so the only time + // we need to validate state is from user interaction + if (options && (state === node.state.selected)) return; + + if (state) { + + // If multiSelect false, unselect previously selected + if (!this._options.multiSelect) { + $.each(this._findNodes('true', 'state.selected'), $.proxy(function (index, node) { + this._setSelected(node, false, $.extend(options, {unselecting: true})); + }, this)); + } + + // Set node state + node.state.selected = true; + + // Set element + if (node.$el) { + node.$el.addClass('node-selected'); + + if (node.selectedIcon || this._options.selectedIcon) { + node.$el.children('span.node-icon') + .removeClass(node.icon || this._options.nodeIcon) + .addClass(node.selectedIcon || this._options.selectedIcon); + } + } + + // Optionally trigger event + this._triggerEvent('nodeSelected', node, options); + } + else { + + // If preventUnselect true + only one remaining selection, disable unselect + if (this._options.preventUnselect && + (options && !options.unselecting) && + (this._findNodes('true', 'state.selected').length === 1)) { + // Fire the nodeSelected event if reselection is allowed + if (this._options.allowReselect) { + this._triggerEvent('nodeSelected', node, options); + } + return this; + } + + // Set node state + node.state.selected = false; + + // Set element + if (node.$el) { + node.$el.removeClass('node-selected'); + + if (node.selectedIcon || this._options.selectedIcon) { + node.$el.children('span.node-icon') + .removeClass(node.selectedIcon || this._options.selectedIcon) + .addClass(node.icon || this._options.nodeIcon); + } + } + + // Optionally trigger event + this._triggerEvent('nodeUnselected', node, options); + } + + return this; + }; + + Tree.prototype._inheritCheckboxChanges = function () { + if (this._options.showCheckbox && this._options.highlightChanges) { + this._checkedNodes = $.grep(this._orderedNodes, function (node) { + return node.state.checked; + }); + } + }; + + Tree.prototype._toggleChecked = function (node, options) { + if (!node) return; + + if (this._options.hierarchicalCheck) { + // Event propagation to the parent/child nodes + var childOptions = $.extend({}, options, {silent: options.silent || !this._options.propagateCheckEvent}); + + var state, currentNode = node; + // Temporarily swap the tree state + node.state.checked = !node.state.checked; + + // Iterate through each parent node + while (currentNode = this._nodes[currentNode.parentId]) { + + // Calculate the state + state = currentNode.nodes.reduce(function (acc, curr) { + return (acc === curr.state.checked) ? acc : undefined; + }, currentNode.nodes[0].state.checked); + + // Set the state + this._setChecked(currentNode, state, childOptions); + } + + if (node.nodes && node.nodes.length > 0) { + // Copy the content of the array + var child, children = node.nodes.slice(); + // Iterate through each child node + while (children && children.length > 0) { + child = children.pop(); + + // Set the state + this._setChecked(child, node.state.checked, childOptions); + + // Append children to the end of the list + if (child.nodes && child.nodes.length > 0) { + children = children.concat(child.nodes.slice()); + } + } + } + // Swap back the tree state + node.state.checked = !node.state.checked; + } + + this._setChecked(node, !node.state.checked, options); + }; + + Tree.prototype._setChecked = function (node, state, options) { + + // We never pass options when rendering, so the only time + // we need to validate state is from user interaction + if (options && state === node.state.checked) return; + + // Highlight the node if its checkbox has unsaved changes + if (this._options.highlightChanges) { + node.$el.toggleClass('node-check-changed', (this._checkedNodes.indexOf(node) == -1) == state); + } + + if (state) { + + // Set node state + node.state.checked = true; + + // Set element + if (node.$el) { + node.$el.addClass('node-checked').removeClass('node-checked-partial'); + node.$el.children('span.check-icon') + .removeClass(this._options.uncheckedIcon) + .removeClass(this._options.partiallyCheckedIcon) + .addClass(this._options.checkedIcon); + } + + // Optionally trigger event + this._triggerEvent('nodeChecked', node, options); + } + else if (state === undefined && this._options.hierarchicalCheck) { + + // Set node state to partially checked + node.state.checked = undefined; + + // Set element + if (node.$el) { + node.$el.addClass('node-checked-partial').removeClass('node-checked'); + node.$el.children('span.check-icon') + .removeClass(this._options.uncheckedIcon) + .removeClass(this._options.checkedIcon) + .addClass(this._options.partiallyCheckedIcon); + } + + // Optionally trigger event, partially checked is technically unchecked + this._triggerEvent('nodeUnchecked', node, options); + } else { + + // Set node state to unchecked + node.state.checked = false; + + // Set element + if (node.$el) { + node.$el.removeClass('node-checked node-checked-partial'); + node.$el.children('span.check-icon') + .removeClass(this._options.checkedIcon) + .removeClass(this._options.partiallyCheckedIcon) + .addClass(this._options.uncheckedIcon); + } + + // Optionally trigger event + this._triggerEvent('nodeUnchecked', node, options); + } + }; + + Tree.prototype._setDisabled = function (node, state, options) { + + // We never pass options when rendering, so the only time + // we need to validate state is from user interaction + if (options && state === node.state.disabled) return; + + if (state) { + + // Set node state to disabled + node.state.disabled = true; + + // Disable all other states + if (options && !options.keepState) { + this._setSelected(node, false, options); + this._setChecked(node, false, options); + this._setExpanded(node, false, options); + } + + // Set element + if (node.$el) { + node.$el.addClass('node-disabled'); + } + + // Optionally trigger event + this._triggerEvent('nodeDisabled', node, options); + } + else { + + // Set node state to enabled + node.state.disabled = false; + + // Set element + if (node.$el) { + node.$el.removeClass('node-disabled'); + } + + // Optionally trigger event + this._triggerEvent('nodeEnabled', node, options); + } + }; + + Tree.prototype._setSearchResult = function (node, state, options) { + if (options && state === node.searchResult) return; + + if (state) { + + node.searchResult = true; + + if (node.$el) { + node.$el.addClass('node-result'); + } + } + else { + + node.searchResult = false; + + if (node.$el) { + node.$el.removeClass('node-result'); + } + } + }; + + Tree.prototype._render = function () { + if (!this._initialized) { + + // Setup first time only components + this.$wrapper = this._template.tree.clone(); + this.$element.empty() + .addClass(pluginName) + .append(this.$wrapper); + + this._injectStyle(); + + this._initialized = true; + } + + var previousNode; + $.each(this._orderedNodes, $.proxy(function (id, node) { + this._renderNode(node, previousNode); + previousNode = node; + }, this)); + + this._triggerEvent('rendered', this._orderedNodes, _default.options); + }; + + Tree.prototype._renderNode = function (node, previousNode) { + if (!node) return; + + if (!node.$el) { + node.$el = this._newNodeEl(node, previousNode) + .addClass('node-' + this._elementId); + } + else { + node.$el.empty(); + } + + // Append .classes to the node + node.$el.addClass(node.class); + + // Set the #id of the node if specified + if (node.id) { + node.$el.attr('id', node.id); + } + + // Append custom data- attributes to the node + if (node.dataAttr) { + $.each(node.dataAttr, function (key, value) { + node.$el.attr('data-' + key, value); + }); + } + + // Set / update nodeid; it can change as a result of addNode etc. + node.$el.attr('data-nodeId', node.nodeId); + + // Set the tooltip attribute if present + if (node.tooltip) { + node.$el.attr('title', node.tooltip); + } + + // Add indent/spacer to mimic tree structure + for (var i = 0; i < (node.level - 1); i++) { + node.$el.append(this._template.indent.clone()); + } + + // Add expand / collapse or empty spacer icons + node.$el + .append( + node.nodes || node.lazyLoad ? this._template.icon.expand.clone() : this._template.icon.empty.clone() + ); + + // Add checkbox and node icons + if (this._options.checkboxFirst) { + this._addCheckbox(node); + this._addIcon(node); + this._addImage(node); + } else { + this._addIcon(node); + this._addImage(node); + this._addCheckbox(node); + } + + // Add text + if (this._options.wrapNodeText) { + var wrapper = this._template.text.clone(); + node.$el.append(wrapper); + wrapper.append(node.text); + } else { + node.$el.append(node.text); + } + + // Add tags as badges + if (this._options.showTags && node.tags) { + $.each(node.tags, $.proxy(function addTag(id, tag) { + node.$el + .append(this._template.badge.clone() + .addClass( + (typeof tag === 'object' ? tag.class : undefined) + || node.tagsClass + || this._options.tagsClass + ) + .append( + (typeof tag === 'object' ? tag.text : undefined) + || tag + ) + ); + }, this)); + } + + // Set various node states + this._setSelected(node, node.state.selected); + this._setChecked(node, node.state.checked); + this._setSearchResult(node, node.searchResult); + this._setExpanded(node, node.state.expanded); + this._setDisabled(node, node.state.disabled); + this._setVisible(node, node.state.visible); + + // Trigger nodeRendered event + this._triggerEvent('nodeRendered', node, _default.options); + }; + + // Add checkable icon + Tree.prototype._addCheckbox = function (node) { + if (this._options.showCheckbox && (node.hideCheckbox === undefined || node.hideCheckbox === false)) { + node.$el + .append(this._template.icon.check.clone()); + } + } + + // Add node icon + Tree.prototype._addIcon = function (node) { + if (this._options.showIcon && !(this._options.showImage && node.image)) { + node.$el + .append(this._template.icon.node.clone() + .addClass(node.icon || this._options.nodeIcon) + ); + } + } + + Tree.prototype._addImage = function (node) { + if (this._options.showImage && node.image) { + node.$el + .append(this._template.image.clone() + .addClass('node-image') + .css('background-image', "url('" + node.image + "')") + ); + } + } + + // Creates a new node element from template and + // ensures the template is inserted at the correct position + Tree.prototype._newNodeEl = function (node, previousNode) { + var $el = this._template.node.clone(); + + if (previousNode) { + // typical usage, as nodes are rendered in + // sort order we add after the previous element + previousNode.$el.after($el); + } else { + // we use prepend instead of append, + // to cater for root inserts i.e. nodeId 0.0 + this.$wrapper.prepend($el); + } + + return $el; + }; + + // Recursively remove node elements from DOM + Tree.prototype._removeNodeEl = function (node) { + if (!node) return; + + if (node.nodes) { + $.each(node.nodes, $.proxy(function (index, node) { + this._removeNodeEl(node); + }, this)); + } + node.$el.remove(); + }; + + // Expand node, rendering it's immediate children + Tree.prototype._expandNode = function (node) { + if (!node.nodes) return; + + $.each(node.nodes.slice(0).reverse(), $.proxy(function (index, childNode) { + childNode.level = node.level + 1; + this._renderNode(childNode, node.$el); + }, this)); + }; + + // Add inline style into head + Tree.prototype._injectStyle = function () { + if (this._options.injectStyle && !document.getElementById(this._styleId)) { + $('').appendTo('head'); + } + }; + + // Construct trees style based on user options + Tree.prototype._buildStyle = function () { + var style = '.node-' + this._elementId + '{'; + + // Basic bootstrap style overrides + if (this._options.color) { + style += 'color:' + this._options.color + ';'; + } + + if (this._options.backColor) { + style += 'background-color:' + this._options.backColor + ';'; + } + + if (!this._options.showBorder) { + style += 'border:none;'; + } + else if (this._options.borderColor) { + style += 'border:1px solid ' + this._options.borderColor + ';'; + } + style += '}'; + + if (this._options.onhoverColor) { + style += '.node-' + this._elementId + ':not(.node-disabled):hover{' + + 'background-color:' + this._options.onhoverColor + ';' + + '}'; + } + + // Style search results + if (this._options.highlightSearchResults && (this._options.searchResultColor || this._options.searchResultBackColor)) { + + var innerStyle = '' + if (this._options.searchResultColor) { + innerStyle += 'color:' + this._options.searchResultColor + ';'; + } + if (this._options.searchResultBackColor) { + innerStyle += 'background-color:' + this._options.searchResultBackColor + ';'; + } + + style += '.node-' + this._elementId + '.node-result{' + innerStyle + '}'; + style += '.node-' + this._elementId + '.node-result:hover{' + innerStyle + '}'; + } + + // Style selected nodes + if (this._options.highlightSelected && (this._options.selectedColor || this._options.selectedBackColor)) { + + var innerStyle = '' + if (this._options.selectedColor) { + innerStyle += 'color:' + this._options.selectedColor + ';'; + } + if (this._options.selectedBackColor) { + innerStyle += 'background-color:' + this._options.selectedBackColor + ';'; + } + + style += '.node-' + this._elementId + '.node-selected{' + innerStyle + '}'; + style += '.node-' + this._elementId + '.node-selected:hover{' + innerStyle + '}'; + } + + // Style changed nodes + if (this._options.highlightChanges) { + var innerStyle = 'color: ' + this._options.changedNodeColor + ';'; + style += '.node-' + this._elementId + '.node-check-changed{' + innerStyle + '}'; + } + + // Node level style overrides + $.each(this._orderedNodes, $.proxy(function (index, node) { + if (node.color || node.backColor) { + var innerStyle = ''; + if (node.color) { + innerStyle += 'color:' + node.color + ';'; + } + if (node.backColor) { + innerStyle += 'background-color:' + node.backColor + ';'; + } + style += '.node-' + this._elementId + '[data-nodeId="' + node.nodeId + '"]{' + innerStyle + '}'; + } + + if (node.iconColor) { + var innerStyle = 'color:' + node.iconColor + ';'; + style += '.node-' + this._elementId + '[data-nodeId="' + node.nodeId + '"] .node-icon{' + innerStyle + '}'; + } + }, this)); + + return this._css + style; + }; + + Tree.prototype._template = { + tree: $(''), + node: $('
  • '), + indent: $(''), + icon: { + node: $(''), + expand: $(''), + check: $(''), + empty: $('') + }, + image: $(''), + badge: $(''), + text: $('') + }; + + Tree.prototype._css = '.treeview .list-group-item{cursor:pointer}.treeview span.indent{margin-left:10px;margin-right:10px}.treeview span.icon{width:12px;margin-right:5px}.treeview .node-disabled{color:silver;cursor:not-allowed}' + + + /** + Returns an array of matching node objects. + @param {String} pattern - A pattern to match against a given field + @return {String} field - Field to query pattern against + */ + Tree.prototype.findNodes = function (pattern, field) { + return this._findNodes(pattern, field); + }; + + + /** + Returns an ordered aarray of node objects. + @return {Array} nodes - An array of all nodes + */ + Tree.prototype.getNodes = function () { + return this._orderedNodes; + }; + + /** + Returns parent nodes for given nodes, if valid otherwise returns undefined. + @param {Array} nodes - An array of nodes + @returns {Array} nodes - An array of parent nodes + */ + Tree.prototype.getParents = function (nodes) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + var parentNodes = []; + $.each(nodes, $.proxy(function (index, node) { + var parentNode = node.parentId ? this._nodes[node.parentId] : false; + if (parentNode) { + parentNodes.push(parentNode); + } + }, this)); + return parentNodes; + }; + + /** + Returns an array of sibling nodes for given nodes, if valid otherwise returns undefined. + @param {Array} nodes - An array of nodes + @returns {Array} nodes - An array of sibling nodes + */ + Tree.prototype.getSiblings = function (nodes) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + var siblingNodes = []; + $.each(nodes, $.proxy(function (index, node) { + var parent = this.getParents([node]); + var nodes = parent[0] ? parent[0].nodes : this._tree; + siblingNodes = nodes.filter(function (obj) { + return obj.nodeId !== node.nodeId; + }); + }, this)); + + // flatten possible nested array before returning + return $.map(siblingNodes, function (obj) { + return obj; + }); + }; + + /** + Returns an array of selected nodes. + @returns {Array} nodes - Selected nodes + */ + Tree.prototype.getSelected = function () { + return this._findNodes('true', 'state.selected'); + }; + + /** + Returns an array of unselected nodes. + @returns {Array} nodes - Unselected nodes + */ + Tree.prototype.getUnselected = function () { + return this._findNodes('false', 'state.selected'); + }; + + /** + Returns an array of expanded nodes. + @returns {Array} nodes - Expanded nodes + */ + Tree.prototype.getExpanded = function () { + return this._findNodes('true', 'state.expanded'); + }; + + /** + Returns an array of collapsed nodes. + @returns {Array} nodes - Collapsed nodes + */ + Tree.prototype.getCollapsed = function () { + return this._findNodes('false', 'state.expanded'); + }; + + /** + Returns an array of checked nodes. + @returns {Array} nodes - Checked nodes + */ + Tree.prototype.getChecked = function () { + return this._findNodes('true', 'state.checked'); + }; + + /** + Returns an array of unchecked nodes. + @returns {Array} nodes - Unchecked nodes + */ + Tree.prototype.getUnchecked = function () { + return this._findNodes('false', 'state.checked'); + }; + + /** + Returns an array of disabled nodes. + @returns {Array} nodes - Disabled nodes + */ + Tree.prototype.getDisabled = function () { + return this._findNodes('true', 'state.disabled'); + }; + + /** + Returns an array of enabled nodes. + @returns {Array} nodes - Enabled nodes + */ + Tree.prototype.getEnabled = function () { + return this._findNodes('false', 'state.disabled'); + }; + + + /** + Add nodes to the tree. + @param {Array} nodes - An array of nodes to add + @param {optional Object} parentNode - The node to which nodes will be added as children + @param {optional number} index - Zero based insert index + @param {optional Object} options + */ + Tree.prototype.addNode = function (nodes, parentNode, index, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + if (parentNode instanceof Array) { + parentNode = parentNode[0]; + } + + options = $.extend({}, _default.options, options); + + // identify target nodes; either the tree's root or a parent's child nodes + var targetNodes; + if (parentNode && parentNode.nodes) { + targetNodes = parentNode.nodes; + } else if (parentNode) { + targetNodes = parentNode.nodes = []; + } else { + targetNodes = this._tree; + } + + // inserting nodes at specified positions + $.each(nodes, $.proxy(function (i, node) { + var insertIndex = (typeof(index) === 'number') ? (index + i) : (targetNodes.length + 1); + targetNodes.splice(insertIndex, 0, node); + }, this)); + + // initialize new state and render changes + this._setInitialStates({nodes: this._tree}, 0) + .done($.proxy(function () { + if (parentNode && !parentNode.state.expanded) { + this._setExpanded(parentNode, true, options); + } + this._render(); + }, this)); + } + + /** + Add nodes to the tree after given node. + @param {Array} nodes - An array of nodes to add + @param {Object} node - The node to which nodes will be added after + @param {optional Object} options + */ + Tree.prototype.addNodeAfter = function (nodes, node, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + if (node instanceof Array) { + node = node[0]; + } + + options = $.extend({}, _default.options, options); + + this.addNode(nodes, this.getParents(node)[0], (node.index + 1), options); + } + + /** + Add nodes to the tree before given node. + @param {Array} nodes - An array of nodes to add + @param {Object} node - The node to which nodes will be added before + @param {optional Object} options + */ + Tree.prototype.addNodeBefore = function (nodes, node, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + if (node instanceof Array) { + node = node[0]; + } + + options = $.extend({}, _default.options, options); + + this.addNode(nodes, this.getParents(node)[0], node.index, options); + } + + /** + Removes given nodes from the tree. + @param {Array} nodes - An array of nodes to remove + @param {optional Object} options + */ + Tree.prototype.removeNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + var targetNodes, parentNode; + $.each(nodes, $.proxy(function (index, node) { + + // remove nodes from tree + parentNode = this._nodes[node.parentId]; + if (parentNode) { + targetNodes = parentNode.nodes; + } else { + targetNodes = this._tree; + } + targetNodes.splice(node.index, 1); + + // remove node from DOM + this._removeNodeEl(node); + }, this)); + + // initialize new state and render changes + this._setInitialStates({nodes: this._tree}, 0) + .done(this._render.bind(this)); + }; + + /** + Updates / replaces a given tree node + @param {Object} node - A single node to be replaced + @param {Object} newNode - THe replacement node + @param {optional Object} options + */ + Tree.prototype.updateNode = function (node, newNode, options) { + if (node instanceof Array) { + node = node[0]; + } + + options = $.extend({}, _default.options, options); + + // insert new node + var targetNodes; + var parentNode = this._nodes[node.parentId]; + if (parentNode) { + targetNodes = parentNode.nodes; + } else { + targetNodes = this._tree; + } + targetNodes.splice(node.index, 1, newNode); + + // remove old node from DOM + this._removeNodeEl(node); + + // initialize new state and render changes + this._setInitialStates({nodes: this._tree}, 0) + .done(this._render.bind(this)); + }; + + + /** + Selects given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.selectNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setSelected(node, true, options); + }, this)); + }; + + /** + Unselects given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.unselectNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setSelected(node, false, options); + }, this)); + }; + + /** + Toggles a node selected state; selecting if unselected, unselecting if selected. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.toggleNodeSelected = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._toggleSelected(node, options); + }, this)); + }; + + + /** + Collapse all tree nodes + @param {optional Object} options + */ + Tree.prototype.collapseAll = function (options) { + options = $.extend({}, _default.options, options); + options.levels = options.levels || 999; + this.collapseNode(this._tree, options); + }; + + /** + Collapse a given tree node + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.collapseNode = function (nodes, options) { + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setExpanded(node, false, options); + }, this)); + }; + + /** + Expand all tree nodes + @param {optional Object} options + */ + Tree.prototype.expandAll = function (options) { + options = $.extend({}, _default.options, options); + options.levels = options.levels || 999; + this.expandNode(this._tree, options); + }; + + /** + Expand given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.expandNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + // Do not re-expand already expanded nodes + if (node.state.expanded) return; + + if (typeof(this._options.lazyLoad) === 'function' && node.lazyLoad) { + this._lazyLoad(node); + } + + this._setExpanded(node, true, options); + if (node.nodes) { + this._expandLevels(node.nodes, options.levels-1, options); + } + }, this)); + }; + + Tree.prototype._expandLevels = function (nodes, level, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setExpanded(node, (level > 0) ? true : false, options); + if (node.nodes) { + this._expandLevels(node.nodes, level-1, options); + } + }, this)); + }; + + /** + Reveals given tree nodes, expanding the tree from node to root. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.revealNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + var parentNode = node; + var tmpNode; + while (tmpNode = this.getParents([parentNode])[0]) { + parentNode = tmpNode; + this._setExpanded(parentNode, true, options); + }; + }, this)); + }; + + /** + Toggles a node's expanded state; collapsing if expanded, expanding if collapsed. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.toggleNodeExpanded = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._toggleExpanded(node, options); + }, this)); + }; + + + /** + Check all tree nodes + @param {optional Object} options + */ + Tree.prototype.checkAll = function (options) { + options = $.extend({}, _default.options, options); + + var nodes = $.grep(this._orderedNodes, function (node) { + return !node.state.checked; + }); + $.each(nodes, $.proxy(function (index, node) { + this._setChecked(node, true, options); + }, this)); + }; + + /** + Checks given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.checkNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setChecked(node, true, options); + }, this)); + }; + + /** + Uncheck all tree nodes + @param {optional Object} options + */ + Tree.prototype.uncheckAll = function (options) { + options = $.extend({}, _default.options, options); + + var nodes = $.grep(this._orderedNodes, function (node) { + return node.state.checked || node.state.checked === undefined; + }); + $.each(nodes, $.proxy(function (index, node) { + this._setChecked(node, false, options); + }, this)); + }; + + /** + Uncheck given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.uncheckNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setChecked(node, false, options); + }, this)); + }; + + /** + Toggles a node's checked state; checking if unchecked, unchecking if checked. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.toggleNodeChecked = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._toggleChecked(node, options); + }, this)); + }; + + /** + Saves the current state of checkboxes as default, cleaning up any highlighted changes + */ + Tree.prototype.unmarkCheckboxChanges = function () { + this._inheritCheckboxChanges(); + + $.each(this._nodes, function (index, node) { + node.$el.removeClass('node-check-changed'); + }); + }; + + /** + Disable all tree nodes + @param {optional Object} options + */ + Tree.prototype.disableAll = function (options) { + options = $.extend({}, _default.options, options); + + var nodes = this._findNodes('false', 'state.disabled'); + $.each(nodes, $.proxy(function (index, node) { + this._setDisabled(node, true, options); + }, this)); + }; + + /** + Disable given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.disableNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setDisabled(node, true, options); + }, this)); + }; + + /** + Enable all tree nodes + @param {optional Object} options + */ + Tree.prototype.enableAll = function (options) { + options = $.extend({}, _default.options, options); + + var nodes = this._findNodes('true', 'state.disabled'); + $.each(nodes, $.proxy(function (index, node) { + this._setDisabled(node, false, options); + }, this)); + }; + + /** + Enable given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.enableNode = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setDisabled(node, false, options); + }, this)); + }; + + /** + Toggles a node's disabled state; disabling is enabled, enabling if disabled. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + Tree.prototype.toggleNodeDisabled = function (nodes, options) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + options = $.extend({}, _default.options, options); + + $.each(nodes, $.proxy(function (index, node) { + this._setDisabled(node, !node.state.disabled, options); + }, this)); + }; + + + /** + Searches the tree for nodes (text) that match given criteria + @param {String} pattern - A given string to match against + @param {optional Object} options - Search criteria options + @return {Array} nodes - Matching nodes + */ + Tree.prototype.search = function (pattern, options) { + options = $.extend({}, _default.searchOptions, options); + + var previous = this._getSearchResults(); + var results = []; + + if (pattern && pattern.length > 0) { + + if (options.exactMatch) { + pattern = '^' + pattern + '$'; + } + + var modifier = 'g'; + if (options.ignoreCase) { + modifier += 'i'; + } + + results = this._findNodes(pattern, 'text', modifier); + } + + // Clear previous results no longer matched + $.each(this._diffArray(results, previous), $.proxy(function (index, node) { + this._setSearchResult(node, false, options); + }, this)); + + // Set new results + $.each(this._diffArray(previous, results), $.proxy(function (index, node) { + this._setSearchResult(node, true, options); + }, this)); + + // Reveal hidden nodes + if (results && options.revealResults) { + this.revealNode(results); + } + + this._triggerEvent('searchComplete', results, options); + + return results; + }; + + /** + Clears previous search results + */ + Tree.prototype.clearSearch = function (options) { + options = $.extend({}, { render: true }, options); + + var results = $.each(this._getSearchResults(), $.proxy(function (index, node) { + this._setSearchResult(node, false, options); + }, this)); + + this._triggerEvent('searchCleared', results, options); + }; + + Tree.prototype._getSearchResults = function () { + return this._findNodes('true', 'searchResult'); + }; + + Tree.prototype._diffArray = function (a, b) { + var diff = []; + $.grep(b, function (n) { + if ($.inArray(n, a) === -1) { + diff.push(n); + } + }); + return diff; + }; + + /** + Find nodes that match a given criteria + @param {String} pattern - A given string to match against + @param {optional String} attribute - Attribute to compare pattern against + @param {optional String} modifier - Valid RegEx modifiers + @return {Array} nodes - Nodes that match your criteria + */ + Tree.prototype._findNodes = function (pattern, attribute, modifier) { + attribute = attribute || 'text'; + modifier = modifier || 'g'; + return $.grep(this._orderedNodes, $.proxy(function (node) { + var val = this._getNodeValue(node, attribute); + if (typeof val === 'string') { + return val.match(new RegExp(pattern, modifier)); + } + }, this)); + }; + + /** + Recursive find for retrieving nested attributes values + All values are return as strings, unless invalid + @param {Object} obj - Typically a node, could be any object + @param {String} attr - Identifies an object property using dot notation + @return {String} value - Matching attributes string representation + */ + Tree.prototype._getNodeValue = function (obj, attr) { + var index = attr.indexOf('.'); + if (index > 0) { + var _obj = obj[attr.substring(0, index)]; + var _attr = attr.substring(index + 1, attr.length); + return this._getNodeValue(_obj, _attr); + } + else { + if (obj.hasOwnProperty(attr) && obj[attr] !== undefined) { + return obj[attr].toString(); + } + else { + return undefined; + } + } + }; + + var logError = function (message) { + if (window.console) { + window.console.error(message); + } + }; + + // Prevent against multiple instantiations, + // handle updates and method calls + $.fn[pluginName] = function (options, args) { + + var result; + if (this.length == 0) { + throw "No element has been found!"; + } + + this.each(function () { + var _this = $.data(this, pluginName); + if (typeof options === 'string') { + if (!_this) { + logError('Not initialized, can not call method : ' + options); + } + else if (!$.isFunction(_this[options]) || options.charAt(0) === '_') { + logError('No such method : ' + options); + } + else { + if (!(args instanceof Array)) { + args = [ args ]; + } + result = _this[options].apply(_this, args); + } + } + else if (typeof options === 'boolean') { + result = _this; + } + else { + $.data(this, pluginName, new Tree(this, $.extend(true, {}, options))); + } + }); + + return result || this; + }; + +})(jQuery, window, document); diff --git a/assets/ts_src/BSTreeSearchOptions.ts b/assets/ts_src/BSTreeSearchOptions.ts new file mode 100644 index 00000000..b993d9b0 --- /dev/null +++ b/assets/ts_src/BSTreeSearchOptions.ts @@ -0,0 +1,7 @@ +import BSTreeViewEventOptions from "./BSTreeViewEventOptions"; + +export default class BSTreeSearchOptions extends BSTreeViewEventOptions { + ignoreCase: boolean = true; + exactMatch: boolean = false; + revealResults: boolean = true; +} \ No newline at end of file diff --git a/assets/ts_src/BSTreeView.ts b/assets/ts_src/BSTreeView.ts new file mode 100644 index 00000000..863ac4c3 --- /dev/null +++ b/assets/ts_src/BSTreeView.ts @@ -0,0 +1,1866 @@ +import BSTreeViewOptions from "./BSTreeViewOptions"; +import BSTreeViewEventOptions from "./BSTreeViewEventOptions"; +import {default as BSTreeViewNode} from "./BSTreeViewNode"; +import BSTreeViewNodeState from "./BSTreeViewNodeState"; +import BSTreeViewSelectOptions from "./BSTreeViewSelectOptions"; +import BSTreeViewDisableOptions from "./BSTreeViewDisableOptions"; +import BSTreeViewExpandOptions from "./BSTreeViewExpandOptions"; +import BSTreeSearchOptions from "./BSTreeSearchOptions"; + + +const pluginName = 'treeview'; + +const EVENT_LOADING = 'bs-tree:loading'; +const EVENT_LOADING_FAILED = 'bs-tree:loadingFailed'; +const EVENT_INITIALIZED = 'bs-tree:initialized'; +const EVENT_NODE_RENDERED = 'bs-tree:nodeRendered'; +const EVENT_RENDERED = 'bs-tree:rendered'; +const EVENT_DESTROYED = 'bs-tree:destroyed'; + +const EVENT_NODE_CHECKED = 'bs-tree:nodeChecked'; +const EVENT_NODE_COLLAPSED = 'bs-tree:nodeCollapsed'; +const EVENT_NODE_DISABLED = 'bs-tree:nodeDisabled'; +const EVENT_NODE_ENABLED = 'bs-tree:nodeEnabled'; +const EVENT_NODE_EXPANDED = 'bs-tree:nodeExpanded'; +const EVENT_NODE_SELECTED = 'bs-tree:nodeSelected'; +const EVENT_NODE_UNCHECKED = 'bs-tree:nodeUnchecked'; +const EVENT_NODE_UNSELECTED = 'bs-tree:nodeUnselected'; + +const EVENT_SEARCH_COMPLETED = 'bs-tree:searchCompleted'; +const EVENT_SEARCH_CLEARED = 'bs-tree:searchCleared'; + +function templateElement(tagType: string, classes: string): HTMLElement { + const el = document.createElement(tagType); + if(classes.length > 0) { + el.classList.add(...classes.split(" ")); + } + return el; +} + +export default class BSTreeView +{ + _template = { + tree: templateElement('ul', "list-group"), + node: templateElement("li", "list-group-item"), + indent: templateElement("span", "indent"), + icon: { + node: templateElement("span", "icon node-icon"), + expand: templateElement("span", "icon expand-icon"), + check: templateElement("span", "icon check-icon"), + empty: templateElement("span", "icon") + }, + image: templateElement("span", "image"), + badge: templateElement("span", ""), + text: templateElement("span", "text"), + }; + + _css = '.treeview .list-group-item{cursor:pointer}.treeview span.indent{margin-left:10px;margin-right:10px}.treeview span.icon{width:12px;margin-right:5px}.treeview .node-disabled{color:silver;cursor:not-allowed}' + + + /** + * @param {HTMLElement} The HTMLElement this tree applies to + */ + element: HTMLElement; + + wrapper: HTMLElement|null; + + /** + * {string} + * @private + */ + _elementId: string; + + _styleId: string; + + _tree: BSTreeViewNode[]; + + _nodes: Map; + _orderedNodes: Map; + + _checkedNodes: BSTreeViewNode[]; + + /** + * @private + * {boolean} + */ + _initialized: boolean; + + _options: BSTreeViewOptions; + + constructor(element: HTMLElement, options: BSTreeViewOptions|object) + { + this.element = element; + this._elementId = element.id; + this._styleId = this._elementId + '-style'; + + this._init(options); + } + + _init (options: BSTreeViewOptions|object) + { + this._tree = []; + this._initialized = false; + + //If options is a BSTreeViewOptions object, we can use it directly + if(options instanceof BSTreeViewOptions) { + this._options = options; + } else { + //Otherwise we have to apply our options object on it + this._options = new BSTreeViewOptions(options); + } + + // Cache empty icon DOM template + this._template.icon.empty.classList.add(...this._options.emptyIcon.split(" ")); + + this._destroy(); + this._subscribeEvents(); + + this._triggerEvent('loading', null, new BSTreeViewEventOptions({silent: true})); + this._load(this._options) + .then((data) => { + // load done + return this._tree = data; + }) + .catch((error) => { + // load fail + this._triggerEvent('loadingFailed', error, new BSTreeViewEventOptions()); + }) + .then((treeData) => { + // initialize data + if(treeData) { + return this._setInitialStates(new BSTreeViewNode({nodes: treeData}), 0); + } + }) + .then(() => { + // render to DOM + this._render(); + }) + ; + } + + _load (options: BSTreeViewOptions): Promise { + if (options.data) { + return this._loadLocalData(options); + } else if (options.ajaxURL) { + return this._loadRemoteData(options); + } + throw new Error("No data source defined."); + } + + _loadRemoteData (options: BSTreeViewOptions): Promise { + return new Promise((resolve, reject) => { + fetch(options.ajaxURL, options.ajaxConfig).then((response) => { + resolve(response.json()); + }) + .catch((error) => reject(error)); + }); + } + + _loadLocalData (options: BSTreeViewOptions): Promise { + return new Promise((resolve, reject) => { + //if options.data is a string we need to JSON decode it first + if (typeof options.data === 'string') { + try { + resolve(JSON.parse(options.data)); + } catch (error) { + reject(error); + } + } else { + resolve(options.data); + } + }); + }; + + _remove () { + this._destroy(); + //$.removeData(this, pluginName); + const styleElement = document.getElementById(this._styleId); + styleElement.remove(); + }; + + _destroy () { + if (!this._initialized) return; + this._initialized = false; + + this._triggerEvent('destroyed', null, new BSTreeViewEventOptions()); + + // Switch off events + this._unsubscribeEvents(); + + // Tear down + this.wrapper.remove(); + this.wrapper = null; + }; + + _unsubscribeEvents () { + if (typeof (this._options.onLoading) === 'function') { + this.element.removeEventListener(EVENT_LOADING, this._options.onLoading); + } + + if (typeof (this._options.onLoadingFailed) === 'function') { + this.element.removeEventListener(EVENT_LOADING_FAILED, this._options.onLoadingFailed); + } + + if (typeof (this._options.onInitialized) === 'function') { + this.element.removeEventListener(EVENT_INITIALIZED, this._options.onInitialized); + } + + if (typeof (this._options.onNodeRendered) === 'function') { + this.element.removeEventListener(EVENT_NODE_RENDERED, this._options.onNodeRendered); + } + + if (typeof (this._options.onRendered) === 'function') { + this.element.removeEventListener(EVENT_RENDERED, this._options.onRendered); + } + + if (typeof (this._options.onDestroyed) === 'function') { + this.element.removeEventListener(EVENT_DESTROYED, this._options.onDestroyed); + } + + this.element.removeEventListener('click', this._clickHandler.bind(this)); + + if (typeof (this._options.onNodeChecked) === 'function') { + this.element.removeEventListener(EVENT_NODE_CHECKED, this._options.onNodeChecked); + } + + if (typeof (this._options.onNodeCollapsed) === 'function') { + this.element.removeEventListener(EVENT_NODE_COLLAPSED, this._options.onNodeCollapsed); + } + + if (typeof (this._options.onNodeDisabled) === 'function') { + this.element.removeEventListener(EVENT_NODE_DISABLED, this._options.onNodeDisabled); + } + + if (typeof (this._options.onNodeEnabled) === 'function') { + this.element.removeEventListener(EVENT_NODE_ENABLED, this._options.onNodeEnabled); + } + + if (typeof (this._options.onNodeExpanded) === 'function') { + this.element.removeEventListener(EVENT_NODE_EXPANDED, this._options.onNodeExpanded); + } + + if (typeof (this._options.onNodeSelected) === 'function') { + this.element.removeEventListener(EVENT_NODE_SELECTED, this._options.onNodeSelected); + } + + if (typeof (this._options.onNodeUnchecked) === 'function') { + this.element.removeEventListener(EVENT_NODE_UNCHECKED, this._options.onNodeUnchecked); + } + + if (typeof (this._options.onNodeUnselected) === 'function') { + this.element.removeEventListener(EVENT_NODE_UNSELECTED, this._options.onNodeUnselected); + } + + if (typeof (this._options.onSearchComplete) === 'function') { + this.element.removeEventListener(EVENT_SEARCH_COMPLETED, this._options.onSearchComplete); + } + + if (typeof (this._options.onSearchCleared) === 'function') { + this.element.removeEventListener(EVENT_SEARCH_CLEARED, this._options.onSearchCleared); + } + }; + + _subscribeEvents () { + this._unsubscribeEvents(); + + if (typeof (this._options.onLoading) === 'function') { + this.element.addEventListener(EVENT_LOADING, this._options.onLoading); + } + + if (typeof (this._options.onLoadingFailed) === 'function') { + this.element.addEventListener(EVENT_LOADING_FAILED, this._options.onLoadingFailed); + } + + if (typeof (this._options.onInitialized) === 'function') { + this.element.addEventListener(EVENT_INITIALIZED, this._options.onInitialized); + } + + if (typeof (this._options.onNodeRendered) === 'function') { + this.element.addEventListener(EVENT_NODE_RENDERED, this._options.onNodeRendered); + } + + if (typeof (this._options.onRendered) === 'function') { + this.element.addEventListener(EVENT_RENDERED, this._options.onRendered); + } + + if (typeof (this._options.onDestroyed) === 'function') { + this.element.addEventListener(EVENT_DESTROYED, this._options.onDestroyed); + } + + this.element.addEventListener('click', this._clickHandler.bind(this)); + + if (typeof (this._options.onNodeChecked) === 'function') { + this.element.addEventListener(EVENT_NODE_CHECKED, this._options.onNodeChecked); + } + + if (typeof (this._options.onNodeCollapsed) === 'function') { + this.element.addEventListener(EVENT_NODE_COLLAPSED, this._options.onNodeCollapsed); + } + + if (typeof (this._options.onNodeDisabled) === 'function') { + this.element.addEventListener(EVENT_NODE_DISABLED, this._options.onNodeDisabled); + } + + if (typeof (this._options.onNodeEnabled) === 'function') { + this.element.addEventListener(EVENT_NODE_ENABLED, this._options.onNodeEnabled); + } + + if (typeof (this._options.onNodeExpanded) === 'function') { + this.element.addEventListener(EVENT_NODE_EXPANDED, this._options.onNodeExpanded); + } + + if (typeof (this._options.onNodeSelected) === 'function') { + this.element.addEventListener(EVENT_NODE_SELECTED, this._options.onNodeSelected); + } + + if (typeof (this._options.onNodeUnchecked) === 'function') { + this.element.addEventListener(EVENT_NODE_UNCHECKED, this._options.onNodeUnchecked); + } + + if (typeof (this._options.onNodeUnselected) === 'function') { + this.element.addEventListener(EVENT_NODE_UNSELECTED, this._options.onNodeUnselected); + } + + if (typeof (this._options.onSearchComplete) === 'function') { + this.element.addEventListener(EVENT_SEARCH_COMPLETED, this._options.onSearchComplete); + } + + if (typeof (this._options.onSearchCleared) === 'function') { + this.element.addEventListener(EVENT_SEARCH_CLEARED, this._options.onSearchCleared); + } + }; + + _triggerEvent (eventType: string, data: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewEventOptions = null) { + if (options && !options.silent) { + const event = new CustomEvent(eventType, { detail: {data: data, eventOptions: options, treeView: this} }); + + this.element.dispatchEvent(event); + } + } + + /* + Recurse the tree structure and ensure all nodes have + valid initial states. User defined states will be preserved. + For performance we also take this opportunity to + index nodes in a flattened ordered structure + */ + _setInitialStates (node: BSTreeViewNode, level: number): Promise { + this._nodes = new Map(); + + const promise = new Promise((resolve) => { + this._setInitialState(node, level) + resolve(false); + }); + + promise.then(() => { + this._orderedNodes = this._sortNodes(this._nodes); + this._inheritCheckboxChanges(); + this._triggerEvent('initialized', Array.from(this._orderedNodes.values())); + }); + + return promise; + }; + + _setInitialState (node: BSTreeViewNode, level: number): void { + if (!node.nodes) return; + level += 1; + + let parent = node; + node.nodes.forEach((node, index) => { + // level : hierarchical tree level, starts at 1 + node.level = level; + + // index : relative to siblings + node.index = index; + + // nodeId : unique, hierarchical identifier + node.nodeId = (parent && parent.nodeId) ? + parent.nodeId + '.' + node.index : + (level - 1) + '.' + node.index; + + // parentId : transversing up the tree + node.parentId = parent.nodeId; + + // if not provided set selectable default value + if (!node.hasOwnProperty('selectable')) { + node.selectable = true; + } + + // if not provided set checkable default value + if (!node.hasOwnProperty('checkable')) { + node.checkable = true; + } + + // where provided we should preserve states + node.state = node.state || new BSTreeViewNodeState(); + + // set checked state; unless set always false + if (!node.state.hasOwnProperty('checked')) { + node.state.checked = false; + } + + // convert the undefined string if hierarchical checks are enabled + if (this._options.hierarchicalCheck && node.state.checked === null) { + node.state.checked = null; + } + + // set enabled state; unless set always false + if (!node.state.hasOwnProperty('disabled')) { + node.state.disabled = false; + } + + // set expanded state; if not provided based on levels + if (!node.state.hasOwnProperty('expanded')) { + if (!node.state.disabled && + (level < this._options.levels) && + (node.nodes && node.nodes.length > 0)) { + node.state.expanded = true; + } + else { + node.state.expanded = false; + } + } + + // set selected state; unless set always false + if (!node.state.hasOwnProperty('selected')) { + node.state.selected = false; + } + + // set visible state; based parent state plus levels + if ((parent && parent.state && parent.state.expanded) || + (level <= this._options.levels)) { + node.state.visible = true; + } + else { + node.state.visible = false; + } + + // recurse child nodes and transverse the tree, depth-first + if (node.nodes) { + if (node.nodes.length > 0) { + this._setInitialState(node, level); + } + else { + delete node.nodes; + } + } + + // add / update indexed collection + this._nodes[node.nodeId] = node; + }) + }; + + _sortNodes (nodes: Map): Map { + return new Map([...nodes].sort((pairA, pairB) => { + //Index 0 of our pair variables contains the index of our map + + if (pairA[0] === pairB[0]) return 0; + const a = pairA[0].split('.').map(function (level) { return parseInt(level); }); + const b = pairB[0].split('.').map(function (level) { return parseInt(level); }); + + const c = Math.max(a.length, b.length); + for (let i=0; i 0) return +1; + if (a[i] - b[i] < 0) return -1; + } + })); + }; + + _clickHandler (event: Event) { + const target = event.target as HTMLElement; + const node = this.targetNode(target); + if (!node || node.state.disabled) return; + + const classList = target.classList; + if (classList.contains('expand-icon')) { + this._toggleExpanded(node); + } + else if (classList.contains('check-icon')) { + if (node.checkable) { + this._toggleChecked(node); + } + } + else { + if (node.selectable) { + this._toggleSelected(node); + } else { + this._toggleExpanded(node); + } + } + }; + + /* Looks up the DOM for the closest parent list item to retrieve the + * data attribute nodeid, which is used to lookup the node in the flattened structure. */ + targetNode (target: HTMLElement): BSTreeViewNode { + const nodeElement = target.closest('li.list-group-item') as HTMLElement; + const nodeId = nodeElement.dataset.nodeId; + const node = this._nodes[nodeId]; + if (!node) { + console.warn('Error: node does not exist'); + } + return node; + }; + + _toggleExpanded (node: BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()) { + if (!node) return; + + // Lazy-load the child nodes if possible + if (typeof(this._options.lazyLoad) === 'function' && node.lazyLoad) { + this._lazyLoad(node); + } else { + this._setExpanded(node, !node.state.expanded, options); + } + }; + + _lazyLoad (node: BSTreeViewNode) { + if(!node.lazyLoad) return; + + // Show a different icon while loading the child nodes + const span = node.el.querySelector('span.expand-icon'); + span.classList.remove(...this._options.expandIcon.split(' ')); + span.classList.add(...this._options.loadingIcon.split(' ')); + + this._options.lazyLoad(node, (nodes) => { + // Adding the node will expand its parent automatically + this.addNode(nodes, node); + }); + // Only the first expand should do a lazy-load + node.lazyLoad = false; + }; + + _setExpanded (node: BSTreeViewNode, state: boolean, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()): void { + + // We never pass options when rendering, so the only time + // we need to validate state is from user interaction + if (options && state === node.state.expanded) return; + + if (state && node.nodes) { + + // Set node state + node.state.expanded = true; + + // Set element + if (node.el) { + const span = node.el.querySelector('span.expand-icon'); + span.classList.remove(...this._options.expandIcon.split(" ")) + span.classList.remove(...this._options.loadingIcon.split(" ")) + span.classList.add(...this._options.collapseIcon.split(" ")); + } + + // Expand children + if (node.nodes && options) { + node.nodes.forEach((node) => { + this._setVisible(node, true, options); + }); + } + + // Optionally trigger event + this._triggerEvent('nodeExpanded', node, options); + } + else if (!state) { + + // Set node state + node.state.expanded = false; + + // Set element + if (node.el) { + const span = node.el.querySelector('span.expand-icon'); + span.classList.remove(...this._options.collapseIcon.split(" ")); + span.classList.add(...this._options.expandIcon.split(" ")); + } + + // Collapse children + if (node.nodes && options) { + node.nodes.forEach ((node) => { + this._setVisible(node, false, options); + this._setExpanded(node, false, options); + }); + } + + // Optionally trigger event + this._triggerEvent('nodeCollapsed', node, options); + } + }; + + _setVisible (node: BSTreeViewNode, state: boolean, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()): void { + + if (options && state === node.state.visible) return; + + if (state) { + + // Set node state + node.state.visible = true; + + // Set element + if (node.el) { + node.el.classList.remove('node-hidden'); + } + } + else { + + // Set node state to unchecked + node.state.visible = false; + + // Set element + if (node.el) { + node.el.classList.add('node-hidden'); + } + } + }; + + _toggleSelected (node: BSTreeViewNode, options: BSTreeViewSelectOptions = new BSTreeViewSelectOptions()): this { + if (!node) return; + this._setSelected(node, !node.state.selected, options); + return this; + }; + + _setSelected (node: BSTreeViewNode, state: boolean, options = new BSTreeViewSelectOptions()): this { + + // We never pass options when rendering, so the only time + // we need to validate state is from user interaction + if (options && (state === node.state.selected)) return; + + if (state) { + + // If multiSelect false, unselect previously selected + if (!this._options.multiSelect) { + const selectedNodes = this._findNodes('true', 'state.selected'); + selectedNodes.forEach((node) => { + options.unselecting = true; + + this._setSelected(node, false, options); + }); + } + + // Set node state + node.state.selected = true; + + // Set element + if (node.el) { + node.el.classList.add('node-selected'); + + if (node.selectedIcon || this._options.selectedIcon) { + const span = node.el.querySelector('span.node-icon'); + span.classList.remove(...(node.icon || this._options.nodeIcon).split(" ")); + span.classList.add(...(node.selectedIcon || this._options.selectedIcon).split(" ")); + } + } + + // Optionally trigger event + this._triggerEvent('nodeSelected', node, options); + } + else { + + // If preventUnselect true + only one remaining selection, disable unselect + if (this._options.preventUnselect && + (options && !options.unselecting) && + (this._findNodes('true', 'state.selected').length === 1)) { + // Fire the nodeSelected event if reselection is allowed + if (this._options.allowReselect) { + this._triggerEvent('nodeSelected', node, options); + } + return this; + } + + // Set node state + node.state.selected = false; + + // Set element + if (node.el) { + node.el.classList.remove('node-selected'); + + if (node.selectedIcon || this._options.selectedIcon) { + const span = node.el.querySelector('span.node-icon'); + span.classList.remove(...(node.selectedIcon || this._options.selectedIcon).split(" ")) + span.classList.add(...(node.icon || this._options.nodeIcon).split(" ")); + } + } + + // Optionally trigger event + this._triggerEvent('nodeUnselected', node, options); + } + + return this; + }; + + _inheritCheckboxChanges (): void { + if (this._options.showCheckbox && this._options.highlightChanges) { + this._checkedNodes = []; + this._orderedNodes.forEach((node) => { + if(node.state.checked) { + this._checkedNodes.push(node); + } + }); + } + }; + + _toggleChecked (node: BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()): this { + if (!node) return; + + if (this._options.hierarchicalCheck) { + // Event propagation to the parent/child nodes + const childOptions = new BSTreeViewEventOptions(options); + childOptions.silent = options.silent || !this._options.propagateCheckEvent; + + let state: boolean|null; + let currentNode = node; + // Temporarily swap the tree state + node.state.checked = !node.state.checked; + + currentNode = this._nodes[currentNode.parentId] + // Iterate through each parent node + while (currentNode) { + + // Calculate the state + state = currentNode.nodes.reduce((acc, curr) => { + return (acc === curr.state.checked) ? acc : null; + }, currentNode.nodes[0].state.checked); + + // Set the state + this._setChecked(currentNode, state, childOptions); + + currentNode = this._nodes[currentNode.parentId] + } + + if (node.nodes && node.nodes.length > 0) { + // Copy the content of the array + let child, children = node.nodes.slice(); + // Iterate through each child node + while (children && children.length > 0) { + child = children.pop(); + + // Set the state + this._setChecked(child, node.state.checked, childOptions); + + // Append children to the end of the list + if (child.nodes && child.nodes.length > 0) { + children = children.concat(child.nodes.slice()); + } + } + } + // Swap back the tree state + node.state.checked = !node.state.checked; + } + + this._setChecked(node, !node.state.checked, options); + }; + + _setChecked (node: BSTreeViewNode, state: boolean, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()) { + // We never pass options when rendering, so the only time + // we need to validate state is from user interaction + if (options && state === node.state.checked) return; + + // Highlight the node if its checkbox has unsaved changes + if (this._options.highlightChanges) { + const nodeNotInCheckList = this._checkedNodes.indexOf(node) == -1; + if(nodeNotInCheckList == state) { + node.el.classList.add('node-check-changed'); + } else { + node.el.classList.remove('node-check-changed'); + } + } + + if (state) { + + // Set node state + node.state.checked = true; + + // Set element + if (node.el) { + node.el.classList.add('node-checked'); + node.el.classList.remove('node-checked-partial'); + const span = node.el.querySelector('span.check-icon'); + span.classList.remove(...this._options.uncheckedIcon.split(" ")) + span.classList.remove(...this._options.partiallyCheckedIcon.split(" ")) + span.classList.add(...this._options.checkedIcon.split(" ")); + } + + // Optionally trigger event + this._triggerEvent('nodeChecked', node, options); + } + else if (state === null && this._options.hierarchicalCheck) { + + // Set node state to partially checked + node.state.checked = null; + + // Set element + if (node.el) { + node.el.classList.add('node-checked-partial'); + node.el.classList.remove('node-checked'); + const span = node.el.querySelector('span.check-icon'); + span.classList.remove(...this._options.uncheckedIcon.split(" ")); + span.classList.remove(...this._options.checkedIcon.split(" ")); + span.classList.add(...this._options.partiallyCheckedIcon.split(" ")); + } + + // Optionally trigger event, partially checked is technically unchecked + this._triggerEvent('nodeUnchecked', node, options); + } else { + + // Set node state to unchecked + node.state.checked = false; + + // Set element + if (node.el) { + node.el.classList.remove('node-checked node-checked-partial'); + const span = node.el.querySelector('span.check-icon'); + span.classList.remove(...this._options.checkedIcon.split(" ")); + span.classList.remove(...this._options.partiallyCheckedIcon.split(" ")); + span.classList.add(...this._options.uncheckedIcon.split(" ")); + } + + // Optionally trigger event + this._triggerEvent('nodeUnchecked', node, options); + } + }; + + _setDisabled (node: BSTreeViewNode, state: boolean, options: BSTreeViewDisableOptions = new BSTreeViewDisableOptions()) { + + // We never pass options when rendering, so the only time + // we need to validate state is from user interaction + if (options && state === node.state.disabled) return; + + if (state) { + + // Set node state to disabled + node.state.disabled = true; + + // Disable all other states + if (options && !options.keepState) { + this._setSelected(node, false, options); + this._setChecked(node, false, options); + this._setExpanded(node, false, options); + } + + // Set element + if (node.el) { + node.el.classList.add('node-disabled'); + } + + // Optionally trigger event + this._triggerEvent('nodeDisabled', node, options); + } + else { + + // Set node state to enabled + node.state.disabled = false; + + // Set element + if (node.el) { + node.el.classList.remove('node-disabled'); + } + + // Optionally trigger event + this._triggerEvent('nodeEnabled', node, options); + } + }; + + _setSearchResult (node: BSTreeViewNode, state: boolean, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()) { + if (options && state === node.searchResult) return; + + if (state) { + + node.searchResult = true; + + if (node.el) { + node.el.classList.add('node-result'); + } + } + else { + + node.searchResult = false; + + if (node.el) { + node.el.classList.remove('node-result'); + } + } + }; + + _render(): void { + if (!this._initialized) { + + // Setup first time only components + this.wrapper = this._template.tree.cloneNode(true) as HTMLElement; + //Empty this element + while(this.element.firstChild) { + this.element.removeChild(this.element.firstChild); + } + + + this.element.classList.add(...pluginName.split(" ")) + this.element.appendChild(this.wrapper); + + this._injectStyle(); + + this._initialized = true; + } + + let previousNode: BSTreeViewNode|null = null; + this._orderedNodes.forEach((node) => { + this._renderNode(node, previousNode); + previousNode = node; + }); + + this._triggerEvent('rendered', Array.from(this._orderedNodes.values()), new BSTreeViewEventOptions()); + }; + + _renderNode(node: BSTreeViewNode, previousNode: BSTreeViewNode|null): void { + if (!node) return; + + if (!node.el) { + node.el = this._newNodeEl(node, previousNode); + node.el.classList.add('node-' + this._elementId); + } + else { + node.el.innerHTML = ""; + } + + // Append .classes to the node + node.el.classList.add(...node.class.split(" ")); + + // Set the #id of the node if specified + if (node.id) { + node.el.id = node.id; + } + + // Append custom data- attributes to the node + if (node.dataAttr) { + for (const key in node.dataAttr) { + if (node.dataAttr.hasOwnProperty(key)) { + node.el.setAttribute('data-' + key, node.dataAttr[key]); + } + } + } + + // Set / update nodeid; it can change as a result of addNode etc. + node.el.dataset.nodeId = node.nodeId; + + // Set the tooltip attribute if present + if (node.tooltip) { + node.el.title = node.tooltip; + } + + // Add indent/spacer to mimic tree structure + for (let i = 0; i < (node.level - 1); i++) { + node.el.append(this._template.indent.cloneNode(true) as HTMLElement); + } + + // Add expand / collapse or empty spacer icons + node.el + .append( + node.nodes || node.lazyLoad ? this._template.icon.expand.cloneNode(true) as HTMLElement : this._template.icon.empty.cloneNode(true) as HTMLElement + ); + + // Add checkbox and node icons + if (this._options.checkboxFirst) { + this._addCheckbox(node); + this._addIcon(node); + this._addImage(node); + } else { + this._addIcon(node); + this._addImage(node); + this._addCheckbox(node); + } + + // Add text + if (this._options.wrapNodeText) { + const wrapper = this._template.text.cloneNode(true) as HTMLElement; + node.el.append(wrapper); + wrapper.append(node.text); + } else { + node.el.append(node.text); + } + + // Add tags as badges + if (this._options.showTags && node.tags) { + node.tags.forEach(tag => { + const template = this._template.badge.cloneNode(true) as HTMLElement; + template.classList.add( + //@ts-ignore + ...((typeof tag === 'object' ? tag.class : undefined) + || node.tagsClass + || this._options.tagsClass).split(" ") + ); + template.append( + //@ts-ignore + (typeof tag === 'object' ? tag.text : undefined) + || tag + ); + + node.el.append(template); + }); + } + + // Set various node states + this._setSelected(node, node.state.selected); + this._setChecked(node, node.state.checked); + this._setSearchResult(node, node.searchResult); + this._setExpanded(node, node.state.expanded); + this._setDisabled(node, node.state.disabled); + this._setVisible(node, node.state.visible); + + // Trigger nodeRendered event + this._triggerEvent('nodeRendered', node, new BSTreeViewEventOptions()); + }; + +// Add checkable icon + _addCheckbox (node: BSTreeViewNode): void { + if (this._options.showCheckbox && (node.hideCheckbox === undefined || node.hideCheckbox === false)) { + node.el + .append(this._template.icon.check.cloneNode(true) as HTMLElement); + } + } + +// Add node icon + _addIcon (node: BSTreeViewNode): void { + if (this._options.showIcon && !(this._options.showImage && node.image)) { + const template = this._template.icon.node.cloneNode(true) as HTMLElement; + template.classList.add(...(node.icon || this._options.nodeIcon).split(" ")) + + node.el.append(template); + } + } + + _addImage (node: BSTreeViewNode): void { + if (this._options.showImage && node.image) { + const template = this._template.image.cloneNode(true) as HTMLElement; + template.classList.add('node-image'); + template.style.backgroundImage = "url('" + node.image + "')"; + + + node.el + .append( + + ); + } + } + +// Creates a new node element from template and +// ensures the template is inserted at the correct position + _newNodeEl (node: BSTreeViewNode, previousNode: BSTreeViewNode|null): HTMLElement { + let template = this._template.node.cloneNode(true) as HTMLElement; + + if (previousNode) { + // typical usage, as nodes are rendered in + // sort order we add after the previous element + previousNode.el.after(template); + } else { + // we use prepend instead of append, + // to cater for root inserts i.e. nodeId 0.0 + this.wrapper.prepend(template); + } + + return template; + }; + +// Recursively remove node elements from DOM + _removeNodeEl (node: BSTreeViewNode): void { + if (!node) return; + + if (node.nodes) { + node.nodes.forEach((node) => { + this._removeNodeEl(node); + }); + } + node.el.remove(); + }; + +// Expand node, rendering it's immediate children + _expandNode (node: BSTreeViewNode): void { + if (!node.nodes) return; + + node.nodes.slice(0).reverse().forEach((childNode) => { + childNode.level = node.level + 1; + this._renderNode(childNode, node); + }); + }; + +// Add inline style into head + _injectStyle (): void { + if (this._options.injectStyle && !document.getElementById(this._styleId)) { + const styleElement = document.createElement('style'); + styleElement.id = this._styleId; + styleElement.type='text/css'; + styleElement.innerHTML = this._buildStyle(); + document.head.appendChild(styleElement); + } + }; + +// Construct trees style based on user options + _buildStyle () { + let style = '.node-' + this._elementId + '{'; + + // Basic bootstrap style overrides + if (this._options.color) { + style += 'color:' + this._options.color + ';'; + } + + if (this._options.backColor) { + style += 'background-color:' + this._options.backColor + ';'; + } + + if (!this._options.showBorder) { + style += 'border:none;'; + } + else if (this._options.borderColor) { + style += 'border:1px solid ' + this._options.borderColor + ';'; + } + style += '}'; + + if (this._options.onhoverColor) { + style += '.node-' + this._elementId + ':not(.node-disabled):hover{' + + 'background-color:' + this._options.onhoverColor + ';' + + '}'; + } + + // Style search results + if (this._options.highlightSearchResults && (this._options.searchResultColor || this._options.searchResultBackColor)) { + + let innerStyle = '' + if (this._options.searchResultColor) { + innerStyle += 'color:' + this._options.searchResultColor + ';'; + } + if (this._options.searchResultBackColor) { + innerStyle += 'background-color:' + this._options.searchResultBackColor + ';'; + } + + style += '.node-' + this._elementId + '.node-result{' + innerStyle + '}'; + style += '.node-' + this._elementId + '.node-result:hover{' + innerStyle + '}'; + } + + // Style selected nodes + if (this._options.highlightSelected && (this._options.selectedColor || this._options.selectedBackColor)) { + + let innerStyle = '' + if (this._options.selectedColor) { + innerStyle += 'color:' + this._options.selectedColor + ';'; + } + if (this._options.selectedBackColor) { + innerStyle += 'background-color:' + this._options.selectedBackColor + ';'; + } + + style += '.node-' + this._elementId + '.node-selected{' + innerStyle + '}'; + style += '.node-' + this._elementId + '.node-selected:hover{' + innerStyle + '}'; + } + + // Style changed nodes + if (this._options.highlightChanges) { + let innerStyle = 'color: ' + this._options.changedNodeColor + ';'; + style += '.node-' + this._elementId + '.node-check-changed{' + innerStyle + '}'; + } + + // Node level style overrides + this._orderedNodes.forEach((node) => { + if (node.color || node.backColor) { + let innerStyle = ''; + if (node.color) { + innerStyle += 'color:' + node.color + ';'; + } + if (node.backColor) { + innerStyle += 'background-color:' + node.backColor + ';'; + } + style += '.node-' + this._elementId + '[data-nodeId="' + node.nodeId + '"]{' + innerStyle + '}'; + } + + if (node.iconColor) { + let innerStyle = 'color:' + node.iconColor + ';'; + style += '.node-' + this._elementId + '[data-nodeId="' + node.nodeId + '"] .node-icon{' + innerStyle + '}'; + } + }); + + return this._css + style; + }; + + /** + Returns an array of matching node objects. + @param {String} pattern - A pattern to match against a given field + @return {String} field - Field to query pattern against + */ + findNodes (pattern: string, field: string): BSTreeViewNode[] { + return this._findNodes(pattern, field); + }; + + + /** + Returns an ordered aarray of node objects. + @return {Array} nodes - An array of all nodes + */ + getNodes (): Map { + return this._orderedNodes; + }; + + /** + Returns parent nodes for given nodes, if valid otherwise returns undefined. + @param {Array} nodes - An array of nodes + @returns {Array} nodes - An array of parent nodes + */ + getParents (nodes: BSTreeViewNode[]|BSTreeViewNode): BSTreeViewNode[] { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + let parentNodes = []; + nodes.forEach((node) => { + const parentNode = node.parentId ? this._nodes[node.parentId] : false; + if (parentNode) { + parentNodes.push(parentNode); + } + }); + return parentNodes; + }; + + /** + Returns an array of sibling nodes for given nodes, if valid otherwise returns undefined. + @param {Array} nodes - An array of nodes + @returns {Array} nodes - An array of sibling nodes + */ + getSiblings (nodes: BSTreeViewNode[]|BSTreeViewNode): BSTreeViewNode[] { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + let siblingNodes = []; + nodes.forEach((node) => { + let parent = this.getParents([node]); + let nodes = parent[0] ? parent[0].nodes : this._tree; + siblingNodes = nodes.filter(function (obj) { + return obj.nodeId !== node.nodeId; + }); + }); + + // flatten possible nested array before returning + return siblingNodes.map((obj) => { + return obj; + }); + }; + + /** + Returns an array of selected nodes. + @returns {Array} nodes - Selected nodes + */ + getSelected (): BSTreeViewNode[] { + return this._findNodes('true', 'state.selected'); + }; + + /** + Returns an array of unselected nodes. + @returns {Array} nodes - Unselected nodes + */ + getUnselected (): BSTreeViewNode[] { + return this._findNodes('false', 'state.selected'); + }; + + /** + Returns an array of expanded nodes. + @returns {Array} nodes - Expanded nodes + */ + getExpanded (): BSTreeViewNode[] { + return this._findNodes('true', 'state.expanded'); + }; + + /** + Returns an array of collapsed nodes. + @returns {Array} nodes - Collapsed nodes + */ + getCollapsed (): BSTreeViewNode[] { + return this._findNodes('false', 'state.expanded'); + }; + + /** + Returns an array of checked nodes. + @returns {Array} nodes - Checked nodes + */ + getChecked (): BSTreeViewNode[] { + return this._findNodes('true', 'state.checked'); + }; + + /** + Returns an array of unchecked nodes. + @returns {Array} nodes - Unchecked nodes + */ + getUnchecked (): BSTreeViewNode[] { + return this._findNodes('false', 'state.checked'); + }; + + /** + Returns an array of disabled nodes. + @returns {Array} nodes - Disabled nodes + */ + getDisabled (): BSTreeViewNode[] { + return this._findNodes('true', 'state.disabled'); + }; + + /** + Returns an array of enabled nodes. + @returns {Array} nodes - Enabled nodes + */ + getEnabled (): BSTreeViewNode[] { + return this._findNodes('false', 'state.disabled'); + }; + + + /** + Add nodes to the tree. + @param {Array} nodes - An array of nodes to add + @param {optional Object} parentNode - The node to which nodes will be added as children + @param {optional number} index - Zero based insert index + @param {optional Object} options + */ + addNode (nodes: BSTreeViewNode[]|BSTreeViewNode, parentNode: BSTreeViewNode|null = null, index: number = null, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + if (parentNode instanceof Array) { + parentNode = parentNode[0]; + } + + // identify target nodes; either the tree's root or a parent's child nodes + let targetNodes; + if (parentNode && parentNode.nodes) { + targetNodes = parentNode.nodes; + } else if (parentNode) { + targetNodes = parentNode.nodes = []; + } else { + targetNodes = this._tree; + } + + // inserting nodes at specified positions + nodes.forEach((node, i) => { + let insertIndex = (typeof(index) === 'number') ? (index + i) : (targetNodes.length + 1); + targetNodes.splice(insertIndex, 0, node); + }); + + // initialize new state and render changes + this._setInitialStates(new BSTreeViewNode({nodes: this._tree}), 0) + .then(() =>{ + if (parentNode && !parentNode.state.expanded) { + this._setExpanded(parentNode, true, options); + } + this._render(); + }); + } + + /** + Add nodes to the tree after given node. + @param {Array} nodes - An array of nodes to add + @param {Object} node - The node to which nodes will be added after + @param {optional Object} options + */ + addNodeAfter (nodes: BSTreeViewNode[]|BSTreeViewNode, node: BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + if (node instanceof Array) { + node = node[0]; + } + + this.addNode(nodes, this.getParents(node)[0], (node.index + 1), options); + } + + /** + Add nodes to the tree before given node. + @param {Array} nodes - An array of nodes to add + @param {Object} node - The node to which nodes will be added before + @param {optional Object} options + */ + addNodeBefore (nodes: BSTreeViewNode[]|BSTreeViewNode, node: BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + if (node instanceof Array) { + node = node[0]; + } + + this.addNode(nodes, this.getParents(node)[0], node.index, options); + } + + /** + Removes given nodes from the tree. + @param {Array} nodes - An array of nodes to remove + @param {optional Object} options + */ + removeNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + let targetNodes: BSTreeViewNode[]; + let parentNode: BSTreeViewNode; + nodes.forEach((node) => { + + // remove nodes from tree + parentNode = this._nodes[node.parentId]; + if (parentNode) { + targetNodes = parentNode.nodes; + } else { + targetNodes = this._tree; + } + targetNodes.splice(node.index, 1); + + // remove node from DOM + this._removeNodeEl(node); + }); + + // initialize new state and render changes + this._setInitialStates(new BSTreeViewNode({nodes: this._tree}), 0) + .then(this._render.bind(this)); + }; + + /** + Updates / replaces a given tree node + @param {Object} node - A single node to be replaced + @param {Object} newNode - THe replacement node + @param {optional Object} options + */ + updateNode (node: BSTreeViewNode, newNode: BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()) { + if (node instanceof Array) { + node = node[0]; + } + + // insert new node + let targetNodes; + const parentNode = this._nodes[node.parentId]; + if (parentNode) { + targetNodes = parentNode.nodes; + } else { + targetNodes = this._tree; + } + targetNodes.splice(node.index, 1, newNode); + + // remove old node from DOM + this._removeNodeEl(node); + + // initialize new state and render changes + this._setInitialStates(new BSTreeViewNode({nodes: this._tree}), 0) + .then(this._render.bind(this)); + }; + + + /** + Selects given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + selectNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewSelectOptions = new BSTreeViewSelectOptions()) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._setSelected(node, true, options); + }); + }; + + /** + Unselects given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + unselectNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewSelectOptions) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + + nodes.forEach((node) => { + this._setSelected(node, false, options); + }); + }; + + /** + Toggles a node selected state; selecting if unselected, unselecting if selected. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + toggleNodeSelected (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewSelectOptions = new BSTreeViewSelectOptions()) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._toggleSelected(node, options); + }, this); + }; + + + /** + Collapse all tree nodes + @param {optional Object} options + */ + collapseAll (options: BSTreeViewExpandOptions = new BSTreeViewExpandOptions()) { + options.levels = options.levels || 999; + this.collapseNode(this._tree, options); + }; + + /** + Collapse a given tree node + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + collapseNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewExpandOptions = new BSTreeViewExpandOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._setExpanded(node, false, options); + }); + }; + + /** + Expand all tree nodes + @param {optional Object} options + */ + expandAll (options: BSTreeViewExpandOptions = new BSTreeViewExpandOptions()): void { + options.levels = options.levels || 999; + this.expandNode(this._tree, options); + }; + + /** + Expand given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + expandNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewExpandOptions = new BSTreeViewExpandOptions()) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + // Do not re-expand already expanded nodes + if (node.state.expanded) return; + + if (typeof(this._options.lazyLoad) === 'function' && node.lazyLoad) { + this._lazyLoad(node); + } + + this._setExpanded(node, true, options); + if (node.nodes) { + this._expandLevels(node.nodes, options.levels-1, options); + } + }); + }; + + _expandLevels (nodes: BSTreeViewNode[]|BSTreeViewNode, level: number, options: BSTreeViewExpandOptions = new BSTreeViewExpandOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._setExpanded(node, (level > 0), options); + if (node.nodes) { + this._expandLevels(node.nodes, level-1, options); + } + }); + }; + + /** + Reveals given tree nodes, expanding the tree from node to root. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + revealNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewExpandOptions = new BSTreeViewExpandOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + let parentNode = node; + let tmpNode; + while (tmpNode = this.getParents([parentNode])[0]) { + parentNode = tmpNode; + this._setExpanded(parentNode, true, options); + } + }); + }; + + /** + Toggles a node's expanded state; collapsing if expanded, expanding if collapsed. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + toggleNodeExpanded (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewExpandOptions = new BSTreeViewExpandOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._toggleExpanded(node, options); + }); + + }; + + + /** + Check all tree nodes + @param {optional Object} options + */ + checkAll (options: BSTreeViewEventOptions = new BSTreeViewEventOptions()): void { + this._orderedNodes.forEach((node) => { + if(!node.state.checked) { + this._setChecked(node, true, options); + } + }); + }; + + /** + Checks given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + checkNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._setChecked(node, true, options); + }); + }; + + /** + Uncheck all tree nodes + @param {optional Object} options + */ + uncheckAll (options: BSTreeViewEventOptions = new BSTreeViewEventOptions()): void { + this._orderedNodes.forEach((node) => { + if(node.state.checked || node.state.checked === undefined) { + this._setChecked(node, false, options); + } + }); + }; + + /** + Uncheck given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + uncheckNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._setChecked(node, false, options); + }); + }; + + /** + Toggles a node's checked state; checking if unchecked, unchecking if checked. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + toggleNodeChecked (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewEventOptions = new BSTreeViewEventOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._toggleChecked(node, options); + }); + }; + + /** + Saves the current state of checkboxes as default, cleaning up any highlighted changes + */ + unmarkCheckboxChanges (): void { + this._inheritCheckboxChanges(); + + this._nodes.forEach((node) => { + node.el.classList.remove('node-check-changed'); + }); + }; + + /** + Disable all tree nodes + @param {optional Object} options + */ + disableAll (options: BSTreeViewDisableOptions = new BSTreeViewDisableOptions()): void { + const nodes = this._findNodes('false', 'state.disabled'); + nodes.forEach((node) => { + this._setDisabled(node,true, options); + }); + }; + + /** + Disable given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + disableNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewDisableOptions = new BSTreeViewDisableOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._setDisabled(node, true, options); + }); + }; + + /** + Enable all tree nodes + @param {optional Object} options + */ + enableAll (options: BSTreeViewDisableOptions = new BSTreeViewDisableOptions()): void { + const nodes = this._findNodes('true', 'state.disabled'); + + nodes.forEach((node) => { + this._setDisabled(node, false, options); + }); + }; + + /** + Enable given tree nodes + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + enableNode (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewDisableOptions = new BSTreeViewDisableOptions()) { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._setDisabled(node, false, options); + }) + }; + + /** + Toggles a node's disabled state; disabling is enabled, enabling if disabled. + @param {Array} nodes - An array of nodes + @param {optional Object} options + */ + toggleNodeDisabled (nodes: BSTreeViewNode[]|BSTreeViewNode, options: BSTreeViewDisableOptions = new BSTreeViewDisableOptions()): void { + if (!(nodes instanceof Array)) { + nodes = [nodes]; + } + + nodes.forEach((node) => { + this._setDisabled(node, !node.state.disabled, options); + }) + }; + + + /** + Searches the tree for nodes (text) that match given criteria + @param {String} pattern - A given string to match against + @param {optional Object} options - Search criteria options + @return {Array} nodes - Matching nodes + */ + search (pattern: string, options: BSTreeSearchOptions = new BSTreeSearchOptions()): BSTreeViewNode[] { + let previous = this._getSearchResults(); + let results = []; + + if (pattern && pattern.length > 0) { + + if (options.exactMatch) { + pattern = '^' + pattern + '$'; + } + + let modifier = 'g'; + if (options.ignoreCase) { + modifier += 'i'; + } + + results = this._findNodes(pattern, 'text', modifier); + } + + // Clear previous results no longer matched + this._diffArray(results, previous).forEach((node) => { + this._setSearchResult(node, false, options); + }); + + // Set new results + this._diffArray(previous, results).forEach((node) => { + this._setSearchResult(node, true, options); + }); + + // Reveal hidden nodes + if (results && options.revealResults) { + this.revealNode(results); + } + + this._triggerEvent(EVENT_SEARCH_COMPLETED, results, options); + + return results; + }; + + /** + Clears previous search results + */ + clearSearch (options: BSTreeSearchOptions = new BSTreeSearchOptions()): void { + const results = this._getSearchResults(); + results.forEach((node) => { + this._setSearchResult(node, false, options); + }); + + this._triggerEvent(EVENT_SEARCH_CLEARED, results, options); + }; + + _getSearchResults (): BSTreeViewNode[] { + return this._findNodes('true', 'searchResult'); + }; + + _diffArray (a: Array, b: Array) { + let diff: Array = []; + + b.forEach((n) => { + if (a.indexOf(n) === -1) { + diff.push(n); + } + }); + return diff; + }; + + /** + Find nodes that match a given criteria + @param {String} pattern - A given string to match against + @param {optional String} attribute - Attribute to compare pattern against + @param {optional String} modifier - Valid RegEx modifiers + @return {Array} nodes - Nodes that match your criteria + */ + _findNodes (pattern: string, attribute: string = 'text', modifier: string = 'g'): BSTreeViewNode[] { + + let tmp = []; + + this._orderedNodes.forEach((node) => { + const val = this._getNodeValue(node, attribute); + if (typeof val === 'string') { + if(val.match(new RegExp(pattern, modifier))) { + tmp.push(node); + } + } + }); + + return tmp; + }; + + /** + Recursive find for retrieving nested attributes values + All values are return as strings, unless invalid + @param {Object} obj - Typically a node, could be any object + @param {String} attr - Identifies an object property using dot notation + @return {String} value - Matching attributes string representation + */ + _getNodeValue (obj: object, attr: string): string { + const index = attr.indexOf('.'); + if (index > 0) { + const _obj = obj[attr.substring(0, index)]; + const _attr = attr.substring(index + 1, attr.length); + return this._getNodeValue(_obj, _attr); + } + else { + if (obj.hasOwnProperty(attr) && obj[attr] !== undefined) { + return obj[attr].toString(); + } + else { + return undefined; + } + } + }; + +} \ No newline at end of file diff --git a/assets/ts_src/BSTreeViewDisableOptions.ts b/assets/ts_src/BSTreeViewDisableOptions.ts new file mode 100644 index 00000000..f74773cb --- /dev/null +++ b/assets/ts_src/BSTreeViewDisableOptions.ts @@ -0,0 +1,8 @@ +import BSTreeViewEventOptions from "./BSTreeViewEventOptions"; + + +export default class BSTreeViewDisableOptions extends BSTreeViewEventOptions +{ + unselecting: boolean; + keepState: boolean; +} \ No newline at end of file diff --git a/assets/ts_src/BSTreeViewEventOptions.ts b/assets/ts_src/BSTreeViewEventOptions.ts new file mode 100644 index 00000000..3c268257 --- /dev/null +++ b/assets/ts_src/BSTreeViewEventOptions.ts @@ -0,0 +1,12 @@ +export default class BSTreeViewEventOptions { + silent: boolean = false; + ignoreChildren: boolean = false; + + lazyLoad: boolean = false; + + constructor(options: BSTreeViewEventOptions|object = null) { + if(options) { + Object.assign(this, options); + } + } +} \ No newline at end of file diff --git a/assets/ts_src/BSTreeViewExpandOptions.ts b/assets/ts_src/BSTreeViewExpandOptions.ts new file mode 100644 index 00000000..e1f8bb6e --- /dev/null +++ b/assets/ts_src/BSTreeViewExpandOptions.ts @@ -0,0 +1,7 @@ +import BSTreeViewEventOptions from "./BSTreeViewEventOptions"; + + +export default class BSTreeViewExpandOptions extends BSTreeViewEventOptions +{ + levels: number = 999; +} \ No newline at end of file diff --git a/assets/ts_src/BSTreeViewNode.ts b/assets/ts_src/BSTreeViewNode.ts new file mode 100644 index 00000000..adfc5d53 --- /dev/null +++ b/assets/ts_src/BSTreeViewNode.ts @@ -0,0 +1,44 @@ +import BSTreeViewNodeState from "./BSTreeViewNodeState"; +import BSTreeViewOptions from "./BSTreeViewOptions"; + +export default class BSTreeViewNode { + text: string; + icon: string; + image: string; + selectedIcon: string; + color: string; + backColor: string; + iconColor: string; + iconBackground: string; + selectable: boolean; + checkable: boolean; + state: BSTreeViewNodeState; + tags: string[]; + dataAttr: object; + id: string; + class: string; + hideCheckbox: boolean; + nodes: BSTreeViewNode[]; + tooltip: string; + + lazyLoad: boolean; + tagsClass: string; + + + el: HTMLElement; + + searchResult: boolean; + + + level: number; + index: number; + nodeId: string; + parentId: string + + constructor(options: BSTreeViewNode|object = null) { + if(options) { + Object.assign(this, options); + } + } +} + diff --git a/assets/ts_src/BSTreeViewNodeState.ts b/assets/ts_src/BSTreeViewNodeState.ts new file mode 100644 index 00000000..216fc126 --- /dev/null +++ b/assets/ts_src/BSTreeViewNodeState.ts @@ -0,0 +1,8 @@ +export default class BSTreeViewNodeState { + checked: boolean|null; + disabled: boolean = false; + expanded: boolean; + selected: boolean; + + visible: boolean; +} \ No newline at end of file diff --git a/assets/ts_src/BSTreeViewOptions.ts b/assets/ts_src/BSTreeViewOptions.ts new file mode 100644 index 00000000..99d72151 --- /dev/null +++ b/assets/ts_src/BSTreeViewOptions.ts @@ -0,0 +1,78 @@ +import BSTreeViewNode from "./BSTreeViewNode"; + +export default class BSTreeViewOptions { + injectStyle: boolean = true; + + levels: number = 2; + + data: BSTreeViewNode[]|string = null; + ajaxURL: string = null; + ajaxConfig: RequestInit = { + method: "GET", + }; + + expandIcon: string = 'glyphicon glyphicon-plus'; + collapseIcon: string = 'glyphicon glyphicon-minus'; + loadingIcon: string = 'glyphicon glyphicon-hourglass'; + emptyIcon: string = 'glyphicon'; + nodeIcon: string = ''; + selectedIcon: string = ''; + checkedIcon: string = 'glyphicon glyphicon-check'; + partiallyCheckedIcon: string = 'glyphicon glyphicon-expand'; + uncheckedIcon: string = 'glyphicon glyphicon-unchecked'; + tagsClass: string = 'badge'; + + color: string = undefined; + backColor: string = undefined; + borderColor: string = undefined; + changedNodeColor: string = '#39A5DC'; + onhoverColor: string = '#F5F5F5'; + selectedColor: string = '#FFFFFF'; + selectedBackColor: string = '#428bca'; + searchResultColor: string = '#D9534F'; + searchResultBackColor: string = undefined; + + highlightSelected: boolean = true; + highlightSearchResults: boolean = true; + showBorder: boolean = true; + showIcon: boolean = true; + showImage: boolean = false; + showCheckbox: boolean = false; + checkboxFirst: boolean = false; + highlightChanges: boolean = false; + showTags: boolean = false; + multiSelect: boolean = false; + preventUnselect: boolean = false; + allowReselect: boolean = false; + hierarchicalCheck: boolean = false; + propagateCheckEvent: boolean = false; + wrapNodeText: boolean = false; + + // Event handlers + onLoading: (event: Event) => void = undefined; + onLoadingFailed: (event: Event) => void = undefined; + onInitialized: (event: Event) => void = undefined; + onNodeRendered: (event: Event) => void = undefined; + onRendered: (event: Event) => void = undefined; + onDestroyed: (event: Event) => void = undefined; + + onNodeChecked: (event: Event) => void = undefined; + onNodeCollapsed: (event: Event) => void = undefined; + onNodeDisabled: (event: Event) => void = undefined; + onNodeEnabled: (event: Event) => void = undefined; + onNodeExpanded: (event: Event) => void = undefined; + onNodeSelected: (event: Event) => void = undefined; + onNodeUnchecked: (event: Event) => void = undefined; + onNodeUnselected: (event: Event) => void = undefined; + + onSearchComplete: (event: Event) => void = undefined; + onSearchCleared: (event: Event) => void = undefined; + + lazyLoad: (node: BSTreeViewNode, renderer: (nodes: BSTreeViewNode[]) => void) => void = undefined; + + constructor(options: BSTreeViewOptions|object = null) { + if(options) { + Object.assign(this, options); + } + } +} \ No newline at end of file diff --git a/assets/ts_src/BSTreeViewSelectOptions.ts b/assets/ts_src/BSTreeViewSelectOptions.ts new file mode 100644 index 00000000..f110c43e --- /dev/null +++ b/assets/ts_src/BSTreeViewSelectOptions.ts @@ -0,0 +1,7 @@ +import BSTreeViewEventOptions from "./BSTreeViewEventOptions"; + + +export default class BSTreeViewSelectOptions extends BSTreeViewEventOptions +{ + unselecting: boolean; +} \ No newline at end of file diff --git a/assets/tsconfig.json b/assets/tsconfig.json index 9c05d57d..adc8b23b 100644 --- a/assets/tsconfig.json +++ b/assets/tsconfig.json @@ -1,10 +1,10 @@ { "compilerOptions": { "module": "commonjs", - "target": "es5", + "target": "es6", "sourceMap": true, "typeRoots": ["../node_modules"], - "types": ["jquery", "bootstrap", "jquery.form", "bootstrap-treeview", "bootbox", "typeahead", "marked"] + "types": [] }, "exclude": [ "node_modules" diff --git a/webpack.config.js b/webpack.config.js index 9bd1f8f4..484b4744 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -100,7 +100,7 @@ Encore //.enableSassLoader() // uncomment if you use TypeScript - //.enableTypeScriptLoader() + .enableTypeScriptLoader() // uncomment if you use React //.enableReactPreset()