Source: treenode.js

import * as _ from 'lodash';
import { baseStateChange } from './lib/base-state-change';
import { collectionToModel } from './lib/collection-to-model';
import { objectToNode } from './lib/object-to-node';
import { Promise } from 'es6-promise';
import { recurseDown } from './lib/recurse-down';
import { standardizePromise } from './lib/standardize-promise';
import TreeNodes from './treenodes';

/**
 * Helper method to clone an ITree config object.
 *
 * Rejects non-clonable properties like ref.
 *
 * @private
 * @param {object} itree ITree configuration object
 * @param {array} excludeKeys Keys to exclude, if any
 * @return {object} Cloned ITree.
 */
function cloneItree(itree, excludeKeys) {
    const clone = {};
    excludeKeys = _.castArray(excludeKeys);
    excludeKeys.push('ref');

    _.each(itree, (v, k) => {
        if (!_.includes(excludeKeys, k)) {
            clone[k] = _.cloneDeep(v);
        }
    });

    return clone;
}

/**
 * Get or set a state value.
 *
 * This is a base method and will not invoke related changes, for example
 * setting selected=false will not trigger any deselection logic.
 *
 * @private
 * @param {TreeNode} node Tree node.
 * @param {string} property Property name.
 * @param {boolean} val New value, if setting.
 * @return {boolean} Current value on read, old value on set.
 */
function baseState(node, property, val) {
    const currentVal = node.itree.state[property];

    if (typeof val !== 'undefined' && currentVal !== val) {
        // Update values
        node.itree.state[property] = val;

        if (property !== 'rendered') {
            node.markDirty();
        }

        // Emit an event
        node._tree.emit('node.state.changed', node, property, currentVal, val);
    }

    return currentVal;
}

/**
 * Represents a singe node object within the tree.
 *
 * @param {TreeNode} source TreeNode to copy.
 * @return {TreeNode} Tree node object.
 */
class TreeNode {
    constructor(tree, source, excludeKeys) {
        this._tree = tree;

        if (source instanceof TreeNode) {
            excludeKeys = _.castArray(excludeKeys);
            excludeKeys.push('_tree');

            // Iterate manually for better perf
            _.each(source, (value, key) => {
                // Skip properties
                if (!_.includes(excludeKeys, key)) {
                    if (_.isObject(value)) {
                        if (value instanceof TreeNodes) {
                            this[key] = value.clone();
                        }
                        else if (key === 'itree') {
                            this[key] = cloneItree(value);
                        }
                        else {
                            this[key] = _.cloneDeep(value);
                        }
                    }
                    else {
                        // Copy primitives
                        this[key] = value;
                    }
                }
            });
        }
    }

    /**
     * Add a child to this node.
     *
     * @param {object} child Node object.
     * @return {TreeNode} Node object.
     */
    addChild(child) {
        if (_.isArray(this.children) || !_.isArrayLike(this.children)) {
            this.children = new TreeNodes(this._tree);
            this.children._context = this;
        }

        return this.children.addNode(child);
    }

    /**
     * Add multiple children to this node.
     *
     * @param {object} children Array of nodes.
     * @return {TreeNodes} Array of node objects.
     */
    addChildren(children) {
        const nodes = new TreeNodes(this._tree);

        if (_.isArray(this.children) || !_.isArrayLike(this.children)) {
            this.children = new TreeNodes(this._tree);
            this.children._context = this;
        }

        this.children.batch();
        _.each(children, child => {
            nodes.push(this.addChild(child));
        });
        this.children.end();

        return nodes;
    }

    /**
     * Ensure this node allows dynamic children.
     *
     * @private
     * @return {boolean} True if tree/node allows dynamic children.
     */
    allowDynamicLoad() {
        return this._tree.isDynamic && (_.isArrayLike(this.children) || this.children === true);
    }

