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
+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 type { components } from "../api/schema";
@@ -10,7 +10,13 @@ import { Label } from "@/components/ui/label";
type FieldDefinitionView = components["schemas"]["FieldDefinitionView"];
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 {
return (
@@ -56,17 +62,17 @@ function OptionsSelect({
);
}
export function FieldInput({
export function FieldInput<TValues extends { fields: Record<string, unknown> }>({
definition,
form,
}: {
definition: FieldDefinitionView;
form: FieldForm;
form: FieldForm<TValues>;
}) {
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 name = fieldPath<TValues>(definition.key);
const placeholder = t("form.selectPlaceholder");
switch (definition.data_type) {
@@ -133,7 +139,7 @@ export function FieldInput({
<Input
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
@@ -145,7 +151,7 @@ export function FieldInput({
<Input
id={`${definition.key}-sv`}
{...form.register(`fields.${definition.key}.sv` as `fields.${string}`)}
{...form.register(fieldPath<TValues>(`${definition.key}.sv`))}
/>
</div>
);
@@ -187,7 +193,7 @@ export function FieldInput({
}
}
function TermField({
function TermField<TValues extends { fields: Record<string, unknown> }>({
definition,
form,
label,
@@ -195,7 +201,7 @@ function TermField({
placeholder,
}: {
definition: FieldDefinitionView;
form: FieldForm;
form: FieldForm<TValues>;
label: string;
lang: string;
placeholder: string;
@@ -208,7 +214,7 @@ function TermField({
<Controller
control={form.control}
name={`fields.${definition.key}` as `fields.${string}`}
name={fieldPath<TValues>(definition.key)}
rules={{ required: definition.required }}
render={({ field }) => (
<OptionsSelect
@@ -225,7 +231,7 @@ function TermField({
);
}
function AuthorityField({
function AuthorityField<TValues extends { fields: Record<string, unknown> }>({
definition,
form,
label,
@@ -233,7 +239,7 @@ function AuthorityField({
placeholder,
}: {
definition: FieldDefinitionView;
form: FieldForm;
form: FieldForm<TValues>;
label: string;
lang: string;
placeholder: string;
@@ -246,7 +252,7 @@ function AuthorityField({
<Controller
control={form.control}
name={`fields.${definition.key}` as `fields.${string}`}
name={fieldPath<TValues>(definition.key)}
rules={{ required: definition.required }}
render={({ field }) => (
<OptionsSelect