diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index c4c82fe..24f6934 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -43,12 +43,31 @@ export function useMe() { }); } -export function useObjectsPage(limit: number, offset: number) { +export type ObjectListParams = { + limit: number; + offset: number; + sort?: string; + order?: "asc" | "desc"; + visibility?: string; + q?: string; +}; + +export function useObjectsPage(params: ObjectListParams) { return useQuery({ - queryKey: ["objects", { limit, offset }], + queryKey: ["objects", params], + placeholderData: keepPreviousData, queryFn: async () => { const { data, error } = await api.GET("/api/admin/objects", { - params: { query: { limit, offset } }, + params: { + query: { + limit: params.limit, + offset: params.offset, + sort: params.sort, + order: params.order, + visibility: params.visibility, + q: params.q, + }, + }, }); if (error || !data) throw new Error("failed to load objects"); diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 2ed788b..b51343e 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -2,7 +2,7 @@ "app": { "name": "Collection" }, "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search" }, "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" }, - "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object" }, + "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" } }, "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" }, "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" }, diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 456d269..feaaa52 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -2,7 +2,7 @@ "app": { "name": "Samling" }, "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök" }, "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" }, - "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål" }, + "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" } }, "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" }, "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" }, diff --git a/web/src/objects/object-list.test.tsx b/web/src/objects/object-list.test.tsx deleted file mode 100644 index 4801876..0000000 --- a/web/src/objects/object-list.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { beforeEach, expect, test } from "vitest"; -import { screen } from "@testing-library/react"; -import { http, HttpResponse } from "msw"; -import { Routes, Route } from "react-router-dom"; -import { server } from "../test/server"; -import { renderApp } from "../test/render"; -import { ObjectList } from "./object-list"; -import i18n from "../i18n"; - -beforeEach(async () => { - await i18n.changeLanguage("en"); -}); - -function tree() { - return ( - - } /> - } /> - - ); -} - -test("renders object rows with number, name and visibility", async () => { - renderApp(tree(), { route: "/objects" }); - expect(await screen.findByText("LM-0042")).toBeInTheDocument(); - expect(screen.getByText("Amphora")).toBeInTheDocument(); - expect(screen.getByText("Public")).toBeInTheDocument(); -}); - -test("shows an empty state when there are no objects", async () => { - server.use( - http.get("/api/admin/objects", () => - HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }), - ), - ); - renderApp(tree(), { route: "/objects" }); - expect(await screen.findByText(/no objects yet/i)).toBeInTheDocument(); -}); - -test("shows an error state on failure", async () => { - server.use( - http.get("/api/admin/objects", () => new HttpResponse(null, { status: 500 })), - ); - renderApp(tree(), { route: "/objects" }); - expect(await screen.findByText(/could not load objects/i)).toBeInTheDocument(); -}); diff --git a/web/src/objects/object-list.tsx b/web/src/objects/object-list.tsx deleted file mode 100644 index 95d4432..0000000 --- a/web/src/objects/object-list.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useState } from "react"; -import { Link, NavLink } from "react-router-dom"; -import { useTranslation } from "react-i18next"; - -import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useObjectsPage } from "../api/queries"; -import { VisibilityBadge } from "./visibility-badge"; - -const LIMIT = 50; - -export function ObjectList() { - const { t } = useTranslation(); - const [offset, setOffset] = useState(0); - - const { data, isLoading, isError } = useObjectsPage(LIMIT, offset); - - const header = ( -
- - {t("objects.new")} - -
- ); - - if (isLoading) { - return ( -
- {header} -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- ); - } - - if (isError) { - return ( -
- {header} -

{t("objects.loadError")}

-
- ); - } - - if (!data || data.items.length === 0) { - return ( -
- {header} -

{t("objects.empty")}

-
- ); - } - - const from = data.total === 0 ? 0 : offset + 1; - const to = Math.min(offset + LIMIT, data.total); - - return ( -
- {header} - -
- - {from}–{to} {t("objects.of")} {data.total} - - - - - -
-
- ); -} diff --git a/web/src/objects/objects-page.test.tsx b/web/src/objects/objects-page.test.tsx index 02fd1a7..0ad92bc 100644 --- a/web/src/objects/objects-page.test.tsx +++ b/web/src/objects/objects-page.test.tsx @@ -18,10 +18,17 @@ function tree() { ); } -test("selecting a row shows its detail in the right pane", async () => { +test("the table is the landing view; the detail prompt is not a fixed column", async () => { + renderApp(tree(), { route: "/objects" }); + + // Table rows render full-width; no detail panel (and thus no prompt) until a row is opened. + expect(await screen.findByText("Amphora")).toBeInTheDocument(); + expect(screen.queryByText(/select an object/i)).not.toBeInTheDocument(); +}); + +test("clicking a row opens its detail in the side panel", async () => { renderApp(tree(), { route: "/objects" }); - // Wait for both the prompt (right pane) and the list rows (left pane) to load. - await screen.findByText(/select an object/i); await userEvent.click(await screen.findByText("Amphora")); + expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument(); }); diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx index 10d5b67..ff28b48 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -1,16 +1,25 @@ -import { Outlet } from "react-router-dom"; +import { Outlet, useMatch } from "react-router-dom"; -import { ObjectList } from "./object-list"; +import { ObjectsTable } from "./objects-table"; export function ObjectsPage() { + // Interim layout (Phase 3 makes this a responsive pane/drawer): the table is the + // full-width landing view; when a `:id`/`:id/edit` child route is active we render + // the nested as a simple right-side panel. + const detailMatch = useMatch("/objects/:id"); + const editMatch = useMatch("/objects/:id/edit"); + const detail = detailMatch ?? editMatch; + return ( -
-
- -
+
- +
+ {detail && ( +
+ +
+ )}
); } diff --git a/web/src/objects/objects-table.stories.tsx b/web/src/objects/objects-table.stories.tsx new file mode 100644 index 0000000..dc31bc9 --- /dev/null +++ b/web/src/objects/objects-table.stories.tsx @@ -0,0 +1,48 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, userEvent, waitFor, within } from 'storybook/test' +import { http, HttpResponse } from 'msw' + +import { ObjectsTable } from './objects-table' + +const meta = { + component: ObjectsTable, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + play: async ({ canvas }) => { + await expect(await canvas.findByText('Amphora')).toBeVisible() + await expect(canvas.getByText('LM-0042')).toBeVisible() + }, +} + +// Clicking the Name header sorts ascending and the active header reports its +// aria-sort (the preview already provides the router; URL state lives there). +export const Sorted: Story = { + play: async ({ canvas }) => { + await canvas.findByText('Amphora') + const header = canvas.getByRole('columnheader', { name: /Name/i }) + await userEvent.click(within(header).getByRole('button')) + await waitFor(() => expect(header).toHaveAttribute('aria-sort', 'ascending')) + }, +} + +export const Empty: Story = { + parameters: { + msw: { + handlers: [ + http.get('/api/admin/objects', () => + HttpResponse.json({ items: [], total: 0, limit: 50, offset: 0 }), + ), + ], + }, + }, + play: async ({ canvas }) => { + await waitFor(() => + expect(within(canvas.getByRole('table')).getByText(/no objects yet/i)).toBeVisible(), + ) + }, +} diff --git a/web/src/objects/objects-table.test.tsx b/web/src/objects/objects-table.test.tsx new file mode 100644 index 0000000..1f3ea6e --- /dev/null +++ b/web/src/objects/objects-table.test.tsx @@ -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 ( + + } /> + } /> + + ); +} + +/** 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(); +}); diff --git a/web/src/objects/objects-table.tsx b/web/src/objects/objects-table.tsx new file mode 100644 index 0000000..4da6892 --- /dev/null +++ b/web/src/objects/objects-table.tsx @@ -0,0 +1,319 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react"; + +import type { components } from "../api/schema"; +import { useObjectsPage } from "../api/queries"; +import { useDebouncedValue } from "../lib/use-debounced-value"; +import { useConfig } from "../config/config-context"; +import { VisibilityBadge } from "./visibility-badge"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; + +type AdminObjectView = components["schemas"]["AdminObjectView"]; + +const PAGE_SIZES = [25, 50, 100, 200]; +const VIS = ["all", "draft", "internal", "public"] as const; +const DEFAULT_SORT = "object_number"; +const DEFAULT_LIMIT = 50; + +type SortColumn = "object_number" | "object_name" | "updated_at"; + +const COLUMN_KEYS: Record = { + object_number: "objects.columns.number", + object_name: "objects.columns.name", + updated_at: "objects.columns.updated", +}; + +export function ObjectsTable() { + const { t, i18n } = useTranslation(); + const { default_timezone } = useConfig(); + const navigate = useNavigate(); + const { id: selectedId } = useParams(); + const [params, setParams] = useSearchParams(); + + const sort = params.get("sort") ?? DEFAULT_SORT; + const order = (params.get("order") === "desc" ? "desc" : "asc") as "asc" | "desc"; + const visibility = params.get("visibility") ?? "all"; + const limit = Number(params.get("limit")) || DEFAULT_LIMIT; + const offset = Number(params.get("offset")) || 0; + const qParam = params.get("q") ?? ""; + + const [qText, setQText] = useState(qParam); + const q = useDebouncedValue(qText, 300); + + // Mirror the search-panel pattern: sync the debounced quick-filter into the URL + // (setParams is router state, not component setState, so the lint rule allows it). + // Guard on the URL already matching `q` so re-renders caused by other URL updates + // (e.g. pagination changing `offset`) don't re-run this and clobber that state. + useEffect(() => { + const term = q.trim(); + + setParams( + (prev) => { + if ((prev.get("q") ?? "") === term) return prev; + + const next = new URLSearchParams(prev); + + if (term) next.set("q", term); + else next.delete("q"); + + next.delete("offset"); + + return next; + }, + { replace: true }, + ); + }, [q, setParams]); + + const { data, isLoading, isError } = useObjectsPage({ + limit, + offset, + sort, + order, + visibility: visibility === "all" ? undefined : visibility, + q: q.trim() || undefined, + }); + + const setParam = (mutate: (next: URLSearchParams) => void) => + setParams( + (prev) => { + const next = new URLSearchParams(prev); + mutate(next); + return next; + }, + { replace: true }, + ); + + const toggleSort = (col: SortColumn) => + setParam((next) => { + const curOrder = next.get("order") === "desc" ? "desc" : "asc"; + const curSort = next.get("sort") ?? DEFAULT_SORT; + const nextOrder = curSort === col && curOrder === "asc" ? "desc" : "asc"; + + next.set("sort", col); + next.set("order", nextOrder); + next.delete("offset"); + }); + + const setVisibility = (value: string) => + setParam((next) => { + if (value === "all") next.delete("visibility"); + else next.set("visibility", value); + + next.delete("offset"); + }); + + const setLimit = (value: number) => + setParam((next) => { + next.set("limit", String(value)); + next.delete("offset"); + }); + + const goToOffset = (value: number) => + setParam((next) => { + if (value <= 0) next.delete("offset"); + else next.set("offset", String(value)); + }); + + const dateFmt = new Intl.DateTimeFormat(i18n.language, { + dateStyle: "medium", + timeZone: default_timezone, + }); + const formatUpdated = (iso: string) => { + const parsed = new Date(iso); + + return Number.isNaN(parsed.getTime()) ? iso : dateFmt.format(parsed); + }; + + const headerCell = (col: SortColumn) => { + const active = sort === col; + const ariaSort = active ? (order === "asc" ? "ascending" : "descending") : "none"; + const Icon = !active ? ChevronsUpDown : order === "asc" ? ChevronUp : ChevronDown; + + return ( + + + + ); + }; + + const total = data?.total ?? 0; + const from = total === 0 ? 0 : offset + 1; + const to = Math.min(offset + limit, total); + + const toolbar = ( +
+ setQText(event.target.value)} + placeholder={t("objects.filter")} + aria-label={t("objects.filter")} + className="max-w-xs" + /> +
+ {VIS.map((value) => { + const active = visibility === value; + + return ( + + ); + })} +
+ + {t("objects.new")} + +
+ ); + + const columns = ( + + + {headerCell("object_number")} + {headerCell("object_name")} + + {t("objects.columns.visibility")} + + + {t("objects.columns.location")} + + + {t("objects.columns.count")} + + {headerCell("updated_at")} + + + ); + + let body; + + if (isLoading) { + body = ( + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + ))} + + ); + } else if (isError) { + body = ( + + + + {t("objects.loadError")} + + + + ); + } else if (!data || data.items.length === 0) { + body = ( + + + + {t("objects.empty")} + + + + ); + } else { + body = ( + + {data.items.map((item) => { + const object = item as AdminObjectView; + const selected = object.id === selectedId; + + return ( + navigate(`/objects/${object.id}?${params}`)} + className={`cursor-pointer border-b text-sm ${ + selected ? "bg-indigo-50" : "hover:bg-neutral-50" + }`} + > + {object.object_number} + {object.object_name} + + + + {object.current_location ?? "—"} + {object.number_of_objects} + {formatUpdated(object.updated_at)} + + ); + })} + + ); + } + + return ( +
+ {toolbar} +
+ + {columns} + {body} +
+
+
+ + + {from}–{to} {t("objects.of")} {total} + + + + + +
+
+ ); +}