Skip to the code
cf.

Table from a columns config

The shop's back office shows stock, orders, and suppliers in tables that all look alike. Build Table, a named export of App.jsx, that draws any of them from a description of its columns. The default export, App, is only for the Preview.

Table takes caption (a string), columns (an array of { key, header, render }, where render is optional), and rows (an array of objects, each with a unique id).

Draw a <table> with the caption in a <caption>. The head has one row with a <th scope="col"> per column, showing its header, in the order of columns.

The body has one <tr> per row, in order. Each row has one <td> per column, in the order of columns: if the column has a render function, the cell shows render(row) (it gets the whole row, and may return text or JSX), and otherwise it shows row[key].

When rows is empty, the body has a single row with a single cell that reads No rows to show. and spans every column (colSpan set to the number of columns). Use keys so React logs no key warning.

<Table
  caption="Teas in stock"
  columns={[
    { key: "name", header: "Tea" },
    { key: "price", header: "Price", render: (row) => "$" + row.price.toFixed(2) },
  ]}
  rows={[{ id: 1, name: "Sencha", price: 4 }]}
/>
 
// draws:
<table>
  <caption>Teas in stock</caption>
  <thead><tr><th scope="col">Tea</th><th scope="col">Price</th></tr></thead>
  <tbody><tr><td>Sencha</td><td>$4.00</td></tr></tbody>
</table>

What the tests check

  1. the caption names the table
    render(<Table caption="Teas in stock" columns={[{ key: "name", header: "Tea" }]} rows={[{ id: 1, name: "Sencha" }]} />);
    expect(screen.getByRole("table", { name: "Teas in stock" })).toBeInTheDocument();
    
  2. has a column header per column, in order, with scope col
    const columns = [
      { key: "name", header: "Tea" },
      { key: "price", header: "Price" },
      { key: "stock", header: "Stock" },
    ];
    render(<Table caption="Teas" columns={columns} rows={[{ id: 1, name: "Sencha", price: 4, stock: 12 }]} />);
    const headers = screen.getAllByRole("columnheader");
    expect(headers.map((header) => header.textContent)).toEqual(["Tea", "Price", "Stock"]);
    for (const header of headers) {
      expect(header.tagName).toBe("TH");
      expect(header).toHaveAttribute("scope", "col");
    }
    
  3. has a row per item, with each column's value in order
    const columns = [
      { key: "name", header: "Tea" },
      { key: "origin", header: "From" },
    ];
    const rows = [
      { id: 1, name: "Sencha", origin: "Japan" },
      { id: 2, name: "Oolong", origin: "Taiwan" },
    ];
    render(<Table caption="Teas" columns={columns} rows={rows} />);
    const bodyRows = screen.getAllByRole("row").slice(1);
    expect(bodyRows).toHaveLength(2);
    expect(within(bodyRows[0]).getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["Sencha", "Japan"]);
    expect(within(bodyRows[1]).getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["Oolong", "Taiwan"]);
    
  4. uses a column's render function for its cells
    const columns = [
      { key: "name", header: "Tea" },
      { key: "price", header: "Price", render: (row) => "$" + row.price.toFixed(2) },
      { key: "stock", header: "Stock", render: (row) => (row.stock < 5 ? <strong>Low</strong> : row.stock) },
    ];
    const rows = [
      { id: 1, name: "Sencha", price: 4, stock: 12 },
      { id: 2, name: "Oolong", price: 6.5, stock: 3 },
    ];
    render(<Table caption="Teas" columns={columns} rows={rows} />);
    const bodyRows = screen.getAllByRole("row").slice(1);
    expect(within(bodyRows[0]).getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["Sencha", "$4.00", "12"]);
    const lowCell = within(bodyRows[1]).getAllByRole("cell")[2];
    expect(within(lowCell).getByText("Low").tagName).toBe("STRONG");
    
  5. passes the whole row to render
    const columns = [{ key: "label", header: "Tea", render: (row) => row.name + " (" + row.origin + ")" }];
    render(<Table caption="Teas" columns={columns} rows={[{ id: 7, name: "Sencha", origin: "Japan" }]} />);
    expect(screen.getByRole("cell", { name: "Sencha (Japan)" })).toBeInTheDocument();
    
  6. follows the order of columns it's given
    const rows = [{ id: 1, name: "Sencha", origin: "Japan" }];
    const { rerender } = render(<Table caption="Teas" columns={[{ key: "name", header: "Tea" }, { key: "origin", header: "From" }]} rows={rows} />);
    rerender(<Table caption="Teas" columns={[{ key: "origin", header: "From" }, { key: "name", header: "Tea" }]} rows={rows} />);
    expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual(["From", "Tea"]);
    expect(screen.getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["Japan", "Sencha"]);
    
  7. shows one message across every column when there are no rows
    const columns = [
      { key: "name", header: "Tea" },
      { key: "price", header: "Price" },
      { key: "stock", header: "Stock" },
    ];
    render(<Table caption="Teas" columns={columns} rows={[]} />);
    expect(screen.getAllByRole("row")).toHaveLength(2);
    const cells = screen.getAllByRole("cell");
    expect(cells).toHaveLength(1);
    expect(cells[0]).toHaveTextContent("No rows to show.");
    expect(cells[0]).toHaveAttribute("colspan", "3");
    
  8. shows a value of 0 rather than an empty cell
    const columns = [
      { key: "name", header: "Tea" },
      { key: "stock", header: "Stock" },
    ];
    render(<Table caption="Teas" columns={columns} rows={[{ id: 1, name: "Matcha", stock: 0 }]} />);
    expect(screen.getAllByRole("cell").map((cell) => cell.textContent)).toEqual(["Matcha", "0"]);
    
  9. gives every row and cell a key
    const columns = [
      { key: "name", header: "Tea" },
      { key: "price", header: "Price", render: (row) => "$" + row.price.toFixed(2) },
    ];
    const rows = [
      { id: 1, name: "Sencha", price: 4 },
      { id: 2, name: "Oolong", price: 6.5 },
    ];
    render(<Table caption="Teas" columns={columns} rows={rows} />);
    expect(logs.join("\n")).not.toMatch(/unique "key"/);
    

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