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:
@@ -4,5 +4,6 @@
|
|||||||
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
||||||
"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 —" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
||||||
"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 —" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { expect, test } from "vitest";
|
||||||
|
import { screen } from "@testing-library/react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { renderApp } from "../test/render";
|
||||||
|
import { FieldInput } from "./field-input";
|
||||||
|
import { fieldDefinitions } from "../test/fixtures";
|
||||||
|
|
||||||
|
function Harness({ defKey }: { defKey: string }) {
|
||||||
|
const def = fieldDefinitions.find((d) => d.key === defKey)!;
|
||||||
|
const form = useForm({ defaultValues: { fields: {} as Record<string, unknown> } });
|
||||||
|
|
||||||
|
return <FieldInput definition={def} form={form} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("text field renders a text input labelled in the active locale", async () => {
|
||||||
|
renderApp(<Harness defKey="inscription" />);
|
||||||
|
expect(await screen.findByLabelText("Inscription")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("boolean field renders a checkbox", async () => {
|
||||||
|
renderApp(<Harness defKey="is_fragment" />);
|
||||||
|
expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("localized_text renders sv and en inputs", async () => {
|
||||||
|
renderApp(<Harness defKey="title_ml" />);
|
||||||
|
expect(await screen.findByLabelText(/title.*\(en\)/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/title.*\(sv\)/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("term field renders a select populated from the vocabulary", async () => {
|
||||||
|
renderApp(<Harness defKey="material" />);
|
||||||
|
expect(await screen.findByText("Bronze")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("authority field renders a select populated by kind", async () => {
|
||||||
|
renderApp(<Harness defKey="maker" />);
|
||||||
|
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user