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