Source: node_modules/react-aiot/src/index.js

/**
 * Sticky and resizable All-In-One-Tree with sortable nodes and a nice looking toolbar.
 * This module provides an exported default Tree component and some helper functions.
 * 
 * <p>Used dependencies:
 * <ul>
 *  <li><strong>{@link https://npmjs.com/package/antd/|antd}:</strong> UI library components. Only a few components are imported, see {@link module:react-aiot/components|components}.</li>
 *  <li><strong>{@link https://npmjs.com/package/dom-scroll-into-view/|dom-scroll-into-view}:</strong> Scroll DOM element into view port</li>
 *  <li><strong>{@link https://npmjs.com/package/immer/|immer}:</strong> Create the next immutable state by mutating the current one</li>
 *  <li><strong>{@link https://npmjs.com/package/react-stickynode/|react-stickynode}:</strong> Create sticky components</li>
 *  <li><strong>{@link https://npmjs.com/package/sortablejs/|sortablejs}:</strong> Make the tree sortable</li>
 * </ul></p>
 * 
 * <p>Builds:
 * <ul>
 *  <li>The usual build is excluding react and react-dom.</li>
 *  <li>If you are using the UMD version in your browser you also have to add <code>immer</code> and <code>sortablejs</code> to your scripts.</li>
 * </ul></p>
 * @module react-aiot
 */

import classNames from 'classnames';
import React from 'react';
import { uuid, Storage, SUPPORTS_LOCAL_STORAGE, updateTreeItemById, getTreeItemById, buildOrderedParentPairs, getTreeParentById } from './util';
import { handleSearch, handleSearchBlur, handleSearchClose, handleSearchKeyDown } from './util';
import { handleSortableTreeInit, handleSortableTree, handleSortableTreeWillUpdate } from './util';
import { Header, ResizeButton, TreeNode, Input, BusyIcon, Alert, Spin, Icon,
    Toolbar, ToolbarButton, Creatable, Dropdown, Menu, Tooltip, Button, message, Popconfirm } from './components';
import Sticky from 'react-stickynode';
import './style/style.scss';

