Skip to the code
cf.

Kanban board

The tea shop team tracks its jobs on a whiteboard, and wants it on screen. Build KanbanBoard, the default export: a board with three columns of cards.

It takes initialCards, an array of { id, title, column } where column is "todo", "doing", or "done". It's optional, with a few tea shop jobs as the default. It only sets the starting cards: after that, the board keeps its own. Don't change the array you're given or the objects in it.

Show the columns in this order: To do, Doing, Done. Each is a <section> labelled by its <h2> heading (with aria-labelledby), so it's a region named after the column. Under the heading, show how many cards it holds: 0 cards, 1 card, 2 cards. Then show its cards as a list (<ul> with an <li> per card), in order.

Each card shows its title and buttons to move it one column left or right. Name each button after the card and where it goes, with aria-label: Move Fix the kettle to To do, Move Fix the kettle to Done. Cards in To do have no move-left button, and cards in Done have no move-right button. A moved card goes to the end of its new column.

Above the columns, a form adds cards: a text input labelled New card and an Add card button. Submitting it (with the button or Enter) adds a card with the trimmed title to the end of To do and clears the input. A blank title adds nothing.

<KanbanBoard
  initialCards={[
    { id: 1, title: "Restock matcha", column: "todo" },
    { id: 2, title: "Fix the kettle", column: "doing" },
  ]}
/>
 
// the Doing column:
<section aria-labelledby="doing-heading">
  <h2 id="doing-heading">Doing</h2>
  <p>1 card</p>
  <ul>
    <li>
      Fix the kettle
      <button aria-label="Move Fix the kettle to To do">←</button>
      <button aria-label="Move Fix the kettle to Done">→</button>
    </li>
  </ul>
</section>

