Skip to the code
cf.

Dynamic ingredient fields

MediumForms

Build App({ onSubmit }): the ingredient list on a tea shop's recipe editor, say for a masala chai. It's a form with one row per ingredient, and it starts with one empty row.

Each row is a <fieldset> whose <legend> is Ingredient 1, Ingredient 2, and so on, numbered by the row's place in the list right now. Inside are two text boxes labelled Name and Amount, and a Remove button.

An Add ingredient button adds an empty row at the end. Remove deletes its own row, and is disabled while only one row is left. Neither button submits the form.

Rows keep what was typed in them. Removing a row in the middle leaves every other row's name and amount as they were, in the same order, only renumbered. A row added later starts empty.

A Save recipe button submits the form. That calls onSubmit(list) once, where list is an array of { name, amount }, one per row in order, with both values trimmed. Rows whose name is blank are left out.

// After typing two rows and pressing Save recipe:
onSubmit([
  { name: "Assam", amount: "2 tsp" },
  { name: "Milk", amount: "1 cup" },
]);

What the tests check

  1. starts with one empty row
    render(<App onSubmit={spy()} />);
    expect(screen.getAllByRole("group")).toHaveLength(1);
    const row = screen.getByRole("group", { name: "Ingredient 1" });
    expect(within(row).getByLabelText("Name")).toHaveValue("");
    expect(within(row).getByLabelText("Amount")).toHaveValue("");
    
  2. Add ingredient adds an empty row at the end, without saving
    const onSubmit = spy();
    render(<App onSubmit={onSubmit} />);
    await user.type(within(screen.getByRole("group", { name: "Ingredient 1" })).getByLabelText("Name"), "Assam");
    await user.click(screen.getByRole("button", { name: "Add ingredient" }));
    expect(screen.getAllByRole("group")).toHaveLength(2);
    expect(within(screen.getByRole("group", { name: "Ingredient 1" })).getByLabelText("Name")).toHaveValue("Assam");
    expect(within(screen.getByRole("group", { name: "Ingredient 2" })).getByLabelText("Name")).toHaveValue("");
    expect(onSubmit).not.toHaveBeenCalled();
    
  3. Remove is disabled while only one row is left
    render(<App onSubmit={spy()} />);
    expect(screen.getByRole("button", { name: "Remove" })).toBeDisabled();
    await user.click(screen.getByRole("button", { name: "Add ingredient" }));
    for (const button of screen.getAllByRole("button", { name: "Remove" })) expect(button).toBeEnabled();
    
  4. removing a middle row keeps what you typed in the others
    const row = (n) => screen.getByRole("group", { name: "Ingredient " + n });
    render(<App onSubmit={spy()} />);
    const add = screen.getByRole("button", { name: "Add ingredient" });
    await user.click(add);
    await user.click(add);
    await user.type(within(row(1)).getByLabelText("Name"), "Assam");
    await user.type(within(row(1)).getByLabelText("Amount"), "2 tsp");
    await user.type(within(row(2)).getByLabelText("Name"), "Milk");
    await user.type(within(row(2)).getByLabelText("Amount"), "1 cup");
    await user.type(within(row(3)).getByLabelText("Name"), "Cardamom");
    await user.type(within(row(3)).getByLabelText("Amount"), "3 pods");
    await user.click(within(row(2)).getByRole("button", { name: "Remove" }));
    expect(screen.getAllByRole("group")).toHaveLength(2);
    expect(within(row(1)).getByLabelText("Name")).toHaveValue("Assam");
    expect(within(row(1)).getByLabelText("Amount")).toHaveValue("2 tsp");
    expect(within(row(2)).getByLabelText("Name")).toHaveValue("Cardamom");
    expect(within(row(2)).getByLabelText("Amount")).toHaveValue("3 pods");
    
  5. removing the first row renumbers the rest
    const row = (n) => screen.getByRole("group", { name: "Ingredient " + n });
    render(<App onSubmit={spy()} />);
    await user.click(screen.getByRole("button", { name: "Add ingredient" }));
    await user.type(within(row(1)).getByLabelText("Name"), "Assam");
    await user.type(within(row(2)).getByLabelText("Name"), "Ginger");
    await user.click(within(row(1)).getByRole("button", { name: "Remove" }));
    expect(screen.getAllByRole("group")).toHaveLength(1);
    expect(within(row(1)).getByLabelText("Name")).toHaveValue("Ginger");
    
  6. a row added after a removal starts empty
    const row = (n) => screen.getByRole("group", { name: "Ingredient " + n });
    render(<App onSubmit={spy()} />);
    await user.click(screen.getByRole("button", { name: "Add ingredient" }));
    await user.type(within(row(1)).getByLabelText("Name"), "Assam");
    await user.type(within(row(2)).getByLabelText("Name"), "Milk");
    await user.click(within(row(1)).getByRole("button", { name: "Remove" }));
    await user.click(screen.getByRole("button", { name: "Add ingredient" }));
    expect(within(row(1)).getByLabelText("Name")).toHaveValue("Milk");
    expect(within(row(2)).getByLabelText("Name")).toHaveValue("");
    expect(within(row(2)).getByLabelText("Amount")).toHaveValue("");
    
  7. Save recipe calls onSubmit once with every row, trimmed, in order
    const row = (n) => screen.getByRole("group", { name: "Ingredient " + n });
    const onSubmit = spy();
    render(<App onSubmit={onSubmit} />);
    await user.click(screen.getByRole("button", { name: "Add ingredient" }));
    await user.type(within(row(1)).getByLabelText("Name"), "  Assam ");
    await user.type(within(row(1)).getByLabelText("Amount"), "2 tsp  ");
    await user.type(within(row(2)).getByLabelText("Name"), "Milk");
    await user.type(within(row(2)).getByLabelText("Amount"), " 1 cup");
    await user.click(screen.getByRole("button", { name: "Save recipe" }));
    expect(onSubmit).toHaveBeenCalledTimes(1);
    expect(onSubmit).toHaveBeenCalledWith([
      { name: "Assam", amount: "2 tsp" },
      { name: "Milk", amount: "1 cup" },
    ]);
    
  8. rows with a blank name are left out of the list
    const row = (n) => screen.getByRole("group", { name: "Ingredient " + n });
    const onSubmit = spy();
    render(<App onSubmit={onSubmit} />);
    const add = screen.getByRole("button", { name: "Add ingredient" });
    await user.click(add);
    await user.click(add);
    await user.type(within(row(1)).getByLabelText("Name"), "Ginger");
    await user.type(within(row(2)).getByLabelText("Name"), "   ");
    await user.type(within(row(2)).getByLabelText("Amount"), "a pinch");
    await user.click(screen.getByRole("button", { name: "Save recipe" }));
    expect(onSubmit).toHaveBeenCalledWith([{ name: "Ginger", amount: "" }]);
    

Learn more

App.jsxSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).