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:
+22
-3
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 (
|
||||
<Routes>
|
||||
<Route path="/objects" element={<ObjectList />} />
|
||||
<Route path="/objects/:id" element={<ObjectList />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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 = (
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<Link to="/objects/new" className="text-sm font-medium text-indigo-600">
|
||||
{t("objects.new")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<div className="space-y-2 p-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<p className="p-4 text-sm text-red-600">{t("objects.loadError")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.items.length === 0) {
|
||||
return (
|
||||
<div>
|
||||
{header}
|
||||
<p className="p-4 text-sm text-neutral-500">{t("objects.empty")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const from = data.total === 0 ? 0 : offset + 1;
|
||||
const to = Math.min(offset + LIMIT, data.total);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{header}
|
||||
<ul className="flex-1 overflow-auto">
|
||||
{data.items.map((object) => (
|
||||
<li key={object.id}>
|
||||
<NavLink
|
||||
to={`/objects/${object.id}`}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center justify-between gap-2 border-b px-3 py-2 text-sm ${
|
||||
isActive ? "bg-indigo-50" : "hover:bg-neutral-50"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
<span className="text-neutral-500">{object.object_number}</span>{" "}
|
||||
{object.object_name}
|
||||
</span>
|
||||
<VisibilityBadge visibility={object.visibility} />
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-neutral-500">
|
||||
<span>
|
||||
{from}–{to} {t("objects.of")} {data.total}
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - LIMIT))}
|
||||
>
|
||||
{t("objects.prev")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={to >= data.total}
|
||||
onClick={() => setOffset(offset + LIMIT)}
|
||||
>
|
||||
{t("objects.next")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 <Outlet/> as a simple right-side panel.
|
||||
const detailMatch = useMatch("/objects/:id");
|
||||
const editMatch = useMatch("/objects/:id/edit");
|
||||
const detail = detailMatch ?? editMatch;
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||||
<div className="overflow-hidden border-r">
|
||||
<ObjectList />
|
||||
</div>
|
||||
<div className={`grid h-full ${detail ? "grid-cols-[1fr_28rem]" : "grid-cols-1"}`}>
|
||||
<div className="overflow-hidden">
|
||||
<Outlet />
|
||||
<ObjectsTable />
|
||||
</div>
|
||||
{detail && (
|
||||
<div className="overflow-auto border-l">
|
||||
<Outlet />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof ObjectsTable>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
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(),
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<SortColumn, string> = {
|
||||
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 (
|
||||
<th key={col} scope="col" aria-sort={ariaSort} className="px-3 py-2 text-left font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(col)}
|
||||
className="flex items-center gap-1 hover:text-neutral-900"
|
||||
>
|
||||
{t(COLUMN_KEYS[col])}
|
||||
<Icon className="size-3.5 text-neutral-400" aria-hidden="true" />
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
const total = data?.total ?? 0;
|
||||
const from = total === 0 ? 0 : offset + 1;
|
||||
const to = Math.min(offset + limit, total);
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b p-3">
|
||||
<Input
|
||||
value={qText}
|
||||
onChange={(event) => setQText(event.target.value)}
|
||||
placeholder={t("objects.filter")}
|
||||
aria-label={t("objects.filter")}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<div className="flex gap-1 text-xs">
|
||||
{VIS.map((value) => {
|
||||
const active = visibility === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setVisibility(value)}
|
||||
className={`rounded px-2 py-1 ${active ? "bg-indigo-600 text-white" : "border"}`}
|
||||
>
|
||||
{value === "all" ? t("search.all") : t(`visibility.${value}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Link to="/objects/new" className={`${buttonVariants({ size: "sm" })} ml-auto`}>
|
||||
{t("objects.new")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const columns = (
|
||||
<thead className="border-b bg-neutral-50 text-xs text-neutral-500">
|
||||
<tr>
|
||||
{headerCell("object_number")}
|
||||
{headerCell("object_name")}
|
||||
<th scope="col" className="px-3 py-2 text-left font-medium">
|
||||
{t("objects.columns.visibility")}
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 text-left font-medium">
|
||||
{t("objects.columns.location")}
|
||||
</th>
|
||||
<th scope="col" className="px-3 py-2 text-right font-medium">
|
||||
{t("objects.columns.count")}
|
||||
</th>
|
||||
{headerCell("updated_at")}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
|
||||
let body;
|
||||
|
||||
if (isLoading) {
|
||||
body = (
|
||||
<tbody>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<tr key={i} className="border-b">
|
||||
<td colSpan={6} className="px-3 py-2">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
);
|
||||
} else if (isError) {
|
||||
body = (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-6 text-center text-sm text-red-600">
|
||||
{t("objects.loadError")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
} else if (!data || data.items.length === 0) {
|
||||
body = (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-6 text-center text-sm text-neutral-500">
|
||||
{t("objects.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<tbody>
|
||||
{data.items.map((item) => {
|
||||
const object = item as AdminObjectView;
|
||||
const selected = object.id === selectedId;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={object.id}
|
||||
aria-selected={selected}
|
||||
onClick={() => navigate(`/objects/${object.id}?${params}`)}
|
||||
className={`cursor-pointer border-b text-sm ${
|
||||
selected ? "bg-indigo-50" : "hover:bg-neutral-50"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-neutral-500">{object.object_number}</td>
|
||||
<td className="px-3 py-2 font-medium">{object.object_name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<VisibilityBadge visibility={object.visibility} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-neutral-600">{object.current_location ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{object.number_of_objects}</td>
|
||||
<td className="px-3 py-2 text-neutral-600">{formatUpdated(object.updated_at)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{toolbar}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
{columns}
|
||||
{body}
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t px-3 py-2 text-xs text-neutral-500">
|
||||
<label className="flex items-center gap-1">
|
||||
<span>{t("objects.pageSize")}</span>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(event) => setLimit(Number(event.target.value))}
|
||||
aria-label={t("objects.pageSize")}
|
||||
className="rounded border bg-white px-1 py-0.5"
|
||||
>
|
||||
{PAGE_SIZES.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span>
|
||||
{from}–{to} {t("objects.of")} {total}
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={offset === 0}
|
||||
onClick={() => goToOffset(Math.max(0, offset - limit))}
|
||||
>
|
||||
{t("objects.prev")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={offset + limit >= total}
|
||||
onClick={() => goToOffset(offset + limit)}
|
||||
>
|
||||
{t("objects.next")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user