feat(web): nested object routes + in-pane edit form + edit flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 00:54:02 +02:00
parent 22b37c138b
commit 9880f24dd2
9 changed files with 146 additions and 20 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
import { useParams } from "react-router-dom";
import { Link, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useObject, useFieldDefinitions } from "../api/queries";
@@ -57,6 +57,9 @@ export function ObjectDetail() {
<div className="mb-4 flex items-center gap-3">
<h2 className="text-xl font-semibold">{object.object_name}</h2>
<VisibilityBadge visibility={object.visibility} />
<Link to={`/objects/${object.id}/edit`} className="text-sm font-medium text-indigo-600">
{t("actions.edit")}
</Link>
</div>
<Field label={t("fieldsLabels.objectNumber")} value={object.object_number} />
<Field label={t("fieldsLabels.count")} value={object.number_of_objects} />
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { Routes, Route } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
import { ObjectEditForm } from "./object-edit-form";
import { amphora } from "../test/fixtures";
function tree() {
return (
<Routes>
<Route path="/objects/:id/edit" element={<ObjectEditForm />} />
<Route path="/objects/:id" element={<div>detail view</div>} />
</Routes>
);
}
test("edit: prefilled, save -> PUT core + PUT fields -> back to detail", async () => {
let putCore: unknown;
let putFields: unknown;
server.use(
http.get("/api/admin/objects/:id", () =>
HttpResponse.json({ ...amphora, fields: { inscription: "old" } }),
),
http.put("/api/admin/objects/:id", async ({ request }) => {
putCore = await request.json();
return new HttpResponse(null, { status: 204 });
}),
http.put("/api/admin/objects/:id/fields", async ({ request }) => {
putFields = await request.json();
return new HttpResponse(null, { status: 204 });
}),
);
renderApp(tree(), { route: `/objects/${amphora.id}/edit` });
const name = await screen.findByDisplayValue("Amphora");
await userEvent.clear(name);
await userEvent.type(name, "Big amphora");
await userEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => expect(screen.getByText("detail view")).toBeInTheDocument());
expect((putCore as { object_name: string }).object_name).toBe("Big amphora");
expect((putFields as { inscription: string }).inscription).toBe("old");
});
+62
View File
@@ -0,0 +1,62 @@
import { useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useObject, useUpdateObject, useSetFields } from "../api/queries";
import { ObjectForm, type ObjectCore, type ObjectFormValues } from "./object-form";
export function ObjectEditForm() {
const { t } = useTranslation();
const { id } = useParams();
const navigate = useNavigate();
const location = useLocation();
const { data: object, isLoading } = useObject(id!);
const update = useUpdateObject();
const setFields = useSetFields();
const [error, setError] = useState<string | null>(
(location.state as { fieldsError?: boolean } | null)?.fieldsError ? t("form.rejected") : null,
);
if (isLoading) return <div className="p-4" role="status" aria-label="loading" />;
if (!object) return <p className="p-4 text-sm text-neutral-500">{t("objects.notFound")}</p>;
const core: ObjectCore = {
object_number: object.object_number,
object_name: object.object_name,
number_of_objects: object.number_of_objects,
brief_description: object.brief_description ?? null,
current_location: object.current_location ?? null,
current_owner: object.current_owner ?? null,
recorder: object.recorder ?? null,
recording_date: object.recording_date ?? null,
};
const defaults = { core, fields: object.fields as Record<string, unknown> };
const onSubmit = async (values: ObjectFormValues) => {
setError(null);
try {
await update.mutateAsync({ id: id!, body: values.core });
await setFields.mutateAsync({ id: id!, fields: values.fields });
} catch {
setError(t("form.rejected"));
return;
}
navigate(`/objects/${id}`);
};
return (
<ObjectForm
mode="edit"
defaults={defaults}
formError={error}
onSubmit={onSubmit}
onCancel={() => navigate(`/objects/${id}`)}
/>
);
}
+6 -2
View File
@@ -4,12 +4,16 @@ import userEvent from "@testing-library/user-event";
import { Routes, Route } from "react-router-dom";
import { renderApp } from "../test/render";
import { ObjectsPage } from "./objects-page";
import { ObjectDetail } from "./object-detail";
import { SelectPrompt } from "./select-prompt";
function tree() {
return (
<Routes>
<Route path="/objects" element={<ObjectsPage />} />
<Route path="/objects/:id" element={<ObjectsPage />} />
<Route path="/objects" element={<ObjectsPage />}>
<Route index element={<SelectPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
</Routes>
);
}
+2 -13
View File
@@ -1,26 +1,15 @@
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Outlet } from "react-router-dom";
import { ObjectList } from "./object-list";
import { ObjectDetail } from "./object-detail";
export function ObjectsPage() {
const { t } = useTranslation();
const { id } = useParams();
return (
<div className="grid h-full grid-cols-[20rem_1fr]">
<div className="overflow-hidden border-r">
<ObjectList />
</div>
<div className="overflow-hidden">
{id ? (
<ObjectDetail />
) : (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
{t("objects.selectPrompt")}
</div>
)}
<Outlet />
</div>
</div>
);
+11
View File
@@ -0,0 +1,11 @@
import { useTranslation } from "react-i18next";
export function SelectPrompt() {
const { t } = useTranslation();
return (
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
{t("objects.selectPrompt")}
</div>
);
}