Source: node_modules/react-aiot/src/components/TreeNode.js

/** @module react-aiot/components/TreeNode */
import React from 'react';
import classNames from 'classnames';
import { Spin } from '.';
import scrollIntoView from 'dom-scroll-into-view';
import { flatArrayEqual, parents } from '../util';

/**
 * Tree node with child nodes and rename / create mode.
 * 
 * @param {object} props Properties
 * @param {string|id} props.id The unique id for this node. Added as data-li-id to the li DOMElement and as data-id to the .aiot-node div-DOMElement
 * @param {string} [props.hash=''] Use this field to force rerender of the node. This is useful if you use a state management library like mobx-state-tree and try to splice a child node.
 * @param {string} [props.className] Additional class name for the .aiot-node div DOMElement
 * @param {React.Element} [props.icon] The icon before the title
 * @param {React.Element} [props.iconActive] The active icon before the title (replaces icon)
 * @param {object[]} [props.childNodes=[]] If setted it must be a {@link module:react-aiot/components/TreeNode|TreeNode} property object array and it is added as child node to the current node
 * @param {string} [props.title] The title
 * @param {string} [props.count] The count
 * @param {object<key,string>} [props.attr] Additional attributes for the .aiot-node div DOMElement
 * @param {React.Element|string} [props.renameSaveText] If $rename is true this button text is showed next to the input field
 * @param {React.Element|string} [props.renameAddText] If $create is true this button text is showed next to the input field of the new created node
 * @param {boolean} [props.$busy] If true the node gets overlayed by a spinning loader
 * @param {boolean} [props.$droppable=true] If true the .aiot-node gets the additional class .aiot-droppable
 * @param {boolean} [props.$visible=true] If true this node is rendered
 * @param {boolean} [props.$rename] If true the title is replaced with an input field
 * @param {object} [props.$create] If setted it must be a {@link module:react-aiot/components/TreeNode|TreeNode} property map and it is added as child node to the current node
 * @param {boolean} [props.searchSelected] If true the .aiot-node gets the additional class .aiot-search-selected
 * @param {boolean} [props.expandedState=true] If true the child nodes of this node are rendered
 * @param {boolean} [props.displayChildren=true] If true the child nodes are renderable
 * @param {boolean} [props.selected] The selected ids. If the selected ids contains the current id the .aiot-node gets an additional class .aiot-active
 * @param {module:react-aiot/components/TreeNode#onRenameClose} [props.onRenameClose] 
 * @param {module:react-aiot/components/TreeNode#onAddClose} [props.onAddClose] 
 * @param {module:react-aiot/components/TreeNode#onSelect} [props.onSelect] 
 * @param {module:react-aiot/components/TreeNode#onNodePressF2} [props.onNodePressF2] 
 * @param {module:react-aiot/components/TreeNode#onExpand} [props.onExpand] 
 * @param {module:react-aiot/components/TreeNode#onUlRef} [props.onUlRef] 
 * @extends React.Component
 */
export default class TreeNode extends React.Component {
    static defaultProps = {
        id: undefined,
        hash: '',
        className: undefined,
        icon: undefined,
        iconActive: undefined,
        childNodes: [],
        title: '',
        count: 0,
        attr: {},
        renameSaveText: 'Save',
        renameAddText: 'Add',
        $busy: false,
        $droppable: true,
        $visible: true,
        $rename: undefined,
        $create: undefined,
        searchSelected: false,
        expandedState: true,
        displayChildren: true,
        selected: false,
        onRenameClose: undefined,
        onAddClose: undefined,
        onSelect: undefined,
        onNodePressF2: undefined,
        onExpand: undefined,
        onUlRef: undefined
    }
    
    static stateKeys = 'expanded,inputValue,initialInputValue'.split(',')
    
