Files
biggus-dickus/web/src/objects/objects-table.test.tsx
T
logaritmisk 49f694d1fb feat(web): full-width sortable/filterable objects table with URL state (#44)
Replace the narrow ObjectList with a full-width ObjectsTable whose state
(sort/order/q/visibility/limit/offset) lives entirely in the URL via
useSearchParams. Sortable headers toggle sort+dir with aria-sort, a
debounced quick-filter and visibility chips mirror the search-panel
pattern, and a pagination footer offers prev/next + page-size select.
Rows deep-link to /objects/:id preserving the query string.

useObjectsPage now takes an ObjectListParams object (sort/order/
visibility/q) with keepPreviousData. ObjectsPage renders the table as
the full-width landing view, surfacing the nested <Outlet/> detail as a
simple right-side panel only when a :id child route is active (Phase 3
makes this responsive). object-list.tsx and its test are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:34:13 +02:00

137 lines
4.6 KiB
TypeScript

import { beforeEach, expect, test } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Route, Routes } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { objectsPage } from "../test/fixtures";
import { ObjectsTable } from "./objects-table";
import { ObjectDetail } from "./object-detail";
import i18n from "../i18n";
beforeEach(async () => {
await i18n.changeLanguage("en");
});
function tree() {
return (
<Routes>
<Route path="/objects" element={<ObjectsTable />} />
<Route path="/objects/:id" element={<ObjectDetail />} />
</Routes>
);
}
/** Capture the query string of every objects request so assertions can inspect URL → request flow. */
function captureRequests() {
const calls: URLSearchParams[] = [];
server.use(
http.get("/api/admin/objects", ({ request }) => {
calls.push(new URL(request.url).searchParams);
return HttpResponse.json(objectsPage);
}),
);
return calls;
}
function last(calls: URLSearchParams[]): URLSearchParams {
return calls[calls.length - 1];
}
test("renders a row per object with number, name, visibility, location and count", async () => {
renderApp(tree(), { route: "/objects" });
expect(await screen.findByText("Amphora")).toBeInTheDocument();
expect(screen.getByText("LM-0042")).toBeInTheDocument();
// "Public" is also a filter chip and the location is shared across fixtures;
// scope column assertions to the Amphora row.
const row = screen.getByText("Amphora").closest("tr")!;
expect(within(row).getByText("Public")).toBeInTheDocument();
expect(within(row).getByText("Vault 3")).toBeInTheDocument();
});
test("clicking a sortable header updates sort/order and aria-sort", async () => {
const calls = captureRequests();
renderApp(tree(), { route: "/objects" });
await screen.findByText("Amphora");
const nameHeader = screen.getByRole("columnheader", { name: /Name/i });
expect(nameHeader).toHaveAttribute("aria-sort", "none");
await userEvent.click(within(nameHeader).getByRole("button"));
await waitFor(() => expect(nameHeader).toHaveAttribute("aria-sort", "ascending"));
await waitFor(() => {
const cur = last(calls);
expect(cur.get("sort")).toBe("object_name");
expect(cur.get("order")).toBe("asc");
});
await userEvent.click(within(nameHeader).getByRole("button"));
await waitFor(() => expect(nameHeader).toHaveAttribute("aria-sort", "descending"));
});
test("typing in the quick filter sets q (debounced)", async () => {
const calls = captureRequests();
renderApp(tree(), { route: "/objects" });
await screen.findByText("Amphora");
await userEvent.type(screen.getByLabelText(/filter objects/i), "amph");
await waitFor(() => expect(last(calls).get("q")).toBe("amph"));
});
test("a visibility chip sets the visibility param", async () => {
const calls = captureRequests();
renderApp(tree(), { route: "/objects" });
await screen.findByText("Amphora");
await userEvent.click(screen.getByRole("button", { name: /^draft$/i }));
await waitFor(() => expect(last(calls).get("visibility")).toBe("draft"));
});
test("pagination next/prev change the offset", async () => {
const calls = captureRequests();
// total > limit so Next is enabled.
server.use(
http.get("/api/admin/objects", ({ request }) => {
calls.push(new URL(request.url).searchParams);
return HttpResponse.json({ ...objectsPage, total: 200 });
}),
);
renderApp(tree(), { route: "/objects" });
await screen.findByText("Amphora");
await userEvent.click(screen.getByRole("button", { name: /next/i }));
await waitFor(() => expect(last(calls).get("offset")).toBe("50"));
// Back to the first page: the URL drops `offset`, so the request sends offset 0.
await userEvent.click(screen.getByRole("button", { name: /previous/i }));
await waitFor(() => expect(last(calls).get("offset")).toBe("0"));
});
test("the page-size select sets the limit", async () => {
const calls = captureRequests();
renderApp(tree(), { route: "/objects" });
await screen.findByText("Amphora");
await userEvent.selectOptions(screen.getByLabelText(/per page/i), "100");
await waitFor(() => expect(last(calls).get("limit")).toBe("100"));
});
test("clicking a row deep-links to /objects/:id preserving the query string", async () => {
renderApp(tree(), { route: "/objects?sort=object_name&order=desc" });
await userEvent.click(await screen.findByText("Amphora"));
expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
});