taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 9cac91b04e2c0b6e8136a67c3780ec89d210be75
parent 743c045513ff8900109335afd65dcdb8865dba5e
Author: Florian Dold <florian@dold.me>
Date:   Wed, 26 Mar 2025 19:34:34 +0100

forms: use a form model to contain handlers

Diffstat:
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/CaseUpdate.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/CreateAccount.tsx | 4++--
Mpackages/aml-backoffice-ui/src/pages/NewMeasure.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/Search.tsx | 10+++++-----
Mpackages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx | 16+++++-----------
Mpackages/aml-backoffice-ui/src/pages/Transfers.tsx | 4++--
Mpackages/aml-backoffice-ui/src/pages/UnlockAccount.tsx | 7++-----
Mpackages/aml-backoffice-ui/src/pages/decision/Events.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Information.tsx | 4++--
Mpackages/aml-backoffice-ui/src/pages/decision/Justification.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Measures.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Properties.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/decision/Rules.tsx | 2+-
Mpackages/kyc-ui/src/pages/FillForm.tsx | 4++--
Mpackages/kyc-ui/src/pages/TriggerForms.tsx | 8++++----
Mpackages/kyc-ui/src/pages/TriggerKyc.tsx | 4++--
Mpackages/web-util/src/forms/FormProvider.tsx | 9+++++++++
Mpackages/web-util/src/forms/fields/InputArray.tsx | 2+-
Mpackages/web-util/src/forms/forms-ui.tsx | 30++++++++++++++----------------
Mpackages/web-util/src/forms/forms-utils.ts | 10+++++-----
Mpackages/web-util/src/hooks/useForm.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
22 files changed, 122 insertions(+), 81 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -467,7 +467,7 @@ function SubmitNewDecision({ autoCapitalize="none" autoCorrect="off" > - <FormUI design={formDesign} handler={decisionForm.handler} /> + <FormUI design={formDesign} model={decisionForm.model} /> <div class="mt-6 flex items-center justify-end gap-x-6"> <button diff --git a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx @@ -136,7 +136,7 @@ export function CaseUpdate({ <Fragment> <LocalNotificationBanner notification={notification} /> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI design={theForm.config} handler={form.handler} /> + <FormUI design={theForm.config} model={form.model} /> </div> <div class="mt-6 flex items-center justify-end gap-x-6"> diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx @@ -89,7 +89,7 @@ export function CreateAccount(): VNode { const design = createAccountForm(i18n, settings.allowInsecurePassword); - const { handler, status } = useForm<FormType>( + const { model: handler, status } = useForm<FormType>( design, { password: undefined, @@ -116,7 +116,7 @@ export function CreateAccount(): VNode { </div> <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> - <FormUI design={design} handler={handler} /> + <FormUI design={design} model={handler} /> <div class="mt-8"> <Button type="submit" diff --git a/packages/aml-backoffice-ui/src/pages/NewMeasure.tsx b/packages/aml-backoffice-ui/src/pages/NewMeasure.tsx @@ -169,7 +169,7 @@ export function NewMeasure({}: {}): VNode { <i18n.Translate>Add measure</i18n.Translate> </h2> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <button disabled={form.status.status === "fail"} diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx @@ -85,7 +85,7 @@ export function Search() { autoCapitalize="none" autoCorrect="off" > - <FormUI design={design} handler={paytoForm.handler} /> + <FormUI design={design} model={paytoForm.model} /> </form> {(function () { @@ -327,7 +327,7 @@ function XTalerBankForm({ autoCapitalize="none" autoCorrect="off" > - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <button disabled={form.status.status === "fail"} @@ -370,7 +370,7 @@ function IbanForm({ autoCapitalize="none" autoCorrect="off" > - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <button disabled={form.status.status === "fail"} @@ -419,7 +419,7 @@ function WalletForm({ autoCapitalize="none" autoCorrect="off" > - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <button disabled={form.status.status === "fail"} @@ -461,7 +461,7 @@ function GenericForm({ autoCapitalize="none" autoCorrect="off" > - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <button disabled={form.status.status === "fail"} class="disabled:bg-gray-100 disabled:text-gray-500 m-4 rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx @@ -13,24 +13,18 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - AbsoluteTime, - AmountJson, - TalerExchangeApi, - TranslatedString, -} from "@gnu-taler/taler-util"; +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { FormDesign, FormUI, UIFormElementConfig, - UIHandlerId, useForm, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; -import { Fragment, VNode, h } from "preact"; -import { AmlEvent } from "./CaseDetails.js"; +import { VNode, h } from "preact"; import { useEffect } from "preact/hooks"; +import { AmlEvent } from "./CaseDetails.js"; /** * the exchange doesn't have a consistent api @@ -98,13 +92,13 @@ export function ShowConsolidated({ : [], }; - const { handler, update } = useForm(design, fixed); + const { model: handler, update } = useForm(design, fixed); useEffect(() => { update(fixed); }, [until.t_ms]); - return <FormUI design={design} handler={handler} />; + return <FormUI design={design} model={handler} />; } interface Consolidated { diff --git a/packages/aml-backoffice-ui/src/pages/Transfers.tsx b/packages/aml-backoffice-ui/src/pages/Transfers.tsx @@ -147,7 +147,7 @@ export function Transfers({ </h1> </div> </div> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <Attention type="low" title={i18n.str`No transfers yet.`}> <i18n.Translate> @@ -187,7 +187,7 @@ export function Transfers({ </h1> </div> </div> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white"> <table class="min-w-full divide-y divide-gray-300"> diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx @@ -19,7 +19,6 @@ import { InputLine, InternationalizationAPI, LocalNotificationBanner, - UIHandlerId, useForm, useLocalNotificationHandler, useTranslationContext, @@ -51,7 +50,7 @@ export function UnlockAccount(): VNode { const design = unlockAccountForm(i18n); - const { handler, status } = useForm<FormType>( + const { model: handler, status } = useForm<FormType>( design, { password: undefined, @@ -103,9 +102,7 @@ export function UnlockAccount(): VNode { name="password" type="password" required - // handler={handler.password} - //FIXME: we shoud be able to access the handler directly #9663 - handler={handler.fieldHandlers["root.0"]} + handler={handler.getHandlerForAttributeKey("password")} /> </div> diff --git a/packages/aml-backoffice-ui/src/pages/decision/Events.tsx b/packages/aml-backoffice-ui/src/pages/decision/Events.tsx @@ -90,7 +90,7 @@ export function Events({ account }: { account: string }): VNode { return ( <div> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> </div> ); } diff --git a/packages/aml-backoffice-ui/src/pages/decision/Information.tsx b/packages/aml-backoffice-ui/src/pages/decision/Information.tsx @@ -141,7 +141,7 @@ function FillCustomerData({ </div> <div> {!errors ? undefined : <ErrorsSummary errors={errors as any} />} - <FormUI design={theForm.config} handler={form.handler} /> + <FormUI design={theForm.config} model={form.model} /> </div> </div> ); @@ -177,7 +177,7 @@ function SelectForm({ return ( <div> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> </div> ); } diff --git a/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx b/packages/aml-backoffice-ui/src/pages/decision/Justification.tsx @@ -58,7 +58,7 @@ export function Justification({}: {}): VNode { return ( <div> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> </div> ); } diff --git a/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx b/packages/aml-backoffice-ui/src/pages/decision/Measures.tsx @@ -52,7 +52,7 @@ export function Measures({}: {}): VNode { return ( <div> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <ShowMeasuresToSelect /> </div> ); diff --git a/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx b/packages/aml-backoffice-ui/src/pages/decision/Properties.tsx @@ -108,7 +108,7 @@ function ReloadForm({ merged }: { merged: any }): VNode { }); return ( <div> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> </div> ); } diff --git a/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx b/packages/aml-backoffice-ui/src/pages/decision/Rules.tsx @@ -86,7 +86,7 @@ export function Rules({ account }: { account: string }): VNode { <i18n.Translate>Add a new rule</i18n.Translate> </h2> - <FormUI design={design} handler={form.handler} /> + <FormUI design={design} model={form.model} /> <button disabled={form.status.status === "fail"} diff --git a/packages/kyc-ui/src/pages/FillForm.tsx b/packages/kyc-ui/src/pages/FillForm.tsx @@ -96,7 +96,7 @@ export function FillForm({ return <div>no id for this form, can't upload</div>; } - const { handler, status } = useForm<FormType>(theForm.config, {}); + const { model: handler, status } = useForm<FormType>(theForm.config, {}); const validatedForm = status.status !== "ok" ? undefined : status.result; const submitHandler = @@ -140,7 +140,7 @@ export function FillForm({ <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> <LocalNotificationBanner notification={notification} /> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI handler={handler} design={theForm.config} /> + <FormUI model={handler} design={theForm.config} /> </div> {preferences.showDebugInfo ? ( diff --git a/packages/kyc-ui/src/pages/TriggerForms.tsx b/packages/kyc-ui/src/pages/TriggerForms.tsx @@ -65,7 +65,7 @@ export function TriggerForms({ formId }: Props): VNode { ], }, }; - const { handler, status } = useForm<FormType>(theForm.config, { + const { model: handler, status } = useForm<FormType>(theForm.config, { form: formId, }); @@ -76,7 +76,7 @@ export function TriggerForms({ formId }: Props): VNode { <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> <LocalNotificationBanner notification={notification} /> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI handler={handler} design={theForm.config} /> + <FormUI model={handler} design={theForm.config} /> </div> {!selected ? undefined : <ShowForm form={selected} />} </div> @@ -84,12 +84,12 @@ export function TriggerForms({ formId }: Props): VNode { } function ShowForm({ form }: { form: FormMetadata }) { - const { handler, status } = useForm<FormType>(form.config, {}); + const { model: handler, status } = useForm<FormType>(form.config, {}); return ( <Fragment> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI handler={handler} design={form.config} /> + <FormUI model={handler} design={form.config} /> </div> </Fragment> ); diff --git a/packages/kyc-ui/src/pages/TriggerKyc.tsx b/packages/kyc-ui/src/pages/TriggerKyc.tsx @@ -75,7 +75,7 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { }, }; - const { handler, status } = useForm<FormType>(theForm.config, { + const { model: handler, status } = useForm<FormType>(theForm.config, { amount: Amounts.parseOrThrow(`${config.config.currency}:1000000`), }); @@ -178,7 +178,7 @@ export function TriggerKyc({ onKycStarted }: Props): VNode { <div class="rounded-lg bg-white px-5 py-6 shadow m-4"> <LocalNotificationBanner notification={notification} /> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - <FormUI handler={handler} design={theForm.config} /> + <FormUI model={handler} design={theForm.config} /> </div> <div class="mt-6 flex items-center justify-end gap-x-6"> diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx @@ -62,9 +62,18 @@ export interface UIFormProps<ValType> { } export type UIFieldHandler<T = any> = { + /** + * Name of the field that this handler is responsible for. + */ name: string; + + /** + * Current value of the field. + */ value: T | undefined; + onChange: (s: T | undefined) => void; + error?: TranslatedString; /** diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx @@ -53,7 +53,7 @@ function ArrayForm({ <div class="grid grid-cols-1 gap-y-8 "> <SingleColumnFormSectionUI fields={fields} - handler={form.handler} + model={form.model} name={name} /> </div> diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -2,7 +2,7 @@ import { h as create, Fragment, h, VNode } from "preact"; import { ErrorAndLabel, FormErrors, - FormFieldStateMap, + FormModel, useForm, } from "../hooks/useForm.js"; // import { getConverterById, useTranslationContext } from "../index.browser.js"; @@ -27,14 +27,14 @@ export function DefaultForm<T>({ design: FormDesign; initial: object; }): VNode { - const { handler, status } = useForm(design, initial); + const { model: handler, status } = useForm(design, initial); const [shorten, setShorten] = useState(true); return ( <div> <hr class="mt-3 mb-3" /> - <FormUI design={design} handler={handler} /> + <FormUI design={design} model={handler} /> <hr class="mt-3 mb-3" /> <label> <input @@ -96,12 +96,12 @@ export const DEFAULT_FORM_UI_NAME = "form-ui"; export function FormUI<T>({ name = DEFAULT_FORM_UI_NAME, design, - handler, + model, focus, }: { name?: string; design: FormDesign; - handler: FormFieldStateMap; + model: FormModel; focus?: boolean; }): VNode { switch (design.type) { @@ -114,7 +114,7 @@ export function FormUI<T>({ key={i} name={name} section={section} - handler={handler} + model={model} focus={focus} /> ); @@ -135,7 +135,7 @@ export function FormUI<T>({ <SingleColumnFormSectionUI name={name} fields={design.fields} - handler={handler} + model={model} focus={focus} /> ); @@ -148,11 +148,11 @@ export function DoubleColumnFormSectionUI<T>({ section, name, focus, - handler, + model: model, }: { sectionKey: string; name: string; - handler: FormFieldStateMap; + model: FormModel; section: DoubleColumnFormSection; focus?: boolean; }): VNode { @@ -161,10 +161,8 @@ export function DoubleColumnFormSectionUI<T>({ i18n, sectionKey, section.fields, - handler, + model, ); - console.log(`sectionKey`, sectionKey); - console.log(`hiddenSections`, handler.hiddenSections); const allHidden = fs.every((v) => { // FIXME: Handler should probably be present for all Form UI fields, not just some. if ("handler" in v.properties) { @@ -172,7 +170,7 @@ export function DoubleColumnFormSectionUI<T>({ } return false; }); - const sectionHidden = handler.hiddenSections.has(sectionKey); + const sectionHidden = model.isSectionHidden(sectionKey); if (allHidden || sectionHidden) { return ( <RenderAllFieldsByUiConfig @@ -212,11 +210,11 @@ export function DoubleColumnFormSectionUI<T>({ export function SingleColumnFormSectionUI<T>({ fields, name, - handler, + model: model, focus, }: { name: string; - handler: FormFieldStateMap; + model: FormModel; fields: UIFormElementConfig[]; focus?: boolean; }): VNode { @@ -229,7 +227,7 @@ export function SingleColumnFormSectionUI<T>({ <div class="p-3"> <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> <RenderAllFieldsByUiConfig - fields={convertFormConfigToUiField(i18n, `root`, fields, handler)} + fields={convertFormConfigToUiField(i18n, `root`, fields, model)} focus={focus} /> </div> diff --git a/packages/web-util/src/forms/forms-utils.ts b/packages/web-util/src/forms/forms-utils.ts @@ -6,7 +6,7 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { format, parse } from "date-fns"; -import { FormFieldStateMap } from "../hooks/useForm.js"; +import { FormModel } from "../hooks/useForm.js"; import { InternationalizationAPI, UIFieldElementDescription, @@ -21,14 +21,14 @@ import { UIFormElementConfig, UIFormFieldBaseConfig } from "./forms-types.js"; * * @param i18n_ * @param fieldConfig - * @param form + * @param formModel * @returns */ export function convertFormConfigToUiField( i18n_: InternationalizationAPI, parentKey: string | number, fieldConfig: UIFormElementConfig[], - form: FormFieldStateMap, + formModel: FormModel, ): UIFormField[] { const result = fieldConfig.map((config, fieldIndex) => { if (config.type === "void") return undefined; @@ -84,7 +84,7 @@ export function convertFormConfigToUiField( i18n_, `${parentKey}.${fieldIndex}`, config.fields, - form, + formModel, ), }, }; @@ -92,7 +92,7 @@ export function convertFormConfigToUiField( } } const uiKey = `${parentKey}.${fieldIndex}`; - const handler = form.fieldHandlers[uiKey]; + const handler = formModel.getHandlerForUiField(uiKey); const name = handler.name; // FIXME: first computed prop, all should be computed const hidden = diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -31,12 +31,55 @@ import { } from "../index.browser.js"; /** - * Mapping from the key of a form field to the state of the form field. + * Underlying state model for the form UI. */ -export type FormFieldStateMap = { - fieldHandlers: { [x: string]: UIFieldHandler }; - hiddenSections: Set<string | number>; -}; +export interface FormModel { + /** + * 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. + * + * (In the future, this might be changed to return the only currently + * visible handler.) + */ + getHandlerForAttributeKey(attributeKey: string): UIFieldHandler; + + /** + * Check if a section of the form is hidden. + */ + isSectionHidden(sectionName: string): boolean; +} + +/** + * Implementation of {@link FormModel}. + */ +class FormModelImpl implements FormModel { + public fieldHandlers: { [x: string]: UIFieldHandler } = {}; + public hiddenSections: Set<string | number> = new Set(); + + 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; + } + } + throw Error(`no handler for attribute path ${attributeKey}`); + } + + isSectionHidden(sectionName: string): boolean { + return this.hiddenSections.has(sectionName); + } +} export type FormValues<T> = { [k in keyof T]: T[k] extends string ? string | undefined : FormValues<T[k]>; @@ -81,8 +124,11 @@ export type FormStatus<T> = errors: FormErrors<T>; }; +/** + * FIMXE: Consider renaming this to FormModel and folding the current FormModel into it. + */ export type FormState<T> = { - handler: FormFieldStateMap; + model: FormModel; status: FormStatus<T>; update: (f: FormValues<T>) => void; }; @@ -98,7 +144,7 @@ export function useForm<T>( const [formValue, formUpdateHandler] = useState<RecursivePartial<FormValues<T>>>(initialValue); - const { handler, result, errors } = constructFormHandler( + const { model, result, errors } = constructFormHandler( design, formValue, formUpdateHandler, @@ -112,7 +158,7 @@ export function useForm<T>( } as FormStatus<T>; return { - handler, + model, status, update: (f) => { formUpdateHandler(f as any); @@ -218,14 +264,11 @@ function constructFormHandler<T>( onValueChange: (d: RecursivePartial<FormValues<T>>) => void, i18n: InternationalizationAPI, ): { - handler: FormFieldStateMap; + model: FormModel; result: FormStatus<T>; errors: FormErrors<T> | undefined; } { - let handler: FormFieldStateMap = { - fieldHandlers: {}, - hiddenSections: new Set(), - }; + let model: FormModelImpl = new FormModelImpl(); let result = {} as FormStatus<T>; let errors: FormErrors<T> | undefined = undefined; @@ -274,7 +317,7 @@ function constructFormHandler<T>( result = setValueIntoPath(result, path, field.value) ?? {}; } - handler.fieldHandlers[handlerUiPath] = field; + model.fieldHandlers[handlerUiPath] = field; } switch (design.type) { @@ -282,7 +325,7 @@ function constructFormHandler<T>( design.sections.forEach((sec, secIndex) => { const hidden = sec.hide && sec.hide(result); if (hidden) { - handler.hiddenSections.add(`${secIndex}`); + model.hiddenSections.add(`${secIndex}`); } sec.fields.forEach((f, fieldIndex) => createFieldHandler(f, hidden, `${secIndex}.${fieldIndex}`), @@ -301,5 +344,5 @@ function constructFormHandler<T>( } } - return { handler, result, errors }; + return { model, result, errors }; }