feat(web): dynamic FieldInput (text/integer/date/boolean/localized_text/term/authority)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 00:31:05 +02:00
parent b23a48c310
commit cb191225cc
4 changed files with 307 additions and 2 deletions
+264
View File
@@ -0,0 +1,264 @@
import { Controller, type UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { components } from "../api/schema";
import { useAuthorities, useTerms } from "../api/queries";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
type LabelView = components["schemas"]["LabelView"];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyForm = UseFormReturn<{ fields: Record<string, any> }>;
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: AnyForm;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language.startsWith("sv") ? "sv" : "en";
const label = labelIn(definition.labels, lang);
const name = `fields.${definition.key}` as `fields.${string}`;
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={(checked) => field.onChange(checked === 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 `fields.${string}`)}
/>
<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 `fields.${string}`)}
/>
</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: AnyForm;
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 `fields.${string}`}
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: AnyForm;
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 `fields.${string}`}
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>
);
}