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:
+1
-1
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user