Files
biggus-dickus/docs/superpowers/plans/2026-06-04-frontend-spa-milestone-2.md
2026-06-04 00:16:22 +02:00

53 KiB
Raw Permalink Blame History

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 ObjectCreateRequest201 {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-definitionsFieldDefinitionView[] (key, data_type, vocabulary_id?, authority_kind?, required, group?, labels).
  • GET /api/admin/vocabularies/{id}/termsTermView[]. 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

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
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 nulls. pnpm typecheck is the arbiter.)

  • Step 3: Extend default handlers — append handlers in web/src/test/handlers.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 testsweb/src/api/queries.authoring.test.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
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
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

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 failspnpm test src/objects/field-input.test.tsx → FAIL (no FieldInput).

  • Step 3: Implementweb/src/objects/field-input.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: Runpnpm test src/objects/field-input.test.tsx → PASS (5 tests). Then pnpm test (all), pnpm typecheck, pnpm lint, pnpm build → clean.

  • Step 6: Commit

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

"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:

"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
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: Implementweb/src/objects/object-form.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: Runpnpm test src/objects/object-form.test.tsx → PASS (3 tests). Then full pnpm test / typecheck / lint / build clean.

  • Step 5: Commit

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

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: Implementweb/src/objects/object-new-page.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):

<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:

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: Runpnpm 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

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

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:

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
<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
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: Implementweb/src/objects/object-edit-form.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:

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: Runpnpm 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

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

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: Implementweb/src/objects/delete-object-dialog.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: Runpnpm test src/objects/delete-object-dialog.test.tsx → PASS. Full suite / typecheck / lint / build clean.

  • Step 6: Commit

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 actionsobject-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:

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

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 23. ✓
  • 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 16. ✓
  • 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 45; 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 46.

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.