Skip to the code
cf.

Sortable, filterable table

The tea shop's stock page lists its teas in a table. Build App: it takes teas, a list of { id, name, origin, price } (price is a number), and shows them in a <table> with three columns, Name, Origin, and Price. When teas isn't given, as in the Preview, show a few of your own.

Each header cell is a <th scope="col"> holding a <button> with the column's name. Prices show as dollars with two decimals, like $8.50. At first the rows are in the order they were given.

Clicking a column's button sorts the rows by that column, ascending, and gives its header aria-sort="ascending". Clicking the same button again sorts descending (aria-sort="descending"), and again flips it back. Clicking another column sorts by that one, ascending, and the old header loses its aria-sort. Only the sorted column's header has aria-sort.

Names and origins sort alphabetically, prices as numbers, so $8.50 comes before $12.00. Rows that tie keep the order they were given, in both directions.

Above the table, a text box labelled Filter teas keeps only the rows whose name or origin contains its text, ignoring case and the spaces around it, still in the current sort order. When no row matches, the table body shows one row with one cell: No teas match. Don't change the teas array you're given.

<App teas={[
  { id: 1, name: "Sencha", origin: "Japan", price: 12 },
  { id: 2, name: "Assam", origin: "India", price: 8.5 },
]} />
 
// after clicking Price:
<th scope="col" aria-sort="ascending"><button>Price</button></th>
// rows: Assam, India, $8.50 | Sencha, Japan, $12.00

What the tests check

  1. shows the teas in their original order, prices as dollars, and no aria-sort
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    render(<App teas={teas} />);
    expect(column(0)).toEqual(["Sencha", "Assam", "Gyokuro", "Darjeeling", "Rooibos"]);
    expect(column(2)).toEqual(["$12.00", "$8.50", "$30.00", "$14.00", "$6.00"]);
    for (const header of screen.getAllByRole("columnheader")) {
      expect(header).toHaveAttribute("scope", "col");
      expect(header).not.toHaveAttribute("aria-sort");
    }
    
  2. clicking a header sorts by it ascending and sets aria-sort
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    render(<App teas={teas} />);
    await user.click(screen.getByRole("button", { name: "Name" }));
    expect(column(0)).toEqual(["Assam", "Darjeeling", "Gyokuro", "Rooibos", "Sencha"]);
    expect(screen.getByRole("columnheader", { name: "Name" })).toHaveAttribute("aria-sort", "ascending");
    
  3. clicking the same header again flips the direction
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    render(<App teas={teas} />);
    const name = screen.getByRole("button", { name: "Name" });
    await user.click(name);
    await user.click(name);
    expect(column(0)).toEqual(["Sencha", "Rooibos", "Gyokuro", "Darjeeling", "Assam"]);
    expect(screen.getByRole("columnheader", { name: "Name" })).toHaveAttribute("aria-sort", "descending");
    await user.click(name);
    expect(column(0)).toEqual(["Assam", "Darjeeling", "Gyokuro", "Rooibos", "Sencha"]);
    expect(screen.getByRole("columnheader", { name: "Name" })).toHaveAttribute("aria-sort", "ascending");
    
  4. sorts prices as numbers
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    render(<App teas={teas} />);
    await user.click(screen.getByRole("button", { name: "Price" }));
    expect(column(2)).toEqual(["$6.00", "$8.50", "$12.00", "$14.00", "$30.00"]);
    await user.click(screen.getByRole("button", { name: "Price" }));
    expect(column(2)).toEqual(["$30.00", "$14.00", "$12.00", "$8.50", "$6.00"]);
    
  5. keeps tied rows in their original order, both ways
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    render(<App teas={teas} />);
    const origin = screen.getByRole("button", { name: "Origin" });
    await user.click(origin);
    expect(column(0)).toEqual(["Assam", "Darjeeling", "Sencha", "Gyokuro", "Rooibos"]);
    await user.click(origin);
    expect(column(0)).toEqual(["Rooibos", "Sencha", "Gyokuro", "Assam", "Darjeeling"]);
    
  6. another header sorts ascending and takes aria-sort
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    render(<App teas={teas} />);
    await user.click(screen.getByRole("button", { name: "Name" }));
    await user.click(screen.getByRole("button", { name: "Price" }));
    expect(column(0)).toEqual(["Rooibos", "Assam", "Sencha", "Darjeeling", "Gyokuro"]);
    expect(screen.getByRole("columnheader", { name: "Price" })).toHaveAttribute("aria-sort", "ascending");
    expect(screen.getByRole("columnheader", { name: "Name" })).not.toHaveAttribute("aria-sort");
    
  7. the filter keeps rows whose name or origin contains the text, in sort order
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    render(<App teas={teas} />);
    await user.click(screen.getByRole("button", { name: "Price" }));
    await user.click(screen.getByRole("button", { name: "Price" }));
    const filter = screen.getByLabelText("Filter teas");
    await user.type(filter, " JA ");
    expect(column(0)).toEqual(["Gyokuro", "Sencha"]);
    await user.clear(filter);
    await user.type(filter, "dar");
    expect(column(0)).toEqual(["Darjeeling"]);
    
  8. shows No teas match when nothing matches
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    render(<App teas={teas} />);
    await user.type(screen.getByLabelText("Filter teas"), "matcha");
    const rows = screen.getAllByRole("row").slice(1);
    expect(rows).toHaveLength(1);
    expect(within(rows[0]).getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["No teas match"]);
    
  9. doesn't change the array it's given
    const teas = [
      { id: 1, name: "Sencha", origin: "Japan", price: 12 },
      { id: 2, name: "Assam", origin: "India", price: 8.5 },
      { id: 3, name: "Gyokuro", origin: "Japan", price: 30 },
      { id: 4, name: "Darjeeling", origin: "India", price: 14 },
      { id: 5, name: "Rooibos", origin: "South Africa", price: 6 },
    ];
    const column = (n) => screen.getAllByRole("row").slice(1).map((row) => within(row).getAllByRole("cell")[n].textContent);
    const frozen = Object.freeze(teas.map((tea) => Object.freeze({ ...tea })));
    render(<App teas={frozen} />);
    await user.click(screen.getByRole("button", { name: "Price" }));
    await user.click(screen.getByRole("button", { name: "Price" }));
    expect(column(0)).toEqual(["Gyokuro", "Darjeeling", "Sencha", "Assam", "Rooibos"]);
    expect(frozen.map((tea) => tea.name)).toEqual(["Sencha", "Assam", "Gyokuro", "Darjeeling", "Rooibos"]);
    

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