dodil/erp-ui
tests/board-shell-states.test.tsx
import { act, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "@radix-ui/themes";
import { AppShell, EmptyState, ErrorState, KanbanBoard, LoadingState, NotFoundState, Stat, StatGrid, Toaster, useToast } from "../src";
import { a11y, renderInTheme } from "./helpers";
type Deal = { id: string; name: string; stage: string; locked?: boolean };
const deals: Deal[] = [
{ id: "O-1", name: "Acme 2027", stage: "discovery" },
{ id: "O-2", name: "Lumen pilot", stage: "proposal" },
{ id: "O-3", name: "Fjord video", stage: "proposal", locked: true },
];
const columns = [{ id: "discovery", title: "Discovery" }, { id: "proposal", title: "Proposal", subtitle: "50%" },
{ id: "closed_won", title: "Won", closedReason: "needs an accepted quote" }];
function board(onMove?: (d: Deal, to: string) => void, items: Deal[] | undefined = deals, extra = {}) {
return renderInTheme(
<KanbanBoard label="Pipeline" columns={columns} items={items} itemKey={(d) => d.id} columnOf={(d) => d.stage}
itemLabel={(d) => d.name} renderCard={(d) => <span>{d.name}</span>} onMove={onMove}
moveRefusal={(d) => (d.locked ? "you do not own this deal" : undefined)} {...extra} />,
);
}
test("a board shows each column with its count, and a card moves by keyboard through its menu", async () => {
const moves: string[] = [];
const { container } = board((d, to) => moves.push(`${d.id}→${to}`));
expect(screen.getByRole("group", { name: "Proposal: 2" })).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Discovery: 1" })).toHaveTextContent("Acme 2027");
expect(await a11y(container)).toEqual([]);
screen.getByRole("button", { name: "Move Acme 2027" }).focus();
await userEvent.keyboard("{Enter}");
const item = await screen.findByRole("menuitem", { name: "Proposal" });
await userEvent.click(item);
expect(moves).toEqual(["O-1→proposal"]);
});
test("a move the API would refuse is not offered: the column says why, and so does a card that cannot move", async () => {
const moves: string[] = [];
board((d, to) => moves.push(`${d.id}→${to}`));
await userEvent.click(screen.getByRole("button", { name: "Move Acme 2027" }));
expect(await screen.findByRole("menuitem", { name: /Won — needs an accepted quote/ })).toHaveAttribute("data-disabled");
await userEvent.keyboard("{Escape}");
await userEvent.click(screen.getByRole("button", { name: "Move Fjord video" }));
const menu = await screen.findByRole("menu");
expect(within(menu).getByText("you do not own this deal")).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Discovery" })).toHaveAttribute("data-disabled");
expect(moves).toEqual([]);
});
test("a read-only board offers no moves; empty, loading and error are states of the board", () => {
const { unmount } = board(undefined);
expect(screen.queryByRole("button", { name: /Move/ })).not.toBeInTheDocument();
unmount();
const empty = board(undefined, [], { empty: <EmptyState title="No open deals" /> });
expect(screen.getByText("No open deals")).toBeInTheDocument();
empty.unmount();
const loading = board(undefined, deals, { items: undefined, loading: true });
expect(screen.getByRole("status")).toHaveTextContent("Loading");
loading.unmount();
board(undefined, deals, { error: "the pipeline did not answer" });
expect(screen.getByRole("alert")).toHaveTextContent("the pipeline did not answer");
});
test("the states read the same everywhere", async () => {
let retried = 0;
const { container } = renderInTheme(<>
<LoadingState label="Loading deals…" />
<EmptyState title="No tasks today" description="Enjoy it." action={<Button>New task</Button>} />
<ErrorState error="missing permission: quote:quote:approve" onRetry={() => retried++} />
<NotFoundState what="This lead" />
<StatGrid label="Today"><Stat label="Overdue" value="3" tone="danger" hint="tasks" /><Stat label="Weighted" value="83,400.00 EUR" /></StatGrid>
</>);
expect(screen.getByRole("status")).toHaveTextContent("Loading deals…");
expect(screen.getByRole("alert")).toHaveTextContent("missing permission: quote:quote:approve");
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(retried).toBe(1);
expect(screen.getByText("This lead does not exist, or you cannot see it.")).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Today" })).toHaveTextContent("83,400.00 EUR");
expect(await a11y(container)).toEqual([]);
});
test("a toast confirms a step that worked", async () => {
function Send() { const toast = useToast(); return <Button onClick={() => toast({ title: "Quote sent" })}>Send</Button>; }
renderInTheme(<Toaster><Send /></Toaster>);
await userEvent.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Quote sent")).toBeInTheDocument();
});
test("the shell: the modules' navigation, the current page marked, router clicks, a skip link, light/dark", async () => {
const went: string[] = [];
const looks: string[] = [];
const { container } = renderInTheme(
<AppShell product="CRM" user={{ name: "tom.rep@example.com", detail: "sales" }} appearance="system"
onAppearanceChange={(a) => looks.push(a)} onNavigate={(h) => went.push(h)}
nav={[{ section: "Sell", items: [{ href: "/app/", label: "Home", active: true }, { href: "/app/pipeline/deals", label: "Pipeline" }] }]}>
<h1>Home</h1>
</AppShell>,
);
const main = within(container.querySelector("aside")!);
expect(main.getByRole("link", { name: "Home" })).toHaveAttribute("aria-current", "page");
await userEvent.click(main.getByRole("link", { name: "Pipeline" }));
expect(went).toEqual(["/app/pipeline/deals"]);
expect(screen.getByRole("link", { name: "Skip to content" })).toHaveAttribute("href", "#main");
await userEvent.click(screen.getByRole("button", { name: /Theme: system/ }));
expect(looks).toEqual(["light"]);
expect(await a11y(container)).toEqual([]);
act(() => {});
});