1

I am trying to implement a multi-step wizard using a state machine and am unsure how to handle some configurations. To illustrate this I put together an example of a wizard that helps you prepare a dish. Assuming the following example what would be the appropriate way to model this form/wizard behavior as a state machine?

Step 1 - Dish

  • pick a dish from ["Salad", "Pasta", "Pizza"]

Step 2 - Preparation Method

  • pick a preparation method from ["Oven", "Microwave"]

Step 3 - Ingredients

  • add and select ingredients in a form, depending on the dish and the preparation method the form will look different
// ingredients based on previous selections
("Pizza", "Oven") => ["tomato", "cheese", "pepperoni"]
("Pizza", "Microwave") => ["cheese", "pepperoni", "mushrooms"]
("Pasta", "Oven") => ["parmesan", "butter", "creme fraiche"]
("Pasta", "Microwave") => ["parmesan", "creme fraiche"]
("Salad") => ["cucumber", "feta cheese", "lettuce"]

I tried to simplify the problem as much as possible. Here are my questions:

  1. In step 3 I want to show a form with various fields of different types. The selections in step 1 and 2 define which fields will be shown in the form in step 3. What is the appropriate way to specify this form configuration?

  2. Step 2 should be skipped if the selected dish from step 1 is "Salad". What is the appropriate way to declare this?

I plan to implement this using xstate as the project I'm working on is written in react.

Edit: I updated the example in reaction to Martins answer. (see my comment on his answer)

Edit 2: I updated the example in reaction to Davids answer. (see my comment on his answer)

matteok
  • 2,189
  • 3
  • 30
  • 54

2 Answers2

2

For the overall flow, you can use guarded transitions to skip the method step if "salad" was selected:

const machine = createMachine({
  initial: 'pick a dish',
  context: {
    dish: null,
    method: null
  },
  states: {
    'pick a dish': {
      on: {
        'dish.select': [
          {
            target: 'ingredients',
            cond: (_, e) => e.value === 'salad'
          },
          {
            target: 'prep method',
            actions: assign({ dish: (_, e) => e.value })
          }
        ]
      }
    },
    'prep method': {
      on: {
        'method.select': {
          target: 'ingredients',
          actions: assign({ method: (_, e) => e.value })
        }
      }
    },
    'ingredients': {
      // ...
    }
  }
});

And you can use the data-driven configuration from Matin's answer to dynamically show ingredients based on the context.dish and context.method.

David Khourshid
  • 4,918
  • 1
  • 11
  • 10
  • Thank you for the answer. This solves the issue with skipping the step. Regarding the ingredients I updated my question to illustrate the complex mapping between selections in the first two steps and the ingredients in the last step. I am unsure how to model this in a declarative way especially if the wizard/form adds more steps and ingredients. I realise that my metaphor doesn't make so much sense anymore but I hope the problem I'm trying to solve is understandable. – matteok Jul 07 '20 at 16:21
1

You need to have a data structure that holds data and the relationship between them then you can use state to store the selected item and have your logic to display/hide specific step.

Below is just a simple example to show how you can do it:

Sandbox example link

  const data = [
  {
   // I recommend to use a unique id for any items that can be selective
    dish: "Salad",
    ingredients: ["ingredient-A", "ingredient-B", "ingredient-C"],
    preparationMethods: []
  },
  {
    dish: "Pasta",
    ingredients: ["ingredient-E", "ingredient-F", "ingredient-G"],
    preparationMethods: ["Oven", "Microwave"]
  },
  {
    dish: "Pizza",
    ingredients: ["ingredient-H", "ingredient-I", "ingredient-G"],
    preparationMethods: ["Oven", "Microwave"]
  }
];


export default function App() {
  const [selectedDish, setSelectedDish] = useState(null);
  const [selectedMethod, setSelectedMethod] = useState(null);
  const [currentStep, setCurrentStep] = useState(1);

  const onDishChange = event => {
    const selecetedItem = data.filter(
      item => item.dish === event.target.value
    )[0];
    setSelectedDish(selecetedItem);
    setSelectedMethod(null);
    setCurrentStep(selecetedItem.preparationMethods.length > 0 ? 2 : 3);
  };
  const onMethodChange = event => {
    setSelectedMethod(event.target.value);
    setCurrentStep(3);
  };
  const onBack = () => {
    setCurrentStep(
      currentStep === 3 && selectedMethod === null ? 1 : currentStep - 1
    );
  };

  useEffect(() => {
    switch (currentStep) {
      case 1:
        setSelectedDish(null);
        setSelectedMethod(null);
        break;
      case 2:
        setSelectedMethod(null);
        break;
      case 3:
      default:
    }
  }, [currentStep]);

  return (
    <div className="App">
      {currentStep === 1 && <Step1 onDishChange={onDishChange} />}
      {currentStep === 2 && (
        <Step2
          onMethodChange={onMethodChange}
          selectedMethod={selectedMethod}
          selectedDish={selectedDish}
        />
      )}
      {currentStep === 3 && <Step3 selectedDish={selectedDish} />}
      {selectedDish !== null && (
        <>
          <hr />
          <div>Selected Dish: {selectedDish.dish}</div>
          {selectedMethod !== null && (
            <div>Selected Method: {selectedMethod}</div>
          )}
        </>
      )}
      <br />
      {currentStep > 1 && <button onClick={onBack}> Back </button>}
    </div>
  );
}

const Step1 = ({ onDishChange }) => (
  <>
    <h5>Step 1:</h5>
    <select onChange={onDishChange}>
      <option value={null} disabled selected>
        Select a dish
      </option>
      {data.map(item => (
        <option key={item.dish} value={item.dish}>
          {item.dish}
        </option>
      ))}
    </select>
  </>
);

const Step2 = ({ onMethodChange, selectedMethod, selectedDish }) => (
  <>
    <h5>Step 2:</h5>
    <div>
      <select onChange={onMethodChange} value={selectedMethod}>
        <option value={null} disabled selected>
          Select a method
        </option>
        {selectedDish.preparationMethods.map(method => (
          <option key={method} value={method}>
            {method}
          </option>
        ))}
      </select>
    </div>
  </>
);

const Step3 = ({ selectedDish }) => (
  <>
    <h5>Step 3:</h5>
    <h4>List of ingredient: </h4>
    {selectedDish.ingredients.map(ingredient => (
      <div key={ingredient}>{ingredient}</div>
    ))}
  </>
);
Matin Kajabadi
  • 3,414
  • 1
  • 17
  • 21
  • Thank you for the answer. This would indeed solve the case I described. The actual I am facing is more complex however. The decision on what form fields to display in the final step is dependent on more than one selection of the previous steps which is why I cannot just declare it as data. I will update my example to reflect that. – matteok Jul 07 '20 at 10:43