537b847acb
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
228 lines
6.7 KiB
TypeScript
228 lines
6.7 KiB
TypeScript
import { useEffect } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { useFieldDefinitions } from "../api/queries";
|
|
import { useConfig } from "../config/config-context";
|
|
import { FieldInput } from "./field-input";
|
|
import { pruneFields } from "./prune-fields";
|
|
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,
|
|
fieldErrorKey,
|
|
fieldErrorCode,
|
|
}: {
|
|
mode: "create" | "edit";
|
|
defaults?: { core: ObjectCore; fields: Record<string, unknown> };
|
|
onSubmit: (values: ObjectFormValues, opts?: { createAnother?: boolean }) => Promise<boolean> | boolean;
|
|
onCancel: () => void;
|
|
formError?: string | null;
|
|
fieldErrorKey?: string | null;
|
|
fieldErrorCode?: string | null;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { default_language } = useConfig();
|
|
|
|
const { data: definitions } = useFieldDefinitions();
|
|
|
|
const localizedTextKeys = new Set(
|
|
(definitions ?? []).filter((d) => d.data_type === "localized_text").map((d) => d.key),
|
|
);
|
|
|
|
const form = useForm<FormShape>({
|
|
defaultValues: {
|
|
core: defaults?.core ?? EMPTY_CORE,
|
|
visibility: "draft",
|
|
fields: defaults?.fields ?? {},
|
|
},
|
|
});
|
|
|
|
const { register, handleSubmit, formState: { errors, isSubmitting } } = form;
|
|
|
|
useEffect(() => {
|
|
if (fieldErrorKey) {
|
|
const codeKey = fieldErrorCode ? `form.fieldError.${fieldErrorCode}` : "";
|
|
const message =
|
|
fieldErrorCode && t(codeKey) !== codeKey
|
|
? t(codeKey)
|
|
: t("form.fieldRejected", { field: fieldErrorKey });
|
|
form.setError(`fields.${fieldErrorKey}` as never, { type: "server", message });
|
|
}
|
|
}, [fieldErrorKey, fieldErrorCode, form, t]);
|
|
|
|
const runSubmit = (createAnother: boolean) =>
|
|
handleSubmit(async (data) => {
|
|
const fields = pruneFields(data.fields, localizedTextKeys, default_language);
|
|
const values =
|
|
mode === "create"
|
|
? { core: data.core, visibility: data.visibility, fields }
|
|
: { core: data.core, fields };
|
|
const ok = await onSubmit(values, { createAnother });
|
|
if (ok && createAnother) {
|
|
form.reset({ core: EMPTY_CORE, visibility: "draft", fields: {} });
|
|
document.getElementById("object_number")?.focus();
|
|
}
|
|
});
|
|
|
|
const submit = runSubmit(false);
|
|
|
|
const coreField = (
|
|
key: keyof ObjectCore,
|
|
labelKey: string,
|
|
opts?: { type?: string; required?: boolean; min?: number },
|
|
) => (
|
|
<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,
|
|
...(opts?.min !== undefined
|
|
? { min: { value: opts.min, message: t("form.minCount") } }
|
|
: {}),
|
|
}
|
|
: { required: opts?.required },
|
|
)}
|
|
/>
|
|
|
|
{errors.core?.[key] && (
|
|
<p role="alert" className="text-xs text-destructive">
|
|
{errors.core[key]?.message || t("form.required")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<form
|
|
onSubmit={submit}
|
|
onKeyDown={(e) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
e.preventDefault();
|
|
void submit();
|
|
}
|
|
}}
|
|
className="space-y-4 overflow-auto p-4"
|
|
>
|
|
{formError && (
|
|
<p role="alert" className="text-sm text-destructive">
|
|
{formError}
|
|
</p>
|
|
)}
|
|
|
|
{coreField("object_number", "objectNumber", { required: true })}
|
|
{coreField("object_name", "objectName", { required: true })}
|
|
{coreField("number_of_objects", "count", { type: "number", required: true, min: 1 })}
|
|
{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-md 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="label-caption">
|
|
{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-destructive">
|
|
{errors.fields[def.key]?.message ?? t("form.required")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</fieldset>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
{isSubmitting ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
|
</Button>
|
|
|
|
{mode === "create" && (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={isSubmitting}
|
|
onClick={() => void runSubmit(true)()}
|
|
>
|
|
{t("form.createAnother")}
|
|
</Button>
|
|
)}
|
|
|
|
<Button type="button" variant="ghost" disabled={isSubmitting} onClick={onCancel}>
|
|
{t("form.cancel")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|