    /**
     * Assign source object(s) to this node.
     *
     * @param {object} source Source object(s)
     * @return {TreeNode} Node object.
     */
    assign() {
        _.assign(this, ...arguments);

        this.markDirty();
        this.context().applyChanges();

        return this;
    }

    /**
     * Check if node available.
     *
     * @return {boolean} True if available.
     */
    available() {
        return (!this.hidden() && !this.removed());
    }

    /**
     * Blur focus from this node.
     *
     * @return {TreeNode} Node object.
     */
    blur() {
        this.state('editing', false);

        return baseStateChange('focused', false, 'blurred', this);
    }

    /**
     * Mark node as checked.
     *
     * @param {boolean} shallow Skip auto-checking children.
     * @return {TreeNode} Node object.
     */
    check(shallow) {
        this._tree.batch();

        // Will we automatically apply state changes to our children
        const deep = !shallow && this._tree.config.checkbox.autoCheckChildren;

        baseStateChange('checked', true, 'checked', this, deep);

        // Refresh parent
        if (this.hasParent()) {
            this.getParent().refreshIndeterminateState();
        }

        this._tree.end();

        return this;
    }

    /**
     * Get whether this node is checked.
     *
     * @return {boolean} True if node checked.
     */
    checked() {
        return this.state('checked');
    }

    /**
     * Hide parents without any visible children.
     *
     * @return {TreeNode} Node object.
     */
    clean() {
        this.recurseUp(node => {
            if (node.hasParent()) {
                const parent = node.getParent();
                if (!parent.hasVisibleChildren()) {
                    parent.hide();
                }
            }
        });

        return this;
    }

    /**
     * Clone this node.
     *
     * @param {array} excludeKeys Keys to exclude from the clone.
     * @return {TreeNode} New node object.
     */
    clone(excludeKeys) {
        return new TreeNode(this._tree, this, excludeKeys);
    }

    /**
     * Collapse this node.
     *
     * @return {TreeNode} Node object.
     */
    collapse() {
        return baseStateChange('collapsed', true, 'collapsed', this);
    }

    /**
     * Get whether this node is collapsed.
     *
     * @return {boolean} True if node collapsed.
     */
    collapsed() {
        return this.state('collapsed');
    }

    /**
     * Get the containing context. If no parent present, the root context is returned.
     *
     * @return {TreeNodes} Node array object.
     */
    context() {
        return this.hasParent() ? this.getParent().children : this._tree.model;
    }

    /**
     * Copy node to another tree instance.
     *
     * @param {object} dest Destination Inspire Tree.
     * @param {boolean} hierarchy Include necessary ancestors to match hierarchy.
     * @param {boolean} includeState Include itree.state object.
     * @return {object} Property "to" for defining destination.
     */
    copy(dest, hierarchy, includeState) {
        if (!dest || !_.isFunction(dest.addNode)) {
            throw new Error('Destination must be an Inspire Tree instance.');
        }

        let node = this;
        if (hierarchy) {
            node = node.copyHierarchy(false, includeState);
        }

        return dest.addNode(_.cloneDeep(node.toObject(false, includeState)));
    }

    /**
     * Copy all parents of a node.
     *
     * @param {boolean} excludeNode Exclude given node from hierarchy.
     * @param {boolean} includeState Include itree.state object.
     * @return {TreeNode} Root node object with hierarchy.
     */
    copyHierarchy(excludeNode, includeState) {
        const nodes = [];
        let parents = this.getParents();

        // Remove old hierarchy data
        _.each(parents, node => {
            nodes.push(node.toObject(excludeNode, includeState));
        });

        parents = nodes.reverse();

        if (!excludeNode) {
            const clone = this.toObject(true, includeState);

            // Filter out hidden children
            if (this.hasChildren()) {
                clone.children = this.children.filterBy(n => !n.state('hidden')).toArray();

                clone.children._context = clone;
            }

            nodes.push(clone);
        }

        const hierarchy = nodes[0];
        const l = nodes.length;
        let pointer = hierarchy;
        _.each(nodes, (parent, key) => {
            const children = [];

            if (key + 1 < l) {
                children.push(nodes[key + 1]);
                pointer.children = children;

                pointer = pointer.children[0];
            }
        });

        return objectToNode(this._tree, hierarchy);
    }

