feat(web): ObjectForm (core + dynamic flexible fields, RHF, validation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 00:40:19 +02:00
parent cf0b34b254
commit 616c232a22
5 changed files with 265 additions and 15 deletions
+1 -1
View File
@@ -5,5 +5,5 @@
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of" }, "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of" },
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" }, "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" },
"form": { "selectPlaceholder": "— select —" } "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" }
} }
+1 -1
View File
@@ -5,5 +5,5 @@
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av" }, "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av" },
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" }, "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" },
"form": { "selectPlaceholder": "— välj —" } "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" }
} }
+19 -13
View File
@@ -1,4 +1,4 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type Path, type UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { components } from "../api/schema"; import type { components } from "../api/schema";
@@ -10,7 +10,13 @@ import { Label } from "@/components/ui/label";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"]; type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
type LabelView = components["schemas"]["LabelView"]; type LabelView = components["schemas"]["LabelView"];
type FieldForm = UseFormReturn<{ fields: Record<string, unknown> }>; type FieldForm<TValues extends { fields: Record<string, unknown> }> = UseFormReturn<TValues>;
function fieldPath<TValues extends { fields: Record<string, unknown> }>(
key: string,
): Path<TValues> {
return `fields.${key}` as Path<TValues>;
}
function labelIn(labels: LabelView[], lang: string): string { function labelIn(labels: LabelView[], lang: string): string {
return ( return (
@@ -56,17 +62,17 @@ function OptionsSelect({
); );
} }
export function FieldInput({ export function FieldInput<TValues extends { fields: Record<string, unknown> }>({
definition, definition,
form, form,
}: { }: {
definition: FieldDefinitionView; definition: FieldDefinitionView;
form: FieldForm; form: FieldForm<TValues>;
}) { }) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language.startsWith("sv") ? "sv" : "en"; const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const label = labelIn(definition.labels, lang); const label = labelIn(definition.labels, lang);
const name = `fields.${definition.key}` as `fields.${string}`; const name = fieldPath<TValues>(definition.key);
const placeholder = t("form.selectPlaceholder"); const placeholder = t("form.selectPlaceholder");
switch (definition.data_type) { switch (definition.data_type) {
@@ -133,7 +139,7 @@ export function FieldInput({
<Input <Input
id={`${definition.key}-en`} id={`${definition.key}-en`}
{...form.register(`fields.${definition.key}.en` as `fields.${string}`, { required: definition.required })} {...form.register(fieldPath<TValues>(`${definition.key}.en`), { required: definition.required })}
/> />
<Label <Label
@@ -145,7 +151,7 @@ export function FieldInput({
<Input <Input
id={`${definition.key}-sv`} id={`${definition.key}-sv`}
{...form.register(`fields.${definition.key}.sv` as `fields.${string}`)} {...form.register(fieldPath<TValues>(`${definition.key}.sv`))}
/> />
</div> </div>
); );
@@ -187,7 +193,7 @@ export function FieldInput({
} }
} }
function TermField({ function TermField<TValues extends { fields: Record<string, unknown> }>({
definition, definition,
form, form,
label, label,
@@ -195,7 +201,7 @@ function TermField({
placeholder, placeholder,
}: { }: {
definition: FieldDefinitionView; definition: FieldDefinitionView;
form: FieldForm; form: FieldForm<TValues>;
label: string; label: string;
lang: string; lang: string;
placeholder: string; placeholder: string;
@@ -208,7 +214,7 @@ function TermField({
<Controller <Controller
control={form.control} control={form.control}
name={`fields.${definition.key}` as `fields.${string}`} name={fieldPath<TValues>(definition.key)}
rules={{ required: definition.required }} rules={{ required: definition.required }}
render={({ field }) => ( render={({ field }) => (
<OptionsSelect <OptionsSelect
@@ -225,7 +231,7 @@ function TermField({
); );
} }
function AuthorityField({ function AuthorityField<TValues extends { fields: Record<string, unknown> }>({
definition, definition,
form, form,
label, label,
@@ -233,7 +239,7 @@ function AuthorityField({
placeholder, placeholder,
}: { }: {
definition: FieldDefinitionView; definition: FieldDefinitionView;
form: FieldForm; form: FieldForm<TValues>;
label: string; label: string;
lang: string; lang: string;
placeholder: string; placeholder: string;
@@ -246,7 +252,7 @@ function AuthorityField({
<Controller <Controller
control={form.control} control={form.control}
name={`fields.${definition.key}` as `fields.${string}`} name={fieldPath<TValues>(definition.key)}
rules={{ required: definition.required }} rules={{ required: definition.required }}
render={({ field }) => ( render={({ field }) => (
<OptionsSelect <OptionsSelect
+49
View File
@@ -0,0 +1,49 @@
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 }));
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();
});
+195
View File
@@ -0,0 +1,195 @@
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>;
} & 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>
);
}
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;
}