From cb191225ccb695d88665da885a81ac646c816c76 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Thu, 4 Jun 2026 00:31:05 +0200 Subject: [PATCH] feat(web): dynamic FieldInput (text/integer/date/boolean/localized_text/term/authority) Co-Authored-By: Claude Sonnet 4.6 --- web/src/i18n/en.json | 3 +- web/src/i18n/sv.json | 3 +- web/src/objects/field-input.test.tsx | 39 ++++ web/src/objects/field-input.tsx | 264 +++++++++++++++++++++++++++ 4 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 web/src/objects/field-input.test.tsx create mode 100644 web/src/objects/field-input.tsx diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 7b36382..054bf06 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -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" }, "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" }, - "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" } + "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, + "form": { "selectPlaceholder": "— select —" } } diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index c05032b..4432fd4 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -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" }, "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" }, - "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" } + "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, + "form": { "selectPlaceholder": "— välj —" } } diff --git a/web/src/objects/field-input.test.tsx b/web/src/objects/field-input.test.tsx new file mode 100644 index 0000000..e8f8b3d --- /dev/null +++ b/web/src/objects/field-input.test.tsx @@ -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 } }); + + return ; +} + +test("text field renders a text input labelled in the active locale", async () => { + renderApp(); + expect(await screen.findByLabelText("Inscription")).toBeInTheDocument(); +}); + +test("boolean field renders a checkbox", async () => { + renderApp(); + expect(await screen.findByRole("checkbox", { name: /is fragment/i })).toBeInTheDocument(); +}); + +test("localized_text renders sv and en inputs", async () => { + renderApp(); + 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(); + expect(await screen.findByText("Bronze")).toBeInTheDocument(); +}); + +test("authority field renders a select populated by kind", async () => { + renderApp(); + expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument(); +}); diff --git a/web/src/objects/field-input.tsx b/web/src/objects/field-input.tsx new file mode 100644 index 0000000..89e5d8f --- /dev/null +++ b/web/src/objects/field-input.tsx @@ -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 }>; + +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 onChange(e.target.value)} + > + + + {options.map((o) => ( + + ))} + + ); +} + +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 ( +
+ + + +
+ ); + + case "date": + return ( +
+ + + +
+ ); + + case "boolean": + return ( +
+ ( + field.onChange(checked === true)} + /> + )} + /> + + +
+ ); + + case "localized_text": + return ( +
+ + + + + + + + + +
+ ); + + case "term": + return ( + + ); + + case "authority": + return ( + + ); + + case "text": + default: + return ( +
+ + + +
+ ); + } +} + +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 ( +
+ + + ( + + )} + /> +
+ ); +} + +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 ( +
+ + + ( + + )} + /> +
+ ); +}