    /**
     * Deselect this node.
     *
     * If selection.require is true and this is the last selected
     * node, the node will remain in a selected state.
     *
     * @param {boolean} shallow Skip auto-deselecting children.
     * @return {TreeNode} Node object.
     */
    deselect(shallow) {
        if (this.selected() && (!this._tree.config.selection.require || this._tree.selected().length > 1)) {
            this.context().batch();

            // Will we apply this state change to our children?
            const deep = !shallow && this._tree.config.selection.autoSelectChildren;

            baseStateChange('selected', false, 'deselected', this, deep);

            this.context().end();
        }

        return this;
    }

    /**
     * Get whether node editable. Required editing.edit to be enable via config.
     *
     * @return {boolean} True if node editable.
     */
    editable() {
        return this._tree.config.editable && this._tree.config.editing.edit && this.state('editable');
    }

    /**
     * Get whether node is currently in edit mode.
     *
     * @return {boolean} True if node in edit mode.
     */
    editing() {
        return this.state('editing');
    }

    /**
     * Expand this node.
     *
     * @return {Promise<TreeNode>} Promise resolved on successful load and expand of children.
     */
    expand() {
        return new Promise((resolve, reject) => {
            const allow = (this.hasChildren() || (this._tree.isDynamic && this.children === true));

            if (allow && (this.collapsed() || this.hidden())) {
                this.state('collapsed', false);
                this.state('hidden', false);

                this._tree.emit('node.expanded', this);

                if (this._tree.isDynamic && this.children === true) {
                    this.loadChildren().then(resolve).catch(reject);
                }
                else {
                    this.context().applyChanges();
                    resolve(this);
                }
            }
            else {
                // Resolve immediately
                resolve(this);
            }
        });
    }

    /**
     * Get whether node expanded.
     *
     * @return {boolean} True if expanded.
     */
    expanded() {
        return !this.collapsed();
    }

    /**
     * Expand parent nodes.
     *
     * @return {TreeNode} Node object.
     */
    expandParents() {
        if (this.hasParent()) {
            this.getParent().recurseUp(node => {
                node.expand();
            });
        }

        return this;
    }

    /**
     * Focus a node without changing its selection.
     *
     * @return {TreeNode} Node object.
     */
    focus() {
        if (!this.focused()) {
            // Batch selection changes
            this._tree.batch();
            this._tree.blurDeep();
            this.state('focused', true);

            // Emit this event
            this._tree.emit('node.focused', this);

            // Mark hierarchy dirty and apply
            this.markDirty();
            this._tree.end();
        }

        return this;
    }

    /**
     * Get whether this node is focused.
     *
     * @return {boolean} True if node focused.
     */
    focused() {
        return this.state('focused');
    }

    /**
     * Get children for this node. If no children exist, an empty TreeNodes
     * collection is returned for safe chaining.
     *
     * @return {TreeNodes} Array of node objects.
     */
    getChildren() {
        return this.hasChildren() ? this.children : new TreeNodes(this._tree);
    }

    /**
     * Get the immediate parent, if any.
     *
     * @return {TreeNode} Node object.
     */
    getParent() {
        return this.itree.parent;
    }

    /**
     * Get parent nodes. Excludes any siblings.
     *
     * @return {TreeNodes} Node objects.
     */
    getParents() {
        const parents = new TreeNodes(this._tree);

        if (this.hasParent()) {
            this.getParent().recurseUp(node => {
                parents.push(node);
            });
        }

        return parents;
    }

    /**
     * Get a textual hierarchy for a given node. An array
     * of text from this node's root ancestor to the given node.
     *
     * @return {array} Array of node texts.
     */
    getTextualHierarchy() {
        const paths = [];

        this.recurseUp(node => {
            paths.unshift(node.text);
        });

        return paths;
    }

