commit 0ddb81e6ddfef7b275dd0bfc28a74853ae5b7ade parent 99c8a394dffb04b9e11188f1ba8bf8ce9965a3b4 Author: Florian Dold <dold@taler.net> Date: Mon, 31 Aug 2026 15:27:17 +0200 web-util: separate logical fields from UI occurrences Diffstat:
27 files changed, 526 insertions(+), 210 deletions(-)
diff --git a/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx b/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx @@ -1129,29 +1129,33 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { name="show_all" threeState={false} handler={{ - value: filter.showAll, - onChange(v) { - filter.showAll = !!v; - if (!v) { - filter.classId = classId; - } else { - filter.classId = undefined; - } - setFilter(structuredClone(filter)); + field: { + value: filter.showAll, + onChange(v) { + filter.showAll = !!v; + if (!v) { + filter.classId = classId; + } else { + filter.classId = undefined; + } + setFilter(structuredClone(filter)); + }, + name: "show_all", }, - name: "show_all", }} /> <InputText label={i18n.str`Account`} name="account" handler={{ - value: filter.account, - onChange(v) { - filter.account = v; - setFilter(structuredClone(filter)); + field: { + value: filter.account, + onChange(v) { + filter.account = v; + setFilter(structuredClone(filter)); + }, + name: "account", }, - name: "account", }} /> {filter.showAll ? ( @@ -1159,13 +1163,15 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { label={i18n.str`Group ID`} name="crcid" handler={{ - value: String(filter.classId), - onChange(v) { - const id = !v ? undefined : Number.parseInt(v, 10); - filter.classId = id; - setFilter(structuredClone(filter)); + field: { + value: String(filter.classId), + onChange(v) { + const id = !v ? undefined : Number.parseInt(v, 10); + filter.classId = id; + setFilter(structuredClone(filter)); + }, + name: "crcid", }, - name: "crcid", }} /> ) : undefined} diff --git a/packages/taler-exchange-aml-webui/src/components/UnlockSession.tsx b/packages/taler-exchange-aml-webui/src/components/UnlockSession.tsx @@ -144,7 +144,7 @@ export function UnlockSession({ officer }: { officer: OfficerLocked }): VNode { name="password" type="password" required - handler={handler.getHandlerForAttributeKey("password")} + handler={handler.getUiFieldForAttribute("password")} /> </div> diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Information.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Information.tsx @@ -134,9 +134,11 @@ function FillCustomerData({ ], ); const expirationHandler: UIFieldHandler = { - onChange: setExpiration, - value: expiration, - name: "expiration", + field: { + onChange: setExpiration, + value: expiration, + name: "expiration", + }, }; const storedContext = diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Rules.test.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Rules.test.tsx @@ -77,9 +77,7 @@ test("expiration shortcuts update the expiration form", async () => { <div> <ExpirationShortcuts referenceNow={referenceNow} - onChange={(value) => - form.model.getHandlerForAttributeKey("expiration").onChange(value) - } + onChange={(value) => form.fields.expiration.onChange(value)} /> <FormUI design={design} model={form.model} /> </div> diff --git a/packages/taler-exchange-aml-webui/src/pages/decision/Rules.tsx b/packages/taler-exchange-aml-webui/src/pages/decision/Rules.tsx @@ -578,9 +578,7 @@ function UpdateRulesForm({ <ExpirationShortcuts referenceNow={referenceNow} onChange={(expiration) => - expirationForm.model - .getHandlerForAttributeKey("expiration") - .onChange(expiration) + expirationForm.fields.expiration.onChange(expiration) } /> <FormUI design={expirationFormDesign} model={expirationForm.model} /> diff --git a/packages/web-util/src/components/Accessibility.test.tsx b/packages/web-util/src/components/Accessibility.test.tsx @@ -54,10 +54,12 @@ test("single-select choices can be selected with the keyboard", async () => { { value: "CH", label: i18n.str`Switzerland` }, ]} handler={{ - name: "country", - value: undefined, - onChange(value) { - selected = value; + field: { + name: "country", + value: undefined, + onChange(value) { + selected = value; + }, }, }} />, diff --git a/packages/web-util/src/forms/AcceptTosForm.tsx b/packages/web-util/src/forms/AcceptTosForm.tsx @@ -20,6 +20,11 @@ type ReaderDocument = { type ReaderError = "load" | "format" | "large"; +export type AcceptTosFormValues = { + [TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE]: boolean; + [TalerFormAttributes.ACCEPTED_TERMS_OF_SERVICE]: string; +}; + export function AcceptTosOfficerView({ design, data, @@ -330,16 +335,16 @@ function TermsReaderDialog(props: { export function AcceptTosForm(props: { name: string; design: AcceptTosFormDesign; - model: FormModel; + model: FormModel<AcceptTosFormValues>; disabled?: boolean; focus?: boolean; onSubmit?: () => void; }): VNode { const { i18n } = useTranslationContext(); - const downloaded = props.model.getHandlerForAttributeKey( + const downloaded = props.model.getUiFieldForAttribute( TalerFormAttributes.DOWNLOADED_TERMS_OF_SERVICE, ); - const accepted = props.model.getHandlerForAttributeKey( + const accepted = props.model.getUiFieldForAttribute( TalerFormAttributes.ACCEPTED_TERMS_OF_SERVICE, ); const [open, setOpen] = useState(false); @@ -381,7 +386,7 @@ export function AcceptTosForm(props: { return; } setDocument({ content, contentType: type }); - downloaded.onChange(true); + downloaded.field.onChange(true); } catch (cause) { if (controller.signal.aborted) return; console.error("Could not load terms of service", cause); @@ -407,8 +412,8 @@ export function AcceptTosForm(props: { const checkboxId = `${props.name}-accepted-terms-of-service`; const hintId = `${props.name}-accepted-terms-of-service-hint`; - const hasRead = downloaded.value === true; - const isAccepted = accepted.value === props.design.tosVersion; + const hasRead = downloaded.field.value === true; + const isAccepted = accepted.field.value === props.design.tosVersion; // Translators: %1$s is the name of the organization or service provider // whose terms the user must review. const providerDescription = i18n.str`Read the terms provided by ${ @@ -472,7 +477,7 @@ export function AcceptTosForm(props: { disabled={!hasRead || props.disabled} aria-describedby={hintId} onChange={(event) => { - accepted.onChange( + accepted.field.onChange( event.currentTarget.checked ? props.design.tosVersion : undefined, diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx @@ -66,29 +66,42 @@ export interface UIFormProps<ValType> { handler?: UIFieldHandler<ValType>; } -export type UIFieldHandler<T = any> = { - /** - * Name of the field that this handler is responsible for. - */ - name: string | undefined; +/** + * State shared by every UI field that edits the same form attribute. + */ +export type FormFieldController<T = any> = { + /** Attribute path in the form result. */ + name: string; - /** - * Current value of the field. - */ + /** Current attribute value. */ value: T | undefined; onChange: (s: T | undefined) => void; +}; - error?: TranslatedString; +/** + * State belonging to one occurrence of a field in the form UI. + */ +export type UIFieldHandler<T = any> = { + field: FormFieldController<T>; - /** - * Root result of the form. - */ - formRootResult?: any; + error?: TranslatedString; hidden?: boolean; }; +export function resolveFieldHandler<T>( + handler: UIFieldHandler<T> | undefined, + field: string | number | symbol, +): FormFieldController<T> & Pick<UIFieldHandler<T>, "error"> { + if (!handler) { + throw Error( + `Field ${field.toString()} doesn't have handler and is not in a form provider context.`, + ); + } + return { ...handler.field, error: handler.error }; +} + export interface IconAddon { type: "icon"; icon: VNode; diff --git a/packages/web-util/src/forms/fields/ExternalLink.tsx b/packages/web-util/src/forms/fields/ExternalLink.tsx @@ -1,8 +1,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; import { RenderAddon } from "./InputLine.js"; -import { Addon, UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { Addon, resolveFieldHandler, UIFormProps } from "../FormProvider.js"; interface Props { label: TranslatedString; @@ -27,8 +26,7 @@ export function ExternalLink({ required, disabled, }: Props & UIFormProps<boolean>): VNode { - const { onChange, error } = - handler ?? noHandlerPropsAndNoContextForField(name); + const { onChange, error } = resolveFieldHandler(handler, name); return ( <div class="sm:col-span-6"> {before !== undefined && <RenderAddon addon={before} />} diff --git a/packages/web-util/src/forms/fields/InputAbsoluteTime.tsx b/packages/web-util/src/forms/fields/InputAbsoluteTime.tsx @@ -4,8 +4,7 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Calendar } from "../Calendar.js"; import { Dialog } from "../Dialog.js"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { InputLine } from "./InputLine.js"; export function InputAbsoluteTime( @@ -14,8 +13,10 @@ export function InputAbsoluteTime( const pattern = properties.pattern ?? "dd/MM/yyyy"; const [open, setOpen] = useState(false); - const { value, onChange } = - properties.handler ?? noHandlerPropsAndNoContextForField(properties.name); + const { value, onChange } = resolveFieldHandler( + properties.handler, + properties.name, + ); return ( <Fragment> <InputLine diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx @@ -8,19 +8,11 @@ import { } from "../../hooks/useForm.js"; import { useTranslationContext } from "../../context/translation.js"; import { RenderAllFieldsByUiConfig } from "../forms-ui.js"; -import { UIFormProps } from "../FormProvider.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { UIFormElementConfig } from "../forms-types.js"; import { convertFormConfigToUiField } from "../forms-utils.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -export function noHandlerPropsAndNoContextForField( - field: string | number | symbol, -): never { - throw Error( - `Field ${field.toString()} doesn't have handler and is not in a form provider context.`, - ); -} - type FormType = {}; type Editor = { type: "add" } | { type: "edit"; index: number }; const EMPTY_FORM = {}; @@ -146,8 +138,10 @@ export function InputArray( const { fields, labelField, label, required, tooltip, hidden, help } = props; const { i18n } = useTranslationContext(); - const { value, onChange, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange, error } = resolveFieldHandler( + props.handler, + props.name, + ); const [dirty, setDirty] = useState(false); const [editor, setEditor] = useState<Editor | undefined>(); const returnFocusRef = useRef<HTMLButtonElement | null>(null); diff --git a/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx @@ -1,7 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; export interface ChoiceH<V> { @@ -15,8 +14,7 @@ export function InputChoiceHorizontal<ChoiceVal>( } & UIFormProps<ChoiceVal>, ): VNode { const { hidden, choices, label, tooltip, help, required, converter } = props; - const { value, onChange } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange } = resolveFieldHandler(props.handler, props.name); if (hidden) { return <Fragment />; } diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.tsx @@ -1,8 +1,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { useEffect, useId } from "preact/hooks"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; /** @@ -28,8 +27,7 @@ export function InputChoiceStacked<Choices>( const { choices, name, label, tooltip, help, hidden, required, converter } = props; - const { value, onChange } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange } = resolveFieldHandler(props.handler, props.name); const groupName = `choice-${useId()}`; useEffect(() => { diff --git a/packages/web-util/src/forms/fields/InputDownloadLink.tsx b/packages/web-util/src/forms/fields/InputDownloadLink.tsx @@ -1,7 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; -import { Addon, UIFormProps } from "../FormProvider.js"; +import { Addon, resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { RenderAddon } from "./InputLine.js"; interface Props { @@ -28,8 +27,10 @@ export function InputDownloadLink(props: Props & UIFormProps<boolean>): VNode { after, fileName, } = props; - const { value, onChange, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange, error } = resolveFieldHandler( + props.handler, + props.name, + ); return ( <div class="col-span-6" data-downloaded={!!value}> diff --git a/packages/web-util/src/forms/fields/InputDrilldown.tsx b/packages/web-util/src/forms/fields/InputDrilldown.tsx @@ -2,8 +2,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { useTranslationContext } from "../../context/translation.js"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { InputSelectOne } from "./InputSelectOne.js"; export interface ChoiceH<V> { @@ -17,8 +16,7 @@ export function InputDrilldown( } & UIFormProps<any>, ): VNode { const { hidden, choices, required } = props; - const { onChange } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { onChange } = resolveFieldHandler(props.handler, props.name); const [choiceStack, setChoiceStack] = useState<string[]>([]); const { i18n: localI18n } = useTranslationContext(); useEffect(() => setChoiceStack([]), [choices]); @@ -42,24 +40,26 @@ export function InputDrilldown( required={required} disabled={props.disabled} handler={{ - name: choiceStack.join(","), - onChange(x) { - if (x != null) { - const cs = [...choiceStack.slice(0, lvl), x]; - let myCh = props.choices; - for (let c of cs) { - myCh = myCh[c]; + field: { + name: choiceStack.join(","), + onChange(x) { + if (x != null) { + const cs = [...choiceStack.slice(0, lvl), x]; + let myCh = props.choices; + for (let c of cs) { + myCh = myCh[c]; + } + if (typeof myCh === "string") { + onChange(myCh); + } + setChoiceStack(cs); + } else { + setChoiceStack([...choiceStack.slice(0, lvl)]); + onChange(undefined); } - if (typeof myCh === "string") { - onChange(myCh); - } - setChoiceStack(cs); - } else { - setChoiceStack([...choiceStack.slice(0, lvl)]); - onChange(undefined); - } + }, + value: choiceStack[lvl], }, - value: choiceStack[lvl], }} choices={Object.keys(ch).map((x) => ({ label: x as TranslatedString, diff --git a/packages/web-util/src/forms/fields/InputDuration.tsx b/packages/web-util/src/forms/fields/InputDuration.tsx @@ -2,15 +2,16 @@ import { Duration } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { useTranslationContext } from "../../context/translation.js"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { InputWrapper } from "./InputLine.js"; export function InputDuration(props: UIFormProps<Duration>): VNode { const { name, placeholder, before, after, converter, disabled } = props; const { i18n } = useTranslationContext(); - const { value, onChange, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange, error } = resolveFieldHandler( + props.handler, + props.name, + ); const forever = !!value && Duration.isForever(value as Duration); const specDuration = diff --git a/packages/web-util/src/forms/fields/InputDurationText.tsx b/packages/web-util/src/forms/fields/InputDurationText.tsx @@ -2,9 +2,8 @@ import { Duration } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; import { useEffect, useRef } from "preact/hooks"; import { useTranslationContext } from "../../context/translation.js"; -import { UIFormProps } from "../FormProvider.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { InputLine } from "./InputLine.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; const PATTERN = /^(?<value>[0-9]+)(?<unit>[smhDMY])$/; type DurationUnit = "s" | "m" | "h" | "D" | "M" | "Y"; @@ -58,8 +57,7 @@ function formatDuration(value?: Duration): string { export function InputDurationText(props: UIFormProps<Duration>): VNode { const { i18n } = useTranslationContext(); - const handler = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const handler = resolveFieldHandler(props.handler, props.name); const forever = !!handler.value && Duration.isForever(handler.value); const lastFiniteValue = useRef<Duration>(Duration.fromSpec({})); @@ -73,7 +71,6 @@ export function InputDurationText(props: UIFormProps<Duration>): VNode { <div class="sm:col-span-6"> <InputLine {...props} - handler={handler} disabled={props.disabled || forever} type="text" converter={{ fromStringUI: parseDuration, toStringUI: formatDuration }} diff --git a/packages/web-util/src/forms/fields/InputFile.tsx b/packages/web-util/src/forms/fields/InputFile.tsx @@ -10,9 +10,8 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useTranslationContext } from "../../context/translation.js"; -import { UIFormProps } from "../FormProvider.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { FileFieldData } from "../forms-types.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; export function InputFile( @@ -21,8 +20,10 @@ export function InputFile( const { i18n } = useTranslationContext(); const { label, tooltip, required, help, maxBytes, accept, disabled, hidden } = props; - const { value, onChange, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange, error } = resolveFieldHandler( + props.handler, + props.name, + ); const [fileError, setFileError] = useState<string>(); if (hidden) return <Fragment />; diff --git a/packages/web-util/src/forms/fields/InputIsoDate.tsx b/packages/web-util/src/forms/fields/InputIsoDate.tsx @@ -20,8 +20,7 @@ import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { Calendar } from "../Calendar.js"; import { Dialog } from "../Dialog.js"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { InputLine } from "./InputLine.js"; export interface InputIsoDateProps { @@ -52,8 +51,10 @@ export function InputIsoDate( const pattern = properties.pattern ?? "dd/MM/yyyy"; const [open, setOpen] = useState(false); - const { value, onChange } = - properties.handler ?? noHandlerPropsAndNoContextForField(properties.name); + const { value, onChange } = resolveFieldHandler( + properties.handler, + properties.name, + ); useEffect(() => { if (!value && !!properties.defaultValue) { diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx @@ -2,8 +2,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useEffect, useId, useRef } from "preact/hooks"; import { composeRef, saveRef } from "../../components/utils.js"; -import { Addon, UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { Addon, resolveFieldHandler, UIFormProps } from "../FormProvider.js"; //@ts-ignore const TooltipIcon = ( @@ -195,8 +194,10 @@ export function InputLine<Value = string>( const input = useRef<HTMLTextAreaElement | HTMLInputElement>(); const inputId = `input-${useId()}`; - const { value, onChange, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange, error } = resolveFieldHandler( + props.handler, + props.name, + ); const fromString: (s: string) => any = converter?.fromStringUI ?? defaultFromString; diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx @@ -11,8 +11,7 @@ import { Fragment, VNode, h } from "preact"; import { useId, useRef, useState } from "preact/hooks"; import { useTranslationContext } from "../../context/translation.js"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { ChoiceS } from "./InputChoiceStacked.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; @@ -35,8 +34,10 @@ export function InputSelectMultiple<ChoiceVal>( max, } = props; const { i18n } = useTranslationContext(); - const { value, onChange, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange, error } = resolveFieldHandler( + props.handler, + props.name, + ); const [filter, setFilter] = useState<string | undefined>(undefined); const [activeIndex, setActiveIndex] = useState(0); diff --git a/packages/web-util/src/forms/fields/InputSelectOne.tsx b/packages/web-util/src/forms/fields/InputSelectOne.tsx @@ -2,8 +2,7 @@ import { i18n } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { useId, useRef, useState } from "preact/hooks"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { ChoiceS } from "./InputChoiceStacked.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; @@ -17,8 +16,10 @@ export function InputSelectOne<Choices>( ): VNode { const { label, choices, placeholder, tooltip, required, help, hidden } = props; - const { value, onChange, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange, error } = resolveFieldHandler( + props.handler, + props.name, + ); const [filter, setFilter] = useState<string | undefined>(undefined); const [activeIndex, setActiveIndex] = useState(0); diff --git a/packages/web-util/src/forms/fields/InputToggle.tsx b/packages/web-util/src/forms/fields/InputToggle.tsx @@ -1,7 +1,6 @@ import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { UIFormProps } from "../FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { resolveFieldHandler, UIFormProps } from "../FormProvider.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; /** @@ -29,8 +28,10 @@ export function InputToggle( falseValue = false, onlyTrueValue = false, } = props; - const { value, onChange, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const { value, onChange, error } = resolveFieldHandler( + props.handler, + props.name, + ); const [dirty, setDirty] = useState<boolean>(); const isOn = trueValue === value; diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -19,7 +19,7 @@ import { UIFormElementConfig, } from "./forms-types.js"; import { convertFormConfigToUiField } from "./forms-utils.js"; -import { AcceptTosForm } from "./AcceptTosForm.js"; +import { AcceptTosForm, AcceptTosFormValues } from "./AcceptTosForm.js"; export function DefaultForm<T>({ design, @@ -30,7 +30,7 @@ export function DefaultForm<T>({ design: FormDesign; initial: object; }): VNode { - const { model: handler, status } = useForm(design, initial); + const { model: handler, status } = useForm<T>(design, initial as any); const [shorten, setShorten] = useState(true); @@ -102,7 +102,7 @@ export function FormUI<T>({ }: { name?: string; design: FormDesign; - model: FormModel; + model: FormModel<T>; focus?: boolean; disabled?: boolean; onSubmit?: () => void; @@ -113,7 +113,7 @@ export function FormUI<T>({ <AcceptTosForm name={name} design={design} - model={model} + model={model as unknown as FormModel<AcceptTosFormValues>} focus={focus} onSubmit={onSubmit} disabled={disabled} @@ -173,7 +173,7 @@ export function DoubleColumnFormSectionUI<T>({ }: { sectionKey: string; name: string; - model: FormModel; + model: FormModel<T>; section: DoubleColumnFormSection; focus?: boolean; disabled?: boolean; @@ -257,7 +257,7 @@ export function SingleColumnFormSectionUI<T>({ onSubmit, }: { name: string; - model: FormModel; + model: FormModel<T>; fields: UIFormElementConfig[]; focus?: boolean; disabled?: boolean; diff --git a/packages/web-util/src/forms/forms-utils.ts b/packages/web-util/src/forms/forms-utils.ts @@ -22,24 +22,18 @@ import { UIFormElementConfig, UIFormFieldBaseConfig } from "./forms-types.js"; * @param formModel * @returns */ -export function convertFormConfigToUiField( +export function convertFormConfigToUiField<T>( i18n_: InternationalizationAPI, parentKey: string | number, fieldConfig: UIFormElementConfig[], - formModel: FormModel, + formModel: FormModel<T>, ): UIFormField[] { const result = fieldConfig.map((config, fieldIndex) => { if (config.type === "void") return undefined; const uiKey = `${parentKey}.${fieldIndex}`; const handler = formModel.getHandlerForUiField(uiKey); - const name = handler.name; - // FIXME: first computed prop, all should be computed - const hidden = - config.hidden === true - ? true - : config.hide - ? config.hide(handler.value, handler.formRootResult) - : undefined; + const name = handler.field.name; + const hidden = handler.hidden; switch (config.type) { case "htmlIframe": { @@ -442,7 +436,7 @@ function convertInputFieldsProps( converter, handler, name, - hidden: config.hidden, + hidden: handler.hidden, required: config.required, disabled: config.disabled, help: config.help, diff --git a/packages/web-util/src/hooks/useForm.test.tsx b/packages/web-util/src/hooks/useForm.test.tsx @@ -0,0 +1,177 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ + +import { setupI18n, TranslatedString } from "@gnu-taler/taler-util"; +import assert from "node:assert/strict"; +import test from "node:test"; +import { Window } from "happy-dom"; +import { h } from "preact"; +import { FormState, useForm } from "./useForm.js"; +import { FormDesign } from "../forms/forms-types.js"; + +setupI18n("en", {}); + +function installDom(): Window { + const window = new Window({ url: "https://forms.example/" }); + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + HTMLElement: window.HTMLElement, + MutationObserver: window.MutationObserver, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} + +type Values = { + password: string; + nested: { note: string }; + tags: string[]; +}; + +const duplicateDesign: FormDesign = { + type: "single-column", + fields: [ + { + id: "password", + type: "text", + required: true, + label: "Password" as TranslatedString, + }, + { + id: "password", + type: "secret", + validator: () => "second validation" as TranslatedString, + label: "Password confirmation" as TranslatedString, + }, + { + id: "nested.note", + type: "text", + label: "Note" as TranslatedString, + }, + { + id: "tags", + type: "selectMultiple", + choices: [], + label: "Tags" as TranslatedString, + }, + ], +}; + +const checkFieldTypes = (form: FormState<Values>): void => { + const password: string | undefined = form.fields.password.value; + const note: string | undefined = form.fields.nested.note.value; + void password; + void note; + // @ts-expect-error password controllers reject non-string updates + form.fields.password.onChange(42); + // @ts-expect-error fixed form fields do not expose unknown properties + form.fields.missing; +}; +void checkFieldTypes; + +test("logical fields are shared across UI occurrences", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + let form: FormState<Values> | undefined; + + function Harness() { + form = useForm<Values>(duplicateDesign, { + password: undefined, + nested: { note: "hello" }, + tags: [], + }); + return null; + } + + render(<Harness />); + assert.ok(form); + const handlers = form.model.getUiFieldsForAttribute("password"); + assert.equal(handlers.length, 2); + assert.equal(handlers[0]!.field, handlers[1]!.field); + assert.equal(handlers[0]!.field, form.fields.password); + assert.equal(form.fields.nested.note.value, "hello"); + assert.deepEqual(form.fields.tags.value, []); + assert.equal(handlers[0]!.error, "required"); + assert.equal(handlers[1]!.error, "second validation"); + assert.throws( + () => form!.model.getUiFieldForAttribute("password"), + /expected one UI field.*found 2/, + ); + assert.equal( + form.model.getUiFieldForAttribute("nested.note").field, + form.fields.nested.note, + ); + + cleanup(); + await window.happyDOM.abort(); +}); + +test("missing fields and conflicting attribute paths fail explicitly", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + let form: FormState<{ present: string }> | undefined; + + function MissingHarness() { + form = useForm<{ present: string }>( + { + type: "single-column", + fields: [ + { + id: "present", + type: "text", + label: "Present" as TranslatedString, + }, + ], + }, + {}, + ); + return null; + } + + render(<MissingHarness />); + assert.ok(form); + assert.deepEqual(form.model.getUiFieldsForAttribute("missing" as never), []); + assert.throws( + () => form!.model.getUiFieldForAttribute("missing" as never), + /no UI field/, + ); + cleanup(); + + function CollisionHarness() { + useForm<{ foo: string | { bar: string } }>( + { + type: "single-column", + fields: [ + { id: "foo", type: "text", label: "Foo" as TranslatedString }, + { + id: "foo.bar", + type: "text", + label: "Bar" as TranslatedString, + }, + ], + }, + {}, + ); + return null; + } + + assert.throws(() => render(<CollisionHarness />), /conflicts with leaf foo/); + cleanup(); + await window.happyDOM.abort(); +}); diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -18,6 +18,7 @@ import { AbsoluteTime, AmountJson, assertUnreachable, + Duration, TalerFormAttributes, TalerExchangeApi, TranslatedString, @@ -27,32 +28,84 @@ import { InternationalizationAPI, useTranslationContext, } from "../context/translation.js"; -import { UIFieldHandler } from "../forms/FormProvider.js"; +import { FormFieldController, UIFieldHandler } from "../forms/FormProvider.js"; import { + FileFieldData, FormDesign, FormMetadata, UIFormElementConfig, } from "../forms/forms-types.js"; +type FormLeafValue = + | string + | number + | boolean + | bigint + | symbol + | null + | undefined + | AmountJson + | AbsoluteTime + | Duration + | readonly unknown[] + | TalerExchangeApi.AmlState + | FileFieldData; + +export type FormFields<T> = { + readonly [K in keyof T]-?: NonNullable<T[K]> extends FormLeafValue + ? FormFieldController<T[K]> + : NonNullable<T[K]> extends object + ? FormFields<NonNullable<T[K]>> + : FormFieldController<T[K]>; +}; + +export type FormFieldPath<T> = string extends keyof T + ? string + : { + [K in keyof T & string]: NonNullable<T[K]> extends FormLeafValue + ? K + : NonNullable<T[K]> extends object + ? K | `${K}.${FormFieldPath<NonNullable<T[K]>>}` + : K; + }[keyof T & string]; + +export type FormFieldValue<T, P extends string> = P extends keyof T + ? T[P] + : P extends `${infer K}.${infer R}` + ? K extends keyof T + ? FormFieldValue<NonNullable<T[K]>, R> + : unknown + : string extends keyof T + ? T[string & keyof T] + : unknown; + /** * Underlying state model for the form UI. */ -export interface FormModel { +export interface FormModel<T = any> { + /** Logical form fields, shaped like the form result. */ + readonly fields: FormFields<T>; + /** * Get a handler for an UI field based on the field identifier. */ getHandlerForUiField(fieldId: string): UIFieldHandler; /** - * Get the field handler for an attribute. - * - * If there are multiple handlers for the same attribute path, - * an arbitrary handler is returned. + * Get every UI occurrence for an attribute, in design order. + */ + getUiFieldsForAttribute<P extends FormFieldPath<T> & string>( + attributeKey: P, + ): Array<UIFieldHandler<FormFieldValue<T, P>>>; + + /** + * Get the only UI occurrence for an attribute. * - * (In the future, this might be changed to return the only currently - * visible handler.) + * Throws when the attribute is missing or has multiple UI occurrences. */ - getHandlerForAttributeKey(attributeKey: string): UIFieldHandler; + getUiFieldForAttribute<P extends FormFieldPath<T> & string>( + attributeKey: P, + ): UIFieldHandler<FormFieldValue<T, P>>; /** * Check if a section of the form is hidden. @@ -63,42 +116,108 @@ export interface FormModel { /** * Implementation of {@link FormModel}. */ -class FormModelImpl implements FormModel { +class FormModelImpl<T> implements FormModel<T> { public fieldHandlers: { [x: string]: UIFieldHandler } = {}; public hiddenSections: Set<string | number> = new Set(); + public fields = Object.create(null) as FormFields<T>; + private attributeHandlers = new Map<string, UIFieldHandler[]>(); getHandlerForUiField(fieldId: string): UIFieldHandler { return this.fieldHandlers[fieldId]; } - getHandlerForAttributeKey(attributeKey: string): UIFieldHandler { - for (const h of Object.values(this.fieldHandlers)) { - if (h.name === attributeKey) { - return h; - } + getUiFieldsForAttribute<P extends FormFieldPath<T> & string>( + attributeKey: P, + ): Array<UIFieldHandler<FormFieldValue<T, P>>> { + return (this.attributeHandlers.get(attributeKey) ?? []) as Array< + UIFieldHandler<FormFieldValue<T, P>> + >; + } + + getUiFieldForAttribute<P extends FormFieldPath<T> & string>( + attributeKey: P, + ): UIFieldHandler<FormFieldValue<T, P>> { + const handlers = this.getUiFieldsForAttribute(attributeKey); + if (handlers.length === 0) { + throw Error(`no UI field for attribute path ${attributeKey}`); } - throw Error(`no handler for attribute path ${attributeKey}`); + if (handlers.length !== 1) { + throw Error( + `expected one UI field for attribute path ${attributeKey}, found ${handlers.length}`, + ); + } + return handlers[0]!; } isSectionHidden(sectionName: string): boolean { return this.hiddenSections.has(sectionName); } + + registerUiField(uiPath: string, handler: UIFieldHandler): void { + this.fieldHandlers[uiPath] = handler; + if (handler.field.name === "<none>") return; + const handlers = this.attributeHandlers.get(handler.field.name) ?? []; + handlers.push(handler); + this.attributeHandlers.set(handler.field.name, handlers); + setControllerIntoTree(this.fields, handler.field.name, handler.field); + } +} + +function isFieldController(value: unknown): value is FormFieldController { + return ( + typeof value === "object" && + value !== null && + "name" in value && + "onChange" in value + ); +} + +function setControllerIntoTree( + root: object, + attributePath: string, + controller: FormFieldController, +): void { + const parts = attributePath.split("."); + let current = root as Record<string, unknown>; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]!; + const existing = current[part]; + if (isFieldController(existing)) { + throw Error( + `form attribute path ${attributePath} conflicts with leaf ${parts.slice(0, i + 1).join(".")}`, + ); + } + if (existing === undefined) { + current[part] = Object.create(null); + } else if (typeof existing !== "object" || existing === null) { + throw Error(`invalid form attribute path ${attributePath}`); + } + current = current[part] as Record<string, unknown>; + } + const leaf = parts[parts.length - 1]!; + const existing = current[leaf]; + if (existing !== undefined && existing !== controller) { + throw Error( + `form attribute path ${attributePath} conflicts with an existing branch`, + ); + } + current[leaf] = controller; } export type FormValues<T> = { - [k in keyof T]: T[k] extends string ? string | undefined : FormValues<T[k]>; + [K in keyof T]: NonNullable<T[K]> extends FormLeafValue + ? T[K] | undefined + : NonNullable<T[K]> extends object + ? FormValues<NonNullable<T[K]>> + : T[K] | undefined; }; export type RecursivePartial<T> = { - [k in keyof T]?: T[k] extends string - ? string - : T[k] extends AmountJson - ? T[k] - : T[k] extends Array<any> - ? T[k] - : T[k] extends TalerExchangeApi.AmlState - ? T[k] - : RecursivePartial<T[k]>; + [K in keyof T]?: NonNullable<T[K]> extends FormLeafValue + ? T[K] + : NonNullable<T[K]> extends object + ? RecursivePartial<NonNullable<T[K]>> + : T[K]; }; export type ErrorAndLabel = { @@ -108,15 +227,11 @@ export type ErrorAndLabel = { }; export type FormErrors<T> = { - [k in keyof T]?: T[k] extends string + [K in keyof T]?: NonNullable<T[K]> extends FormLeafValue ? ErrorAndLabel - : T[k] extends AmountJson - ? ErrorAndLabel - : T[k] extends AbsoluteTime - ? ErrorAndLabel - : T[k] extends TalerExchangeApi.AmlState - ? ErrorAndLabel - : FormErrors<T[k]>; + : NonNullable<T[K]> extends object + ? FormErrors<NonNullable<T[K]>> + : ErrorAndLabel; }; export type FormStatus<T> = @@ -136,7 +251,8 @@ export type FormStatus<T> = */ export type FormState<T> = { design: FormDesign; - model: FormModel; + model: FormModel<T>; + fields: FormFields<T>; status: FormStatus<T>; update: (f: FormValues<T>) => void; }; @@ -197,6 +313,7 @@ export function useForm<T>( return { model, + fields: model.fields, status, update: (f) => { formUpdateHandler(f as any); @@ -334,11 +451,12 @@ function constructFormHandler<T>( onValueChange: (d: RecursivePartial<FormValues<T>>) => void, i18n: InternationalizationAPI, ): { - model: FormModel; + model: FormModel<T>; result: FormStatus<T>; errors: FormErrors<T> | undefined; } { - let model: FormModelImpl = new FormModelImpl(); + const model = new FormModelImpl<T>(); + const controllers = new Map<string, FormFieldController>(); let result = {} as FormStatus<T>; let errors: FormErrors<T> | undefined = undefined; @@ -375,21 +493,26 @@ function constructFormHandler<T>( errors = setValueIntoPath(errors, path, currentError); } - function updater(newValue: unknown) { - const updated = setValueIntoPath(formValue, path, newValue) ?? {}; - onValueChange(updated); + let controller = controllers.get(formElement.id); + if (!controller) { + controller = { + name: formElement.id, + value: currentValue, + onChange(newValue: unknown) { + const updated = setValueIntoPath(formValue, path, newValue) ?? {}; + onValueChange(updated); + }, + }; + controllers.set(formElement.id, controller); } field = { - name: formElement.id, + field: controller, error: currentError?.message, - value: currentValue, - onChange: updater, - formRootResult: result, hidden, }; if (!hidden) { - result = setValueIntoPath(result, path, field.value) ?? {}; + result = setValueIntoPath(result, path, controller.value) ?? {}; } } else { const hidden = @@ -397,15 +520,16 @@ function constructFormHandler<T>( formElement.hidden || (formElement.hide && formElement.hide({}, result)); field = { - name: "<none>", - value: undefined, - onChange: () => {}, - formRootResult: result, + field: { + name: "<none>", + value: undefined, + onChange: () => {}, + }, hidden, }; } - model.fieldHandlers[handlerUiPath] = field; + model.registerUiField(handlerUiPath, field); } switch (design.type) { @@ -464,16 +588,19 @@ function constructFormHandler<T>( if (currentError) { errors = setValueIntoPath(errors, path, currentError); } - const handler: UIFieldHandler = { + const controller: FormFieldController = { name: field.name, value: currentValue, - error: currentError?.message, - formRootResult: result, onChange: (newValue) => { onValueChange(setValueIntoPath(formValue, path, newValue) ?? {}); }, }; - model.fieldHandlers[`accept-tos.${field.name}`] = handler; + const handler: UIFieldHandler = { + field: controller, + error: currentError?.message, + }; + controllers.set(field.name, controller); + model.registerUiField(`accept-tos.${field.name}`, handler); result = setValueIntoPath(result, path, currentValue) ?? {}; } break;