320 lines
9.3 KiB
TypeScript
320 lines
9.3 KiB
TypeScript
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { Button } from "./button";
|
|
import { DataTable, type DataTableColumn } from "./data-table";
|
|
|
|
type ReleaseRow = {
|
|
id: string;
|
|
lane: string;
|
|
note: string;
|
|
owner: string;
|
|
risk: number;
|
|
status: string;
|
|
};
|
|
|
|
const rows: ReleaseRow[] = [
|
|
{
|
|
id: "legal",
|
|
lane: "Legal",
|
|
note: "Footnote needs one more pass before the customer note goes out.",
|
|
owner: "Ava",
|
|
risk: 3,
|
|
status: "Pending"
|
|
},
|
|
{
|
|
id: "support",
|
|
lane: "Support",
|
|
note: "Macro pack is staged and aligned with the migration narrative.",
|
|
owner: "Nia",
|
|
risk: 1,
|
|
status: "Ready"
|
|
},
|
|
{
|
|
id: "engineering",
|
|
lane: "Engineering",
|
|
note: "Canary checks are green and ready for the 10% wave.",
|
|
owner: "Mika",
|
|
risk: 2,
|
|
status: "Watching"
|
|
},
|
|
{
|
|
id: "editorial",
|
|
lane: "Editorial",
|
|
note: "Copy is locked and the public note stays staged.",
|
|
owner: "Jun",
|
|
risk: 4,
|
|
status: "Staged"
|
|
}
|
|
];
|
|
|
|
const columns: DataTableColumn<ReleaseRow>[] = [
|
|
{
|
|
accessor: "lane",
|
|
header: "Lane",
|
|
id: "lane",
|
|
sortable: true
|
|
},
|
|
{
|
|
accessor: "owner",
|
|
header: "Owner",
|
|
id: "owner",
|
|
sortable: true
|
|
},
|
|
{
|
|
accessor: "status",
|
|
header: "Status",
|
|
id: "status"
|
|
},
|
|
{
|
|
accessor: "risk",
|
|
align: "end",
|
|
header: "Risk",
|
|
id: "risk",
|
|
sortable: true
|
|
},
|
|
{
|
|
cell: (row) => row.note,
|
|
header: "Narrative",
|
|
id: "note",
|
|
searchValue: (row) => row.note
|
|
}
|
|
];
|
|
|
|
function getBodyRows() {
|
|
return screen
|
|
.getAllByRole("row")
|
|
.filter((row) => row.closest("tbody") !== null);
|
|
}
|
|
|
|
function getRenderedLanes() {
|
|
return getBodyRows().map((row) => within(row).getAllByRole("cell")[0].textContent);
|
|
}
|
|
|
|
describe("DataTable", () => {
|
|
it("renders semantic table slots, toolbar search, and row actions", () => {
|
|
render(
|
|
<DataTable
|
|
columns={columns}
|
|
renderRowActions={(row) => <Button size="sm">Open {row.lane}</Button>}
|
|
rows={rows.slice(0, 2)}
|
|
toolbarActions={<Button size="sm" variant="secondary">Create lane</Button>}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByRole("table").closest('[data-slot="root"]')).toBeInTheDocument();
|
|
expect(screen.getByRole("searchbox", { name: "Search rows" })).toHaveAttribute(
|
|
"data-slot",
|
|
"input"
|
|
);
|
|
expect(
|
|
screen.getByRole("button", { name: "Create lane" }).closest('[data-slot="actions"]')
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole("columnheader", { name: /lane/i })).toHaveAttribute(
|
|
"data-slot",
|
|
"header"
|
|
);
|
|
expect(
|
|
screen.getByRole("button", { name: "Open Legal" }).closest('[data-slot="actions"]')
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("sorts rows when a sortable header is activated", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<DataTable columns={columns} rows={rows.slice(0, 3)} />);
|
|
|
|
expect(getRenderedLanes()).toEqual(["Legal", "Support", "Engineering"]);
|
|
|
|
await user.click(within(screen.getByRole("columnheader", { name: /risk/i })).getByRole("button"));
|
|
|
|
expect(getRenderedLanes()).toEqual(["Support", "Engineering", "Legal"]);
|
|
|
|
await user.click(within(screen.getByRole("columnheader", { name: /risk/i })).getByRole("button"));
|
|
|
|
expect(getRenderedLanes()).toEqual(["Legal", "Engineering", "Support"]);
|
|
});
|
|
|
|
it("supports row selection and shows a bulk selection surface", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<DataTable
|
|
columns={columns}
|
|
enableSelection
|
|
rows={rows.slice(0, 2)}
|
|
selectionActions={() => <Button size="sm">Assign owner</Button>}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getByRole("checkbox", { name: "Select row 1" }));
|
|
|
|
expect(getBodyRows()[0]).toHaveAttribute("data-selected", "");
|
|
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: "Assign owner" })).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Clear selection" }));
|
|
|
|
expect(screen.queryByText("1 selected")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("filters rows from the built-in search and falls back to the empty state", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<DataTable columns={columns} rows={rows.slice(0, 3)} />);
|
|
|
|
await user.type(screen.getByRole("searchbox", { name: "Search rows" }), "migration");
|
|
|
|
expect(
|
|
screen.getByText("Macro pack is staged and aligned with the migration narrative.")
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByText("Canary checks are green and ready for the 10% wave.")
|
|
).not.toBeInTheDocument();
|
|
|
|
await user.clear(screen.getByRole("searchbox", { name: "Search rows" }));
|
|
await user.type(screen.getByRole("searchbox", { name: "Search rows" }), "missing");
|
|
|
|
expect(screen.getByText("No matching rows")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText(
|
|
"Try another search, clear filters, or create a new record to repopulate this view."
|
|
)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it("paginates rows and updates the visible range", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<DataTable
|
|
columns={columns}
|
|
defaultPageSize={2}
|
|
pageSizeOptions={[2]}
|
|
rows={rows}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByText("1-2 of 4")).toBeInTheDocument();
|
|
expect(getRenderedLanes()).toEqual(["Legal", "Support"]);
|
|
expect(screen.getByRole("button", { name: "Previous" })).toBeDisabled();
|
|
|
|
await user.click(screen.getByRole("button", { name: "Next" }));
|
|
|
|
expect(screen.getByText("3-4 of 4")).toBeInTheDocument();
|
|
expect(getRenderedLanes()).toEqual(["Engineering", "Editorial"]);
|
|
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
|
|
});
|
|
|
|
it("renders loading status without dropping the table chrome", () => {
|
|
render(<DataTable columns={columns} loading rows={rows} />);
|
|
|
|
expect(screen.getByRole("status")).toHaveTextContent("Loading rows");
|
|
expect(screen.getByRole("columnheader", { name: /lane/i })).toBeInTheDocument();
|
|
expect(screen.getByRole("table").closest('[data-slot="root"]')).toHaveAttribute(
|
|
"data-loading",
|
|
""
|
|
);
|
|
});
|
|
|
|
it("supports controlled sorting and selection callbacks", async () => {
|
|
const user = userEvent.setup();
|
|
const onSortingChange = vi.fn();
|
|
const onSelectionChange = vi.fn();
|
|
|
|
render(
|
|
<DataTable
|
|
columns={columns}
|
|
enableSelection
|
|
onSelectionChange={onSelectionChange}
|
|
onSortingChange={onSortingChange}
|
|
rows={rows.slice(0, 2)}
|
|
/>
|
|
);
|
|
|
|
await user.click(within(screen.getByRole("columnheader", { name: /lane/i })).getByRole("button"));
|
|
await user.click(screen.getByRole("checkbox", { name: "Select row 2" }));
|
|
|
|
expect(onSortingChange).toHaveBeenCalledWith([{ desc: false, id: "lane" }]);
|
|
expect(onSelectionChange).toHaveBeenCalledWith({ support: true });
|
|
});
|
|
|
|
it("toggles hideable columns from the built-in view menu", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<DataTable
|
|
columns={[
|
|
{
|
|
accessor: "lane",
|
|
header: "Lane",
|
|
hideable: false,
|
|
id: "lane"
|
|
},
|
|
{
|
|
accessor: "owner",
|
|
header: "Owner",
|
|
hideable: true,
|
|
id: "owner"
|
|
}
|
|
]}
|
|
rows={rows.slice(0, 2)}
|
|
/>
|
|
);
|
|
|
|
expect(screen.getByRole("columnheader", { name: /owner/i })).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole("button", { name: "View" }));
|
|
await user.click(screen.getByRole("menuitemcheckbox", { name: "Owner" }));
|
|
|
|
expect(screen.queryByRole("columnheader", { name: /owner/i })).not.toBeInTheDocument();
|
|
expect(screen.queryByText("Ava")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("switches density from the built-in view menu", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(<DataTable columns={columns} rows={rows.slice(0, 2)} />);
|
|
|
|
const root = screen.getByRole("table").closest('[data-slot="root"]');
|
|
expect(root).toHaveAttribute("data-density", "comfortable");
|
|
|
|
await user.click(screen.getByRole("button", { name: "View" }));
|
|
await user.click(screen.getByRole("menuitemradio", { name: "Compact" }));
|
|
|
|
expect(root).toHaveAttribute("data-density", "compact");
|
|
expect(screen.getByRole("columnheader", { name: /lane/i })).toHaveAttribute(
|
|
"data-density",
|
|
"compact"
|
|
);
|
|
});
|
|
|
|
it("opens row details inside a sheet", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
render(
|
|
<DataTable
|
|
columns={columns}
|
|
renderRowDetails={(row) => <div>{row.note}</div>}
|
|
rowDetailsDescription={(row) => `${row.lane} handoff`}
|
|
rowDetailsTitle={(row) => `${row.lane} detail`}
|
|
rows={rows.slice(0, 2)}
|
|
/>
|
|
);
|
|
|
|
await user.click(screen.getAllByRole("button", { name: "Open details" })[0]);
|
|
|
|
const detailHeading = await screen.findByText("Legal detail");
|
|
const sheet = detailHeading.closest('[data-slot="content"]');
|
|
|
|
expect(detailHeading).toBeInTheDocument();
|
|
expect(screen.getByText("Legal handoff")).toBeInTheDocument();
|
|
expect(
|
|
within(sheet as HTMLElement).getByText(
|
|
"Footnote needs one more pass before the customer note goes out."
|
|
)
|
|
).toBeInTheDocument();
|
|
});
|
|
});
|