/**
 * AIO properties
 * 
 * @typedef module:react-aiot~Tree~Properties
 * @property {string} [theme='default'] The theme, appended as class aio-theme-{theme} to the rendered <div>
 * @property {string} [id] The id for the rendered <div>, otherwise an unique id is generated
 * @property {string} [className] Additional classnames
 * @property {string} [innerClassName] Additional classnames for the inner wrapper (.aio-pad)
 * @property {object} [attr] Additional attributes for the rendered <div>
 * @property {boolean} [isSticky=false] If true the sidebar gets sticky
 * @property {boolean} [isStickyHeader=false] If true the sidebar header gets sticky
 * @property {boolean} [isBusyHeader=false] Mark the header with a spinning loader
 * @property {object} [treeStickyAttr] Sticky options (see {@link https://github.com/yahoo/react-stickynode|react-stickynode})
 * @property {object} [headerStickyAttr] Sticky options (see {@link https://github.com/yahoo/react-stickynode|react-stickynode})
 * @property {boolean} [isResizable=false] If true the sidebar is resizable and collapsable
 * @property {boolean} [isFullWidth=false] If true the width of the sidebar is ignored and no longer resizable
 * @property {int} [defaultWidth=250] The width in px. If there is already a width for this id in the local storage the default width is ignored
 * @property {int} [minWidth=250] Forwarded to {@link module:react-aiot/components/ResizeButton|ResizeButton}
 * @property {int} [maxWidth=800] Forwarded to {@link module:react-aiot/components/ResizeButton|ResizeButton}
 * @property {DOMElement} [opposite] Forwarded to {@link module:react-aiot/components/ResizeButton|ResizeButton}
 * @property {int} [oppositeOffset=16] Forwarded to {@link module:react-aiot/components/ResizeButton|ResizeButton}
 * @property {boolean} [isCreatableLinkDisabled=false] If true no new folder can be created
 * @property {boolean} [isCreatableLinkCancel=false] If true the creatable buttons are replaced with a Cancel button
 * @property {boolean} [isToolbarActive=true] If false the toolbar buttons can not be clicked
 * @property {boolean} [isToolbarBusy=false] If true the toolbar gets overlayed with a spinning loader
 * @property {string} [toolbarActiveButton] If setted a "Cancel" and "Save" (optional) button is showed instead of the toolbar buttons
 * @property {string} [headline='Folders'] The headline
 * @property {string} [renameSaveText='Save'] The rename save text. If you are using a React element please use a single instance object (avoid rerendering).
 * @property {string} [renameAddText='Add'] The rename add text If you are using a React element please use a single instance object (avoid rerendering).
 * @property {object} [createRoot] The create node ({@link module:react-aiot/components/TreeNode|TreeNode} properties) when you want to create a new node on the root tree node
 * @property {int} [rootId=0] The root id
 * @property {int} [sortableDelay=100] The delay the user must hold down the node to make it draggable
 * @property {string} [noFoldersTitle='No folders found'] 
 * @property {string} [noFoldersDescription='Click the above button to create a new folder.'] 
 * @property {string} [noSearchResult='No search results found'] 
 * @property {boolean} [searchable=true] Shows a search input field and allows searching by name (simple contains pattern)
 * @property {boolean} [searchInputBusy=false] If true the input field is overlayed with a spinning loader
 * @property {boolean} [isTreeLinkDisabled=false] If true the nodes are no longer clickable (no action)
 * @property {boolean} [isTreeBusy=false] If true the tree is overlayed with a spinning loader
 * @property {boolean} [isSortable=false] If true the node is sortable (see {@link https://github.com/RubaXa/Sortable|sortablejs}). If true the onSort property has to be set.
 * @property {boolean} [isSortableDisabled=false] If true the nodes are not sortable
 * @property {boolean} [isSortableBusy=false] If true all components which are affected by sort process are overlayed with a spinning loader
 * @property {module:react-aiot/components/TreeNode#onNodePressF2} [onNodePressF2] 
 * @property {module:react-aiot/components/TreeNode#onExpand} [onNodeExpand] 
 * @property {module:react-aiot/components/TreeNode#onRenameClose} [onRenameClose] 
 * @property {module:react-aiot/components/TreeNode#onAddClose} [onAddClose] 
 * @property {module:react-aiot/components/TreeNode#onSelect} [onSelect] 
 * @property {module:react-aiot/components/ResizeButton#onResize} [onResize] 
 * @property {module:react-aiot/components/ResizeButton#onResizeFinished} [onResizeFinished] 
 * @property {module:react-aiot~Tree~onSort} [onSort] 
 * @property {function} [onSortStart] SortableJS onSort event
 * @property {function} [onSortEnd] SortableJS onEnd event
 * @property {object[]} [staticTree=[]] {@link module:react-aiot/components/TreeNode|TreeNode} properties. The static tree showed above the search field. The static tree can have no child nodes and is not sortable.
 * @property {object[]} [tree=[]] {@link module:react-aiot/components/TreeNode|TreeNode} properties
 * @property {module:react-aiot~Tree~onSort} [renderItem]
 */

/**
 * The ReactJS All-in-One-Tree.
 * 
 * @param {module:react-aiot~Tree~Properties} props ReactJS properties
 * @extends React.Component
 */
class Tree extends React.Component {
    
