f3bab3336c
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1208 lines
53 KiB
Markdown
1208 lines
53 KiB
Markdown
# Frontend SPA — Milestone 2 (Object Authoring) Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Create / edit / delete catalogue objects from the SPA, including the dynamic flexible-field form (all field types) — consuming the existing admin endpoints.
|
||
|
||
**Architecture:** New full-width route `/objects/new`; in-pane edit at `/objects/:id/edit` via nested routes under `ObjectsPage` (list + `<Outlet/>` right pane). A shared `ObjectForm` (react-hook-form) renders inventory-minimum core fields plus dynamic flexible fields; `FieldInput` switches on `data_type` to the right control (term/authority are id-valued `<Select>`s populated from the vocab/authority endpoints). Create = `POST /objects` then `PUT /objects/:id/fields`; edit = `PUT /objects/:id` + `PUT .../fields` (replace semantics); delete via an `AlertDialog`.
|
||
|
||
**Tech Stack:** React 19, react-router-dom 7, @tanstack/react-query 5, **react-hook-form**, shadcn/ui (Base UI: select, checkbox, alert-dialog), openapi-fetch typed client, react-i18next, Vitest + RTL + MSW.
|
||
|
||
**Reference spec:** `docs/superpowers/specs/2026-06-04-frontend-spa-milestone-2-design.md`
|
||
|
||
**Baseline (M1, merged):** `web/` has the typed client (`web/src/api/client.ts` `api`; schema `web/src/api/schema.d.ts`), hooks in `web/src/api/queries.ts` (`useMe`, `useObjectsPage`, `useObject`, `useFieldDefinitions`, `useLogin`, `useLogout`), `renderApp` helper (`web/src/test/render.tsx`), MSW harness (`web/src/test/{server,handlers,fixtures}.ts`, `onUnhandledRequest:"error"`), i18n (`web/src/i18n/{en,sv}.json`), shadcn Button/Input/Label/Card/Badge/Skeleton, and the Objects screen (`web/src/objects/{objects-page,object-list,object-detail,visibility-badge}.tsx`). 17 tests green. Run all web commands from `web/`.
|
||
|
||
**Conventions:**
|
||
- shadcn CLI (`pnpm dlx shadcn@latest add <x>`) emits `import {cn} from "src/lib/utils"` — REWRITE to `@/lib/utils`; relocate any file written under a literal `web/@/` dir into `web/src/components/ui/`.
|
||
- i18n: every user-facing string via `t()`; keep `en.json`/`sv.json` key sets identical. Tests that assert English copy run under the default `en` locale (the i18n test resets the singleton).
|
||
- Codename "biggus"/"dickus" must appear NOWHERE.
|
||
- Each task ends green: `pnpm test`, `pnpm typecheck`, `pnpm lint`, `pnpm build` clean.
|
||
|
||
**Backend contract (verify against `web/src/api/schema.d.ts`):**
|
||
- `POST /api/admin/objects` body `ObjectCreateRequest` → `201 {id}`; rejects public/bad-date with 422.
|
||
- `PUT /api/admin/objects/{id}` body `ObjectUpdateRequest` (no visibility) → 204; 404 missing.
|
||
- `DELETE /api/admin/objects/{id}` → 204; 404 missing.
|
||
- `PUT /api/admin/objects/{id}/fields` body = JSON map (replace semantics) → 204; 404; 422 (bare).
|
||
- `GET /api/admin/field-definitions` → `FieldDefinitionView[]` (`key`, `data_type`, `vocabulary_id?`, `authority_kind?`, `required`, `group?`, `labels`).
|
||
- `GET /api/admin/vocabularies/{id}/terms` → `TermView[]`. `GET /api/admin/authorities?kind=` → `AuthorityView[]`.
|
||
- `data_type` ∈ {text, localized_text, integer, date, boolean, term, authority}.
|
||
|
||
---
|
||
|
||
## Task 1: Authoring hooks (queries + mutations) + MSW handlers + shadcn primitives
|
||
|
||
**Files:**
|
||
- Modify: `web/src/api/queries.ts`, `web/src/test/handlers.ts`, `web/src/test/fixtures.ts`, `web/package.json`
|
||
- Create: `web/src/components/ui/{select,checkbox,alert-dialog}.tsx` (via shadcn), `web/src/api/queries.authoring.test.ts`
|
||
|
||
- [ ] **Step 1: Install react-hook-form + add shadcn primitives**
|
||
|
||
```bash
|
||
cd web
|
||
pnpm add react-hook-form
|
||
pnpm dlx shadcn@latest add select checkbox alert-dialog
|
||
```
|
||
Fix any `src/lib/utils` imports in the three new `components/ui/*.tsx` files to `@/lib/utils`; relocate from `web/@/` into `web/src/components/ui/` if the CLI misplaced them. Confirm `pnpm typecheck` after.
|
||
|
||
- [ ] **Step 2: Extend fixtures** — append to `web/src/test/fixtures.ts`
|
||
|
||
```ts
|
||
import type { components } from "../api/schema";
|
||
|
||
export type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||
export type TermView = components["schemas"]["TermView"];
|
||
export type AuthorityView = components["schemas"]["AuthorityView"];
|
||
|
||
export const fieldDefinitions: FieldDefinitionView[] = [
|
||
{ key: "inscription", data_type: "text", vocabulary_id: null, authority_kind: null,
|
||
required: true, group: "Description", labels: [{ lang: "en", label: "Inscription" }, { lang: "sv", label: "Inskription" }] },
|
||
{ key: "count_seen", data_type: "integer", vocabulary_id: null, authority_kind: null,
|
||
required: false, group: null, labels: [{ lang: "en", label: "Count seen" }] },
|
||
{ key: "made_on", data_type: "date", vocabulary_id: null, authority_kind: null,
|
||
required: false, group: null, labels: [{ lang: "en", label: "Made on" }] },
|
||
{ key: "is_fragment", data_type: "boolean", vocabulary_id: null, authority_kind: null,
|
||
required: false, group: null, labels: [{ lang: "en", label: "Is fragment" }] },
|
||
{ key: "title_ml", data_type: "localized_text", vocabulary_id: null, authority_kind: null,
|
||
required: false, group: null, labels: [{ lang: "en", label: "Title" }] },
|
||
{ key: "material", data_type: "term", vocabulary_id: "v-material", authority_kind: null,
|
||
required: false, group: null, labels: [{ lang: "en", label: "Material" }] },
|
||
{ key: "maker", data_type: "authority", vocabulary_id: null, authority_kind: "person",
|
||
required: false, group: null, labels: [{ lang: "en", label: "Maker" }] },
|
||
];
|
||
|
||
export const materialTerms: TermView[] = [
|
||
{ id: "t-bronze", external_uri: null, labels: [{ lang: "en", label: "Bronze" }, { lang: "sv", label: "Brons" }] },
|
||
{ id: "t-wood", external_uri: null, labels: [{ lang: "en", label: "Wood" }] },
|
||
];
|
||
|
||
export const personAuthorities: AuthorityView[] = [
|
||
{ id: "a-ada", kind: "person", external_uri: null, labels: [{ lang: "en", label: "Ada Lovelace" }] },
|
||
];
|
||
```
|
||
(Adapt literals to the generated types — e.g. if optional fields are `?:` rather than `| null`, drop the `null`s. `pnpm typecheck` is the arbiter.)
|
||
|
||
- [ ] **Step 3: Extend default handlers** — append handlers in `web/src/test/handlers.ts`
|
||
|
||
```ts
|
||
import { fieldDefinitions, materialTerms, personAuthorities } from "./fixtures";
|
||
|
||
// add to the `handlers` array:
|
||
http.get("/api/admin/field-definitions", () => HttpResponse.json(fieldDefinitions)),
|
||
http.get("/api/admin/vocabularies/:id/terms", () => HttpResponse.json(materialTerms)),
|
||
http.get("/api/admin/authorities", ({ request }) => {
|
||
const kind = new URL(request.url).searchParams.get("kind");
|
||
return HttpResponse.json(kind === "person" ? personAuthorities : []);
|
||
}),
|
||
http.post("/api/admin/objects", () =>
|
||
HttpResponse.json({ id: "11111111-1111-1111-1111-111111111111" }, { status: 201 })),
|
||
http.put("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
|
||
http.put("/api/admin/objects/:id/fields", () => new HttpResponse(null, { status: 204 })),
|
||
http.delete("/api/admin/objects/:id", () => new HttpResponse(null, { status: 204 })),
|
||
```
|
||
(There is already a `GET /api/admin/field-definitions` handler from M1 returning a single "material" def — REPLACE it with the `fieldDefinitions` one above so the richer set is the default. Keep the existing GET objects / objects/:id handlers.)
|
||
|
||
- [ ] **Step 4: Write the failing hook tests** — `web/src/api/queries.authoring.test.ts`
|
||
|
||
```ts
|
||
import { describe, expect, test } from "vitest";
|
||
import { renderHook, waitFor } from "@testing-library/react";
|
||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||
import type { ReactNode } from "react";
|
||
import {
|
||
useTerms, useAuthorities, useCreateObject, useUpdateObject, useSetFields, useDeleteObject,
|
||
} from "./queries";
|
||
|
||
function wrapper({ children }: { children: ReactNode }) {
|
||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||
}
|
||
|
||
describe("authoring hooks", () => {
|
||
test("useTerms loads a vocabulary's terms", async () => {
|
||
const { result } = renderHook(() => useTerms("v-material"), { wrapper });
|
||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||
expect(result.current.data?.[0].id).toBe("t-bronze");
|
||
});
|
||
|
||
test("useAuthorities loads by kind", async () => {
|
||
const { result } = renderHook(() => useAuthorities("person"), { wrapper });
|
||
await waitFor(() => expect(result.current.data?.length).toBe(1));
|
||
expect(result.current.data?.[0].id).toBe("a-ada");
|
||
});
|
||
|
||
test("useCreateObject returns the new id", async () => {
|
||
const { result } = renderHook(() => useCreateObject(), { wrapper });
|
||
const created = await result.current.mutateAsync({
|
||
object_number: "A-1", object_name: "x", number_of_objects: 1, visibility: "draft",
|
||
});
|
||
expect(created.id).toBe("11111111-1111-1111-1111-111111111111");
|
||
});
|
||
|
||
test("useSetFields / useUpdateObject / useDeleteObject resolve", async () => {
|
||
const setFields = renderHook(() => useSetFields(), { wrapper });
|
||
await setFields.result.current.mutateAsync({ id: "o1", fields: { inscription: "hi" } });
|
||
const update = renderHook(() => useUpdateObject(), { wrapper });
|
||
await update.result.current.mutateAsync({ id: "o1", body: { object_number: "A-1", object_name: "x", number_of_objects: 1 } });
|
||
const del = renderHook(() => useDeleteObject(), { wrapper });
|
||
await del.result.current.mutateAsync("o1");
|
||
expect(true).toBe(true); // mutateAsync rejects on failure; reaching here = success
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run to verify it fails**
|
||
|
||
Run: `pnpm test src/api/queries.authoring.test.ts`
|
||
Expected: FAIL — the new hooks aren't exported yet.
|
||
|
||
- [ ] **Step 6: Implement the hooks** — append to `web/src/api/queries.ts`
|
||
|
||
```ts
|
||
type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"];
|
||
type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"];
|
||
|
||
export function useTerms(vocabularyId: string | null | undefined) {
|
||
return useQuery({
|
||
queryKey: ["terms", vocabularyId],
|
||
enabled: !!vocabularyId,
|
||
queryFn: async () => {
|
||
const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", {
|
||
params: { path: { id: vocabularyId! } },
|
||
});
|
||
if (error || !data) throw new Error("failed to load terms");
|
||
return data;
|
||
},
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
}
|
||
|
||
export function useAuthorities(kind: string | null | undefined) {
|
||
return useQuery({
|
||
queryKey: ["authorities", kind],
|
||
enabled: !!kind,
|
||
queryFn: async () => {
|
||
const { data, error } = await api.GET("/api/admin/authorities", {
|
||
params: { query: { kind: kind! } },
|
||
});
|
||
if (error || !data) throw new Error("failed to load authorities");
|
||
return data;
|
||
},
|
||
staleTime: 5 * 60 * 1000,
|
||
});
|
||
}
|
||
|
||
export function useCreateObject() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async (body: ObjectCreateRequest) => {
|
||
const { data, error } = await api.POST("/api/admin/objects", { body });
|
||
if (error || !data) throw new Error("create failed");
|
||
return data; // { id }
|
||
},
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
|
||
});
|
||
}
|
||
|
||
export function useUpdateObject() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => {
|
||
const { response } = await api.PUT("/api/admin/objects/{id}", {
|
||
params: { path: { id } }, body,
|
||
});
|
||
if (response.status !== 204) throw new Error("update failed");
|
||
},
|
||
onSuccess: (_d, { id }) => {
|
||
void qc.invalidateQueries({ queryKey: ["objects"] });
|
||
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||
},
|
||
});
|
||
}
|
||
|
||
export function useSetFields() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
|
||
const { response } = await api.PUT("/api/admin/objects/{id}/fields", {
|
||
params: { path: { id } },
|
||
body: fields as Record<string, never>, // schema types fields as Record<string,never> (issue #24)
|
||
});
|
||
if (response.status !== 204) throw new Error("set fields failed");
|
||
},
|
||
onSuccess: (_d, { id }) => {
|
||
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||
},
|
||
});
|
||
}
|
||
|
||
export function useDeleteObject() {
|
||
const qc = useQueryClient();
|
||
return useMutation({
|
||
mutationFn: async (id: string) => {
|
||
const { response } = await api.DELETE("/api/admin/objects/{id}", {
|
||
params: { path: { id } },
|
||
});
|
||
if (response.status !== 204) throw new Error("delete failed");
|
||
},
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }),
|
||
});
|
||
}
|
||
```
|
||
(Confirm `api.PUT`/`api.DELETE` exist on the openapi-fetch client and the `body`/`params` shapes match the installed version. The `fields as Record<string,never>` cast mirrors the read-side cast and is tracked by issue #24. If `ObjectCreateRequest`/`ObjectUpdateRequest` aren't the exact generated names, use the actual ones from `schema.d.ts`.)
|
||
|
||
- [ ] **Step 7: Run / typecheck / lint / build**
|
||
|
||
Run: `pnpm test` → all pass (M1's 17 + the new hook tests). `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
|
||
NOTE: replacing the default `field-definitions` handler may affect the M1 `object-detail.test.tsx` (it overrode the handler itself, so it's unaffected) — confirm the full suite stays green; if an M1 test depended on the old single-def handler, update it minimally.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd ..
|
||
git add web
|
||
git commit -m "feat(web): authoring query/mutation hooks + MSW handlers + shadcn select/checkbox/alert-dialog"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `FieldInput` — dynamic control per `data_type`
|
||
|
||
**Files:**
|
||
- Create: `web/src/objects/field-input.tsx`, `web/src/objects/field-input.test.tsx`
|
||
|
||
- [ ] **Step 1: Write the failing test** `web/src/objects/field-input.test.tsx`
|
||
|
||
```tsx
|
||
import { expect, test } from "vitest";
|
||
import { screen } from "@testing-library/react";
|
||
import { useForm } from "react-hook-form";
|
||
import { renderApp } from "../test/render";
|
||
import { FieldInput } from "./field-input";
|
||
import { fieldDefinitions } from "../test/fixtures";
|
||
|
||
function Harness({ defKey }: { defKey: string }) {
|
||
const def = fieldDefinitions.find((d) => d.key === defKey)!;
|
||
const form = useForm({ defaultValues: { fields: {} as Record<string, unknown> } });
|
||
return <FieldInput definition={def} form={form} />;
|
||
}
|
||
|
||
test("text field renders a text input labelled in the active locale", async () => {
|
||
renderApp(<Harness defKey="inscription" />);
|
||
expect(await screen.findByLabelText("Inscription")).toBeInTheDocument();
|
||
});
|
||
|
||
test("boolean field renders a checkbox", async () => {
|
||
renderApp(<Harness defKey="is_fragment" />);
|
||
expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument();
|
||
});
|
||
|
||
test("localized_text renders sv and en inputs", async () => {
|
||
renderApp(<Harness defKey="title_ml" />);
|
||
expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument();
|
||
expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument();
|
||
});
|
||
|
||
test("term field renders a select populated from the vocabulary", async () => {
|
||
renderApp(<Harness defKey="material" />);
|
||
// option label from the term, in the active (en) locale
|
||
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
||
});
|
||
|
||
test("authority field renders a select populated by kind", async () => {
|
||
renderApp(<Harness defKey="maker" />);
|
||
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails** — `pnpm test src/objects/field-input.test.tsx` → FAIL (no `FieldInput`).
|
||
|
||
- [ ] **Step 3: Implement** — `web/src/objects/field-input.tsx`
|
||
|
||
```tsx
|
||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||
import { useTranslation } from "react-i18next";
|
||
import type { components } from "../api/schema";
|
||
import { useTerms, useAuthorities } from "../api/queries";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
|
||
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
|
||
type LabelView = components["schemas"]["LabelView"];
|
||
|
||
function labelIn(labels: LabelView[], lang: string): string {
|
||
return (
|
||
labels.find((l) => l.lang === lang)?.label ??
|
||
labels.find((l) => l.lang === "en")?.label ??
|
||
labels[0]?.label ??
|
||
""
|
||
);
|
||
}
|
||
|
||
// A native <select> keeps the bundle lean and is fully accessible; the shadcn Select
|
||
// can replace it later without changing the value contract (option value = id).
|
||
function OptionsSelect({
|
||
id, value, onChange, options, lang, placeholder,
|
||
}: {
|
||
id: string; value: string; onChange: (v: string) => void;
|
||
options: { id: string; labels: LabelView[] }[]; lang: string; placeholder: string;
|
||
}) {
|
||
return (
|
||
<select id={id} className="w-full rounded border px-2 py-1 text-sm"
|
||
value={value} onChange={(e) => onChange(e.target.value)}>
|
||
<option value="">{placeholder}</option>
|
||
{options.map((o) => (
|
||
<option key={o.id} value={o.id}>{labelIn(o.labels, lang)}</option>
|
||
))}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
export function FieldInput({
|
||
definition, form,
|
||
}: {
|
||
definition: FieldDefinitionView;
|
||
form: UseFormReturn<{ fields: Record<string, unknown> }>;
|
||
}) {
|
||
const { t, i18n } = useTranslation();
|
||
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
|
||
const label = labelIn(definition.labels, lang);
|
||
const name = `fields.${definition.key}` as const;
|
||
const placeholder = t("form.selectPlaceholder");
|
||
|
||
switch (definition.data_type) {
|
||
case "integer":
|
||
return (
|
||
<div className="space-y-1">
|
||
<Label htmlFor={definition.key}>{label}</Label>
|
||
<Input id={definition.key} type="number"
|
||
{...form.register(name, { valueAsNumber: true, required: definition.required })} />
|
||
</div>
|
||
);
|
||
case "date":
|
||
return (
|
||
<div className="space-y-1">
|
||
<Label htmlFor={definition.key}>{label}</Label>
|
||
<Input id={definition.key} type="date"
|
||
{...form.register(name, { required: definition.required })} />
|
||
</div>
|
||
);
|
||
case "boolean":
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Controller control={form.control} name={name}
|
||
render={({ field }) => (
|
||
<Checkbox id={definition.key} checked={!!field.value}
|
||
onCheckedChange={(c) => field.onChange(c === true)} />
|
||
)} />
|
||
<Label htmlFor={definition.key}>{label}</Label>
|
||
</div>
|
||
);
|
||
case "localized_text":
|
||
return (
|
||
<div className="space-y-1">
|
||
<Label>{label}</Label>
|
||
<Label htmlFor={`${definition.key}-en`} className="text-xs text-neutral-500">{label} (EN)</Label>
|
||
<Input id={`${definition.key}-en`} {...form.register(`fields.${definition.key}.en` as const)} />
|
||
<Label htmlFor={`${definition.key}-sv`} className="text-xs text-neutral-500">{label} (SV)</Label>
|
||
<Input id={`${definition.key}-sv`} {...form.register(`fields.${definition.key}.sv` as const)} />
|
||
</div>
|
||
);
|
||
case "term":
|
||
return (
|
||
<TermField definition={definition} form={form} label={label} lang={lang} placeholder={placeholder} />
|
||
);
|
||
case "authority":
|
||
return (
|
||
<AuthorityField definition={definition} form={form} label={label} lang={lang} placeholder={placeholder} />
|
||
);
|
||
case "text":
|
||
default:
|
||
return (
|
||
<div className="space-y-1">
|
||
<Label htmlFor={definition.key}>{label}</Label>
|
||
<Input id={definition.key}
|
||
{...form.register(name, { required: definition.required })} />
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
function TermField({ definition, form, label, lang, placeholder }: {
|
||
definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record<string, unknown> }>;
|
||
label: string; lang: string; placeholder: string;
|
||
}) {
|
||
const { data: terms } = useTerms(definition.vocabulary_id);
|
||
return (
|
||
<div className="space-y-1">
|
||
<Label htmlFor={definition.key}>{label}</Label>
|
||
<Controller control={form.control} name={`fields.${definition.key}` as const}
|
||
rules={{ required: definition.required }}
|
||
render={({ field }) => (
|
||
<OptionsSelect id={definition.key} value={(field.value as string) ?? ""}
|
||
onChange={field.onChange} options={terms ?? []} lang={lang} placeholder={placeholder} />
|
||
)} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AuthorityField({ definition, form, label, lang, placeholder }: {
|
||
definition: FieldDefinitionView; form: UseFormReturn<{ fields: Record<string, unknown> }>;
|
||
label: string; lang: string; placeholder: string;
|
||
}) {
|
||
const { data: authorities } = useAuthorities(definition.authority_kind);
|
||
return (
|
||
<div className="space-y-1">
|
||
<Label htmlFor={definition.key}>{label}</Label>
|
||
<Controller control={form.control} name={`fields.${definition.key}` as const}
|
||
rules={{ required: definition.required }}
|
||
render={({ field }) => (
|
||
<OptionsSelect id={definition.key} value={(field.value as string) ?? ""}
|
||
onChange={field.onChange} options={authorities ?? []} lang={lang} placeholder={placeholder} />
|
||
)} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
NOTES:
|
||
- Uses a **native `<select>`** for term/authority (lean, accessible) rather than the shadcn Select; the value contract (option value = id) is unchanged if you later swap in shadcn Select. (If you prefer the shadcn Select that Task 1 added, use it — but keep the id-as-value contract and ensure `getByText(label)` still finds options.)
|
||
- The form's field state lives under a `fields` object (`fields.<key>`), so RHF dotted paths work; `localized_text` nests `fields.<key>.en` / `.sv`.
|
||
- Add i18n key `form.selectPlaceholder` (= "— select —" / "— välj —") in Task 3's i18n step (or here). For this task's test, add it now to both `en.json` and `sv.json` so `t("form.selectPlaceholder")` resolves.
|
||
|
||
- [ ] **Step 4: Add the i18n key** — in `web/src/i18n/en.json` add `"form": { "selectPlaceholder": "— select —" }`; in `sv.json` add `"form": { "selectPlaceholder": "— välj —" }`. (More `form.*` keys are added in Task 3; keep the `form` namespace.)
|
||
|
||
- [ ] **Step 5: Run** — `pnpm test src/objects/field-input.test.tsx` → PASS (5 tests). Then `pnpm test` (all), `pnpm typecheck`, `pnpm lint`, `pnpm build` → clean.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd ..
|
||
git add web
|
||
git commit -m "feat(web): dynamic FieldInput (text/integer/date/boolean/localized_text/term/authority)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `ObjectForm` — shared core + flexible form
|
||
|
||
**Files:**
|
||
- Create: `web/src/objects/object-form.tsx`, `web/src/objects/object-form.test.tsx`
|
||
- Modify: `web/src/i18n/{en,sv}.json`
|
||
|
||
- [ ] **Step 1: Add i18n form keys** — merge into the `form` namespace of `web/src/i18n/en.json`
|
||
|
||
```json
|
||
"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",
|
||
"flexibleHeading": "Catalogue fields"
|
||
}
|
||
```
|
||
and `sv.json`:
|
||
```json
|
||
"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",
|
||
"flexibleHeading": "Katalogfält"
|
||
}
|
||
```
|
||
(Reuse the existing `fieldsLabels.*` keys from M1 for the core field labels.)
|
||
|
||
- [ ] **Step 2: Write the failing test** `web/src/objects/object-form.test.tsx`
|
||
|
||
```tsx
|
||
import { expect, test, vi } from "vitest";
|
||
import { screen, waitFor } from "@testing-library/react";
|
||
import userEvent from "@testing-library/user-event";
|
||
import { renderApp } from "../test/render";
|
||
import { ObjectForm } from "./object-form";
|
||
|
||
test("create mode: shows visibility (draft/internal only) and submits assembled values", async () => {
|
||
const onSubmit = vi.fn();
|
||
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
|
||
|
||
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||
|
||
const visibility = screen.getByLabelText(/visibility/i) as HTMLSelectElement;
|
||
expect([...visibility.options].map((o) => o.value)).toEqual(
|
||
expect.arrayContaining(["draft", "internal"]),
|
||
);
|
||
expect([...visibility.options].map((o) => o.value)).not.toContain("public");
|
||
|
||
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
|
||
const values = onSubmit.mock.calls[0][0];
|
||
expect(values.core.object_number).toBe("A-9");
|
||
expect(values.visibility).toBe("draft");
|
||
expect(values.fields.inscription).toBe("To the gods");
|
||
});
|
||
|
||
test("required core + required flexible field block submit", async () => {
|
||
const onSubmit = vi.fn();
|
||
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
|
||
await userEvent.click(await screen.findByRole("button", { name: /create object/i }));
|
||
// object_number, object_name, and required flexible "inscription" missing
|
||
await waitFor(() => expect(screen.getAllByText(/required/i).length).toBeGreaterThan(0));
|
||
expect(onSubmit).not.toHaveBeenCalled();
|
||
});
|
||
|
||
test("edit mode: no visibility control, save button, prefilled values", async () => {
|
||
const onSubmit = vi.fn();
|
||
renderApp(
|
||
<ObjectForm mode="edit" onSubmit={onSubmit} onCancel={() => {}}
|
||
defaults={{
|
||
core: { object_number: "A-1", object_name: "Amphora", number_of_objects: 1,
|
||
brief_description: null, current_location: "Vault 3", current_owner: null,
|
||
recorder: null, recording_date: null },
|
||
fields: { inscription: "hi" },
|
||
}} />,
|
||
);
|
||
expect(await screen.findByDisplayValue("Amphora")).toBeInTheDocument();
|
||
expect(screen.queryByLabelText(/visibility/i)).not.toBeInTheDocument();
|
||
expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Implement** — `web/src/objects/object-form.tsx`
|
||
|
||
```tsx
|
||
import { useForm } from "react-hook-form";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useFieldDefinitions } from "../api/queries";
|
||
import { FieldInput } from "./field-input";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
|
||
export type ObjectCore = {
|
||
object_number: string;
|
||
object_name: string;
|
||
number_of_objects: number;
|
||
brief_description: string | null;
|
||
current_location: string | null;
|
||
current_owner: string | null;
|
||
recorder: string | null;
|
||
recording_date: string | null;
|
||
};
|
||
|
||
export type ObjectFormValues = {
|
||
core: ObjectCore;
|
||
visibility?: "draft" | "internal";
|
||
fields: Record<string, unknown>;
|
||
};
|
||
|
||
type FormShape = { core: ObjectCore; visibility: "draft" | "internal"; fields: Record<string, unknown> };
|
||
|
||
const EMPTY_CORE: ObjectCore = {
|
||
object_number: "", object_name: "", number_of_objects: 1,
|
||
brief_description: null, current_location: null, current_owner: null,
|
||
recorder: null, recording_date: null,
|
||
};
|
||
|
||
export function ObjectForm({
|
||
mode, defaults, onSubmit, onCancel, formError,
|
||
}: {
|
||
mode: "create" | "edit";
|
||
defaults?: { core: ObjectCore; fields: Record<string, unknown> };
|
||
onSubmit: (values: ObjectFormValues) => void;
|
||
onCancel: () => void;
|
||
formError?: string | null;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const { data: definitions } = useFieldDefinitions();
|
||
const form = useForm<FormShape>({
|
||
defaultValues: {
|
||
core: defaults?.core ?? EMPTY_CORE,
|
||
visibility: "draft",
|
||
fields: defaults?.fields ?? {},
|
||
},
|
||
});
|
||
const { register, handleSubmit, formState: { errors } } = form;
|
||
|
||
const submit = handleSubmit((data) => {
|
||
const fields = pruneFields(data.fields);
|
||
onSubmit(
|
||
mode === "create"
|
||
? { core: data.core, visibility: data.visibility, fields }
|
||
: { core: data.core, fields },
|
||
);
|
||
});
|
||
|
||
const coreField = (key: keyof ObjectCore, labelKey: string, opts?: { type?: string; required?: boolean }) => (
|
||
<div className="space-y-1">
|
||
<Label htmlFor={key}>{t(`fieldsLabels.${labelKey}`)}</Label>
|
||
<Input id={key} type={opts?.type ?? "text"}
|
||
{...register(`core.${key}` as const,
|
||
opts?.type === "number"
|
||
? { valueAsNumber: true, required: opts?.required }
|
||
: { required: opts?.required })} />
|
||
{errors.core?.[key] && <p role="alert" className="text-xs text-red-600">{t("form.required")}</p>}
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<form onSubmit={submit} className="space-y-4 overflow-auto p-4">
|
||
{formError && <p role="alert" className="text-sm text-red-600">{formError}</p>}
|
||
{coreField("object_number", "objectNumber", { required: true })}
|
||
{coreField("object_name", "objectName", { required: true })}
|
||
{coreField("number_of_objects", "count", { type: "number", required: true })}
|
||
{coreField("brief_description", "briefDescription")}
|
||
{coreField("current_location", "currentLocation")}
|
||
{coreField("current_owner", "currentOwner")}
|
||
{coreField("recorder", "recorder")}
|
||
{coreField("recording_date", "recordingDate", { type: "date" })}
|
||
|
||
{mode === "create" && (
|
||
<div className="space-y-1">
|
||
<Label htmlFor="visibility">{t("form.visibility")}</Label>
|
||
<select id="visibility" className="w-full rounded border px-2 py-1 text-sm"
|
||
{...register("visibility")}>
|
||
<option value="draft">{t("form.draft")}</option>
|
||
<option value="internal">{t("form.internal")}</option>
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{definitions && definitions.length > 0 && (
|
||
<fieldset className="space-y-3 border-t pt-3">
|
||
<legend className="text-xs font-medium uppercase text-neutral-500">{t("form.flexibleHeading")}</legend>
|
||
{definitions.map((def) => (
|
||
<div key={def.key}>
|
||
<FieldInput definition={def} form={form} />
|
||
{errors.fields?.[def.key] && (
|
||
<p role="alert" className="text-xs text-red-600">{t("form.required")}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</fieldset>
|
||
)}
|
||
|
||
<div className="flex gap-2 pt-2">
|
||
<Button type="submit">{mode === "create" ? t("form.create") : t("form.save")}</Button>
|
||
<Button type="button" variant="ghost" onClick={onCancel}>{t("form.cancel")}</Button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
// Drop empty optional values so the replace-semantics field map only carries real values.
|
||
function pruneFields(fields: Record<string, unknown>): Record<string, unknown> {
|
||
const out: Record<string, unknown> = {};
|
||
for (const [key, value] of Object.entries(fields)) {
|
||
if (value === undefined || value === null || value === "") continue;
|
||
if (typeof value === "object" && !Array.isArray(value)) {
|
||
const inner = Object.fromEntries(
|
||
Object.entries(value as Record<string, unknown>).filter(([, v]) => v !== undefined && v !== null && v !== ""),
|
||
);
|
||
if (Object.keys(inner).length > 0) out[key] = inner;
|
||
continue;
|
||
}
|
||
out[key] = value;
|
||
}
|
||
return out;
|
||
}
|
||
```
|
||
NOTES:
|
||
- The `core.object_name` label uses `fieldsLabels.objectName` ("Name"); the test matches `/^name/i` against that label — confirm the M1 `fieldsLabels.objectName` value is "Name"/"Namn" (it is). The `/object number/i` matches `fieldsLabels.objectNumber`.
|
||
- `pruneFields` enforces replace-semantics: empty optional fields are omitted; `localized_text` objects drop empty langs and are omitted entirely if both empty.
|
||
- Number coercion via `valueAsNumber`; date stays a `YYYY-MM-DD` string from the native date input.
|
||
- Required flexible fields use the `definition.required` rule wired in `FieldInput` (Task 2); the error message renders here.
|
||
|
||
- [ ] **Step 4: Run** — `pnpm test src/objects/object-form.test.tsx` → PASS (3 tests). Then full `pnpm test` / typecheck / lint / build clean.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd ..
|
||
git add web
|
||
git commit -m "feat(web): ObjectForm (core + dynamic flexible fields, RHF, validation)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: New-object page + create flow + `/objects/new` route
|
||
|
||
**Files:**
|
||
- Create: `web/src/objects/object-new-page.tsx`, `web/src/objects/object-new-page.test.tsx`
|
||
- Modify: `web/src/app.tsx`, `web/src/objects/object-list.tsx`, `web/src/i18n/{en,sv}.json`
|
||
|
||
- [ ] **Step 1: i18n** — add to the `objects` namespace: `en.json` `"new": "New object"`; `sv.json` `"new": "Nytt föremål"`.
|
||
|
||
- [ ] **Step 2: Write the failing test** `web/src/objects/object-new-page.test.tsx`
|
||
|
||
```tsx
|
||
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 { ObjectNewPage } from "./object-new-page";
|
||
|
||
function tree() {
|
||
return (
|
||
<Routes>
|
||
<Route path="/objects/new" element={<ObjectNewPage />} />
|
||
<Route path="/objects/:id" element={<div>detail for {window.location.pathname}</div>} />
|
||
<Route path="/objects/:id/edit" element={<div>edit page</div>} />
|
||
</Routes>
|
||
);
|
||
}
|
||
|
||
test("create: POST then PUT fields, then navigate to the new object's detail", async () => {
|
||
let postBody: unknown; let fieldsBody: unknown;
|
||
server.use(
|
||
http.post("/api/admin/objects", async ({ request }) => {
|
||
postBody = await request.json();
|
||
return HttpResponse.json({ id: "new-id-1" }, { status: 201 });
|
||
}),
|
||
http.put("/api/admin/objects/:id/fields", async ({ request }) => {
|
||
fieldsBody = await request.json();
|
||
return new HttpResponse(null, { status: 204 });
|
||
}),
|
||
);
|
||
renderApp(tree(), { route: "/objects/new" });
|
||
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
|
||
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||
|
||
await waitFor(() => expect(screen.getByText(/detail for/i)).toBeInTheDocument());
|
||
expect((postBody as { object_number: string }).object_number).toBe("A-9");
|
||
expect((fieldsBody as { inscription: string }).inscription).toBe("To the gods");
|
||
});
|
||
|
||
test("partial create: fields PUT fails -> navigate to edit with an error banner", async () => {
|
||
server.use(
|
||
http.post("/api/admin/objects", () => HttpResponse.json({ id: "new-id-2" }, { status: 201 })),
|
||
http.put("/api/admin/objects/:id/fields", () => new HttpResponse(null, { status: 422 })),
|
||
);
|
||
renderApp(tree(), { route: "/objects/new" });
|
||
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
|
||
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
|
||
await userEvent.type(screen.getByLabelText(/inscription/i), "x");
|
||
await userEvent.click(screen.getByRole("button", { name: /create object/i }));
|
||
await waitFor(() => expect(screen.getByText(/edit page/i)).toBeInTheDocument());
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Implement** — `web/src/objects/object-new-page.tsx`
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import { useNavigate } from "react-router-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import { ObjectForm, type ObjectFormValues } from "./object-form";
|
||
import { useCreateObject, useSetFields } from "../api/queries";
|
||
|
||
export function ObjectNewPage() {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const create = useCreateObject();
|
||
const setFields = useSetFields();
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const onSubmit = async (values: ObjectFormValues) => {
|
||
setError(null);
|
||
let id: string;
|
||
try {
|
||
const created = await create.mutateAsync({
|
||
...values.core,
|
||
visibility: values.visibility ?? "draft",
|
||
});
|
||
id = created.id;
|
||
} catch {
|
||
setError(t("form.rejected"));
|
||
return;
|
||
}
|
||
if (Object.keys(values.fields).length > 0) {
|
||
try {
|
||
await setFields.mutateAsync({ id, fields: values.fields });
|
||
} catch {
|
||
// core record is saved; recover on the edit page so fields can be retried
|
||
navigate(`/objects/${id}/edit`, { state: { fieldsError: true } });
|
||
return;
|
||
}
|
||
}
|
||
navigate(`/objects/${id}`);
|
||
};
|
||
|
||
return (
|
||
<div className="mx-auto max-w-2xl">
|
||
<ObjectForm mode="create" formError={error}
|
||
onSubmit={onSubmit} onCancel={() => navigate("/objects")} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
(`create.mutateAsync` body spreads `values.core` + `visibility` to match `ObjectCreateRequest`. Confirm `ObjectCreateRequest` accepts exactly these keys; if it requires non-null strings vs nulls for optionals, adapt the core nulls to `undefined`/omit. The edit page reads `location.state.fieldsError` in Task 5 to show the banner.)
|
||
|
||
- [ ] **Step 4: Wire the route + list button**
|
||
|
||
In `web/src/app.tsx`, add (as a sibling of `/objects`, inside the protected `AppShell` group, BEFORE/independent of the `/objects` route — React Router ranks static `new` above dynamic `:id`):
|
||
```tsx
|
||
<Route path="/objects/new" element={<ObjectNewPage />} />
|
||
```
|
||
(Import `ObjectNewPage`. Keep the existing `/objects` + `/objects/:id` routes for now; Task 5 converts them to nested children.)
|
||
|
||
In `web/src/objects/object-list.tsx`, add a "New object" link near the list header/footer:
|
||
```tsx
|
||
import { Link } from "react-router-dom";
|
||
// in the footer or a small header row:
|
||
<Link to="/objects/new" className="text-sm font-medium text-indigo-600">{t("objects.new")}</Link>
|
||
```
|
||
(Place it so the existing object-list tests still pass — e.g. a header row above the `<ul>`.)
|
||
|
||
- [ ] **Step 5: Run** — `pnpm test src/objects/object-new-page.test.tsx` → PASS (2 tests). Full `pnpm test` / typecheck / lint / build clean. (Confirm `object-list.test.tsx` still green after adding the link.)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd ..
|
||
git add web
|
||
git commit -m "feat(web): new-object full-width page + create flow + /objects/new"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Nested routes + in-pane edit form + edit flow
|
||
|
||
**Files:**
|
||
- Create: `web/src/objects/object-edit-form.tsx`, `web/src/objects/object-edit-form.test.tsx`
|
||
- Modify: `web/src/objects/objects-page.tsx`, `web/src/app.tsx`, `web/src/objects/object-detail.tsx`
|
||
|
||
- [ ] **Step 1: Convert `ObjectsPage` to render an `<Outlet/>` right pane** — replace `web/src/objects/objects-page.tsx`
|
||
|
||
```tsx
|
||
import { Outlet } from "react-router-dom";
|
||
import { ObjectList } from "./object-list";
|
||
|
||
export function ObjectsPage() {
|
||
return (
|
||
<div className="grid h-full grid-cols-[20rem_1fr]">
|
||
<div className="overflow-hidden border-r">
|
||
<ObjectList />
|
||
</div>
|
||
<div className="overflow-hidden">
|
||
<Outlet />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
Create the index placeholder inline or as a tiny component `web/src/objects/select-prompt.tsx`:
|
||
```tsx
|
||
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>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Nest the routes** — in `web/src/app.tsx`
|
||
|
||
```tsx
|
||
<Route path="/objects/new" element={<ObjectNewPage />} />
|
||
<Route path="/objects" element={<ObjectsPage />}>
|
||
<Route index element={<SelectPrompt />} />
|
||
<Route path=":id" element={<ObjectDetail />} />
|
||
<Route path=":id/edit" element={<ObjectEditForm />} />
|
||
</Route>
|
||
```
|
||
(`ObjectDetail` and `ObjectEditForm` now render inside `ObjectsPage`'s `<Outlet/>`; both read `:id` via `useParams`. Import `SelectPrompt`, `ObjectEditForm`.)
|
||
|
||
- [ ] **Step 3: Write the failing edit test** `web/src/objects/object-edit-form.test.tsx`
|
||
|
||
```tsx
|
||
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");
|
||
});
|
||
```
|
||
(The default `amphora` fixture has `fields: {}` due to the schema typing; the handler override returns `fields: { inscription: "old" }` as a plain object — same approach as M1's detail test.)
|
||
|
||
- [ ] **Step 4: Implement** — `web/src/objects/object-edit-form.tsx`
|
||
|
||
```tsx
|
||
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||
import { useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { ObjectForm, type ObjectFormValues, type ObjectCore } from "./object-form";
|
||
import { useObject, useUpdateObject, useSetFields } from "../api/queries";
|
||
|
||
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}`)} />
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add the Edit action to detail** — first add the i18n key, then the link.
|
||
|
||
In `web/src/i18n/en.json` add an `actions` namespace `"actions": { "edit": "Edit" }`; in `sv.json` `"actions": { "edit": "Redigera" }`. (Task 6 extends this `actions` namespace with `delete`/`confirmDelete`.)
|
||
|
||
In `web/src/objects/object-detail.tsx`, add (in the header row next to the badge) a link to edit:
|
||
```tsx
|
||
import { Link } from "react-router-dom";
|
||
// in the header row next to the badge:
|
||
<Link to={`/objects/${object.id}/edit`} className="text-sm font-medium text-indigo-600">{t("actions.edit")}</Link>
|
||
```
|
||
|
||
- [ ] **Step 6: Run** — `pnpm test src/objects/object-edit-form.test.tsx` → PASS. Full `pnpm test` (M1 detail + objects-page tests now run under nested routing — confirm they still pass; the `objects-page.test.tsx` from M1 asserted clicking a row shows detail, which still holds via nested routes — update that test's route table to the nested shape if needed), typecheck, lint, build clean.
|
||
NOTE: M1's `objects-page.test.tsx` rendered `ObjectsPage` under flat `/objects` + `/objects/:id` routes. With nesting, update that test to render the nested route tree (parent `ObjectsPage` with `:id` child) so the click-through still asserts detail in the right pane.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd ..
|
||
git add web
|
||
git commit -m "feat(web): nested object routes + in-pane edit form + edit flow"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Delete (confirm dialog + flow)
|
||
|
||
**Files:**
|
||
- Create: `web/src/objects/delete-object-dialog.tsx`, `web/src/objects/delete-object-dialog.test.tsx`
|
||
- Modify: `web/src/objects/object-detail.tsx`, `web/src/i18n/{en,sv}.json`
|
||
|
||
- [ ] **Step 1: i18n** — EXTEND the existing `actions` namespace (created in Task 5 with `edit`) by adding `delete` + `confirmDelete`. Final `actions` in `en.json`: `{ "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }`; in `sv.json`: `{ "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }`.
|
||
|
||
- [ ] **Step 2: Write the failing test** `web/src/objects/delete-object-dialog.test.tsx`
|
||
|
||
```tsx
|
||
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 { DeleteObjectDialog } from "./delete-object-dialog";
|
||
|
||
function tree() {
|
||
return (
|
||
<Routes>
|
||
<Route path="/objects/:id" element={<DeleteObjectDialog id="o-1" />} />
|
||
<Route path="/objects" element={<div>objects list</div>} />
|
||
</Routes>
|
||
);
|
||
}
|
||
|
||
test("confirm delete: DELETE then navigate to the list", async () => {
|
||
let deleted = false;
|
||
server.use(http.delete("/api/admin/objects/:id", () => { deleted = true; return new HttpResponse(null, { status: 204 }); }));
|
||
renderApp(tree(), { route: "/objects/o-1" });
|
||
await userEvent.click(await screen.findByRole("button", { name: /delete/i }));
|
||
// confirm in the dialog
|
||
await userEvent.click(await screen.findByRole("button", { name: /^delete$/i }));
|
||
await waitFor(() => expect(screen.getByText("objects list")).toBeInTheDocument());
|
||
expect(deleted).toBe(true);
|
||
});
|
||
```
|
||
(If the trigger and confirm buttons collide on the `/delete/i` name, give the trigger an explicit accessible name like the icon-button `aria-label` and match the confirm with an exact `^Delete$` — adapt the queries so the test unambiguously clicks trigger then confirm.)
|
||
|
||
- [ ] **Step 3: Implement** — `web/src/objects/delete-object-dialog.tsx`
|
||
|
||
```tsx
|
||
import { useNavigate } from "react-router-dom";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useDeleteObject } from "../api/queries";
|
||
import {
|
||
AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogTitle,
|
||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||
} from "@/components/ui/alert-dialog";
|
||
import { Button } from "@/components/ui/button";
|
||
|
||
export function DeleteObjectDialog({ id }: { id: string }) {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const del = useDeleteObject();
|
||
|
||
const onConfirm = async () => {
|
||
await del.mutateAsync(id);
|
||
navigate("/objects");
|
||
};
|
||
|
||
return (
|
||
<AlertDialog>
|
||
<AlertDialogTrigger asChild>
|
||
<Button variant="ghost" size="sm" className="text-red-600">{t("actions.delete")}</Button>
|
||
</AlertDialogTrigger>
|
||
<AlertDialogContent>
|
||
<AlertDialogTitle>{t("actions.delete")}</AlertDialogTitle>
|
||
<AlertDialogDescription>{t("actions.confirmDelete")}</AlertDialogDescription>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel>{t("form.cancel")}</AlertDialogCancel>
|
||
<AlertDialogAction onClick={onConfirm}>{t("actions.delete")}</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
);
|
||
}
|
||
```
|
||
(Adapt the import names to the shadcn alert-dialog the CLI generated — Base UI registry may name parts slightly differently. The contract: a trigger button labelled Delete, a confirm action that calls `useDeleteObject().mutateAsync(id)` then navigates to `/objects`. If trigger/confirm both match `/delete/i`, set the trigger's text via `actions.delete` and the dialog action via the same key — disambiguate in the test by dialog role/scoping, or give the trigger an `aria-label`.)
|
||
|
||
- [ ] **Step 4: Add Delete to detail** — in `web/src/objects/object-detail.tsx`, render `<DeleteObjectDialog id={object.id} />` in the header actions next to Edit.
|
||
|
||
- [ ] **Step 5: Run** — `pnpm test src/objects/delete-object-dialog.test.tsx` → PASS. Full suite / typecheck / lint / build clean.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd ..
|
||
git add web
|
||
git commit -m "feat(web): delete object with confirm dialog"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Final wiring + full verification
|
||
|
||
**Files:**
|
||
- Modify: any remaining wiring; `web/src/objects/object-detail.tsx` (confirm Edit + Delete actions present)
|
||
|
||
- [ ] **Step 1: Confirm the detail header has both actions** — `object-detail.tsx` shows the object, an `Edit` link to `/objects/:id/edit`, and the `DeleteObjectDialog`. Adjust layout so they sit together; keep the visibility badge.
|
||
|
||
- [ ] **Step 2: i18n parity check** — confirm `en.json` and `sv.json` have identical key sets (the new `form.*` and `actions.*` namespaces in both). Run a quick check:
|
||
```bash
|
||
cd web
|
||
node -e "const a=require('./src/i18n/en.json'),b=require('./src/i18n/sv.json');const keys=o=>Object.entries(o).flatMap(([k,v])=>typeof v==='object'?keys(v).map(s=>k+'.'+s):[k]);const ka=keys(a).sort(),kb=keys(b).sort();console.log(JSON.stringify(ka)===JSON.stringify(kb)?'PARITY OK':'MISMATCH:'+JSON.stringify({onlyEn:ka.filter(k=>!kb.includes(k)),onlySv:kb.filter(k=>!ka.includes(k))}))"
|
||
```
|
||
Expected: `PARITY OK`. Fix any mismatch.
|
||
|
||
- [ ] **Step 3: Full verification**
|
||
|
||
```bash
|
||
cd web
|
||
pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size
|
||
```
|
||
Expected: typecheck/lint clean; all tests pass; build clean; bundle ≤150 KB gz (report the number — adding RHF + shadcn select/checkbox/alert-dialog should stay under; if it exceeds, lazy-load the new-object page and edit form via `React.lazy` in `app.tsx`).
|
||
|
||
- [ ] **Step 4: Manual smoke (optional, recommended)** — with the backend running + a Spectrum-seeded field set (or a manually created field definition) + an admin user: `pnpm dev`, create an object with a flexible field, edit it, delete it; switch sv/en.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd ..
|
||
git add web
|
||
git commit -m "feat(web): wire object authoring actions into detail; i18n parity"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (completed)
|
||
|
||
**Spec coverage:**
|
||
- New (full-width `/objects/new`) + create flow → Task 4. ✓
|
||
- Edit (in-pane `/objects/:id/edit`) + nested routes + edit flow → Task 5. ✓
|
||
- Delete + confirm dialog → Task 6. ✓
|
||
- Dynamic flexible-field form, all 7 types (incl. term/authority Selects, sv/en localized_text) → Tasks 2–3. ✓
|
||
- Client validation (required, integer, date) + form-level 422 + partial-create recovery → Tasks 3, 4, 5. ✓
|
||
- Mutations + option hooks + invalidation → Task 1. ✓
|
||
- Visibility only Draft/Internal on create, never on edit → Task 3 (test asserts). ✓
|
||
- i18n sv/en parity → Task 7 (+ keys added per task). ✓
|
||
- Testing Vitest+RTL+MSW → Tasks 1–6. ✓
|
||
- Bundle budget → Task 7 (check:size). ✓
|
||
|
||
**Placeholder scan:** none — every code step shows complete code; the "adapt to the generated/shadcn API" notes are verification instructions with concrete contracts, not deferred work.
|
||
|
||
**Type consistency:** `ObjectFormValues`/`ObjectCore` defined in Task 3 and consumed in Tasks 4–5; hooks `useTerms`/`useAuthorities`/`useCreateObject`/`useUpdateObject`/`useSetFields`/`useDeleteObject` defined in Task 1 and used in 2/4/5/6; `FieldInput` (Task 2) consumed by `ObjectForm` (Task 3); the `fields.<key>` RHF path shape is consistent across FieldInput and ObjectForm; route shapes (`/objects/new`, `/objects/:id`, `/objects/:id/edit`) consistent across Tasks 4–6.
|
||
|
||
## Notes for follow-on
|
||
- Searchable combobox + server-side term search for large vocabularies (deferred).
|
||
- Per-field server error mapping requires the backend `set_fields` 422 to carry field detail (currently bare).
|
||
- `fields` map cast (`Record<string, unknown>`) remains pending issue #24.
|
||
- The native `<select>` for term/authority can be swapped to shadcn Select without changing the id-as-value contract.
|