Search code examples
reactjsdomd3.jsorgchartd3-org-chart

How can I ensure my ReactJS Bumbeishvili D3-Org-Chart updates correctly when layout is changed or data is changed?


I have replicated and simplified my problems on CodeSandbox which can be viewed here: https://codesandbox.io/p/sandbox/d3-org-chart-react-functional-org-chart-gvfz4l?file=%2Fsrc%2Fcomponents%2FdataToggle.js%3A9%2C11

Current issues are...

  1. (RESOLVED) If the swap layout button at the top has been hit AND a node's expand/collapse button is hit, the chart defaults back to its original layout (in this case, left to right layout hierarchy). On second click of a node's expand/collapse button, it will actually perform the desired expand/collapse. Relevant code shown here...
//swapButton.js
const SwapButton = forwardRef(({ onSwapLayout }, ref) => {
  // const [isCycleLayoutOn, setIsCycleLayoutOn] = useState(false);

  const swapLayout = (event) => {
    event.preventDefault();
    onSwapLayout(event);
  };

  return (
    <div>
        <button
          className="context-menu-btns"
          style={{
            backgroundColor: "#4caf50",
            color: "white",
            borderRadius: "0.5rem",
            paddingTop: "0.5rem",
            paddingBottom: "0.5rem",
            paddingLeft: "1rem",
            paddingRight: "1rem",
          }}
          onClick={(event) => swapLayout(event)}
          ref={ref}
        >
          <FaRetweet size="2rem" />
        </button>
    </div>
  );
});

export default SwapButton;

//orgChart.js
const OrganizationalChart = (props) => {
  const d3Container = useRef(null);
  const chartRef = useRef(null);
  const [layoutIndex, setLayoutIndex] = useState(0);

  const handleSwapLayout = useCallback((event) => {
    if (event) {
      setLayoutIndex((prevIndex) => (prevIndex + 1) % 4);
      return layoutIndex;
    }
    return layoutIndex;
  });

  useLayoutEffect(() => {
    const handleLayout = () => handleSwapLayout();

    const chart = new OrgChart();
    if (props.data && d3Container.current) {
      chart
        .container(d3Container.current)
        .data(props.data)
        .nodeWidth((d) => 300)
        .nodeHeight((d) => 150)
        .layout(["left", "top", "right", "bottom"][handleLayout()])
        .compactMarginBetween((d) => 80)
        .onNodeClick((d) => {
          toggleDetailsCard(d);
        })
        .buttonContent((node, state) => {
          return ReactDOMServer.renderToStaticMarkup(
            <CustomExpandButton {...node.node} />
          );
        })
        .nodeContent((d) => {
          return ReactDOMServer.renderToStaticMarkup(
            <CustomNodeContent {...d} />
          );
        })
        .render();

      chartRef.current = chart;
    }
  }, [props, props.data, handleSwapLayout]);

  return (
    <div style={styles.orgChart} ref={d3Container}>
      <div
        style={{
          display: "flex",
          justifyContent: "center",
          alignItems: "center",
          padding: "0.5rem",
        }}
      >
        <SwapButton onSwapLayout={handleSwapLayout} ref={d3Container} />
    </div>
  );
};

export default OrganizationalChart;
  1. If the toggle switch is clicked, it will take 3-4 times of clicking the change dataset button to get the nodes to update. When it updates, it ends up updating backwards. Furthermore, when the expand/collapse button is clicked, it will go back to its original layout.
//dataToggle.js
const DataToggle = ({ onDataChange, isToggleOn }) => {

  const handleChange = (event) => {
    event.preventDefault();
    onDataChange(event);
  };

  return (
    <div>
        <span className={!isToggleOn ? "label_txt_unchecked" : "label_txt"}>OFF</span>
        <label className="switch">
          <input
            checked={isToggleOn}
            type={"checkbox"}
            onChange={(event) => handleChange(event)}
          />
          <span className="slider"></span>
        </label>
        <span className={isToggleOn ? "label_txt_checked" : "label_txt"}>ON</span>
    </div>
  );
};

export default DataToggle;

//App.js
const App = () => {
  const [data, setData] = useState(employees);
  const [isToggleOn, setIsToggleOn] = useState(false);

  const handleDataChange = (event) => {
    event.preventDefault();
    setIsToggleOn(!isToggleOn);
    if (isToggleOn) {
      setData(revisedEmployees);
    } else {
      setData(employees);
    }
  };

  return (
    <React.StrictMode>
        <>
          <h1 style={styles.title}>Organization Chart</h1>
          <div>
            <DataToggle onDataChange={handleDataChange} isToggleOn={isToggleOn}/>
          </div>
          <OrganizationalChart data={data} />     
        </>
    </React.StrictMode>
  );
};//

export default App;

I suspect these issues are related to adding new information into the DOM while not clearing out the old info in the DOM properly. It feels like it is trying to render two different charts or not saving/updating correctly.

I have tried using...

  • d3-org-chart methods: .connectionsUpdate(), .linkUpdate(), .nodeContent(), .nodeUpdate(), .updateNodesState(), and .update() with no success (I'm not confident I'm even employing them correctly). Here is where I got the d3-org-chart from: https://github.com/bumbeishvili/org-chart?tab=readme-ov-file

  • Varying combinations of d3's methods such as .select(), .selectAll(), .enter(), .append(), .join(), .merge(), .exit(), and .remove()... all with no success.

  • useState(), useRef(), useCallback(), useEffect(), useLayoutEffect(),and forwardRef()--no success.

The original template I started this project with is here: https://codesandbox.io/s/org-chart-fnx0zi?file=/src/components/orgChart.js

Ideally, I would like the chart to not render to it's original layout when buttons or clicked unless the webpage is refreshed. Furthermore, I would like it to not take 3-4 clicks of the change dataset button for the nodes position to update under the correct parent node. If there is a way to accomplish the update via d3-org-chart methods, I would prefer that. However, at this point, I'd take any solution, considering I haven't found a solution over the course of 4 months.


Solution

  • All issues resolved on Code Sandbox here... https://codesandbox.io/p/sandbox/d3-org-chart-react-functional-org-chart-forked-cylywl?file=%2Fsrc%2FApp.js%3A23%2C11

    To fix issue 1, put const chartRef = useRef(new OrgChart()) at the beginning and remove const chart = new OrgChart() from useLayoutEffect().

    According to the author of the bumbeishvili d3-org-chart, this is a common mistake when using react. Most likely the org chart was being created multiple times. His solution is posted here: https://codesandbox.io/p/sandbox/d3-org-chart-react-functional-org-chart-forked-kmsd96?file=%2Fsrc%2Fcomponents%2ForgChart.js%3A8%2C39

    To fix issue 2, logic is incorrect on toggle switch. To fix follow CSS and ensure logic is correct which is shown below...

    // In App.js...
     const App = () => {
       const [isToggled, setIsToggled] = useState(false);
     
       return (
           <>
             <div>
               <DataToggle isToggled={isToggled} onToggle={() => setIsToggled(!isToggled)} />
             </div>
             <OrganizationalChart data={isToggled ? revisedEmployees : employees} />     
           </>
       );
    
    //In DataToggle.js...
     import cx from "classnames";
     
     const DataToggle = ({ rounded = false, isToggled, onToggle }) => {
       const sliderCX = cx("slider", {
         'rounded':rounded
       })
       
       return (
         <label className="switch">
           <input type="checkbox" checked={isToggled} onChange={onToggle}/>
           <span className="slider rounded" />
         </label>
       );
     };
     
     export default DataToggle;