    static defaultProps = {
        theme: 'default',
        id: '',
        className: '',
        innerClassName: '',
        style: {},
        attr: {},
        isSticky: false,
        isStickyHeader: false,
        isBusyHeader: false,
        treeStickyAttr: {},
        headerStickyAttr: {},
        isResizable: true,
        isFullWidth: false,
        defaultWidth: 250,
        minWidth: 250,
        maxWidth: 800,
        opposite: undefined,
        oppositeOffset: 16,
    
        // Toolbar
        isCreatableLinkDisabled: false,
        isCreatableLinkCancel: false,
        isToolbarActive: true,
        isToolbarBusy: false,
        toolbarActiveButton: undefined,
        headline: 'Folders',
        renameSaveText: 'Save',
        renameAddText: 'Add',
        createRoot: undefined,
        onAddClose: undefined,
        creatable: {
            buttons: {
                folder: {
                    icon: '<i class="fa fa-folder-open"></i>'
                }
            },
            backButton: {
                label: 'Cancel',
                save: 'Done'
            }
        },
        toolbar: {
            buttons: {
                rename: {
                    content: '<i class="fa fa-pencil"></i>'
                }
            },
            backButton: {
                label: 'Cancel'
            }
        },
        
        // Tree
        rootId: 0,
        sortableDelay: 100,
        noFoldersTitle: 'No folders found',
        noFoldersDescription: 'Click the above button to create a new folder.',
        noSearchResult: 'No search results found',
        searchable: true,
        searchInputBusy: false,
        isTreeLinkDisabled: false,
        isTreeBusy: false,
        isSortable: false,
        isSortableDisabled: false,
        isSortableBusy: false,
        onNodePressF2: undefined,
        onNodeExpand: undefined,
        onRenameClose: undefined,
        onSelect: undefined,
        onResize: undefined,
        /**
         * This function is called when a node item gets reordered per drag&drop.
         * 
         * @callback module:react-aiot~Tree~onSort
         * @param {object} event The event
         * @param {object} event.evt The original event of sortablejs
         * @param {DOMElement} event.from From list (ul)
         * @param {DOMElement} event.to To list (ul)
         * @param {int} event.oldIndex The old indes within the old list
         * @param {int} event.newIndex The new index within the new list
         * @param {string|int} event.id The id of the 
         * @param {DOMElement} event.nextObj The node next to the dragged one
         * @param {DOMElement} event.prevObj The node prev to the dragged one
         * @param {string|int} event.nextId The node id next to the dragged one
         * @param {string|int} event.prevId The node id prev to the dragged one
         * @param {string|int} event.parentFromId The node id of the from list (parent id)
         * @param {string|int} event.parentToId The node id of the to list (parent id)
         * @param {function} event.buildTree You can call this function to get the new tree property for this Tree, you can use it for your <code>setState({ tree: buildTree })</code>.
         */
        onSort: undefined,
        onSortStart: undefined,
        onSortEnd: undefined,
        onResizeFinished: undefined,
        staticTree: [],
        tree: []
    }
    
    /**
     * The constructor creates a local storage instance to save
     * the sidebar width and expand states (if supported and enabled).
     */
    constructor(props) {
        super(props);
        !Tree.propKeys && (Tree.propKeys = Object.keys(Tree.defaultProps));
        
        // State
        this.state = {
            uuid: uuid(),
            collapsed: false,
            stickyTreeCalculatedTop: undefined,
            currentlySorting: false,
            sortingBusy: false, // also available as prop "isSortableBusy"
            
            // Search results
            searchTerm: '',
            resultSelectedNodeIdx: undefined,
            resultTreeBusy: false,
            resultTree: undefined
        };
        
        // Localstorage only when id given
        if (this.props.id && SUPPORTS_LOCAL_STORAGE) {
            this.storage = new Storage(this.id());
        }
        
        (handleSortableTreeInit.bind(this))();
        
        this.handleSearch = handleSearch.bind(this);
        this.handleSearchBlur = handleSearchBlur.bind(this);
        this.handleSearchClose = handleSearchClose.bind(this);
        this.handleSearchKeyDown = handleSearchKeyDown.bind(this);
        this.handleSortableTree = handleSortableTree.bind(this);
        this.handleSortableTreeWillUpdate = handleSortableTreeWillUpdate.bind(this);
    }
    
