How to manage the display of a form with parent and child fields

Viewed 98

I am building a multi-step form (configurator) in React where some of the steps have child steps.

Multi from UI

The steps (including dropdowns) are created based on the following JSON structure:

[
    {
      id: "step_a",
      title: "Step A",
      options: [
        {
          id: "1",
          title: "Option 1",
          child_steps: ["step_b"],
        },
        {
          id: "2",
          title: "Option 2",
          child_steps: ["step_c"],
        },
      ],
    },
    {
      id: "step_b",
      title: "Step B",
      parent_step: "step_a",
      options: [
        {
          id: "1",
          title: "Option 1",
        },
        {
          id: "2",
          title: "Option 2",
        },
      ],
    },
    {
      id: "step_c",
      title: "Step C",
      parent_step: "step_a",
      options: [
        {
          id: "1",
          title: "Option 1",
          child_steps: ["step_d"],
        },
        {
          id: "2",
          title: "Option 2",
        },
      ],
    },
    {
      id: "step_d",
      title: "Step D",
      parent_step: "step_c",
      options: [
        {
          id: "1",
          title: "Option 1",
        },
        {
          id: "2",
          title: "Option 2",
        },
      ],
    },
  ];

Each dropdown in a step has options.

  • Some of the options have a connection to a child step.
  • But some child steps have options that are connected to other child steps.

I am struggling to achieve the combination of the following 3 requirements:

  1. When an option with a connection to a child step is selected, the child step appears and the same applies to the underlying child steps.
  2. When you change to another option in the top-level dropdown, not only the child of that option should collapse, but also the steps of underlying child steps.
  3. Only the form input of the selected and non-collapsed fields should be stored in (configuration) state (and later posted to a server).

App.js

import "./styles.css";
import steps from "./steps.js";
import Step from "./components/Step";

export default function App() {
  return (
    <div className="App">
      <div className="steps">
        {steps.map((step) => (
          <Step key={step.id} step={step} />
        ))}
      </div>
    </div>
  );
}

Step.js

import { ConfiguratorContext } from "../context/ConfiguratorContext"; // Holds configuration state only

const Step = ({ step }) => {
  const { id, title, options, parent_step } = step;
  const [configuration, setConfiguration] = useContext(ConfiguratorContext);

  const handleChange = (e) => {
    const value = e.target.value;
    // Store value of current step to later update configuration in state
    const configurationUpdate = { [id]: value };
    if (options && options.length > 0) {
      options.forEach((option) => {
        // Get current selected option and check whether it has child steps
        if (option.id === value && option.child_steps) {
          option.child_steps.forEach((stepId) => {
            // Prepare values of child steps so they can be stored in configuration state
            const childStep = steps.find((step) => step.id === stepId);
            if (childStep) {
              configurationUpdate[stepId] = childStep.id;
            }
          });
        }
      });
    }
    setConfiguration((prevConfig) => ({
      ...prevConfig,
      ...configurationUpdate
    }));
  };

  const getClassName = () => {
    const classes = ["step"];
    if (parent_step && !configuration[parent_step] && !configuration[id])
      classes.push("d-none");
    return classes.join(" ");
  };

  return (
    <div className={getClassName()}>
      <label>{title}</label>
      <select className="step-dropdown" onChange={handleChange}>
        <option value="">-- Select --</option>
        {options.map((option) => (
          <option key={option.id} value={option.id}>
            {option.title}
          </option>
        ))}
      </select>
    </div>
  );
};

export default Step;

I am working on a solution for already 3 days and did not manage to find one. I have the feeling my approach is not the right one, so I hope someone can give me some advice or ideas on how to approach this problem in a smarter way.

My approaches:

  • Show (child) steps based on values in configuration state
  • Create a seperate state to store which fields are collapsed based on their id
  • Load the steps data in state and store step collapse and configuration under each step

Sandbox of my code:

https://codesandbox.io/embed/epic-almeida-04ykwe?fontsize=14&hidenavigation=1&theme=dark

2 Answers

You can use the following,

App.js

import steps from "./steps.js";
import Step from "./components/Step";

export default function App() {
  return (
    <div className="App">
      <div className="steps">
       {/* iterate through all root level steps (ignore steps with parent step) */}
        {steps.filter(x => !x.parent_step).map((step) => (
          <Step key={step.id} step={step} />
        ))}
      </div>
    </div>
  );
}

Step.js

import { useMemo, useState } from "react";
import steps from "../steps";

const Step = ({ step }) => {
  const { id, title, options, parent_step } = step;

  const handleChange = (e) => {
    const value = e.target.value;
    setSelectedStep(value);
  };

  const getClassName = () => {
    const classes = ["step"];
    return classes.join(" ");
  };
  {/* state to save selected option */}
  const [selectedStep, setSelectedStep] = useState("");
  {/* Calculate the child steps from the selected step (from current steps options get the steps specified by child steps) */}
  const childSteps = useMemo(() => step.options.find(y => y.id === selectedStep)?.child_steps?.map(x => steps.find(z => z.id === x)), [selectedStep, step.options]);

  return (
    <div className={getClassName()}>
      <label>{title}</label>
      <select className="step-dropdown" onChange={handleChange}>
        <option value="">-- Select --</option>
        {options.map((option) => (
          <option key={option.id} value={option.id}>
            {option.title}
          </option>
        ))}
      </select>
      {/* Using recursion to iterate through child steps */}
      {childSteps && childSteps.map((s, i) => <Step key={i} step={s} />)}
    </div>
  );
};

export default Step;

I'm adding a similar version from the first answer, but with more annotations and also addressing your 3rd concern

Only the form input of the selected and non-collapsed fields should be stored in (configuration) state (and later posted to a server).

The solution works by rendering the child components using the child_steps array of each item. So, we need to render only your root steps initially (the ones that don't have a parent step)

  const rootSteps = steps.filter((step) => !step.parent_step);
  return (
    <div className="App">
      <div className="steps">
        {rootSteps.map((step) => (
          <Step key={step.id} step={step} />
        ))}
      </div>

In the step component, we track the selected option with a state when the select changes and update the configuration object as well

  const [selectedOption, setSelectedOption] = useState(null);
  const handleChange = (e) => {
    const value = e.target.value;
    setSelectedOption(options.find((option) => option.id === value));
    setConfiguration((prevConfig) => ({
      ...prevConfig,
      [id]: value
    }));
  };

Then, in your JSX, we iterate through the child_steps and render a Step component for each one. I used a normalized version (stepsMap) of the steps data just for convenience.

      {selectedOption?.child_steps?.map((stepId) => {
        const childStep = stepsMap[stepId];
        return <Step key={childStep.id} step={childStep} />;
      })}

To remove the non-rendered steps from the configuration, we use the clean-up function from a useEffect, which is executed when the component unmounts. Be careful with this approach since it depends on the current rendered elements. Ensure to persist the configuration values before removing the steps from the UI if you want to use them later.

https://codesandbox.io/s/conditional-selects-15rdwk?file=/src/components/Step.js

Related