    /**
     * Get whether the given node is an ancestor of this node.
     *
     * @param {TreeNode} node Node object.
     * @return {boolean} True if node is an ancestor or the given node
     */
    hasAncestor(node) {
        let hasAncestor = false;
        this.recurseUp(n => !(hasAncestor = n.id === node.id));

        return hasAncestor;
    }

    /**
     * Get whether node has any children.
     *
     * @return {boolean} True if has loaded children.
     */
    hasChildren() {
        return (_.isArrayLike(this.children) && this.children.length > 0);
    }

    /**
     * Get whether children have been loaded. Will always be true for non-dynamic nodes.
     *
     * @return {boolean} True if we've attempted to load children.
     */
    hasLoadedChildren() {
        return _.isArrayLike(this.children);
    }

    /**
     * Get whether node has any children, or allows dynamic loading.
     *
     * @return {boolean} True if node has, or will have children.
     */
    hasOrWillHaveChildren() {
        return _.isArrayLike(this.children) ? Boolean(this.children.length) : this.allowDynamicLoad();
    }

    /**
     * Get whether node has a parent.
     *
     * @return {boolean} True if has a parent.
     */
    hasParent() {
        return Boolean(this.itree.parent);
    }

    /**
     * Get whether node has any visible children.
     *
     * @return {boolean} True if children are visible.
     */
    hasVisibleChildren() {
        let hasVisibleChildren = false;

        if (this.hasChildren()) {
            hasVisibleChildren = (this.children.filterBy('available').length > 0);
        }

        return hasVisibleChildren;
    }

    /**
     * Hide this node.
     *
     * @return {TreeNode} Node object.
     */
    hide() {
        const node = baseStateChange('hidden', true, 'hidden', this);

        // Update children
        if (node.hasChildren()) {
            node.children.hide();
        }

        return node;
    }

    /**
     * Get whether this node is hidden.
     *
     * @return {boolean} True if node hidden.
     */
    hidden() {
        return this.state('hidden');
    }

    /**
     * Get a "path" of indices, values which map this node's location within all parent contexts.
     *
     * @return {string} Index path
     */
    indexPath() {
        const indices = [];

        this.recurseUp(node => {
            indices.push(_.indexOf(node.context(), node));
        });

        return indices.reverse().join('.');
    }

    /**
     * Get whether this node is indeterminate.
     *
     * @return {boolean} True if node indeterminate.
     */
    indeterminate() {
        return this.state('indeterminate');
    }

    /**
     * Get whether this node is the first renderable in its context.
     *
     * @return {boolean} True if node is first renderable
     */
    isFirstRenderable() {
        return this === this.context().firstRenderableNode;
    }

    /**
     * Get whether this node is the last renderable in its context.
     *
     * @return {boolean} True if node is last renderable
     */
    isLastRenderable() {
        return this === this.context().lastRenderableNode;
    }

    /**
     * Get whether this node is the only renderable in its context.
     *
     * @return {boolean} True if node is only renderable
     */
    isOnlyRenderable() {
        return this.isFirstRenderable() && this.isLastRenderable();
    }

    /**
     * Find the last + deepest visible child of the previous sibling.
     *
     * @return {TreeNode} Node object.
     */
    lastDeepestVisibleChild() {
        let found;

        if (this.hasChildren() && !this.collapsed()) {
            found = _.findLast(this.children, node => node.visible());

            const res = found.lastDeepestVisibleChild();
            if (res) {
                found = res;
            }
        }

        return found;
    }