    /**
     * When the component did mount initialize the sticky sidebar and header.
     */
    componentDidMount() {
        // Calculate offset of tree if tree and header are sticky
        const { isSticky, isStickyHeader, treeStickyAttr, headerStickyAttr } = this.props,
            obj = document.querySelector('#' + this.id() + ' .aio-fixed-header > div');
        let newTreeCalculatedTop = 0;
        if (isSticky && isStickyHeader && typeof treeStickyAttr.top === 'undefined' && obj) {
            newTreeCalculatedTop = obj.offsetHeight;
            
            const headerStickyTop = headerStickyAttr.top;
            if (typeof headerStickyTop === 'string') {
                const headerStickyTopObj = document.querySelector(headerStickyTop);
                newTreeCalculatedTop += headerStickyTopObj ? headerStickyTopObj.offsetHeight : 0;
            }else if (typeof headerStickyTop === 'number'){
                newTreeCalculatedTop += headerStickyTop;
            }
        }
        
        // Set sticky tree calculated in state
        this.setState({ stickyTreeCalculatedTop: newTreeCalculatedTop });
    }
    
    /**
     * When the component will upate rehandle the sortable tree.
     */
    componentWillUpdate(...args) {
        this.handleSortableTreeWillUpdate(...args);
    }
    
    /**
     * The sidebar gets resized through the ResizeButton and chain it to
     * the onResize method.
     * 
     * @method
     */
    handleResize = (x, collapse) => {
        this.state.collapsed !== collapse && this.setState({ collapsed: collapse });
        this.props.onResize && this.props.onResize(x, collapse);
    }
    
    /**
     * The sidebar is resized successfully. Chain it to the onResizeFinished method
     * and save the width to the local storage if supported and enabled.
     * 
     * @method
     */
    handleResizeFinished = width => {
        if (this.storage) {
            this.storage.setItem('width', width);
            width > 0 && this.storage.setItem('rwidth', width);
        }
        this.props.onResizeFinished && this.props.onResizeFinished(width);
    }
    
    /**
     * A node gets expanded / collapsed. Chain it to the onNodeExpand method and
     * save the state to the local storage if supported and enabled.
     * 
     * @method
     */
    handleNodeExpand = (expanded, node) => {
        const onNodeExpand = this.props.onNodeExpand, id = node.id;
        this.storage && id && this.storage.setItem('expandNodes.' + node.id, expanded);
        onNodeExpand && onNodeExpand(expanded, node);
    }
    
    /**
     * Render a tree.
     * 
     * @param {object[]} tree The tree
     * @param {boolean} [displayChildren=true] If true the first nodes can have childNodes
     * @param {object} [createRoot] Shows an additional node after the last one with an input field
     * @param {string('tree','static','search')} [context='tree'] The tree context
     * @method
     */
    renderTree = (tree, displayChildren = true, createRoot = undefined, context = 'tree') => {
        const nodeAttr = { renderItem, onRenameClose, onAddClose, onSelect, onNodePressF2, renameSaveText, renameAddText } = this.props,
            { isTreeLinkDisabled, rootId } = this.props,
            resultSelectedNodeIdx = this.state.resultSelectedNodeIdx,
            resultTreeLength = typeof resultSelectedNodeIdx === "number" && this.state.resultTree.length,
            expandedState = this.storage && this.storage.getItem('expandNodes') || {},
            className = classNames({
                'aiot-disable-links': isTreeLinkDisabled
            });
        let i = -1;
        
        return <ul className={ className } data-childs-for={ rootId } ref={ displayChildren ? this.handleSortableTree : undefined }>
            { tree.map(node => {
                i++;
                const searchSelected = i % resultTreeLength === resultSelectedNodeIdx % resultTreeLength && !displayChildren,
                    propSearchSelected = context === 'search' ? searchSelected : undefined;

                const createTreeNode = () => (<TreeNode key={ node.id } searchSelected={ propSearchSelected } {...node} onExpand={ this.handleNodeExpand }
                    expandedState={ expandedState } {...nodeAttr} onUlRef={ displayChildren ? this.handleSortableTree : undefined } displayChildren={ displayChildren } />);
                    
                if (renderItem) {
                    /**
                     * This function is called when a node item gets reordered.
                     * 
                     * @callback module:react-aiot~Tree~renderItem
                     * @param {function} createTreeNode A function that creates the default tree node (helpful for wrapper functions)
                     * @param {module:react-aiot/components/TreeNode} TreeNode The ReactJS element
                     * @param {object} node The node item
                     * @returns {module:react-aiot/components/TreeNode}
                     */
                    return renderItem(createTreeNode, TreeNode, node);
                }else{
                    return createTreeNode();
                }
            } ) }
            { !!createRoot && <TreeNode $_create onRenameClose={ onAddClose } renameSaveText={ this.props.renameAddText } {...createRoot} /> }
        </ul>;
    }
    
