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(() => {});
});