    /**
     * Initiate a dynamic load of children for a given node.
     *
     * This requires `tree.config.data` to be a function which accepts
     * three arguments: node, resolve, reject.
     *
     * Use the `node` to filter results.
     *
     * On load success, pass the result array to `resolve`.
     * On error, pass the Error to `reject`.
     *
     * @return {Promise<TreeNodes>} Promise resolving children nodes.
     */
    loadChildren() {
        return new Promise((resolve, reject) => {
            if (!this.allowDynamicLoad()) {
                return reject(new Error('Node does not have or support dynamic children.'));
            }

            this.state('loading', true);
            this.markDirty();
            this.context().applyChanges();

            const complete = (nodes, totalNodes) => {
                // A little type-safety for silly situations
                if (!_.isArrayLike(nodes)) {
                    return reject(new TypeError('Loader requires an array-like `nodes` parameter.'));
                }

                this.context().batch();
                this.state('loading', false);

                const model = collectionToModel(this._tree, nodes, this);
                if (_.isArrayLike(this.children)) {
                    this.children = this.children.concat(model);
                }
                else {
                    this.children = model;
                }

                if (_.parseInt(totalNodes) > nodes.length) {
                    this.children._pagination.total = _.parseInt(totalNodes);
                }

                // If using checkbox mode, share selection with newly loaded children
                if (this._tree.config.selection.mode === 'checkbox' && this.selected()) {
                    this.children.select();
                }

                this.markDirty();
                this.context().end();

                resolve(this.children);

                this._tree.emit('children.loaded', this);
            };

            const error = err => {
                this.state('loading', false);
                this.children = new TreeNodes(this._tree);
                this.children._context = this;
                this.markDirty();
                this.context().applyChanges();

                reject(err);

                this._tree.emit('tree.loaderror', err);
            };

            const pagination = this._tree.constructor.isTreeNodes(this.children) ? this.children.pagination() : null;
            const loader = this._tree.config.data(this, complete, error, pagination);

            // Data loader is likely a promise
            if (_.isObject(loader)) {
                standardizePromise(loader).then(complete).catch(error);
            }
        });
    }

    /**
     * Get whether this node is loading child data.
     *
     * @return {boolean} True if node's children are loading.
     */
    loading() {
        return this.state('loading');
    }

    /**
     * Load additional children.
     *
     * @param {Event} event Click or scroll event if DOM interaction triggered this call.
     * @return {Promise<TreeNodes>} Resolves with request results.
     */
    loadMore() {
        if (!this.children || this.children === true) {
            return Promise.reject(new Error('Children have not yet been loaded.'));
        }

        return this.children.loadMore();
    }

    /**
     * Mark node as dirty. For some systems this assists with rendering tracking.
     *
     * @return {TreeNode} Node object.
     */
    markDirty() {
        if (!this.itree.dirty) {
            this.itree.dirty = true;

            if (this.hasParent()) {
                this.getParent().markDirty();
            }
        }

        return this;
    }

    /**
     * Get whether this node was matched during the last search.
     *
     * @return {boolean} True if node matched.
     */
    matched() {
        return this.state('matched');
    }

    /**
     * Find the next visible sibling of our ancestor. Continues
     * seeking up the tree until a valid node is found or we
     * reach the root node.
     *
     * @return {TreeNode} Node object.
     */
    nextVisibleAncestralSiblingNode() {
        let next;

        if (this.hasParent()) {
            const parent = this.getParent();
            next = parent.nextVisibleSiblingNode();

            if (!next) {
                next = parent.nextVisibleAncestralSiblingNode();
            }
        }

        return next;
    }

    /**
     * Find next visible child node.
     *
     * @return {TreeNode} Node object, if any.
     */
    nextVisibleChildNode() {
        let next;

        if (this.hasChildren()) {
            next = _.find(this.children, child => child.visible());
        }

        return next;
    }

    /**
     * Get the next visible node.
     *
     * @return {TreeNode} Node object if any.
     */
    nextVisibleNode() {
        let next;

        // 1. Any visible children
        next = this.nextVisibleChildNode();

        // 2. Any Siblings
        if (!next) {
            next = this.nextVisibleSiblingNode();
        }

        // 3. Find sibling of ancestor(s)
        if (!next) {
            next = this.nextVisibleAncestralSiblingNode();
        }

        return next;
    }