    /**
     * Render the tree wrapper. In this part the sticky component is ensured and
     * you do not have to worry about it.
     * 
     * @method
     */
    renderTreeWrapper = () => {
        const { isCreatableLinkCancel, createRoot, searchable, searchInputBusy,
            isTreeBusy, staticTree, tree, isSortableBusy, children,
            noFoldersTitle, noFoldersDescription, noSearchResult } = this.props,
            { sortingBusy, searchTerm, resultTree, resultTreeBusy } = this.state;
        
        return <div>
            <div className="aiot-nodes">
                { children }
                { staticTree && this.renderTree(staticTree, false, undefined, 'static') }
                { staticTree && <hr /> }
                { searchable && <div className="aiot-search">
                    <Input disabled={ !tree.length || isCreatableLinkCancel || sortingBusy || isSortableBusy } size="small" value={ searchTerm } onChange={ this.handleSearch } onBlur={ this.handleSearchBlur }
                        onKeyDown={ this.handleSearchKeyDown } suffix={ searchInputBusy || resultTreeBusy
                            ? <BusyIcon />
                            : searchTerm.length
                                ? <Icon type="close" style={{ cursor: 'pointer' }} onClick={ this.handleSearchClose } />
                                : <Icon type="search" /> } />
                </div> }
                <Spin spinning={ !!isTreeBusy || sortingBusy || isSortableBusy } size="small" style={{ minHeight: 50 }}>
                    { this.renderTree(resultTree || tree, !resultTree, resultTree ? undefined : createRoot, resultTree ? 'search' : 'tree') }
                </Spin>
                { tree && !tree.length && !isTreeBusy && 
                    <Alert message={ noFoldersTitle } description={ noFoldersDescription } type="info" showIcon /> }
                { resultTree && !resultTree.length &&
                    <Alert message={ noSearchResult } type="warning" showIcon /> }
            </div>
        </div>;
    }
    
    /**
     * Render the main wrapper with sticky / resize functionality. It also
     * renders the Header with headline and toolbar.
     * 
     * @method
     */
    renderWrapper = () => {
        // Create resize styles
        const props = this.props,
            { isResizable, opposite, minWidth, maxWidth, innerClassName,
             isSticky, isStickyHeader, isSortableBusy, headerStickyAttr, oppositeOffset } = props,
            { currentlySorting, sortingBusy, searchTerm, stickyTreeCalculatedTop, collapsed } = this.state,
             
            // Header
            headerAttr = { headline, creatable, isCreatableLinkDisabled, isCreatableLinkCancel, 
                isToolbarActive, isToolbarBusy, toolbar, toolbarActiveButton, isBusyHeader } = props,
            bodyHeader = <Header {...headerAttr} isToolbarActive={ sortingBusy || isSortableBusy ? false : isToolbarActive }
                isCreatableLinkDisabled={ searchTerm || sortingBusy || isSortableBusy ? true : props.isCreatableLinkDisabled } />,
            
            // Tree
            bodyTreeWrapper = stickyTreeCalculatedTop !== undefined ? this.renderTreeWrapper() : undefined, // Avoid double rendering for massive tree's while waiting for componentDidMount
            className = classNames('aiot-pad', innerClassName, {
                'aiot-currently-sorting': currentlySorting
            });
            
        // Create sticky attributes
        const treeStickyAttr = Object.assign({}, {
            top: stickyTreeCalculatedTop
        }, props.treeStickyAttr);
        
        // Create wrapper with toolbar and so on...
        return <div className={ className }>
            { isResizable && opposite && (
                <ResizeButton opposite={ opposite } minWidth={ minWidth }
                    maxWidth={ maxWidth } initialWidth={ this.storage && this.storage.getItem('width') }
                    restoreWidth={ this.storage && this.storage.getItem('rwidth') }
                    containerId={ this.id() } onResize={ this.handleResize }
                    onResizeFinished={ this.handleResizeFinished } oppositeOffset={ oppositeOffset } />
            ) }
            { !collapsed && (isStickyHeader
                    ? <Sticky className="aiot-fixed-header" { ...headerStickyAttr }>{ bodyHeader }</Sticky>
                    : <div>{ bodyHeader }</div>) }
            { !collapsed && (isSticky
                ? <Sticky { ...treeStickyAttr }>{ bodyTreeWrapper }</Sticky>
                : <div>{ bodyTreeWrapper }</div>) }
        </div>;
    }
    