    constructor(props) {
        super(props);
        !TreeNode.propKeys && (TreeNode.propKeys = Object.keys(TreeNode.defaultProps));
        
        // Get expanded state
        const { id, expandedState } = props,
            expanded = id && typeof expandedState[id] === 'boolean' ? expandedState[id] : true;
        
        this.state = {
            expanded,
            inputValue: '',
            initialInputValue: false
        };
    }
    
    shouldComponentUpdate(nextProps, nextState) {
        const changedProps = TreeNode.propKeys.filter(k => this.props[k] !== nextProps[k]),
            changedState = TreeNode.stateKeys.filter(k => this.state[k] !== nextState[k]);
        if (!changedProps.length && !changedState.length) { // Nothing changed
            return false;
        }
        
        return true;
    }
    
    componentDidUpdate() {
        const { title, $rename, $_create, searchSelected } = this.props;
        
        // Scroll to search result
        searchSelected && this.scrollTo();
        
        // Avoid controlled / uncontrolled switch when creating a new node
        if ($_create) {
            return;
        }
        
        if (this.state.inputValue !== title && $rename && !this.state.initialInputValue) {
            this.setState({ inputValue: title, initialInputValue: true });
        }else if (!$rename && this.state.initialInputValue) {
            this.setState({ inputValue: '', initialInputValue: false });
        }
    }
    
    handleInputKeyDown = e => {
        if (e.key === 'Enter') {
            this.handleButtonSave(true);
        }else if (e.key === 'Escape') {
            this.handleButtonSave(false);
        }
    }
    
    handleNodeKeyDown = e => {
        if (e.key === 'F2' && !this.props.$rename) {
            /**
             * This function is called when a tree node is active and F2 is pressed.
             * Useful to activate the rename process.
             * 
             * @callback module:react-aiot/components/TreeNode#onNodePressF2
             * @param {object} props passed to the TreeNode component
             */
            this.props.onNodePressF2 && this.props.onNodePressF2(this.props);
        }
    }
    
    handleButtonSave = save => {
        const _save = typeof save === 'boolean' ? save : true,
            inputValue = this.state.inputValue;
        /**
         * This function is called when a new tree node should be saved or the
         * add process is cancelled.
         * 
         * @callback module:react-aiot/components/TreeNode#onAddClose
         * @param {boolean} save If true the node should be saved instead of cancelled
         * @param {string} inputValue The name for the node
         * @param {object} props passed to the TreeNode component
         */
        if (_save === true && !inputValue) {
            return;
        }
        
        /**
         * This function is called when a tree node is in rename mode and
         * the rename mode gets closed (ESC), cancelled or saved.
         * 
         * @callback module:react-aiot/components/TreeNode#onRenameClose
         * @param {boolean} save If true the node should be saved instead of cancelled
         * @param {string} inputValue The name for the node
         * @param {object} props passed to the TreeNode component
         */
        this.props.onRenameClose && this.props.onRenameClose(_save, inputValue, this.props);
    }
    
    handleChange = e => {
        this.setState({ inputValue: e.target.value });
    }
    
    handleSelect = e => {
        if (!parents(e.target, '.aiot-disable-links').length) {
            /**
             * This function is called when a tree node gets selected.
             * 
             * @callback module:react-aiot/components/TreeNode#onSelect
             * @param {string|int} id The node id
             */
            this.props.onSelect && this.props.onSelect(this.props.id);
        }
    }
    
    handleToggle = e => {
        const newExpanded = !this.state.expanded,
            onExpand = this.props.onExpand;
        this.setState({ expanded: newExpanded });
        /**
         * This function is called when a tree node is expanded or collapsed
         * 
         * @callback module:react-aiot/components/TreeNode#onExpand
         * @param {boolean} expanded If true the childrens are visible
         * @param {object} props passed to the TreeNode component
         */
        onExpand && onExpand(newExpanded, this.props);
        e.preventDefault();
    }
    
    handleRef = node => {
        this.refNode = node;
        this.props.$_create && this.scrollTo();
    }
    
    scrollTo() {
        const container = this.refNode;
        container && scrollIntoView(container, window, { onlyScrollIfNeeded: true, alignWithTop: false });
    }
    
