feat(web): ObjectForm (core + dynamic flexible fields, RHF, validation)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user