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>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user