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; } } }; }