    render() {
        const { icon, childNodes = [], id, title, count, selected, $rename, $busy, $droppable = true,
                $create, $visible = true, $_create, searchSelected, attr } = this.props,
            nodeAttr = { expandedState, displayChildren, renderItem, onRenameClose,
                onAddClose, onSelect, onNodePressF2, onExpand, onUlRef, renameSaveText, renameAddText } = this.props,
            visibleChildNodes = childNodes && childNodes.filter(({ $visible = true }) => !!$visible),
            togglable = !!(displayChildren && visibleChildNodes && visibleChildNodes.length),
            isExpanded = this.state.expanded || !!$create,
            isActive = $create ? false : $_create ? true : selected,
            className = classNames('aiot-node', this.props.className, {
                'aiot-active': isActive,
                'aiot-forceEnable': !!$rename,
                'aiot-togglable': togglable,
                'aiot-expanded': this.state.expanded,
                'aiot-search-selected': searchSelected,
                'aiot-droppable': $droppable && !$_create
            });
        
        // Tree node perhaps deleted?
        if (!$visible) {
            return null;
        }

        // Get icon
        const useIcon = selected ? this.props.iconActive || this.props.icon : icon,
            useIconObj = <div className="aiot-node-icon">{ useIcon }</div>;
            
        // Sortable
        const isUlVisible = togglable && isExpanded,
            isSortable = !!displayChildren /*@TODO && !isTreeLinkDisabled*/ && !$_create,
            /**
             * This function is called when a tree node is expanded or collapsed
             * 
             * @callback module:react-aiot/components/TreeNode#onUlRef
             * @param {DOMElement} ref The reference
             * @param {string|id} id The node id
             */
            refSortable = ref => displayChildren && ref && onUlRef && onUlRef(ref, id);
        !isUlVisible && displayChildren && onUlRef && onUlRef(undefined, id);
        
        const createTreeNode = node => (<TreeNode key={ node.id } {...node} {...nodeAttr}/>);

        // Result
        return <li className={ classNames({ 'aiot-sortable': isSortable }) } data-li-id={ id }>
            <Spin spinning={ !!$busy } size="small">
                <div data-id={ id } tabIndex="0" className={ className } onClick={ $_create ? undefined : this.handleSelect }
                    onDoubleClick={ $_create ? undefined : this.handleToggle } onKeyDown={ this.handleNodeKeyDown } {...attr} ref={ this.handleRef }>
                    { useIconObj }
                    { $rename
                        ? <input autoFocus className="aiot-node-name" value={ this.state.inputValue }
                            onChange={ this.handleChange } onKeyDown={ this.handleInputKeyDown } />
                        : <div className="aiot-node-name">{ title }</div> }
                    { count > 0 && !$rename && <div className="aiot-node-count">{ count }</div> }
                    { $rename && <button disabled={ !this.state.inputValue } onClick={ this.handleButtonSave }>{ renameSaveText }</button> }
                </div>
            </Spin>
            
            { isUlVisible && <ul data-childs-for={ id } ref={ refSortable }>
                { childNodes.map(node => {
                    if (renderItem) {
                        return renderItem(createTreeNode, TreeNode, node);
                    }else{
                        return createTreeNode(node);
                    }
                } ) }
                { !!$create && <TreeNode $_create onRenameClose={ onAddClose } renameSaveText={ renameAddText } {...$create} /> }
            </ul> }
            
            { !childNodes.length && isSortable && <ul data-childs-for={ id } ref={ refSortable } className="aiot-sortable-empty" /> }
            
            { !!$create && !togglable && <ul>
                <TreeNode $_create onRenameClose={ onAddClose } renameSaveText={ renameAddText } {...$create} />
            </ul> }
            
            { togglable && <div onClick={ this.handleToggle }
                className={ classNames('aiot-expander', { 'aiot-open': isExpanded }) } /> }
        </li>;
    }
}