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