skip to Main Content

I am making a nested menu and submenus and everything has been done as of now.. I am now in the need to make this collapsible menu to get opened by default based on the id given..

You could also take a look at the complete working code snippet below,

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 openMenuId = "3.1.1.1";

const {Component, Fragment} = React;
const {Button, Collapse, Input} = Reactstrap;

class Menu extends Component {
  constructor(props) {
    super(props);
    this.state = {menuItems: []};
  }

  render() {
    return <MenuItemContainer menuItems={this.state.menuItems} />;
  }

  componentDidMount() {
    loadMenu().then(menuItems => this.setState({menuItems}));
  }
}

function MenuItemContainer(props) {
  if (!props.menuItems.length) return null;
  
  const renderMenuItem = menuItem =>
    <li key={menuItem.id}><MenuItem {...menuItem} /></li>;
    
  return <ul>{props.menuItems.map(renderMenuItem)}</ul>;
}
MenuItemContainer.defaultProps = {menuItems: []};

class MenuItem extends Component {
  constructor(props) {
    super(props);
    this.state = {isOpen: false};
    this.toggle = this.toggle.bind(this);
  }

  render() {
    let isLastChild = this.props.children ? false : true;
    return (
      <Fragment>
        <Button onClick={this.toggle}>{this.props.name}</Button>
        <Fragment>
          {isLastChild ? <Input type="checkbox" value={this.props.id} /> : ''}
        </Fragment>
        <Collapse isOpen={this.state.isOpen}>
          <MenuItemContainer menuItems={this.props.children} />
        </Collapse>
      </Fragment>
    );
  }

  toggle() {
    this.setState(({isOpen}) => ({isOpen: !isOpen}));
  }
}