    /**
     * Find the next visible sibling node.
     *
     * @return {TreeNode} Node object, if any.
     */
    nextVisibleSiblingNode() {
        const context = (this.hasParent() ? this.getParent().children : this._tree.nodes());
        const i = _.findIndex(context, { id: this.id });

        return _.find(_.slice(context, i + 1), node => node.visible());
    }

    /**
     * Get pagination object for this tree node.
     *
     * @return {object} Pagination configuration object.
     */
    pagination() {
        return _.get(this, 'children._pagination');
    }

    /**
     * Find the previous visible node.
     *
     * @return {TreeNode} Node object, if any.
     */
    previousVisibleNode() {
        let prev;

        // 1. Any Siblings
        prev = this.previousVisibleSiblingNode();

        // 2. If that sibling has children though, go there
        if (prev && prev.hasChildren() && !prev.collapsed()) {
            prev = prev.lastDeepestVisibleChild();
        }

        // 3. Parent
        if (!prev && this.hasParent()) {
            prev = this.getParent();
        }

        return prev;
    }

    /**
     * Find the previous visible sibling node.
     *
     * @return {TreeNode} Node object, if any.
     */
    previousVisibleSiblingNode() {
        const context = (this.hasParent() ? this.getParent().children : this._tree.nodes());
        const i = _.findIndex(context, { id: this.id });

        return _.findLast(_.slice(context, 0, i), node => node.visible());
    }

    /**
     * Iterate down node and any children.
     *
     * @param {function} iteratee Iteratee function.
     * @return {TreeNode} Node object.
     */
    recurseDown(iteratee) {
        recurseDown(this, iteratee);

        return this;
    }

    /**
     * Iterate up a node and its parents.
     *
     * @param {function} iteratee Iteratee function.
     * @return {TreeNode} Node object.
     */
    recurseUp(iteratee) {
        const result = iteratee(this);

        if (result !== false && this.hasParent()) {
            this.getParent().recurseUp(iteratee);
        }

        return this;
    }

    /**
     * Update the indeterminate state of this node by scanning states of children.
     *
     * True if some, but not all children are checked.
     * False if no children are checked.
     *
     * @return {TreeNode} Node object.
     */
    refreshIndeterminateState() {
        const oldValue = this.indeterminate();
        this.state('indeterminate', false);

        if (this.hasChildren()) {
            const childrenCount = this.children.length;
            let indeterminate = 0;
            let checked = 0;

            this.children.each(n => {
                if (n.checked()) {
                    checked++;
                }

                if (n.indeterminate()) {
                    indeterminate++;
                }
            });

            // Set selected if all children are
            if (checked === childrenCount) {
                baseStateChange('checked', true, 'checked', this);
            }
            else {
                baseStateChange('checked', false, 'unchecked', this);
            }

            // Set indeterminate if any children are, or some children are selected
            if (!this.checked()) {
                this.state('indeterminate', indeterminate > 0 || (childrenCount > 0 && checked > 0 && checked < childrenCount));
            }
        }

        if (this.hasParent()) {
            this.getParent().refreshIndeterminateState();
        }

        if (oldValue !== this.state('indeterminate')) {
            this.markDirty();
        }

        return this;
    }

    /**
     * Remove all current children and re-execute a loadChildren call.
     *
     * @return {Promise<TreeNodes>} Promise resolved on load.
     */
    reload() {
        return new Promise((resolve, reject) => {
            if (!this.allowDynamicLoad()) {
                return reject(new Error('Node or tree does not support dynamic children.'));
            }

            // Reset children
            this.children = true;

            // Collapse
            this.collapse();

            // Load and the proxy the promise
            this.loadChildren().then(resolve).catch(reject);
        });
    }

