Robbert
Robbert

Reputation: 1330

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

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.

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:

Sandbox of my code:

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

Upvotes: 3

Views: 261

Answers (2)

Debashish
Debashish

Reputation: 501

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;

Upvotes: 2

diedu
diedu

Reputation: 20785

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

Upvotes: 1

Related Questions