reliance-react-checkbox-tree
Version:
Fork of checkbox tree in React by Jake Zatecky: https://github.com/jakezatecky/react-checkbox-tree.
372 lines (323 loc) • 12.4 kB
JavaScript
import classNames from 'classnames';
import isEqual from 'lodash/isEqual';
import memoize from 'lodash/memoize';
import { nanoid } from 'nanoid';
import PropTypes from 'prop-types';
import React from 'react';
import Button from './Button';
import constants from './constants';
import NodeModel from './NodeModel';
import TreeNode from './TreeNode';
import iconsShape from './shapes/iconsShape';
import languageShape from './shapes/languageShape';
import listShape from './shapes/listShape';
import nodeShape from './shapes/nodeShape';
class CheckboxTree extends React.Component {
static propTypes = {
nodes: PropTypes.arrayOf(nodeShape).isRequired,
checkModel: PropTypes.oneOf([constants.CheckModel.LEAF, constants.CheckModel.ALL]),
checked: listShape,
direction: PropTypes.string,
disabled: PropTypes.bool,
exclusiveNodes: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string,
exceptions: PropTypes.arrayOf(PropTypes.string),
}),
),
expandDisabled: PropTypes.bool,
expandOnClick: PropTypes.bool,
expanded: listShape,
extraConnections: PropTypes.arrayOf(PropTypes.objectOf(PropTypes.string, PropTypes.string)),
icons: iconsShape,
iconsClass: PropTypes.string,
id: PropTypes.string,
lang: languageShape,
name: PropTypes.string,
nameAsArray: PropTypes.bool,
nativeCheckboxes: PropTypes.bool,
noCascade: PropTypes.bool,
onlyLeafCheckboxes: PropTypes.bool,
optimisticToggle: PropTypes.bool,
showExpandAll: PropTypes.bool,
showNodeIcon: PropTypes.bool,
showNodeTitle: PropTypes.bool,
onCheck: PropTypes.func,
onClick: PropTypes.func,
onExpand: PropTypes.func,
};
static defaultProps = {
checkModel: constants.CheckModel.LEAF,
checked: [],
extraConnections: [],
exclusiveNodes: [],
direction: 'ltr',
disabled: false,
expandDisabled: false,
expandOnClick: false,
expanded: [],
icons: {
check: <span className="rct-icon rct-icon-check" />,
uncheck: <span className="rct-icon rct-icon-uncheck" />,
halfCheck: <span className="rct-icon rct-icon-half-check" />,
expandClose: <span className="rct-icon rct-icon-expand-close" />,
expandOpen: <span className="rct-icon rct-icon-expand-open" />,
expandAll: <span className="rct-icon rct-icon-expand-all" />,
collapseAll: <span className="rct-icon rct-icon-collapse-all" />,
parentClose: <span className="rct-icon rct-icon-parent-close" />,
parentOpen: <span className="rct-icon rct-icon-parent-open" />,
leaf: <span className="rct-icon rct-icon-leaf" />,
},
iconsClass: 'fa4',
id: null,
lang: {
collapseAll: 'Collapse all',
expandAll: 'Expand all',
toggle: 'Toggle',
},
name: undefined,
nameAsArray: false,
nativeCheckboxes: false,
noCascade: false,
onlyLeafCheckboxes: false,
optimisticToggle: true,
showExpandAll: false,
showNodeIcon: true,
showNodeTitle: false,
onCheck: () => {},
onClick: null,
onExpand: () => {},
};
constructor(props) {
super(props);
const model = new NodeModel(props);
model.flattenNodes(props.nodes);
model.deserializeLists({
checked: props.checked,
expanded: props.expanded,
});
this.state = {
id: props.id || `rct-${nanoid()}`,
model,
prevProps: props,
};
this.onCheck = this.onCheck.bind(this);
this.onExpand = this.onExpand.bind(this);
this.onNodeClick = this.onNodeClick.bind(this);
this.onExpandAll = this.onExpandAll.bind(this);
this.onCollapseAll = this.onCollapseAll.bind(this);
this.combineMemorized = memoize((icons1, icons2) => ({ ...icons1, ...icons2 })).bind(this);
}
// eslint-disable-next-line react/sort-comp
static getDerivedStateFromProps(newProps, prevState) {
const { model, prevProps } = prevState;
const { disabled, id, nodes } = newProps;
let newState = { ...prevState, prevProps: newProps };
// Apply new properties to model
model.setProps(newProps);
// Since flattening nodes is an expensive task, only update when there is a node change
if (!isEqual(prevProps.nodes, nodes) || prevProps.disabled !== disabled) {
model.reset();
model.flattenNodes(nodes);
}
if (id !== null) {
newState = { ...newState, id };
}
model.deserializeLists({
checked: newProps.checked,
expanded: newProps.expanded,
});
return newState;
}
onCheck(nodeInfo) {
const {
checkModel, noCascade, onCheck, extraConnections, exclusiveNodes,
} = this.props;
const model = this.state.model.clone();
const node = model.getNode(nodeInfo.value);
const exclusiveNode = exclusiveNodes.find(
(exclusive) => exclusive.value === node.value,
);
if (exclusiveNode) {
model.toggleCheckedExclusive(nodeInfo, nodeInfo.checked, exclusiveNode.exceptions);
} else {
model.toggleChecked(
nodeInfo,
nodeInfo.checked,
checkModel,
noCascade,
extraConnections,
);
const exceptionInExclusiveNode = exclusiveNodes.find(
(exclusive) => exclusive.exceptions.find(
(exception) => node.value.match(exception),
),
);
if (!exceptionInExclusiveNode) model.clearExclusives(exclusiveNodes);
}
onCheck(model.serializeList('checked'), { ...node, ...nodeInfo });
}
onExpand(nodeInfo) {
const { onExpand } = this.props;
const model = this.state.model.clone();
const node = model.getNode(nodeInfo.value);
model.toggleNode(nodeInfo.value, 'expanded', nodeInfo.expanded);
onExpand(model.serializeList('expanded'), { ...node, ...nodeInfo });
}
onNodeClick(nodeInfo) {
const { onClick } = this.props;
const { model } = this.state;
const node = model.getNode(nodeInfo.value);
onClick({ ...node, ...nodeInfo });
}
onExpandAll() {
this.expandAllNodes();
}
onCollapseAll() {
this.expandAllNodes(false);
}
expandAllNodes(expand = true) {
const { onExpand } = this.props;
onExpand(
this.state.model.clone()
.expandAllNodes(expand)
.serializeList('expanded'),
);
}
renderTreeNodes(nodes, parent = {}) {
const {
expandDisabled,
expandOnClick,
icons,
lang,
onClick,
onlyLeafCheckboxes,
optimisticToggle,
showNodeTitle,
showNodeIcon,
} = this.props;
const { id, model } = this.state;
const { icons: defaultIcons } = CheckboxTree.defaultProps;
const treeNodes = nodes.map((node) => {
const key = node.value;
const flatNode = model.getNode(node.value);
const children = flatNode.isParent ? this.renderTreeNodes(node.children, node) : null;
// Determine the check state after all children check states have been determined
// This is done during rendering as to avoid an additional loop during the
// deserialization of the `checked` property
flatNode.checkState = flatNode.checked ? 1 : 0;
// Show checkbox only if this is a leaf node or showCheckbox is true
const showCheckbox = onlyLeafCheckboxes ? flatNode.isLeaf : flatNode.showCheckbox;
// Render only if parent is expanded or if there is no root parent
const parentExpanded = parent.value ? model.getNode(parent.value).expanded : true;
if (!parentExpanded) {
return null;
}
return (
<TreeNode
key={key}
checked={flatNode.checkState}
className={node.className}
disabled={flatNode.disabled}
expandDisabled={expandDisabled}
expandOnClick={expandOnClick}
expanded={flatNode.expanded}
icon={node.icon}
icons={this.combineMemorized(defaultIcons, icons)}
label={node.label}
lang={lang}
optimisticToggle={optimisticToggle}
isLeaf={flatNode.isLeaf}
isParent={flatNode.isParent}
showCheckbox={showCheckbox}
showNodeIcon={showNodeIcon}
title={showNodeTitle ? node.title || node.label : node.title}
treeId={id}
value={node.value}
onCheck={this.onCheck}
onClick={onClick && this.onNodeClick}
onExpand={this.onExpand}
>
{children}
</TreeNode>
);
});
return (
<ol>
{treeNodes}
</ol>
);
}
renderExpandAll() {
const { icons: { expandAll, collapseAll }, lang, showExpandAll } = this.props;
if (!showExpandAll) {
return null;
}
return (
<div className="rct-options">
<Button
className="rct-option rct-option-expand-all"
title={lang.expandAll}
onClick={this.onExpandAll}
>
{expandAll}
</Button>
<Button
className="rct-option rct-option-collapse-all"
title={lang.collapseAll}
onClick={this.onCollapseAll}
>
{collapseAll}
</Button>
</div>
);
}
renderHiddenInput() {
const { name, nameAsArray } = this.props;
if (name === undefined) {
return null;
}
if (nameAsArray) {
return this.renderArrayHiddenInput();
}
return this.renderJoinedHiddenInput();
}
renderArrayHiddenInput() {
const { checked, name: inputName } = this.props;
return checked.map((value) => {
const name = `${inputName}[]`;
return <input key={value} name={name} type="hidden" value={value} />;
});
}
renderJoinedHiddenInput() {
const { checked, name } = this.props;
const inputValue = checked.join(',');
return <input name={name} type="hidden" value={inputValue} />;
}
render() {
const {
direction,
disabled,
iconsClass,
nodes,
nativeCheckboxes,
} = this.props;
const { id } = this.state;
const treeNodes = this.renderTreeNodes(nodes);
const className = classNames({
'react-checkbox-tree': true,
'rct-disabled': disabled,
[`rct-icons-${iconsClass}`]: true,
'rct-native-display': nativeCheckboxes,
'rct-direction-rtl': direction === 'rtl',
});
return (
<div className={className} id={id}>
{this.renderExpandAll()}
{this.renderHiddenInput()}
{treeNodes}
</div>
);
}
}
export default CheckboxTree;