taler-typescript-core

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

commit bf03157b6570af6804032e206a3c60a3c7e030f3
parent 35fee72ef3d75b7a9681353ab7a1ca5bacff150e
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon,  6 May 2024 12:47:45 -0300

add required validation

Diffstat:
Mpackages/aml-backoffice-ui/src/forms.json | 336+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/aml-backoffice-ui/src/hooks/form.ts | 291++++++++++++++++++++-----------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/CaseUpdate.tsx | 51++++++++++++++++++++++++++++++++-------------------
Mpackages/aml-backoffice-ui/src/pages/Cases.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/CreateAccount.tsx | 3++-
Dpackages/aml-backoffice-ui/src/utils/converter.ts | 119-------------------------------------------------------------------------------
Mpackages/web-util/src/forms/Group.tsx | 11++++++++---
Apackages/web-util/src/forms/converter.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/forms.ts | 229++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mpackages/web-util/src/forms/index.ts | 1+
Mpackages/web-util/src/forms/ui-form.ts | 2++
11 files changed, 642 insertions(+), 522 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/forms.json b/packages/aml-backoffice-ui/src/forms.json @@ -17,6 +17,7 @@ "name": "customerType", "id": ".customerType", "label": "Type of customer", + "help": "Select one and complete the next form", "required": true, "choices": [ { @@ -47,173 +48,186 @@ "label": "Full name", "required": true } - } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.address", + "id": ".naturalCustomer.address", + "label": "Residential address", + "required": true + } + }, + { + "type": "integer", + "properties": { + "name": "naturalCustomer.telephone", + "id": ".naturalCustomer.telephone", + "label": "Telephone" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.email", + "id": ".naturalCustomer.email", + "label": "E-mail" + } + }, + { + "type": "absoluteTime", + "properties": { + "pattern": "dd/MM/yyyy", + "name": "naturalCustomer.dateOfBirth", + "id": ".naturalCustomer.dateOfBirth", + "label": "Date of birth", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.nationality", + "id": ".naturalCustomer.nationality", + "label": "Nationality", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.document", + "id": ".naturalCustomer.document", + "label": "Identification document", + "required": true + } + }, + { + "type": "file", + "properties": { + "name": "naturalCustomer.documentAttachment", + "id": ".naturalCustomer.documentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".pdf", + "help": "PDF file with max size of 2 mega bytes" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.companyName", + "id": ".naturalCustomer.companyName", + "label": "Company name" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.office", + "id": ".naturalCustomer.office", + "label": "Registered office" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.companyDocument", + "id": ".naturalCustomer.companyDocument", + "label": "Company identification document" + } + }, + { + "type": "file", + "properties": { + "name": "naturalCustomer.companyDocumentAttachment", + "id": ".naturalCustomer.companyDocumentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".png", + "help": "PNG file with max size of 2 mega bytes" + } + } ] } }, + { - "type": "text", - "properties": { - "name": "naturalCustomer.address", - "id": ".naturalCustomer.address", - "label": "Residential address", - "required": true - } - }, - { - "type": "integer", - "properties": { - "name": "naturalCustomer.telephone", - "id": ".naturalCustomer.telephone", - "label": "Telephone" - } - }, - { - "type": "text", - "properties": { - "name": "naturalCustomer.email", - "id": ".naturalCustomer.email", - "label": "E-mail" - } - }, - { - "type": "absoluteTime", - "properties": { - "pattern": "dd/MM/yyyy", - "name": "naturalCustomer.dateOfBirth", - "id": ".naturalCustomer.dateOfBirth", - "label": "Date of birth", - "required": true - } - }, - { - "type": "text", - "properties": { - "name": "naturalCustomer.nationality", - "id": ".naturalCustomer.nationality", - "label": "Nationality", - "required": true - } - }, - { - "type": "text", - "properties": { - "name": "naturalCustomer.document", - "id": ".naturalCustomer.document", - "label": "Identification document", - "required": true - } - }, - { - "type": "file", - "properties": { - "name": "naturalCustomer.documentAttachment", - "id": ".naturalCustomer.documentAttachment", - "label": "Document attachment", - "required": true, - "maxBites": 2097152, - "accept": ".pdf", - "help": "PDF file with max size of 2 mega bytes" - } - }, - { - "type": "text", - "properties": { - "name": "naturalCustomer.companyName", - "id": ".naturalCustomer.companyName", - "label": "Company name" - } - }, - { - "type": "text", - "properties": { - "name": "naturalCustomer.office", - "id": ".naturalCustomer.office", - "label": "Registered office" - } - }, - { - "type": "text", - "properties": { - "name": "naturalCustomer.companyDocument", - "id": ".naturalCustomer.companyDocument", - "label": "Company identification document" - } - }, - { - "type": "file", - "properties": { - "name": "naturalCustomer.companyDocumentAttachment", - "id": ".naturalCustomer.companyDocumentAttachment", - "label": "Document attachment", - "required": true, - "maxBites": 2097152, - "accept": ".png", - "help": "PNG file with max size of 2 mega bytes" - } - }, - { - "type": "text", - "properties": { - "name": "legalCustomer.companyName", - "id": ".legalCustomer.companyName", - "label": "Company name", - "required": true - } - }, - { - "type": "text", - "properties": { - "name": "legalCustomer.domicile", - "id": ".legalCustomer.domicile", - "label": "Domicile", - "required": true - } - }, - { - "type": "text", - "properties": { - "name": "legalCustomer.contactPerson", - "id": ".legalCustomer.contactPerson", - "label": "Contact person" - } - }, - { - "type": "text", - "properties": { - "name": "legalCustomer.telephone", - "id": ".legalCustomer.telephone", - "label": "Telephone" - } - }, - { - "type": "text", - "properties": { - "name": "legalCustomer.email", - "id": ".legalCustomer.email", - "label": "E-mail" - } - }, - { - "type": "text", - "properties": { - "name": "legalCustomer.document", - "id": ".legalCustomer.document", - "label": "Identification document", - "help": "Not older than 12 month" - } - }, - { - "type": "file", + "type": "group", "properties": { - "name": "legalCustomer.documentAttachment", - "id": ".legalCustomer.documentAttachment", - "label": "Document attachment", - "required": true, - "maxBites": 2097152, - "accept": ".png", - "help": "PNG file with max size of 2 mega bytes" + "label": "Natural customer form", + "name": "algo", + "id": "algo", + "before": "a) Country risk (nationality)", + "after": "a) Country risk (nationality)", + "fields": [ + { + "type": "text", + "properties": { + "name": "legalCustomer.companyName", + "id": ".legalCustomer.companyName", + "label": "Company name", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.domicile", + "id": ".legalCustomer.domicile", + "label": "Domicile", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.contactPerson", + "id": ".legalCustomer.contactPerson", + "label": "Contact person" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.telephone", + "id": ".legalCustomer.telephone", + "label": "Telephone" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.email", + "id": ".legalCustomer.email", + "label": "E-mail" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.document", + "id": ".legalCustomer.document", + "label": "Identification document", + "help": "Not older than 12 month" + } + }, + { + "type": "file", + "properties": { + "name": "legalCustomer.documentAttachment", + "id": ".legalCustomer.documentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".png", + "help": "PNG file with max size of 2 mega bytes" + } + } + ] } } ] diff --git a/packages/aml-backoffice-ui/src/hooks/form.ts b/packages/aml-backoffice-ui/src/hooks/form.ts @@ -19,11 +19,14 @@ import { AmountJson, TalerExchangeApi, TranslatedString, - assertUnreachable, } from "@gnu-taler/taler-util"; -import { Addon, InternationalizationAPI, UIFieldBaseDescription, UIFieldHandler, UIFormField, UIFormFieldBaseConfig, UIFormFieldConfig, UIHandlerId } from "@gnu-taler/web-util/browser"; +import { + UIFieldHandler, + UIFormFieldConfig, + UIHandlerId, +} from "@gnu-taler/web-util/browser"; import { useState } from "preact/hooks"; -import { getConverterById } from "../utils/converter.js"; +import { undefinedIfEmpty } from "../pages/CreateAccount.js"; // export type UIField = { // value: string | undefined; @@ -61,10 +64,10 @@ export type FormErrors<T> = { : T[k] extends AmountJson ? TranslatedString : T[k] extends AbsoluteTime - ? TranslatedString - : T[k] extends TalerExchangeApi.AmlState ? TranslatedString - : FormErrors<T[k]>; + : T[k] extends TalerExchangeApi.AmlState + ? TranslatedString + : FormErrors<T[k]>; }; export type FormStatus<T> = @@ -85,17 +88,19 @@ function constructFormHandler<T>( updateForm: (d: RecursivePartial<FormValues<T>>) => void, errors: FormErrors<T> | undefined, ): FormHandler<T> { - const handler = shape.reduce((handleForm, fieldId) => { + const path = fieldId.split("."); - 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 currentValue = getValueDeeper<string>(form as any, path, undefined); + const currentError = getValueDeeper<TranslatedString>( + errors as any, + path, + undefined, + ); const field: UIFieldHandler = { error: currentError, value: currentValue, @@ -103,14 +108,12 @@ function constructFormHandler<T>( state: {}, //FIXME: add the state of the field (hidden, ) }; - return setValueDeeper(handleForm, path, field) - + return setValueDeeper(handleForm, path, field); }, {} as FormHandler<T>); return handler; } - /** * FIXME: Consider sending this to web-utils * @@ -135,7 +138,7 @@ export function useFormState<T>( interface Tree<T> extends Record<string, Tree<T> | T> {} -function getValueDeeper<T>( +export function getValueDeeper<T>( object: Tree<T> | undefined, names: string[], notFoundValue?: T, @@ -146,225 +149,79 @@ function getValueDeeper<T>( return getValueDeeper(object, rest, notFoundValue); } if (object === undefined) { - return notFoundValue + return notFoundValue; } return getValueDeeper(object[head] as Tree<T>, rest, notFoundValue); } -function getValueDeeper2( - object: Record<string, any>, - names: string[], -): UIFieldHandler { - if (names.length === 0) return object as UIFieldHandler; - const [head, ...rest] = names; - if (!head) { - return getValueDeeper2(object, rest); - } - if (object === undefined) { - throw Error("handler not found"); - } - return getValueDeeper2(object[head], rest); -} - - -function setValueDeeper(object: any, names: string[], value: any): any { +export function setValueDeeper(object: any, names: string[], value: any): any { if (names.length === 0) return value; const [head, ...rest] = names; if (!head) { return setValueDeeper(object, rest, value); } if (object === undefined) { - return { [head]: setValueDeeper({}, rest, value) }; + return undefinedIfEmpty({ [head]: setValueDeeper({}, rest, value) }); } - return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) }; -} - -function getAddonById(_id: string | undefined): Addon { - return undefined!; -} - - -function converInputFieldsProps( - form: FormHandler<unknown>, - p: UIFormFieldBaseConfig, -) { - return { - converter: getConverterById(p.converterId, p), - handler: getValueDeeper2(form, p.id.split(".")), - name: p.name, - required: p.required, - disabled: p.disabled, - help: p.help, - placeholder: p.placeholder, - tooltip: p.tooltip, - label: p.label as TranslatedString, - }; + return undefinedIfEmpty({ ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) }); } -function converBaseFieldsProps( - i18n_: InternationalizationAPI, - p: UIFieldBaseDescription, -) { - return { - after: getAddonById(p.addonAfterId), - before: getAddonById(p.addonBeforeId), - hidden: p.hidden, - name: p.name, - help: i18n_.str`${p.help}`, - label: i18n_.str`${p.label}`, - tooltip: i18n_.str`${p.tooltip}`, - }; -} - -export function convertUiField( - i18n_: InternationalizationAPI, - fieldConfig: UIFormFieldConfig[], - form: FormHandler<unknown>, -): UIFormField[] { - return fieldConfig.map((config) => { - // NON input fields - switch (config.type) { - case "caption": { - const resp: UIFormField = { - type: config.type, - properties: converBaseFieldsProps(i18n_, config.properties), - }; - return resp; - } - case "group": { - const resp: UIFormField = { - type: config.type, - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - fields: convertUiField(i18n_, config.properties.fields, form), - }, - }; - return resp; +export function getShapeFromFields( + fields: UIFormFieldConfig[], +): Array<UIHandlerId> { + const shape: Array<UIHandlerId> = []; + fields.forEach((field) => { + if ("id" in field.properties) { + // FIXME: this should be a validation when loading the form + // consistency check + if (shape.indexOf(field.properties.id) !== -1) { + throw Error(`already present: ${field.properties.id}`); } + shape.push(field.properties.id); + } else if (field.type === "group") { + Array.prototype.push.apply( + shape, + getShapeFromFields(field.properties.fields), + ); } - // Input Fields - switch (config.type) { - case "array": { - return { - type: "array", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - labelField: config.properties.labelFieldId, - fields: convertUiField(i18n_, config.properties.fields, form), - }, - } as UIFormField; - } - case "absoluteTime": { - return { - type: "absoluteTime", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; - } - case "amount": { - return { - type: "amount", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; - } - case "choiceHorizontal": { - return { - type: "choiceHorizontal", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - choices: config.properties.choices, - }, - } as UIFormField; - } - case "choiceStacked": { - return { - type: "choiceStacked", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - choices: config.properties.choices, - - }, - }as UIFormField; - } - case "file":{ - console.log("ASDASD", config.properties.accept) - return { - type: "file", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - accept: config.properties.accept, - maxBites: config.properties.maxBytes, - }, - } as UIFormField; - } - case "integer":{ - return { - type: "integer", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; - } - case "selectMultiple":{ - return { - type: "selectMultiple", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - choices: config.properties.choices, - }, - } as UIFormField; - } - case "selectOne": { - return { - type: "selectOne", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - choices: config.properties.choices, - }, - } as UIFormField; - } - case "text": { - return { - type: "text", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; - } - case "textArea": { - return { - type: "text", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; - } - case "toggle": { - return { - type: "toggle", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; + }); + return shape; +} + +export function getRequiredFields( + fields: UIFormFieldConfig[], +): Array<UIHandlerId> { + const shape: Array<UIHandlerId> = []; + fields.forEach((field) => { + if ("id" in field.properties) { + // FIXME: this should be a validation when loading the form + // consistency check + if (shape.indexOf(field.properties.id) !== -1) { + throw Error(`already present: ${field.properties.id}`); } - default: { - assertUnreachable(config); + if (!field.properties.required) { + return; } + shape.push(field.properties.id); + } else if (field.type === "group") { + Array.prototype.push.apply( + shape, + getRequiredFields(field.properties.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/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx @@ -29,17 +29,23 @@ import { LocalNotificationBanner, RenderAllFieldsByUiConfig, UIHandlerId, + convertUiField, + getConverterById, useExchangeApiContext, useLocalNotificationHandler, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { privatePages } from "../Routing.js"; -import { - useUiFormsContext -} from "../context/ui-forms.js"; +import { useUiFormsContext } from "../context/ui-forms.js"; import { preloadedForms } from "../forms/index.js"; -import { FormErrors, convertUiField, useFormState } from "../hooks/form.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"; @@ -101,25 +107,27 @@ export function CaseUpdate({ } const shape: Array<UIHandlerId> = []; + const requiredFields: Array<UIHandlerId> = []; + theForm.config.design.forEach((section) => { - section.fields.forEach((field) => { - if ("id" in field.properties) { - //FIXME: this should be a validation - if (shape.indexOf(field.properties.id) !== -1) { - throw Error(`already present: ${field.properties.id}`) - } - shape.push(field.properties.id); - } - }); + Array.prototype.push.apply(shape, getShapeFromFields(section.fields)); + Array.prototype.push.apply( + requiredFields, + getRequiredFields(section.fields), + ); }); const [form, state] = useFormState<FormType>(shape, initial, (st) => { - const errors = undefinedIfEmpty<FormErrors<FormType>>({ + 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, - comment: !st.comment ? i18n.str`required` : undefined, }); + + const errors = undefinedIfEmpty<FormErrors<FormType> | undefined>( + validateRequiredFields(partialErrors, st, requiredFields), + ); + if (errors === undefined) { return { status: "ok", @@ -127,6 +135,7 @@ export function CaseUpdate({ errors: undefined, }; } + return { status: "fail", result: st as any, @@ -136,6 +145,7 @@ export function CaseUpdate({ const validatedForm = state.status !== "ok" ? undefined : state.result; + console.log(state.errors); const submitHandler = validatedForm === undefined ? undefined @@ -180,7 +190,6 @@ export function CaseUpdate({ } }, ); - return ( <Fragment> <LocalNotificationBanner notification={notification} /> @@ -207,7 +216,12 @@ export function CaseUpdate({ <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, form)} + fields={convertUiField( + i18n, + section.fields, + form, + getConverterById, + )} /> </div> </div> @@ -269,4 +283,3 @@ export function SelectForm({ account }: { account: string }) { </div> ); } - diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -25,6 +25,7 @@ import { InputChoiceHorizontal, Loading, UIHandlerId, + amlStateConverter, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -35,7 +36,6 @@ import { privatePages } from "../Routing.js"; import { FormErrors, RecursivePartial, useFormState } from "../hooks/form.js"; import { undefinedIfEmpty } from "./CreateAccount.js"; import { Officer } from "./Officer.js"; -import { amlStateConverter } from "../utils/converter.js"; type FormType = { state: TalerExchangeApi.AmlState; diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx @@ -89,7 +89,8 @@ function createFormValidator( }; } -export function undefinedIfEmpty<T extends object>(obj: T): T | undefined { +export function undefinedIfEmpty<T extends object | undefined>(obj: T): T | undefined { + if (obj === undefined) return undefined; return Object.keys(obj).some( (k) => (obj as Record<string, T>)[k] !== undefined, ) diff --git a/packages/aml-backoffice-ui/src/utils/converter.ts b/packages/aml-backoffice-ui/src/utils/converter.ts @@ -1,119 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 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. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - 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, - Amounts, - TalerExchangeApi, -} from "@gnu-taler/taler-util"; -import { StringConverter } from "@gnu-taler/web-util/browser"; -import { format, parse } from "date-fns"; - -export const amlStateConverter = { - toStringUI: stringifyAmlState, - fromStringUI: parseAmlState, -}; - -function stringifyAmlState(s: TalerExchangeApi.AmlState | undefined): string { - if (s === undefined) return ""; - switch (s) { - case TalerExchangeApi.AmlState.normal: - return "normal"; - case TalerExchangeApi.AmlState.pending: - return "pending"; - case TalerExchangeApi.AmlState.frozen: - return "frozen"; - } -} - -function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { - switch (s) { - case "normal": - return TalerExchangeApi.AmlState.normal; - case "pending": - return TalerExchangeApi.AmlState.pending; - case "frozen": - return TalerExchangeApi.AmlState.frozen; - default: - throw Error(`unknown AML state: ${s}`); - } -} - -function amountConverter(config: any): StringConverter<AmountJson> { - const currency = config["currency"]; - if (!currency || typeof currency !== "string") { - throw Error(`amount converter needs a currency`); - } - return { - fromStringUI(v: string | undefined): AmountJson { - // FIXME: requires currency - return Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency); - }, - toStringUI(v: unknown): string { - return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); - }, - }; -} - -function absTimeConverter(config: any): StringConverter<AbsoluteTime> { - const pattern = config["pattern"]; - if (!pattern || typeof pattern !== "string") { - throw Error(`absTime converter needs a pattern`); - } - return { - fromStringUI(v: string | undefined): AbsoluteTime { - if (v === undefined) { - return AbsoluteTime.never(); - } - try { - const time = parse(v, pattern, new Date()); - return AbsoluteTime.fromMilliseconds(time.getTime()); - } catch(e) { - return AbsoluteTime.never(); - } - }, - toStringUI(v: unknown): string { - if (v === undefined) return ""; - const d = v as AbsoluteTime; - if (d.t_ms === "never") return "never"; - try { - return format(d.t_ms, pattern) - } catch (e) { - return "" - } - }, - }; -} - -export function getConverterById( - id: string | undefined, - config: unknown, -): StringConverter<unknown> { - if (id === "Taler.AbsoluteTime") { - // @ts-expect-error check this - return absTimeConverter(config); - } - if (id === "Taler.Amount") { - // @ts-expect-error check this - return amountConverter(config); - } - if (id === "TalerExchangeApi.AmlState") { - // @ts-expect-error check this - return amlStateConverter; - } - return undefined!; -} diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx @@ -1,8 +1,11 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; -import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; -import { Addon } from "./FormProvider.js"; +import { RenderAllFieldsByUiConfig, UIFormField, convertUiField } from "./forms.js"; +import { Addon, FormProvider } from "./FormProvider.js"; +import { useField } from "./useField.js"; +import { useTranslationContext } from "../index.browser.js"; +import { getConverterById } from "./converter.js"; interface Props { label: TranslatedString; @@ -32,7 +35,9 @@ export function Group({ </p> )} <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig fields={fields} /> + <RenderAllFieldsByUiConfig + fields={fields} + /> </div> </div> ); diff --git a/packages/web-util/src/forms/converter.ts b/packages/web-util/src/forms/converter.ts @@ -0,0 +1,119 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 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. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + 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, + Amounts, + TalerExchangeApi, +} from "@gnu-taler/taler-util"; +import { format, parse } from "date-fns"; +import { StringConverter } from "./FormProvider.js"; + +export const amlStateConverter = { + toStringUI: stringifyAmlState, + fromStringUI: parseAmlState, +}; + +function stringifyAmlState(s: TalerExchangeApi.AmlState | undefined): string { + if (s === undefined) return ""; + switch (s) { + case TalerExchangeApi.AmlState.normal: + return "normal"; + case TalerExchangeApi.AmlState.pending: + return "pending"; + case TalerExchangeApi.AmlState.frozen: + return "frozen"; + } +} + +function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { + switch (s) { + case "normal": + return TalerExchangeApi.AmlState.normal; + case "pending": + return TalerExchangeApi.AmlState.pending; + case "frozen": + return TalerExchangeApi.AmlState.frozen; + default: + throw Error(`unknown AML state: ${s}`); + } +} + +function amountConverter(config: any): StringConverter<AmountJson> { + const currency = config["currency"]; + if (!currency || typeof currency !== "string") { + throw Error(`amount converter needs a currency`); + } + return { + fromStringUI(v: string | undefined): AmountJson { + // FIXME: requires currency + return Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency); + }, + toStringUI(v: unknown): string { + return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); + }, + }; +} + +function absTimeConverter(config: any): StringConverter<AbsoluteTime> { + const pattern = config["pattern"]; + if (!pattern || typeof pattern !== "string") { + throw Error(`absTime converter needs a pattern`); + } + return { + fromStringUI(v: string | undefined): AbsoluteTime { + if (v === undefined) { + return AbsoluteTime.never(); + } + try { + const time = parse(v, pattern, new Date()); + return AbsoluteTime.fromMilliseconds(time.getTime()); + } catch(e) { + return AbsoluteTime.never(); + } + }, + toStringUI(v: unknown): string { + if (v === undefined) return ""; + const d = v as AbsoluteTime; + if (d.t_ms === "never") return "never"; + try { + return format(d.t_ms, pattern) + } catch (e) { + return "" + } + }, + }; +} + +export function getConverterById( + id: string | undefined, + config: unknown, +): StringConverter<unknown> { + if (id === "Taler.AbsoluteTime") { + // @ts-expect-error check this + return absTimeConverter(config); + } + if (id === "Taler.Amount") { + // @ts-expect-error check this + return amountConverter(config); + } + if (id === "TalerExchangeApi.AmlState") { + // @ts-expect-error check this + return amlStateConverter; + } + return undefined!; +} diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts @@ -13,7 +13,10 @@ import { InputSelectOne } from "./InputSelectOne.js"; import { InputText } from "./InputText.js"; import { InputTextArea } from "./InputTextArea.js"; import { InputToggle } from "./InputToggle.js"; - +import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; +import { InternationalizationAPI, UIFieldBaseDescription } from "../index.browser.js"; +import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; +import {UIFormFieldBaseConfig, UIFormFieldConfig} from "./ui-form.js"; /** * Constrain the type with the ui props */ @@ -142,3 +145,227 @@ export function RenderAllFieldsByUiConfig({ // InputChoiceHorizontal: res.InputChoiceHorizontal(), // }; // } + +/** + * convert field configuration to render function + * + * @param i18n_ + * @param fieldConfig + * @param form + * @returns + */ +export function convertUiField( + i18n_: InternationalizationAPI, + fieldConfig: UIFormFieldConfig[], + form: object, + getConverterById: GetConverterById, +): UIFormField[] { + return fieldConfig.map((config) => { + // NON input fields + switch (config.type) { + case "caption": { + const resp: UIFormField = { + type: config.type, + properties: converBaseFieldsProps(i18n_, config.properties), + }; + return resp; + } + case "group": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + fields: convertUiField(i18n_, config.properties.fields, form, getConverterById), + }, + }; + return resp; + } + } + // Input Fields + switch (config.type) { + case "array": { + return { + type: "array", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + labelField: config.properties.labelFieldId, + fields: convertUiField(i18n_, config.properties.fields, form, getConverterById), + }, + } as UIFormField; + } + case "absoluteTime": { + return { + type: "absoluteTime", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "amount": { + return { + type: "amount", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "choiceHorizontal": { + return { + type: "choiceHorizontal", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "choiceStacked": { + return { + type: "choiceStacked", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + choices: config.properties.choices, + + }, + }as UIFormField; + } + case "file":{ + return { + type: "file", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + accept: config.properties.accept, + maxBites: config.properties.maxBytes, + }, + } as UIFormField; + } + case "integer":{ + return { + type: "integer", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "selectMultiple":{ + return { + type: "selectMultiple", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "selectOne": { + return { + type: "selectOne", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "text": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "textArea": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + case "toggle": { + return { + type: "toggle", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties, getConverterById), + }, + } as UIFormField; + } + default: { + assertUnreachable(config); + } + } + }); +} + + + +function getAddonById(_id: string | undefined): Addon { + return undefined!; +} + + +type GetConverterById = ( + id: string | undefined, + config: unknown, +) => StringConverter<unknown>; + + +function converInputFieldsProps( + form: object, + p: UIFormFieldBaseConfig, + getConverterById: GetConverterById, +) { + return { + converter: getConverterById(p.converterId, p), + handler: getValueDeeper2(form, p.id.split(".")), + name: p.name, + required: p.required, + disabled: p.disabled, + help: p.help, + placeholder: p.placeholder, + tooltip: p.tooltip, + label: p.label as TranslatedString, + }; +} + +function converBaseFieldsProps( + i18n_: InternationalizationAPI, + p: UIFieldBaseDescription, +) { + return { + after: getAddonById(p.addonAfterId), + before: getAddonById(p.addonBeforeId), + hidden: p.hidden, + name: p.name, + help: i18n_.str`${p.help}`, + label: i18n_.str`${p.label}`, + tooltip: i18n_.str`${p.tooltip}`, + }; +} + +function getValueDeeper2( + object: Record<string, any>, + names: string[], +): UIFieldHandler { + if (names.length === 0) return object as UIFieldHandler; + const [head, ...rest] = names; + if (!head) { + return getValueDeeper2(object, rest); + } + if (object === undefined) { + throw Error("handler not found"); + } + return getValueDeeper2(object[head], rest); +} + + diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts @@ -20,5 +20,6 @@ export * from "./InputToggle.js" export * from "./TimePicker.js" export * from "./forms.js" export * from "./ui-form.js" +export * from "./converter.js" export * from "./useField.js" diff --git a/packages/web-util/src/forms/ui-form.ts b/packages/web-util/src/forms/ui-form.ts @@ -262,6 +262,7 @@ const codecForUIFormFieldArrayConfig = (): Codec< > => codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigArray["properties"]>() .property("labelFieldId", codecForUiFieldId()) + // eslint-disable-next-line @typescript-eslint/no-use-before-define .property("fields", codecForList(codecForUiFormField())) .build("UIFormFieldConfigArray.properties"); @@ -328,6 +329,7 @@ const codecForUIFormFieldWithFieldsConfig = (): Codec< codecForUIFormFieldBaseDescriptionTemplate< UIFormFieldConfigGroup["properties"] >() + // eslint-disable-next-line @typescript-eslint/no-use-before-define .property("fields", codecForList(codecForUiFormField())) .build("UIFormFieldConfigGroup.properties");