What the tests check

  1. shows the three columns with their cards and counts
    const cards = [
      { id: 1, title: "Restock matcha", column: "todo" },
      { id: 2, title: "Write the autumn menu", column: "todo" },
      { id: 3, title: "Fix the kettle", column: "doing" },
    ];
    render(<App initialCards={cards} />);
    expect(screen.getAllByRole("heading", { level: 2 }).map((heading) => heading.textContent)).toEqual(["To do", "Doing", "Done"]);
    const todo = screen.getByRole("region", { name: "To do" });
    expect(within(todo).getByText("2 cards")).toBeInTheDocument();
    const items = within(todo).getAllByRole("listitem");
    expect(items.length).toBe(2);
    expect(items[0]).toHaveTextContent("Restock matcha");
    expect(items[1]).toHaveTextContent("Write the autumn menu");
    const doing = screen.getByRole("region", { name: "Doing" });
    expect(within(doing).getByText("1 card")).toBeInTheDocument();
    expect(within(doing).getByText("Fix the kettle")).toBeInTheDocument();
    const done = screen.getByRole("region", { name: "Done" });
    expect(within(done).getByText("0 cards")).toBeInTheDocument();
    expect(within(done).queryAllByRole("listitem").length).toBe(0);
    
  2. moves a card one column to the right
    const cards = [
      { id: 1, title: "Restock matcha", column: "todo" },
      { id: 2, title: "Write the autumn menu", column: "todo" },
      { id: 3, title: "Fix the kettle", column: "doing" },
    ];
    render(<App initialCards={cards} />);
    await user.click(screen.getByRole("button", { name: "Move Restock matcha to Doing" }));
    const todo = screen.getByRole("region", { name: "To do" });
    const doing = screen.getByRole("region", { name: "Doing" });
    expect(within(todo).queryByText("Restock matcha")).not.toBeInTheDocument();
    expect(within(doing).getByText("Restock matcha")).toBeInTheDocument();
    expect(within(todo).getByText("1 card")).toBeInTheDocument();
    expect(within(doing).getByText("2 cards")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Move Restock matcha to Done" }));
    expect(within(screen.getByRole("region", { name: "Done" })).getByText("Restock matcha")).toBeInTheDocument();
    
  3. moves a card one column to the left
    const cards = [
      { id: 1, title: "Restock matcha", column: "todo" },
      { id: 3, title: "Fix the kettle", column: "doing" },
      { id: 4, title: "Order paper cups", column: "done" },
    ];
    render(<App initialCards={cards} />);
    await user.click(screen.getByRole("button", { name: "Move Order paper cups to Doing" }));
    const doing = screen.getByRole("region", { name: "Doing" });
    expect(within(doing).getByText("Order paper cups")).toBeInTheDocument();
    expect(within(doing).getByText("2 cards")).toBeInTheDocument();
    await user.click(screen.getByRole("button", { name: "Move Fix the kettle to To do" }));
    const todo = screen.getByRole("region", { name: "To do" });
    expect(within(todo).getByText("Fix the kettle")).toBeInTheDocument();
    expect(within(todo).getByText("2 cards")).toBeInTheDocument();
    
  4. has no left button in To do and no right button in Done
    const cards = [
      { id: 1, title: "Restock matcha", column: "todo" },
      { id: 3, title: "Fix the kettle", column: "doing" },
      { id: 4, title: "Order paper cups", column: "done" },
    ];
    render(<App initialCards={cards} />);
    const names = (column) => within(screen.getByRole("region", { name: column })).getAllByRole("button").map((button) => button.getAttribute("aria-label"));
    expect(names("To do")).toEqual(["Move Restock matcha to Doing"]);
    expect(names("Doing").sort()).toEqual(["Move Fix the kettle to Done", "Move Fix the kettle to To do"]);
    expect(names("Done")).toEqual(["Move Order paper cups to Doing"]);
    
  5. puts a moved card at the end of its new column
    const cards = [
      { id: 1, title: "Restock matcha", column: "todo" },
      { id: 2, title: "Fix the kettle", column: "doing" },
      { id: 3, title: "Order paper cups", column: "done" },
    ];
    render(<App initialCards={cards} />);
    await user.click(screen.getByRole("button", { name: "Move Restock matcha to Doing" }));
    const items = within(screen.getByRole("region", { name: "Doing" })).getAllByRole("listitem");
    expect(items.length).toBe(2);
    expect(items[0]).toHaveTextContent("Fix the kettle");
    expect(items[1]).toHaveTextContent("Restock matcha");
    
  6. adds a card with a trimmed title to the end of To do
    const cards = [{ id: 1, title: "Restock matcha", column: "todo" }];
    render(<App initialCards={cards} />);
    const input = screen.getByLabelText("New card");
    await user.type(input, "  Clean the teapots  ");
    await user.click(screen.getByRole("button", { name: "Add card" }));
    expect(input).toHaveValue("");
    const todo = screen.getByRole("region", { name: "To do" });
    expect(within(todo).getByText("2 cards")).toBeInTheDocument();
    const items = within(todo).getAllByRole("listitem");
    expect(items[1]).toHaveTextContent("Clean the teapots");
    expect(items[1].textContent).not.toContain("  Clean");
    expect(items[1].textContent).not.toContain("teapots  ");
    expect(screen.getByRole("button", { name: "Move Clean the teapots to Doing" })).toBeInTheDocument();
    await user.type(input, "Wipe the tables{Enter}");
    expect(within(todo).getByText("3 cards")).toBeInTheDocument();
    expect(within(todo).getAllByRole("listitem")[2]).toHaveTextContent("Wipe the tables");
    
  7. ignores a blank title
    const cards = [{ id: 1, title: "Restock matcha", column: "todo" }];
    render(<App initialCards={cards} />);
    await user.click(screen.getByRole("button", { name: "Add card" }));
    await user.type(screen.getByLabelText("New card"), "   ");
    await user.click(screen.getByRole("button", { name: "Add card" }));
    const todo = screen.getByRole("region", { name: "To do" });
    expect(within(todo).getByText("1 card")).toBeInTheDocument();
    expect(within(todo).getAllByRole("listitem").length).toBe(1);
    
  8. moves a new card like any other
    const cards = [
      { id: 1, title: "Restock matcha", column: "todo" },
      { id: 2, title: "Fix the kettle", column: "doing" },
    ];
    render(<App initialCards={cards} />);
    await user.type(screen.getByLabelText("New card"), "Clean the teapots{Enter}");
    await user.click(screen.getByRole("button", { name: "Move Clean the teapots to Doing" }));
    const doing = screen.getByRole("region", { name: "Doing" });
    expect(within(doing).getByText("2 cards")).toBeInTheDocument();
    expect(within(doing).getAllByRole("listitem")[1]).toHaveTextContent("Clean the teapots");
    expect(within(screen.getByRole("region", { name: "To do" })).getByText("1 card")).toBeInTheDocument();
    
  9. doesn't change the cards it was given
    const cards = [
      { id: 1, title: "Restock matcha", column: "todo" },
      { id: 2, title: "Fix the kettle", column: "doing" },
    ];
    const before = JSON.stringify(cards);
    const { unmount } = render(<App initialCards={cards} />);
    await user.type(screen.getByLabelText("New card"), "Clean the teapots{Enter}");
    expect(JSON.stringify(cards)).toBe(before);
    unmount();
    render(<App initialCards={cards} />);
    await user.click(screen.getByRole("button", { name: "Move Restock matcha to Doing" }));
    await user.click(screen.getByRole("button", { name: "Move Fix the kettle to Done" }));
    expect(JSON.stringify(cards)).toBe(before);
    

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).