liquor-tree
Version:
A Vue.js tree component.
742 lines (564 loc) • 14.6 kB
JavaScript
import Node from '../lib/Node'
import Selection from '../lib/Selection'
import find from '../utils/find'
import objectToNode from '../utils/objectToNode'
import { List } from '../utils/stack'
import { TreeParser } from '../utils/treeParser'
import { recurseDown } from '../utils/recurse'
import { get, createTemplate } from '../utils/request'
import sort from '../utils/sort'
import fetchDelay from '../utils/fetchDelay'
export default class Tree {
constructor (vm) {
this.vm = vm
this.options = vm.opts
this.activeElement = null
// We have to convert 'fetchData' to function. It must return Promise always
const fetchData = this.options.fetchData
if (typeof fetchData === 'string') {
this.options.fetchData = ((template) => {
const urlTemplate = createTemplate(template)
return node => {
return get(urlTemplate(node)).catch(this.options.onFetchError)
}
})(fetchData)
}
}
$on (name, ...args) {
this.vm.$on(name, ...args)
}
$once (name, ...args) {
this.vm.$once(name, ...args)
}
$off (name, ...args) {
this.vm.$off(name, ...args)
}
$emit (name, ...args) {
if (this.__silence) {
return
}
this.vm.$emit(name, ...args)
if (this.options.store) {
this.vm.$emit('LIQUOR_NOISE')
}
}
_sort (source, compareFn, deep) {
if (deep !== false) {
this.recurseDown(source, node => {
if (node.hasChildren()) {
sort(node.children, compareFn)
}
})
}
sort(source, compareFn)
}
sortTree (compareFn, deep) {
this._sort(this.model, compareFn, deep)
}
sort (query, compareFn, deep) {
const targetNode = this.find(query, true)
if (!targetNode || !compareFn) {
return
}
targetNode.forEach(node => {
this._sort(node.children, compareFn, deep)
})
}
clearFilter () {
this.recurseDown(node => {
node.state('matched', false)
node.state('visible', true)
node.state('expanded', node.__expanded)
node.__expanded = undefined
node.showChildren = true
})
this.vm.matches.length = 0
this.vm.$emit('tree:filtered', [], '')
}
filter (query) {
if (!query) {
return this.clearFilter()
}
const matches = []
const predicate = this.options.filter.matcher
const { showChildren, plainList } = this.options.filter
// collect nodes
this.recurseDown(node => {
if (predicate(query, node)) {
matches.push(node)
}
node.showChildren = true
// save prev `expanded` state
if (undefined === node.__expanded) {
node.__expanded = node.state('expanded')
}
node.state('visible', false)
node.state('matched', false)
node.state('expanded', true)
})
matches.reverse().forEach(node => {
node.state('matched', true)
node.state('visible', true)
node.showChildren = !plainList
if (node.hasChildren()) {
node.recurseDown(n => {
n.state('visible', !!showChildren)
}, true)
}
node.recurseUp(parent => {
parent.state('visible', true)
parent.state('expanded', true)
})
if (node.hasChildren()) {
node.state('expanded', false)
}
})
this.vm.matches = matches
this.vm.$emit('tree:filtered', matches, query)
return matches
}
selected () {
return new Selection(this, ...this.selectedNodes)
}
checked () {
if (!this.options.checkbox) {
return null
}
return new Selection(this, ...this.checkedNodes)
}
loadChildren (node) {
if (!node) {
return
}
this.$emit('tree:data:fetch', node)
if (this.options.minFetchDelay > 0 && node.vm) {
node.vm.loading = true
}
const result = this.fetch(node)
.then(children => {
node.append(children)
node.isBatch = false
if (this.options.autoCheckChildren) {
if (node.checked()) {
node.recurseDown(child => {
child.state('checked', true)
})
}
node.refreshIndeterminateState()
}
this.$emit('tree:data:received', node)
})
return Promise.all([
fetchDelay(this.options.minFetchDelay),
result
]).then(_ => {
if (node.vm) {
node.vm.loading = false
}
return result
})
}
fetch (node, parseData) {
let result = this.options.fetchData(node)
if (!result.then) {
result = get(result)
.catch(this.options.onFetchError)
}
if (parseData === false) {
return result
}
return result
.then(data => {
try {
return this.parse(data, this.options.modelParse)
} catch (e) {
throw new Error(e)
}
})
.catch(this.options.onFetchError)
}
fetchInitData () {
// simulate root node
const node = {
id: 'root',
name: 'root'
}
return this.fetch(node, false)
}
setModel (data) {
return new Promise(resolve => {
this.model = this.parse(data, this.options.modelParse)
/* eslint-disable */
requestAnimationFrame(_ => {
this.vm.model = this.model
resolve()
})
/* eslint-enable */
/**
* VueJS transform properties to reactives when constructor is running
* And we lose List object (extended from Array)
*/
this.selectedNodes = new List()
this.checkedNodes = new List()
recurseDown(this.model, node => {
node.tree = this
if (node.selected()) {
this.selectedNodes.add(node)
}
if (node.checked()) {
this.checkedNodes.add(node)
if (node.parent) {
node.parent.refreshIndeterminateState()
}
}
if (this.options.autoDisableChildren && node.disabled()) {
node.recurseDown(child => {
child.state('disabled', true)
})
}
})
if (!this.options.multiple && this.selectedNodes.length) {
const top = this.selectedNodes.top()
this.selectedNodes.forEach(node => {
if (top !== node) {
node.state('selected', false)
}
})
this.selectedNodes
.empty()
.add(top)
}
// Nodes can't be selected on init. By it's possible to select through API
if (this.options.checkOnSelect && this.options.checkbox) {
this.unselectAll()
}
})
}
recurseDown (node, fn) {
if (!fn && node) {
fn = node
node = this.model
}
if (typeof fn !== 'function') {
new TypeError('Argument must be a function')
}
return recurseDown(node, fn)
}
select (node, extendList) {
const treeNode = this.getNode(node)
if (!treeNode) {
return false
}
if (this.options.multiple && extendList) {
this.selectedNodes.add(treeNode)
} else {
this.unselectAll()
this.selectedNodes
.empty()
.add(treeNode)
}
return true
}
selectAll () {
if (!this.options.multiple) {
return false
}
this.selectedNodes.empty()
this.recurseDown(node => {
this.selectedNodes.add(
node.select(true)
)
})
return true
}
unselect (node) {
const treeNode = this.getNode(node)
if (!treeNode) {
return false
}
this.selectedNodes.remove(treeNode)
return true
}
unselectAll () {
let node
while (node = this.selectedNodes.pop()) {
node.unselect()
}
return true
}
check (node) {
this.checkedNodes.add(node)
}
uncheck (node) {
this.checkedNodes.remove(node)
}
checkAll () {
this.recurseDown(node => {
if (node.depth === 0) {
if (node.indeterminate()) {
node.state('indeterminate', false)
}
node.check()
}
})
}
uncheckAll () {
let node
while (node = this.checkedNodes.pop()) {
node.uncheck()
}
return true
}
expand (node) {
if (node.expanded()) {
return false
}
node.expand()
return true
}
collapse (node) {
if (node.collapsed()) {
return false
}
node.collapse()
return true
}
toggleExpand (node) {
if (!node.hasChildren()) {
return false
}
node.toggleExpand()
return true
}
toggleCollapse (node) {
if (!node.hasChildren()) {
return false
}
node.toggleCollapse()
return true
}
expandAll () {
this.recurseDown(node => {
if (node.hasChildren() && node.collapsed()) {
node.expand()
}
})
}
collapseAll () {
this.recurseDown(node => {
if (node.hasChildren() && node.expanded()) {
node.collapse()
}
})
}
index (node, verbose) {
let target = node.parent
if (target) {
target = target.children
} else {
target = this.model
}
const index = target.indexOf(node)
if (verbose) {
return {
index: index,
target,
node: target[index]
}
}
return index
}
nextNode (node) {
const { target, index } = this.index(node, true)
return target[index + 1] || null
}
nextVisibleNode (node) {
if (node.hasChildren() && node.expanded()) {
return node.first()
}
const nextNode = this.nextNode(node)
if (!nextNode && node.parent) {
return node.parent.next()
}
return nextNode
}
prevNode (node) {
const { target, index } = this.index(node, true)
return target[index - 1] || null
}
prevVisibleNode (node) {
const prevNode = this.prevNode(node)
if (!prevNode) {
return node.parent
}
if (prevNode.hasChildren() && prevNode.expanded()) {
return prevNode.last()
}
return prevNode
}
addToModel (node, index = this.model.length) {
node = this.objectToNode(node)
this.model.splice(index, 0, node)
this.recurseDown(node, n => {
n.tree = this
})
this.$emit('node:added', node)
return node
}
append (criteria, node) {
const targetNode = this.find(criteria)
if (targetNode) {
return targetNode.append(node)
}
return false
}
prepend (criteria, node) {
const targetNode = this.find(criteria)
if (targetNode) {
return targetNode.prepend(node)
}
return false
}
before (targetNode, sourceNode) {
targetNode = this.find(targetNode)
const position = this.index(targetNode, true)
const node = this.objectToNode(sourceNode)
if (!~position.index) {
return false
}
position.target.splice(
position.index,
0,
node
)
node.parent = targetNode.parent
this.$emit('node:added', node)
return node
}
after (targetNode, sourceNode) {
targetNode = this.find(targetNode)
const position = this.index(targetNode, true)
const node = this.objectToNode(sourceNode)
if (!~position.index) {
return false
}
position.target.splice(
position.index + 1,
0,
node
)
node.parent = targetNode.parent
this.$emit('node:added', node)
return node
}
addNode (node) {
const index = this.model.length
node = objectToNode(node)
this.model.splice(index, 0, node)
this.$emit('node:added', node)
return node
}
remove (criteria, multiple) {
return this.removeNode(
this.find(criteria, multiple)
)
}
removeNode (node) {
if (node instanceof Selection) {
return node.remove()
}
if (!node) {
return false
}
if (!node.parent) {
if (~this.model.indexOf(node)) {
this.model.splice(
this.model.indexOf(node),
1
)
}
} else {
const children = node.parent.children
if (~children.indexOf(node)) {
children.splice(
children.indexOf(node),
1
)
}
}
if (node.parent) {
if (node.parent.indeterminate() && !node.parent.hasChildren()) {
node.parent.state('indeterminate', false)
}
}
if (this.activeElement !== null) {
if (node.id === this.activeElement.id) {
this.activeElement = null
}
}
node.parent = null
this.$emit('node:removed', node)
this.selectedNodes.remove(node)
this.checkedNodes.remove(node)
const matches = this.vm.matches
if (matches && matches.length) {
if (matches.includes(node)) {
matches.splice(
matches.indexOf(node),
1
)
}
}
return node
}
isNode (node) {
return node instanceof Node
}
find (criteria, multiple) {
if (this.isNode(criteria)) {
return criteria
}
const result = find(this.model, criteria)
if (!result || !result.length) {
return new Selection(this, [])
}
if (multiple === true) {
return new Selection(this, result)
}
return new Selection(this, [result[0]])
}
updateData (criteria, callback) {
const nodes = this.find(criteria)
nodes.forEach(node => node.setData(callback(node)))
return nodes
}
getNodeById (id) {
let targetNode = null
recurseDown(this.model, node => {
if ('' + node.id === id) {
targetNode = node
return false
}
})
return targetNode
}
getNode (node) {
if (this.isNode(node)) {
return node
}
return null
}
objectToNode (obj) {
return objectToNode(this, obj)
}
parse (data, options) {
if (!options) {
options = this.options.propertyNames
}
try {
return TreeParser.parse(data, this, options)
} catch (e) {
return []
}
}
}