taler-typescript-core

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

commit b1394a32c8245d05ef04e598705cd94ebaf68ccd
parent b1090b6b3d4b725e05da426b523578c1603e7c6f
Author: Sebastian <sebasjm@gmail.com>
Date:   Fri, 10 Jan 2025 15:42:46 -0300

use the new form implementation in aml

Diffstat:
Mpackages/aml-backoffice-ui/src/forms/simplest.ts | 6+++---
Mpackages/aml-backoffice-ui/src/hooks/form.ts | 204++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx | 1-
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 118+++++++++++++++++++++++++++++++------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/CaseUpdate.tsx | 166++++---------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/CreateAccount.tsx | 102++++++++++++++++++++++++++++---------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/Search.tsx | 209++++++++++++++++++++-----------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx | 59+++++++----------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/UnlockAccount.tsx | 37+++++++++++++++++++++----------------
Mpackages/web-util/src/forms/forms-ui.tsx | 5+++++
10 files changed, 275 insertions(+), 632 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts @@ -15,15 +15,15 @@ */ import type { - DoubleColumnForm, + DoubleColumnFormDesign, DoubleColumnFormSection, InternationalizationAPI, UIHandlerId, } from "@gnu-taler/web-util/browser"; -export const v1 = (i18n: InternationalizationAPI): DoubleColumnForm => ({ +export const v1 = (i18n: InternationalizationAPI): DoubleColumnFormDesign => ({ type: "double-column" as const, - design: [ + sections: [ { title: i18n.str`Simple form`, fields: [ diff --git a/packages/aml-backoffice-ui/src/hooks/form.ts b/packages/aml-backoffice-ui/src/hooks/form.ts @@ -82,59 +82,51 @@ export type FormStatus<T> = errors: FormErrors<T>; }; -function constructFormHandler<T>( - shape: Array<UIHandlerId>, - form: RecursivePartial<FormValues<T>>, - updateForm: (d: RecursivePartial<FormValues<T>>) => void, - errors: FormErrors<T> | undefined, -): FormHandler<T> { - const handler = shape.reduce((handleForm, fieldId) => { - const path = fieldId.split("."); - - function updater(newValue: unknown) { - updateForm(setValueDeeper(form, path, newValue)); - } - - const currentValue = getValueDeeper<string>(form as any, path, undefined); - const currentError = getValueDeeper<TranslatedString>( - errors as any, - path, - undefined, - ); - const field: UIFieldHandler = { - error: currentError, - value: currentValue, - onChange: updater, - state: {}, //FIXME: add the state of the field (hidden, ) - }; - - return setValueDeeper(handleForm, path, field); - }, {} as FormHandler<T>); - - return handler; -} - -/** - * FIXME: Consider sending this to web-utils - * - * - * @param defaultValue - * @param check - * @returns - */ -export function useFormState<T>( - shape: Array<UIHandlerId>, - defaultValue: RecursivePartial<FormValues<T>>, - check: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, -): { handler: FormHandler<T>; status: FormStatus<T> } { - const [form, updateForm] = - useState<RecursivePartial<FormValues<T>>>(defaultValue); - - const status = check(form); - const handler = constructFormHandler(shape, form, updateForm, status.errors); - - return { handler, status }; -} +// function constructFormHandler<T>( +// shape: Array<UIHandlerId>, +// form: RecursivePartial<FormValues<T>>, +// updateForm: (d: RecursivePartial<FormValues<T>>) => void, +// errors: FormErrors<T> | undefined, +// ): FormHandler<T> { +// const handler = shape.reduce((handleForm, fieldId) => { +// const path = fieldId.split("."); + +// function updater(newValue: unknown) { +// updateForm(setValueDeeper(form, path, newValue)); +// } + +// const currentValue = getValueDeeper<string>(form as any, path, undefined); +// const currentError = getValueDeeper<TranslatedString>( +// errors as any, +// path, +// undefined, +// ); +// const field: UIFieldHandler = { +// error: currentError, +// value: currentValue, +// onChange: updater, +// state: {}, //FIXME: add the state of the field (hidden, ) +// }; + +// return setValueDeeper(handleForm, path, field); +// }, {} as FormHandler<T>); + +// return handler; +// } + +// export function useFormState<T>( +// shape: Array<UIHandlerId>, +// defaultValue: RecursivePartial<FormValues<T>>, +// check: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, +// ): { handler: FormHandler<T>; status: FormStatus<T> } { +// const [form, updateForm] = +// useState<RecursivePartial<FormValues<T>>>(defaultValue); + +// const status = check(form); +// const handler = constructFormHandler(shape, form, updateForm, status.errors); + +// return { handler, status }; +// } interface Tree<T> extends Record<string, Tree<T> | T> {} @@ -169,56 +161,56 @@ export function setValueDeeper(object: any, names: string[], value: any): any { }); } -export function getShapeFromFields( - fields: UIFormElementConfig[], -): Array<UIHandlerId> { - const shape: Array<UIHandlerId> = []; - fields.forEach((field) => { - if ("id" in field) { - // FIXME: this should be a validation when loading the form - // consistency check - if (shape.indexOf(field.id) !== -1) { - throw Error(`already present: ${field.id}`); - } - shape.push(field.id); - } else if (field.type === "group") { - Array.prototype.push.apply(shape, getShapeFromFields(field.fields)); - } - }); - return shape; -} - -export function getRequiredFields( - fields: UIFormElementConfig[], -): Array<UIHandlerId> { - const shape: Array<UIHandlerId> = []; - fields.forEach((field) => { - if ("id" in field) { - // FIXME: this should be a validation when loading the form - // consistency check - if (shape.indexOf(field.id) !== -1) { - throw Error(`already present: ${field.id}`); - } - if (!field.required) { - return; - } - shape.push(field.id); - } else if (field.type === "group") { - Array.prototype.push.apply(shape, getRequiredFields(field.fields)); - } - }); - return shape; -} -export function validateRequiredFields<FormType>( - errors: FormErrors<FormType> | undefined, - form: object, - fields: Array<UIHandlerId>, -): FormErrors<FormType> | undefined { - let result: FormErrors<FormType> | undefined = errors; - fields.forEach((f) => { - const path = f.split("."); - const v = getValueDeeper(form as any, path); - result = setValueDeeper(result, path, !v ? "required" : undefined); - }); - return result; -} +// export function getShapeFromFields( +// fields: UIFormElementConfig[], +// ): Array<UIHandlerId> { +// const shape: Array<UIHandlerId> = []; +// fields.forEach((field) => { +// if ("id" in field) { +// // FIXME: this should be a validation when loading the form +// // consistency check +// if (shape.indexOf(field.id) !== -1) { +// throw Error(`already present: ${field.id}`); +// } +// shape.push(field.id); +// } else if (field.type === "group") { +// Array.prototype.push.apply(shape, getShapeFromFields(field.fields)); +// } +// }); +// return shape; +// } + +// export function getRequiredFields( +// fields: UIFormElementConfig[], +// ): Array<UIHandlerId> { +// const shape: Array<UIHandlerId> = []; +// fields.forEach((field) => { +// if ("id" in field) { +// // FIXME: this should be a validation when loading the form +// // consistency check +// if (shape.indexOf(field.id) !== -1) { +// throw Error(`already present: ${field.id}`); +// } +// if (!field.required) { +// return; +// } +// shape.push(field.id); +// } else if (field.type === "group") { +// Array.prototype.push.apply(shape, getRequiredFields(field.fields)); +// } +// }); +// return shape; +// } +// export function validateRequiredFields<FormType>( +// errors: FormErrors<FormType> | undefined, +// form: object, +// fields: Array<UIHandlerId>, +// ): FormErrors<FormType> | undefined { +// let result: FormErrors<FormType> | undefined = errors; +// fields.forEach((f) => { +// const path = f.split("."); +// const v = getValueDeeper(form as any, path); +// result = setValueDeeper(result, path, !v ? "required" : undefined); +// }); +// return result; +// } diff --git a/packages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx @@ -25,7 +25,6 @@ import { useCurrentDecisionRequest, } from "../hooks/decision-request.js"; import { ShowDecisionLimitInfo } from "./CaseDetails.js"; -import { useFormState } from "../hooks/form.js"; export type WizardSteps = | "rules" // define the limits diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -18,64 +18,55 @@ import { AmlDecisionRequest, AmountJson, Amounts, + assertUnreachable, + buildCodecForObject, Codec, + codecForNumber, + codecForString, + codecOptional, CurrencySpecification, HttpStatusCode, KycRule, - LegitimizationRuleSet, OperationFail, OperationOk, - PaytoString, TalerError, TalerErrorDetail, TalerExchangeApi, TranslatedString, - assertUnreachable, - buildCodecForObject, - codecForNumber, - codecForString, - codecOptional, } from "@gnu-taler/taler-util"; import { Attention, Button, - convertUiField, CopyButton, - DefaultForm, - FormConfiguration, + FormDesign, FormMetadata, + FormUI, getConverterById, InternationalizationAPI, Loading, LocalNotificationBanner, RenderAllFieldsByUiConfig, - ShowInputErrorLabel, Time, UIFormElementConfig, UIHandlerId, useExchangeApiContext, + useForm, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format, formatDuration, intervalToDuration } from "date-fns"; -import { Fragment, Ref, VNode, h } from "preact"; +import { Fragment, h, Ref, VNode } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useUiFormsContext } from "../context/ui-forms.js"; import { preloadedForms } from "../forms/index.js"; import { useAccountInformation, useServerMeasures } from "../hooks/account.js"; +import { DecisionRequest } from "../hooks/decision-request.js"; import { useAccountDecisions } from "../hooks/decisions.js"; -import { ShowConsolidated } from "./ShowConsolidated.js"; import { useOfficer } from "../hooks/officer.js"; -import { getShapeFromFields, useFormState } from "../hooks/form.js"; -import { privatePages } from "../Routing.js"; import { CurrentMeasureTable, MeasureInfo } from "./MeasuresTable.js"; import { Officer } from "./Officer.js"; -import { - AmlDecisionRequestWizard, - WizardSteps, -} from "./AmlDecisionRequestWizard.js"; -import { DecisionRequest } from "../hooks/decision-request.js"; +import { ShowConsolidated } from "./ShowConsolidated.js"; export type AmlEvent = | AmlFormEvent @@ -503,22 +494,25 @@ function SubmitNewDecision({ const { lib } = useExchangeApiContext(); const [notification, withErrorHandler] = useLocalNotificationHandler(); - const formDesign: UIFormElementConfig[] = [ - { - id: "justification" as UIHandlerId, - type: "textArea", - required: true, - label: i18n.str`Justification`, - }, - ]; + const formDesign: FormDesign = { + type: "single-column", + fields: [ + { + id: "justification" as UIHandlerId, + type: "textArea", + required: true, + label: i18n.str`Justification`, + }, + ], + }; if (decision.askInformation) { - formDesign.push({ + formDesign.fields.push({ type: "caption", label: i18n.str`Form definition`, help: i18n.str`The user will need to complete this form.`, }); - formDesign.push({ + formDesign.fields.push({ id: "fields" as UIHandlerId, type: "array", required: true, @@ -556,46 +550,35 @@ function SubmitNewDecision({ } const officer = useOfficer(); const session = officer.state === "ready" ? officer.account : undefined; - const decisionForm = useFormState<{ justification: string; fields: object }>( - getShapeFromFields(formDesign), + const decisionForm = useForm<{ justification: string; fields: object }>( + formDesign, { justification: "" }, - (d) => { - d.justification; - return { - status: "ok", - errors: undefined, - result: d as any, - }; - }, ); const customFields = decisionForm.status.result.fields as [ { name: string; type: string }, ]; - const customForm: FormConfiguration | undefined = !decisionForm.status.result - .fields - ? undefined - : { - type: "double-column", - design: [ - { - fields: customFields.map((f) => { - return { - id: f.name, - label: f.name, - type: f.type, - } as UIFormElementConfig; - }), - title: "Required information", - }, - ], - }; + // const customForm: FormDesign | undefined = !decisionForm.status.result.fields + // ? undefined + // : { + // type: "double-column", + // sections: [ + // { + // fields: customFields.map((f) => { + // return { + // id: f.name, + // label: f.name, + // type: f.type, + // } as UIFormElementConfig; + // }), + // title: "Required information", + // }, + // ], + // }; const submitHandler = - decisionForm === undefined || - !session || - (decision.askInformation && customForm === undefined) + decisionForm === undefined || !session || decision.askInformation //&& customForm === undefined) ? undefined : withErrorHandler( () => { @@ -613,7 +596,7 @@ function SubmitNewDecision({ ...decision.request.new_rules.custom_measures, askMoreInfo: { context: { - form: customForm, + // form: customForm, }, // check of type form, it will use the officer defined form check_name: "askContext", @@ -659,16 +642,7 @@ function SubmitNewDecision({ autoCapitalize="none" autoCorrect="off" > - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> - <RenderAllFieldsByUiConfig - fields={convertUiField( - i18n, - formDesign, - decisionForm.handler, - getConverterById, - )} - /> - </div> + <FormUI design={formDesign} handler={decisionForm.handler} /> <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 @@ -25,13 +25,11 @@ import { import { Button, FormMetadata, + FormUI, InternationalizationAPI, LocalNotificationBanner, - RenderAllFieldsByUiConfig, - UIHandlerId, - convertUiField, - getConverterById, useExchangeApiContext, + useForm, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -39,16 +37,8 @@ import { Fragment, VNode, h } from "preact"; import { privatePages } from "../Routing.js"; import { useUiFormsContext } from "../context/ui-forms.js"; import { preloadedForms } from "../forms/index.js"; -import { - FormErrors, - getRequiredFields, - getShapeFromFields, - useFormState, - validateRequiredFields, -} from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { Justification } from "./CaseDetails.js"; -import { undefinedIfEmpty } from "./CreateAccount.js"; import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; function searchForm( @@ -106,59 +96,10 @@ export function CaseUpdate({ return <div>form with id {formId} not found</div>; } - const shape: Array<UIHandlerId> = []; - const requiredFields: Array<UIHandlerId> = []; - - switch (theForm.config.type) { - case "double-column": { - theForm.config.design.forEach((section) => { - Array.prototype.push.apply(shape, getShapeFromFields(section.fields)); - Array.prototype.push.apply( - requiredFields, - getRequiredFields(section.fields), - ); - }); - break; - } - case "single-column": { - Array.prototype.push.apply( - shape, - getShapeFromFields(theForm.config.fields), - ); - Array.prototype.push.apply( - requiredFields, - getRequiredFields(theForm.config.fields), - ); - } - } - - const { handler, status } = useFormState<FormType>(shape, initial, (st) => { - const partialErrors = undefinedIfEmpty<FormErrors<FormType>>({ - state: st.state === undefined ? i18n.str`required` : undefined, - threshold: !st.threshold ? i18n.str`required` : undefined, - when: !st.when ? i18n.str`required` : undefined, - }); - - const errors = undefinedIfEmpty<FormErrors<FormType> | undefined>( - validateRequiredFields(partialErrors, st, requiredFields), - ); + const form = useForm<FormType>(theForm.config, initial); - if (errors === undefined) { - return { - status: "ok", - result: st as any, - errors: undefined, - }; - } - - return { - status: "fail", - result: st as any, - errors, - }; - }); - - const validatedForm = status.status !== "ok" ? undefined : status.result; + const validatedForm = + form.status.status !== "ok" ? undefined : form.status.result; const submitHandler = validatedForm === undefined @@ -210,106 +151,13 @@ export function CaseUpdate({ } }, ); + return ( <Fragment> <LocalNotificationBanner notification={notification} /> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - {(function () { - switch (theForm.config.type) { - case "double-column": { - return theForm.config.design.map((section, i) => { - if (!section) return <Fragment />; - return ( - <div - key={i} - class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3" - > - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - {section.title} - </h2> - {section.description && ( - <p class="mt-1 text-sm leading-6 text-gray-600"> - {section.description} - </p> - )} - </div> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> - <div class="p-3"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig - key={i} - fields={convertUiField( - i18n, - section.fields, - handler, - getConverterById, - )} - /> - </div> - </div> - </div> - </div> - ); - }); - } - case "single-column": { - return ( - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> - <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={convertUiField( - i18n, - theForm.config.fields, - handler, - getConverterById, - )} - /> - </div> - </div> - </div> - ); - } - } - })()} + <FormUI design={theForm.config} handler={form} /> </div> - {/* {theForm.config.design.map((section, i) => { - if (!section) return <Fragment />; - return ( - <div - key={i} - class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3" - > - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - {section.title} - </h2> - {section.description && ( - <p class="mt-1 text-sm leading-6 text-gray-600"> - {section.description} - </p> - )} - </div> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> - <div class="p-3"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig - key={i} - fields={convertUiField( - i18n, - section.fields, - handler, - getConverterById, - )} - /> - </div> - </div> - </div> - </div> - ); - })} - </div> */} <div class="mt-6 flex items-center justify-end gap-x-6"> <a diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx @@ -15,10 +15,13 @@ */ import { Button, + FormDesign, + FormUI, InputLine, InternationalizationAPI, LocalNotificationBanner, UIHandlerId, + useForm, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -28,7 +31,6 @@ import { FormStatus, FormValues, RecursivePartial, - useFormState, } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { usePreferences } from "../hooks/preferences.js"; @@ -43,8 +45,8 @@ function createFormValidator( ) { return function check( state: RecursivePartial<FormValues<FormType>>, - ): FormStatus<FormType> { - const errors = undefinedIfEmpty<FormErrors<FormType>>({ + ): FormErrors<FormType> | undefined { + return undefinedIfEmpty<FormErrors<FormType>>({ password: !state.password ? i18n.str`required` : allowInsecurePassword @@ -65,27 +67,6 @@ function createFormValidator( ? i18n.str`doesn't match` : undefined, }); - - if (errors === undefined) { - const result: FormType = { - password: state.password!, - repeat: state.repeat!, - }; - return { - status: "ok", - result, - errors, - }; - } - const result: RecursivePartial<FormType> = { - password: state.password, - repeat: state.repeat, - }; - return { - status: "fail", - result, - errors, - }; }; } @@ -100,6 +81,24 @@ export function undefinedIfEmpty<T extends object | undefined>( : undefined; } +const createAccountForm = (i18n: InternationalizationAPI): FormDesign => ({ + type: "single-column", + fields: [ + { + id: "password" as UIHandlerId, + type: "text", + label: i18n.str`Password`, + required: true, + }, + { + id: "repeat" as UIHandlerId, + type: "text", + label: i18n.str`Repeat password`, + required: true, + }, + ], +}); + export function CreateAccount(): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); @@ -107,8 +106,10 @@ export function CreateAccount(): VNode { const [notification, withErrorHandler] = useLocalNotificationHandler(); - const { handler, status } = useFormState<FormType>( - [".password", ".repeat"] as Array<UIHandlerId>, + const design = createAccountForm(i18n); + + const { handler, status } = useForm<FormType>( + design, { password: undefined, repeat: undefined, @@ -134,47 +135,16 @@ export function CreateAccount(): VNode { </div> <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> - <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12"> - <form - class="space-y-6" - noValidate - onSubmit={(e) => { - e.preventDefault(); - }} - autoCapitalize="none" - autoCorrect="off" + <FormUI design={design} handler={handler} /> + <div class="mt-8"> + <Button + type="submit" + disabled={!createAccountHandler} + class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + handler={createAccountHandler} > - <div class="mt-2"> - <InputLine<FormType, "password"> - label={i18n.str`Password`} - name="password" - type="password" - required - handler={handler.password} - /> - </div> - - <div class="mt-2"> - <InputLine<FormType, "repeat"> - label={i18n.str`Repeat password`} - name="repeat" - type="password" - required - handler={handler.repeat} - /> - </div> - - <div class="mt-8"> - <Button - type="submit" - disabled={!createAccountHandler} - class="disabled:opacity-50 disabled:cursor-default flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - handler={createAccountHandler} - > - <i18n.Translate>Create</i18n.Translate> - </Button> - </div> - </form> + <i18n.Translate>Create</i18n.Translate> + </Button> </div> </div> </div> diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx @@ -29,8 +29,9 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - convertUiField, encodeCrockForURI, + FormDesign, + FormUI, getConverterById, InternationalizationAPI, Loading, @@ -39,6 +40,7 @@ import { UIFormElementConfig, UIHandlerId, useExchangeApiContext, + useForm, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -49,9 +51,7 @@ import { FormErrors, FormStatus, FormValues, - getShapeFromFields, RecursivePartial, - useFormState, } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { privatePages } from "../Routing.js"; @@ -65,8 +65,12 @@ export function Search() { const [paytoUri, setPayto] = useState<PaytoUri | undefined>(undefined); - const paytoForm = useFormState( - getShapeFromFields(paytoTypeField(i18n)), + const design: FormDesign = { + type: "single-column", + fields: paytoTypeField(i18n), + }; + const paytoForm = useForm<FormPayto>( + design, { paytoType: "iban" }, createFormValidator(i18n), ); @@ -89,16 +93,7 @@ export function Search() { autoCapitalize="none" autoCorrect="off" > - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> - <RenderAllFieldsByUiConfig - fields={convertUiField( - i18n, - paytoTypeField(i18n), - paytoForm.handler, - getConverterById, - )} - /> - </div> + <FormUI design={design} handler={paytoForm.handler} /> </form> {(function () { @@ -310,9 +305,12 @@ function XTalerBankForm({ onSearch: (p: PaytoUri | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); - const fields = talerBankFields(i18n); - const form = useFormState( - getShapeFromFields(fields), + const design: FormDesign = { + type: "single-column", + fields: talerBankFields(i18n), + }; + const form = useForm<PaytoUriTalerBankForm>( + design, {}, createTalerBankPaytoValidator(i18n), ); @@ -335,11 +333,8 @@ function XTalerBankForm({ autoCapitalize="none" autoCorrect="off" > - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> - <RenderAllFieldsByUiConfig - fields={convertUiField(i18n, fields, form.handler, getConverterById)} - /> - </div> + <FormUI design={design} handler={form.handler} /> + <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" @@ -356,9 +351,12 @@ function IbanForm({ onSearch: (p: PaytoUri | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); - const fields = ibanFields(i18n); - const form = useFormState( - getShapeFromFields(fields), + const design: FormDesign = { + type: "single-column", + fields: ibanFields(i18n), + }; + const form = useForm<PaytoUriIBANForm>( + design, {}, createIbanPaytoValidator(i18n), ); @@ -377,11 +375,8 @@ function IbanForm({ autoCapitalize="none" autoCorrect="off" > - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> - <RenderAllFieldsByUiConfig - fields={convertUiField(i18n, fields, form.handler, getConverterById)} - /> - </div> + <FormUI design={design} handler={form.handler} /> + <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" @@ -399,9 +394,12 @@ function WalletForm({ }): VNode { const { i18n } = useTranslationContext(); const { config } = useExchangeApiContext(); - const fields = walletFields(i18n); - const form = useFormState( - getShapeFromFields(fields), + const design: FormDesign = { + type: "single-column", + fields: walletFields(i18n), + }; + const form = useForm<PaytoUriTalerForm>( + design, { exchange: getURLHostnamePortPath(config.keys.base_url), }, @@ -426,11 +424,8 @@ function WalletForm({ autoCapitalize="none" autoCorrect="off" > - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> - <RenderAllFieldsByUiConfig - fields={convertUiField(i18n, fields, form.handler, getConverterById)} - /> - </div> + <FormUI design={design} handler={form.handler} /> + <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" @@ -448,9 +443,12 @@ function GenericForm({ onSearch: (p: PaytoUri | undefined) => void; }): VNode { const { i18n } = useTranslationContext(); - const fields = genericFields(i18n); - const form = useFormState( - getShapeFromFields(fields), + const design: FormDesign = { + type: "single-column", + fields: genericFields(i18n), + }; + const form = useForm<PaytoUriGenericForm>( + design, {}, createGenericPaytoValidator(i18n), ); @@ -468,17 +466,13 @@ function GenericForm({ autoCapitalize="none" autoCorrect="off" > - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> - <RenderAllFieldsByUiConfig - fields={convertUiField(i18n, fields, form.handler, getConverterById)} - /> - </div> + <FormUI design={design} handler={form.handler} /> <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" onClick={() => onSearch(paytoUri)} > - Search + <i18n.Translate>Search</i18n.Translate> </button> </form> ); @@ -491,29 +485,10 @@ interface FormPayto { function createFormValidator(i18n: InternationalizationAPI) { return function check( state: RecursivePartial<FormValues<FormPayto>>, - ): FormStatus<FormPayto> { - const errors = undefinedIfEmpty<FormErrors<FormPayto>>({ + ): FormErrors<FormPayto> | undefined { + return undefinedIfEmpty<FormErrors<FormPayto>>({ paytoType: !state?.paytoType ? i18n.str`required` : undefined, }); - - if (errors === undefined) { - const result: FormPayto = { - paytoType: state.paytoType! as any, - }; - return { - status: "ok", - result, - errors, - }; - } - const result: RecursivePartial<FormPayto> = { - paytoType: state?.paytoType, - }; - return { - status: "fail", - result, - errors, - }; }; } @@ -524,33 +499,14 @@ interface PaytoUriGenericForm { function createGenericPaytoValidator(i18n: InternationalizationAPI) { return function check( state: RecursivePartial<FormValues<PaytoUriGenericForm>>, - ): FormStatus<PaytoUriGenericForm> { - const errors = undefinedIfEmpty<FormErrors<PaytoUriGenericForm>>({ + ): FormErrors<PaytoUriGenericForm> | undefined { + return undefinedIfEmpty<FormErrors<PaytoUriGenericForm>>({ payto: !state.payto ? i18n.str`required` : parsePaytoUri(state.payto) === undefined ? i18n.str`invalid` : undefined, }); - - if (errors === undefined) { - const result: PaytoUriGenericForm = { - payto: state.payto! as any, - }; - return { - status: "ok", - result, - errors, - }; - } - const result: RecursivePartial<PaytoUriGenericForm> = { - // targetType: state.iban - }; - return { - status: "fail", - result, - errors, - }; }; } @@ -561,29 +517,10 @@ interface PaytoUriIBANForm { function createIbanPaytoValidator(i18n: InternationalizationAPI) { return function check( state: RecursivePartial<FormValues<PaytoUriIBANForm>>, - ): FormStatus<PaytoUriIBANForm> { - const errors = undefinedIfEmpty<FormErrors<PaytoUriIBANForm>>({ + ): FormErrors<PaytoUriIBANForm> | undefined { + return undefinedIfEmpty<FormErrors<PaytoUriIBANForm>>({ account: !state.account ? i18n.str`required` : undefined, }); - - if (errors === undefined) { - const result: PaytoUriIBANForm = { - account: state.account!, - }; - return { - status: "ok", - result, - errors, - }; - } - const result: RecursivePartial<PaytoUriIBANForm> = { - account: state.account, - }; - return { - status: "fail", - result, - errors, - }; }; } interface PaytoUriTalerBankForm { @@ -593,32 +530,11 @@ interface PaytoUriTalerBankForm { function createTalerBankPaytoValidator(i18n: InternationalizationAPI) { return function check( state: RecursivePartial<FormValues<PaytoUriTalerBankForm>>, - ): FormStatus<PaytoUriTalerBankForm> { - const errors = undefinedIfEmpty<FormErrors<PaytoUriTalerBankForm>>({ + ): FormErrors<PaytoUriTalerBankForm> | undefined { + return undefinedIfEmpty<FormErrors<PaytoUriTalerBankForm>>({ account: !state.account ? i18n.str`required` : undefined, hostname: !state.hostname ? i18n.str`required` : undefined, }); - - if (errors === undefined) { - const result: PaytoUriTalerBankForm = { - account: state.account!, - hostname: state.hostname!, - }; - return { - status: "ok", - result, - errors, - }; - } - const result: RecursivePartial<PaytoUriTalerBankForm> = { - account: state.account, - hostname: state.hostname, - }; - return { - status: "fail", - result, - errors, - }; }; } @@ -629,8 +545,8 @@ interface PaytoUriTalerForm { function createTalerPaytoValidator(i18n: InternationalizationAPI) { return function check( state: RecursivePartial<FormValues<PaytoUriTalerForm>>, - ): FormStatus<PaytoUriTalerForm> { - const errors = undefinedIfEmpty<FormErrors<PaytoUriTalerForm>>({ + ): FormErrors<PaytoUriTalerForm> | undefined { + return undefinedIfEmpty<FormErrors<PaytoUriTalerForm>>({ exchange: !state.exchange ? i18n.str`required` : undefined, reservePub: !state.reservePub ? i18n.str`required` @@ -638,27 +554,6 @@ function createTalerPaytoValidator(i18n: InternationalizationAPI) { ? i18n.str`Should be 16 charaters` : undefined, }); - - if (errors === undefined) { - const result: PaytoUriTalerForm = { - exchange: state.exchange!, - reservePub: state.reservePub!, - }; - return { - status: "ok", - result, - errors, - }; - } - const result: RecursivePartial<PaytoUriTalerForm> = { - exchange: state.exchange, - reservePub: state.reservePub, - }; - return { - status: "fail", - result, - errors, - }; }; } diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx @@ -20,17 +20,17 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { - FormConfiguration, + FormDesign, + FormUI, RenderAllFieldsByUiConfig, UIFormElementConfig, UIHandlerId, - convertUiField, getConverterById, + useForm, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; -import { getShapeFromFields, useFormState } from "../hooks/form.js"; import { AmlEvent } from "./CaseDetails.js"; /** @@ -66,9 +66,9 @@ export function ShowConsolidated({ const fixed = fixProvidedInfo(cons.kyc); - const formConfig: FormConfiguration = { + const design: FormDesign = { type: "double-column", - design: + sections: Object.entries(fixed).length > 0 ? [ { @@ -98,55 +98,10 @@ export function ShowConsolidated({ ] : [], }; - const shape: Array<UIHandlerId> = formConfig.design.flatMap((field) => - getShapeFromFields(field.fields), - ); - const { handler } = useFormState<{}>(shape, fixed, (result) => { - return { status: "ok", errors: undefined, result }; - }); + const { handler } = useForm(design, fixed); - return ( - <Fragment> - <div class="space-y-10 divide-y divide-gray-900/10"> - {formConfig.design.map((section, i) => { - if (!section) return <Fragment />; - return ( - <div - key={i} - class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3" - > - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - {section.title} - </h2> - {section.description && ( - <p class="mt-1 text-sm leading-6 text-gray-600"> - {section.description} - </p> - )} - </div> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> - <div class="p-3"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig - key={i} - fields={convertUiField( - i18n, - section.fields, - handler, - getConverterById, - )} - /> - </div> - </div> - </div> - </div> - ); - })} - </div> - </Fragment> - ); + return <FormUI design={design} handler={handler} />; } interface Consolidated { diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx @@ -15,14 +15,17 @@ */ import { Button, + FormDesign, InputLine, + InternationalizationAPI, LocalNotificationBanner, UIHandlerId, + useForm, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { FormErrors, useFormState } from "../hooks/form.js"; +import { FormErrors } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { undefinedIfEmpty } from "./CreateAccount.js"; @@ -30,33 +33,35 @@ type FormType = { password: string; }; +const unlockAccountForm = (i18n: InternationalizationAPI): FormDesign => ({ + type: "single-column", + fields: [ + { + id: "password" as UIHandlerId, + type: "text", + label: i18n.str`Password`, + required: true, + }, + ], +}); + export function UnlockAccount(): VNode { const { i18n } = useTranslationContext(); const officer = useOfficer(); const [notification, withErrorHandler] = useLocalNotificationHandler(); - const { handler, status } = useFormState<FormType>( - [".password"] as Array<UIHandlerId>, + const design = unlockAccountForm(i18n); + + const { handler, status } = useForm<FormType>( + design, { password: undefined, }, (state) => { - const errors = undefinedIfEmpty<FormErrors<FormType>>({ + return undefinedIfEmpty<FormErrors<FormType>>({ password: !state.password ? i18n.str`required` : undefined, }); - if (errors === undefined) { - return { - status: "ok", - result: state as FormType, - errors, - }; - } - return { - status: "fail", - result: state, - errors, - }; }, ); diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -33,6 +33,11 @@ export function DefaultForm<T>({ ); } +/** + * FIXME: formDesign should be embedded in formHandler + * @param param0 + * @returns + */ export function FormUI<T>({ design, handler,