const loadMenu = () =>
Promise.resolve([
{
id: "1",
name: "One",
children: [
{
id: "1.1",
name: "One - one",
children: [
{ id: "1.1.1", name: "One - one - one" },
{ id: "1.1.2", name: "One - one - two" },
{ id: "1.1.3", name: "One - one - three" }
]
}
]
},
{ id: "2", name: "Two", children: [{ id: "2.1", name: "Two - one" }] },
{
id: "3",
name: "Three",
children: [
{
id: "3.1",
name: "Three - one",
children: [
{
id: "3.1.1",
name: "Three - one - one",
children: [
{
id: "3.1.1.1",
name: "Three - one - one - one",
children: [
{ id: "3.1.1.1.1", name: "Three - one - one - one - one" }
]
}
]
}
]
}
]
},
{ id: "4", name: "Four" },
{
id: "5",
name: "Five",
children: [
{ id: "5.1", name: "Five - one" },
{ id: "5.2", name: "Five - two" },
{ id: "5.3", name: "Five - three" },
{ id: "5.4", name: "Five - four" }
]
},
{ id: "6", name: "Six" }
]);
const { Component, Fragment } = React;
const { Button, Collapse, Input } = Reactstrap;
const replaceNode = (replacer, node, idPath, i) => {
if (i <= idPath.length && !node) {
// Not at target node yet, create nodes in between
node = {};
}
if (i > idPath.length) {
// Reached target node
return replacer(node);
}
// Construct ID that matches this depth - depth meaning
// the amount of dots in between the ID
const id = idPath.slice(0, i).join(".");
return {
...node,
// Recurse
[id]: replaceNode(replacer, node[id], idPath, i + 1)
};
};
const replaceNodeById = (node, id, visitor) => {
// Pass array of the id's parts instead of working on the string
// directly - easy way to handle multi-number ID parts e.g. 3.1.15.32
return replaceNode(visitor, node, id.split("."), 1);
};
const expandedNode = () => ({});
const unexpandedNode = () => undefined;
const toggleNodeById = (node, id) =>
replaceNodeById(node, id, oldNode =>
oldNode ? unexpandedNode() : expandedNode()
);
const expandNodeById = (node, id) =>
replaceNodeById(node, id, oldNode => expandedNode());
class Menu extends Component {
constructor(props) {
super(props);
this.state = {
menuItems: [],
openMenus: {},
checkedMenus: {}
};
this.handleMenuToggle = this.handleMenuToggle.bind(this);
this.handleChecked = this.handleChecked.bind(this);
}
render() {
const { menuItems, openMenus, checkedMenus } = this.state;
return (
<div
style={{
display: "flex",
flexDirection: "row",
columnCount: 3,
justifyContent: "space-between"
}}
>
<div style={{ paddingTop: "10px" }}>
<MenuItemContainer
openMenus={openMenus}
menuItems={menuItems}
onMenuToggle={this.handleMenuToggle}
checkedMenus={checkedMenus}
onChecked={this.handleChecked}
/>
</div>
<div style={{ padding: "10px", marginLeft: "auto" }}>
<p>Menu state</p>
<pre>{JSON.stringify(openMenus, null, 2)}</pre>
</div>
<div style={{ padding: "10px", width: "177px" }}>
<p>Checkbox state</p>
<pre>{JSON.stringify(checkedMenus, null, 2)}</pre>
</div>
</div>
);
}
componentDidMount() {
const { initialOpenMenuId, initialCheckedMenuIds } = this.props;
loadMenu().then(menuItems => {
const initialMenuState = {};
this.setState({
menuItems,
openMenus: expandNodeById(initialMenuState, initialOpenMenuId),
checkedMenus: initialCheckedMenuIds.reduce(
(acc, val) => ({ ...acc, [val]: true }),
{}
)
});
});
}
handleMenuToggle(toggledId) {
this.setState(({ openMenus }) => ({
openMenus: toggleNodeById(openMenus, toggledId)
}));
}
handleChecked(toggledId) {
this.setState(({ checkedMenus }) => ({
checkedMenus: {
...checkedMenus,
[toggledId]: checkedMenus[toggledId] ? unexpandedNode() : expandedNode()
}
}));
}
}
function MenuItemContainer({
openMenus,
onMenuToggle,
checkedMenus,
onChecked,
menuItems = []
}) {
if (!menuItems.length) return null;
const renderMenuItem = menuItem => (
<li key={menuItem.id}>
<MenuItem
openMenus={openMenus}
onMenuToggle={onMenuToggle}
checkedMenus={checkedMenus}
onChecked={onChecked}
{...menuItem}
/>
</li>
);
return <ul>{menuItems.map(renderMenuItem)}</ul>;
}
class MenuItem extends Component {
constructor(props) {
super(props);
this.handleToggle = this.handleToggle.bind(this);
this.handleChecked = this.handleChecked.bind(this);
}
render() {
const {
children,
name,
id,
openMenus,
onMenuToggle,
checkedMenus,
onChecked
} = this.props;
const isLastChild = !children;
return (
<Fragment>
<Button onClick={isLastChild ? this.handleChecked : this.handleToggle}>
{name}
</Button>
{isLastChild && (
<Input
addon
type="checkbox"
onChange={this.handleChecked}
checked={!!checkedMenus[id]}
value={id}
/>
)}
<Collapse isOpen={openMenus ? !!openMenus[id] : false}>
<MenuItemContainer
menuItems={children}
// Pass down child menus' state
openMenus={openMenus && openMenus[id]}
onMenuToggle={onMenuToggle}
checkedMenus={checkedMenus}
onChecked={onChecked}
/>
</Collapse>
</Fragment>
);
}
handleToggle() {
this.props.onMenuToggle(this.props.id);
}
handleChecked() {
this.props.onChecked(this.props.id);
}
}
ReactDOM.render(
<Menu initialOpenMenuId="3.1.1.1" initialCheckedMenuIds={["3.1.1.1.1"]} />,
document.getElementById("root")
);
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/reactstrap/8.4.1/reactstrap.min.js"></script>
<div id="root"></div>