# 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.