    /**
     * Remove a node from the tree.
     *
     * @param {boolean} includeState Include itree.state object.
     * @return {object} Removed tree node object.
     */
    remove(includeState = false) {
        // Cache parent before we remove the node
        const parent = this.getParent();

        // Remove self
        this.context().remove(this);

        // Refresh parent states
        if (parent) {
            parent.refreshIndeterminateState();
            parent.markDirty();
        }

        const pagination = parent ? parent.pagination() : this._tree.pagination();
        pagination.total--;

        // Export/event
        const exported = this.toObject(false, includeState);
        this._tree.emit('node.removed', exported, parent);

        this.context().applyChanges();

        return exported;
    }

    /**
     * Get whether this node is soft-removed.
     *
     * @return {boolean} True if node soft-removed.
     */
    removed() {
        return this.state('removed');
    }

    /**
     * Get whether this node can be "rendered" when the context is.
     * Hidden and removed nodes may still be included in the DOM,
     * but not "rendered" in a sense they'll be visible.
     *
     * @return {boolean} If not hidden or removed
     */
    renderable() {
        return !this.hidden() && !this.removed();
    }

    /**
     * Get whether this node has been rendered.
     *
     * Will be false if deferred rendering is enable and the node has
     * not yet been loaded, or if a custom DOM renderer is used.
     *
     * @return {boolean} True if node rendered.
     */
    rendered() {
        return this.state('rendered');
    }

    /**
     * Restore state if soft-removed.
     *
     * @return {TreeNode} Node object.
     */
    restore() {
        return baseStateChange('removed', false, 'restored', this);
    }

    /**
     * Select this node.
     *
     * @param {boolean} shallow Skip auto-selecting children.
     * @return {TreeNode} Node object.
     */
    select(shallow) {
        if (!this.selected() && this.selectable()) {
            // Batch selection changes
            this._tree.batch();

            if (this._tree.canAutoDeselect()) {
                const oldVal = this._tree.config.selection.require;
                this._tree.config.selection.require = false;
                this._tree.deselectDeep();
                this._tree.config.selection.require = oldVal;
            }

            // Will we apply this state change to our children?
            const deep = !shallow && this._tree.config.selection.autoSelectChildren;

            baseStateChange('selected', true, 'selected', this, deep);

            // Cache as the last selected node
            this._tree._lastSelectedNode = this;

            // Mark hierarchy dirty and apply
            this.markDirty();
            this._tree.end();
        }

        return this;
    }

    /**
     * Get whether node selectable.
     *
     * @return {boolean} True if node selectable.
     */
    selectable() {
        const allow = this._tree.config.selection.allow(this);
        return typeof allow === 'boolean' ? allow : this.state('selectable');
    }

    /**
     * Get whether this node is selected.
     *
     * @return {boolean} True if node selected.
     */
    selected() {
        return this.state('selected');
    }

    /**
     * Set a root property on this node.
     *
     * @param {string|number} property Property name.
     * @param {*} value New value.
     * @return {TreeNode} Node object.
     */
    set(property, value) {
        this[property] = value;

        this.markDirty();
        this.context().applyChanges();

        return this;
    }

    /**
     * Show this node.
     *
     * @return {TreeNode} Node object.
     */
    show() {
        return baseStateChange('hidden', false, 'shown', this);
    }

    /**
     * Mark this node as "removed" without actually removing it.
     *
     * Expand/show methods will never reveal this node until restored.
     *
     * @return {TreeNode} Node object.
     */
    softRemove() {
        return baseStateChange('removed', true, 'softremoved', this, 'softRemove');
    }

    /**
     * Get or set a state value.
     *
     * This is a base method and will not invoke related changes, for example
     * setting selected=false will not trigger any deselection logic.
     *
     * @param {string|object} obj Property name or Key/Value state object.
     * @param {boolean} val New value, if setting.
     * @return {boolean|object} Old state object, or old value if property name used.
     */
    state(obj, val) {
        if (_.isString(obj)) {
            return baseState(this, obj, val);
        }

        this.context().batch();

        const oldState = {};
        _.each(obj, (value, prop) => {
            oldState[prop] = baseState(this, prop, value);
        });

        this.context().end();

        return oldState;
    }