    /**
     * Render.
     */
    render() {
        const { theme, attr, isFullWidth } = this.props,
            className = classNames('aiot-tree', this.props.className, 'aiot-theme-' + theme, {
                'aiot-wrap-collapse': this.state.collapsed,
                'aiot-full-width': isFullWidth
            }),
            style = Object.assign({}, this.props.style, !isFullWidth && {
                width: this.props.defaultWidth + 'px',
                minWidth: this.props.minWidth + 'px',
                maxWidth: this.props.maxWidth + 'px',
            });

        // Create first wrapper attributes
        const wrapperAttr = {
                id: this.id(),
                style,
                ...attr,
                className,
                ref: div => this.container = div
            };
        return <div { ...wrapperAttr }>{ this.renderWrapper() }</div>;
    }
    
    /**
     * Get an unique id for this container.
     * 
     * @param {string} [append] Append this string to the id with '--' suffix
     * @returns {string}
     * @method
     */
    id(append) {
        const id = this.props.id || this.state.uuid;
        return append ? id + '--' + append : id;
    }
}

// Export
export default Tree;
export {
    /**
     * @type module:react-aiot/util.uuid
     */
    uuid,
    /**
     * @type module:react-aiot/util.updateTreeItemById
     */
    updateTreeItemById,
    /**
     * @type module:react-aiot/util.getTreeParentById
     */
    getTreeParentById,
    /**
     * @type module:react-aiot/util.getTreeItemById
     */
    getTreeItemById,
    /**
     * @type module:react-aiot/util.buildOrderedParentPairs
     */
    buildOrderedParentPairs
};

export {
    /**
     * @type module:react-aiot/components/Header
     */
    Header,
    /**
     * @type module:react-aiot/components/Toolbar
     */
    Toolbar,
    /**
     * @type module:react-aiot/components/ToolbarButton
     */
    ToolbarButton,
    /**
     * @type module:react-aiot/components/Creatable
     */
    Creatable,
    /**
     * @type React.Element
     * @see https://ant.design/components/dropdown/
     */
    Dropdown,
    /**
     * @type React.Element
     * @see https://ant.design/components/menu/
     */
    Menu,
    /**
     * @type module:react-aiot/components/ResizeButton
     */
    ResizeButton,
    /**
     * @type module:react-aiot/components/Tooltip
     */
    Tooltip,
    /**
     * @type module:react-aiot/components/TreeNode
     */
    TreeNode,
    /**
     * @type React.Element
     * @see https://ant.design/components/button/
     */
    Button,
    /**
     * @type React.Element
     * @see https://ant.design/components/input/
     */
    Input,
    /**
     * @type React.Element
     * @see https://ant.design/components/alert/
     */
    Alert,
    /**
     * @type React.Element
     * @see https://ant.design/components/spin/
     */
    Spin,
    /**
     * @type React.Element
     * @see https://ant.design/components/message/
     */
    message,
    /**
     * @type React.Element
     * @see https://ant.design/components/icon/
     */
    Icon,
    /**
     * @type React.Element
     * @see https://ant.design/components/popconfirm/
     */
    Popconfirm
};