feat(web): deep-linkable field selection via /fields/:key (#72)

FieldsPage kept the selected field definition in component state, so
reload lost the selection, fields couldn't be linked/shared, and
back/forward didn't navigate selections — inconsistent with
/vocabularies/:id and /objects/:id.

Move selection into the URL: the route becomes /fields/:key?
(optional segment), FieldList selection navigates, cancel/done
navigates back to /fields, and the page derives the selected def from
the already-cached field-defs query. An unknown or stale key (e.g.
after deleting the selected field) falls back to the create form.

Tests: deep link opens the locked edit form, select→cancel round-trips
through the URL, unknown key falls back to create.

Closes #72

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:44:02 +02:00
parent 3a57c0a77c
commit ada5d06dad
3 changed files with 51 additions and 9 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ const router = createBrowserRouter(
<Route path="/authorities" element={<Navigate to="/authorities/person" replace />} />
<Route path="/authorities/:kind" element={<AuthoritiesPage />} />
<Route
path="/fields"
path="/fields/:key?"
element={
<Suspense fallback={<ListSkeleton />}>
<FieldsPage />
+15 -7
View File
@@ -1,18 +1,23 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import type { components } from "../api/schema";
import { useFieldDefinitions } from "../api/queries";
import { FieldList } from "./field-list";
import { FieldForm } from "./field-form";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
export function FieldsPage() {
const { t } = useTranslation();
const [selected, setSelected] = useState<FieldDefinitionView | null>(null);
const navigate = useNavigate();
const { key } = useParams();
const { data } = useFieldDefinitions();
// Selection lives in the URL (/fields/:key) so it survives reload and can be
// shared, matching /vocabularies/:id. An unknown or absent key falls back to
// the create form. Same cached query as FieldList, so no extra fetch.
const selected = (key && data?.find((def) => def.key === key)) || null;
useDocumentTitle(t("fields.title"));
useBreadcrumb([{ label: t("nav.fields") }]);
@@ -22,13 +27,16 @@ export function FieldsPage() {
<PageTitle className="px-4 pt-4 pb-2">{t("fields.title")}</PageTitle>
<div className="grid flex-1 grid-cols-1 overflow-auto lg:grid-cols-[20rem_1fr] lg:overflow-hidden">
<div className="overflow-hidden border-b lg:border-r lg:border-b-0">
<FieldList selectedKey={selected?.key ?? null} onSelect={setSelected} />
<FieldList
selectedKey={selected?.key ?? null}
onSelect={(def) => navigate(`/fields/${encodeURIComponent(def.key)}`)}
/>
</div>
<div className="overflow-hidden">
<FieldForm
key={selected?.key ?? "create"}
editing={selected}
onDone={() => setSelected(null)}
onDone={() => navigate("/fields")}
/>
</div>
</div>
+35 -1
View File
@@ -11,7 +11,7 @@ import { FieldsPage } from "./fields-page";
function tree() {
return (
<Routes>
<Route path="/fields" element={<FieldsPage />} />
<Route path="/fields/:key?" element={<FieldsPage />} />
</Routes>
);
}
@@ -87,6 +87,40 @@ test("filter narrows the visible fields", async () => {
expect(await screen.findByText(/no matches/i)).toBeInTheDocument();
});
test("deep link /fields/:key opens the edit form for that field", async () => {
renderApp(tree(), { route: "/fields/inscription" });
// edit mode: the key input is locked and prefilled from the URL. The form
// remounts when the defs query resolves, so re-query inside waitFor.
await waitFor(() => expect(screen.getByLabelText(/^key$/i)).toHaveValue("inscription"));
expect(screen.getByLabelText(/^key$/i)).toBeDisabled();
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
test("selecting a field switches to its edit form; cancel returns to create", async () => {
renderApp(tree(), { route: "/fields" });
await userEvent.click(await screen.findByRole("button", { name: /inscription/i }));
await waitFor(() => expect(screen.getByLabelText(/^key$/i)).toHaveValue("inscription"));
expect(screen.getByLabelText(/^key$/i)).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: /cancel/i }));
await waitFor(() => expect(screen.getByLabelText(/^key$/i)).toHaveValue(""));
expect(screen.getByLabelText(/^key$/i)).toBeEnabled();
});
test("an unknown key falls back to the create form", async () => {
renderApp(tree(), { route: "/fields/zzz-does-not-exist" });
await screen.findByText("Inscription");
const key = screen.getByLabelText(/^key$/i);
expect(key).toHaveValue("");
expect(key).toBeEnabled();
});
test("creates a text field — posts the body and clears the key input", async () => {
let body: { key: string; data_type: string } | undefined;