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,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