ReactDOM.render(<Menu />, 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>

Requirement:

I am having an id value stored in const openMenuId = "3.1.1.1.1"; in parent component (you can look this variable below loadMenu array variable)..

Even though there are multiple submenus, this id will only belong to last level children id and hence would have a checkbox for sure so that checkbox needs to be checked and the menus up to parent level need to get opened.

Eg..,

As the openMenuId is "3.1.1.1.1" and hence it is clear that last child level of menu three which is Three - one - one - one - one needs to be checked as the openMenuId and checkbox value has a match here.. Then the respective menus and submenus need to be expanded up to the last level.

This is only for default behavior on the page visited so after that user can collapse back and able to check any other checkboxes in any other menus.. But while visiting the page I will have a particular id that needs to get opened by default and also needs to be checked in the checkbox..

Kindly help me to achieve the result of opening the respective menu by comparing the id passed down as props and make the respective menu checked..

Struggling for a long time, So please help me.. A big thanks in advance..

4

Answers


  1. just add a class .actve or any other you want and style it according to your requirment and then add script if you are using normal js then document.querySelector("youElementClassOrId").classList.toggle("idOrClassYouWantToToggle").
    I jope this will work

    Login or Signup to reply.
  2. 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 openMenuId = "3.1.1.1.1";
    
    const {Component, Fragment} = React;
    const {Button, Collapse, Input} = Reactstrap;
    
    class Menu extends Component {
      constructor(props) {
        super(props);
        this.state = {menuItems: []};
      }
    
      render() {
        return <MenuItemContainer menuItems={this.state.menuItems} />;
      }
    
      componentDidMount() {
        loadMenu().then(menuItems => this.setState({menuItems}));
      }
    }
    
    function MenuItemContainer(props) {
      if (!props.menuItems.length) return null;
      
      const renderMenuItem = menuItem =>
        <li key={menuItem.id}><MenuItem {...menuItem} /></li>;
        
      return <ul>{props.menuItems.map(renderMenuItem)}</ul>;
    }
    MenuItemContainer.defaultProps = {menuItems: []};
    
    class MenuItem extends Component {
      constructor(props) {
        super(props);
        this.state = {isOpen: false};
        this.toggle = this.toggle.bind(this);
      }
    
      render() {
        let isLastChild = this.props.children ? false : true;
        let {isOpen} = this.state;
        if(openMenuId.startsWith(this.props.id)){isOpen = true;}
        return (
          <Fragment>
            <Button onClick={this.toggle}>{this.props.name}</Button>
            <Fragment>
              {isLastChild ? <Input type="checkbox" checked={openMenuId === this.props.id} value={this.props.id} /> : ''}
            </Fragment>
            <Collapse isOpen={isOpen}>
              <MenuItemContainer menuItems={this.props.children} />
            </Collapse>
          </Fragment>
        );
      }
    
      toggle() {
        this.setState(({isOpen}) => ({isOpen: !isOpen}));
      }
    }
    
    ReactDOM.render(<Menu />, 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>
    Login or Signup to reply.
  3. Assuming you need to open certain menu only at the start, you can set MenuItem component to expect a boolean property defaultOpen and use that to set the initial isOpen.

    Then all we need to do is set this property in menuItems on load.

    import React from 'react'
    import { Button, Collapse, Input } from 'reactstrap';
    import 'bootstrap/dist/css/bootstrap.min.css';
    
    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 openMenuId = "3.1.1.1";
    
    const {Component, Fragment} = React;
    
    function setDefaultOpen(menuItems, openMenuId) {
      if(!menuItems) return
      const openMenuItem = menuItems.find(item => openMenuId.startsWith(item.id))
      if(!openMenuItem) return
      openMenuItem.defaultOpen = true
      setDefaultOpen(openMenuItem.children, openMenuId)
    }
    
    export default class Menu extends Component {
      constructor(props) {
        super(props);
        this.state = {menuItems: []};
      }
    
      render() {
        return <MenuItemContainer menuItems={this.state.menuItems} />;
      }
    
      componentDidMount() {
        loadMenu().then(menuItems => {
          setDefaultOpen(menuItems, openMenuId)
          this.setState({menuItems})
        });
      }
    }
    
    function MenuItemContainer(props) {
      if (!props.menuItems.length) return null;
    
      const renderMenuItem = menuItem =>
        <li key={menuItem.id}><MenuItem {...menuItem} /></li>;
    
      return <ul>{props.menuItems.map(renderMenuItem)}</ul>;
    }
    MenuItemContainer.defaultProps = {menuItems: []};
    
    class MenuItem extends Component {
      constructor(props) {
        super(props);
        this.state = {isOpen: props.defaultOpen};
        this.toggle = this.toggle.bind(this);
      }
    
      render() {
        let isLastChild = this.props.children ? false : true;
        return (
          <Fragment>
            <Button onClick={this.toggle}>{this.props.name}</Button>
            <Fragment>
              {isLastChild ? <Input type="checkbox" value={this.props.id} /> : ''}
            </Fragment>
            <Collapse isOpen={this.state.isOpen}>
              <MenuItemContainer menuItems={this.props.children} />
            </Collapse>
          </Fragment>
        );
      }
    
      toggle() {
        this.setState(({isOpen}) => ({isOpen: !isOpen}));
      }
    }
    

    If you need to ability to open a menu item after initial render, then you need to make MenuItem a controlled component.

    i.e. pull up the isOpen state to parent and pass it down to MenuItem component as a prop along with callback function that it will invoke on click passing its id as argument. Callback function in parent will toggle the isOpen property of item with given id in its state.

    Login or Signup to reply.
  4. What a great question! I really enjoyed coming up with a solution for this one.

    As you wanted to give an initial state to both the menu state and the checkbox state, I think that controlling the state of both on the <Menu> level (or even higher!) is a good idea. This not only makes it easy to define an initial state from a parent, but it also grants you more flexibility if you need some more complicated menu or checkbox behavior in the future.

    Since the structure of the menus is recursive, I think that having a recursive structure for the menu state works pretty well. Before I go into the code, here’s a short GIF which, I hope, helps explain what the state looks like:

    Demo

    Video showing menu three columns: the menu, the menu state as JSON, and the checkbox state as JSON. As menus and checkboxes are clicked, the states update.

    Here’s the playground snippet:

    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>

    Answer

    Code walkthrough below.

    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 (
          <MenuItemContainer
            openMenus={openMenus}
            menuItems={menuItems}
            onMenuToggle={this.handleMenuToggle}
            checkedMenus={checkedMenus}
            onChecked={this.handleChecked}
          />
        );
      }
    
      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>

    Walkthrough

    Before I start, I must say that I’ve taken the liberty to change some of the code to use modern JavaScript features like object destructuring,
    array destructuring, rest and default values.

    Creating the state

    So. Since the IDs of the menu items are numbers delimited by a dot, we can take advantage of this when constructing the state. The state is essentially a tree-like structure, with each sub-menu being a child of its parent, and the leaf node (“last menu” or “deepest menu”) having the value of either {} if it’s expanded, or undefined if not. Here’s how the initial state of the menu is constructed:

    <Menu initialOpenMenuId="3.1.1.1" initialCheckedMenuIds={["3.1.1.1.1"]} />
    
    // ...
    
    loadMenu().then(menuItems => {
      const initialMenuState = {};
      this.setState({
        menuItems,
        openMenus: expandNodeById(initialMenuState, initialOpenMenuId),
        checkedMenus: initialCheckedMenuIds.reduce(
          (acc, val) => ({ ...acc, [val]: true }),
          {}
        )
      });
    });
    
    // ...
    
    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());
    
    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 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)
      };
    };
    

    Let’s take this apart bit by bit.

    const expandedNode = () => ({});
    const unexpandedNode = () => undefined;
    

    These are just convenience functions that we define so we can easily change the value we use to represent an expanded and unexpanded node. It also makes the code a little bit more readable compared to just using literal {} or undefined in the code. The expanded and unexpanded values could just as well be true and false, what matters is that the expanded node is truthy and the unexpanded node is falsy. More about that later.

    const toggleNodeById = (node, id) =>
      replaceNodeById(node, id, oldNode =>
        oldNode ? unexpandedNode() : expandedNode()
      );
    const expandNodeById = (node, id) =>
      replaceNodeById(node, id, oldNode => expandedNode());
    

    These functions let us toggle or expand a specific menu in the menu state. The first parameter is the menu state itself, the second is the string ID of a menu (e.g. "3.1.1.1.1"), and the third is the function that does the replacing. Think of this like the function you pass to .map(). The replacer functionality is separated from the actual recursive tree iteration so that you can easily implement more functionality later – for example, if you want some specific menu to be unexpanded, you can just pass in a function that returns unexpandedNode().

    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);
    };
    

    This function is used by the two previous ones to provide a cleaner interface. The ID is split here by the dots (.) which gives us an array of the ID parts. The next function operates on this array instead of the ID string directly, because that way we don’t need to do .indexOf('.') shenanigans.

    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)
      };
    };
    

    The replaceNode function is the meat of the matter. It is a recursive function that produces a new tree from the old menu tree, replacing the old target node with the provided replacer function. If the tree is missing parts from in between, e.g. when the tree is {} but we want to replace the node 3.1.1.1, it creates the parent nodes in between. Kind of like mkdir -p if you’re familiar with the command.

    So that’s the menu state. The checkbox state (checkedMenus) is basically just an index, with the key being the ID and the value true if an item is checked. This state is not recursive, since they don’t need to be unchecked or checked recursively. If you decide that you want to display an indicator that something under this menu item is checked, an easy solution would be to change the checkbox state to be recursive like the menu state.

    Rendering the tree

    The <Menu> component passes down the states to <MenuItemContainer>, which renders the <MenuItem>s.

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

    The <MenuItemContainer> component is not very different from the original component. The <MenuItem> component does look a little bit different, though.

    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);
      }
    }
    

    Here the crucial part is this: openMenus={openMenus && openMenus[id]}. Instead of passing down the entire menu state, we only pass down the state tree which contains the current item’s children. This allows the component to very easily check if it should be open or collapsed – just check if its own ID is found from the object (openMenus ? !!openMenus[id] : false)!

    I also changed the toggle button to toggle the checkbox instead of the menu state if it’s the deepest item in the menu – if this is not what you’re looking for, it’s pretty quick to change back.

    I also make use of !! here to coerce {} and undefined from the menu state into true or false. This is why I said it only matters whether they’re truthy or falsy. The reactstrap components seemed to want explicit true or false instead of truthy/falsy, so that’s why it’s there.

    And finally:

    ReactDOM.render(
      <Menu initialOpenMenuId="3.1.1.1" initialCheckedMenuIds={["3.1.1.1.1"]} />,
      document.getElementById("root")
    );
    

    Here we pass the initial state to <Menu>. The initialOpenMenuId could also be an array (or initialCheckedMenuIds could be a single string), but this fit the question’s spec.

    Room for improvement

    The solution right now passes down lots of state all the way down, such as the onMenuToggle and onChecked callbacks, and the checkedMenus state which is not recursive. These could make use of React’s Context.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search