    /**
     * Get or set multiple state values to a single value.
     *
     * @param {Array} names Property names.
     * @param {boolean} newVal New value, if setting.
     * @return {Array} Array of state booleans
     */
    states(names, newVal) {
        const results = [];

        this.context().batch();

        _.each(names, name => {
            results.push(this.state(name, newVal));
        });

        this.context().end();

        return results;
    }

    /**
     * Swap position with the given node.
     *
     * @param {TreeNode} node Node.
     * @return {TreeNode} Node objects.
     */
    swap(node) {
        this.context().swap(this, node);

        return this;
    }

    /**
     * Toggle checked state.
     *
     * @return {TreeNode} Node object.
     */
    toggleCheck() {
        return (this.checked() ? this.uncheck() : this.check());
    }

    /**
     * Toggle collapsed state.
     *
     * @return {TreeNode} Node object.
     */
    toggleCollapse() {
        return (this.collapsed() ? this.expand() : this.collapse());
    }

    /**
     * Toggle editing state.
     *
     * @return {TreeNode} Node object.
     */
    toggleEditing() {
        this.state('editing', !this.state('editing'));

        this.markDirty();
        this.context().applyChanges();

        return this;
    }

    /**
     * Toggle selected state.
     *
     * @return {TreeNode} Node object.
     */
    toggleSelect() {
        return (this.selected() ? this.deselect() : this.select());
    }

    /**
     * Export this node as a native Object.
     *
     * @param {boolean} excludeChildren Exclude children.
     * @param {boolean} includeState Include itree.state object.
     * @return {object} Node object.
     */
    toObject(excludeChildren = false, includeState = false) {
        const exported = {};

        const keys = _.pull(Object.keys(this), '_tree', 'children', 'itree');

        // Map keys
        _.each(keys, keyName => {
            exported[keyName] = this[keyName];
        });

        // Copy over whitelisted itree data
        // Excludes internal-use junk like parent, dirty, ref
        const itree = exported.itree = {};
        itree.a = this.itree.a;
        itree.icon = this.itree.icon;
        itree.li = this.itree.li;

        if (includeState) {
            itree.state = this.itree.state;
        }

        // If including children, export them
        if (!excludeChildren && this.hasChildren() && _.isFunction(this.children.toArray)) {
            exported.children = this.children.toArray();
        }

        return exported;
    }

    /**
     * Get the text content of this tree node.
     *
     * @return {string} Text content.
     */
    toString() {
        return this.text;
    }

    /**
     * Get the tree this node ultimately belongs to.
     *
     * @return {InspireTree} Tree instance.
     */
    tree() {
        return this.context().tree();
    }

    /**
     * Uncheck this node.
     *
     * @param {boolean} shallow Skip auto-unchecking children.
     * @return {TreeNode} Node object.
     */
    uncheck(shallow) {
        this._tree.batch();

        // Will we apply this state change to our children?
        const deep = !shallow && this._tree.config.checkbox.autoCheckChildren;

        baseStateChange('checked', false, 'unchecked', this, deep);

        // Reset indeterminate state
        this.state('indeterminate', false);

        // Refresh our parent
        if (this.hasParent()) {
            this.getParent().refreshIndeterminateState();
        }

        this._tree.end();

        return this;
    }

    /**
     * Get whether node is visible to a user. Returns false
     * if it's hidden, or if any ancestor is hidden or collapsed.
     *
     * @return {boolean} Whether visible.
     */
    visible() {
        let isVisible = true;
        if (this.hidden() || this.removed() || (this._tree.usesNativeDOM && !this.rendered())) {
            isVisible = false;
        }
        else if (this.hasParent()) {
            if (this.getParent().collapsed()) {
                isVisible = false;
            }
            else {
                isVisible = this.getParent().visible();
            }
        }
        else {
            isVisible = true;
        }

        return isVisible;
    }
}

export default TreeNode;