taler-typescript-core

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

commit 178ce8d6b8bdf5f3139d14c91058e2e54044bffd
parent 3b318663bd9e729a18cdf18ac10244f02bd42df2
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon,  9 Dec 2024 14:13:27 -0300

array input fixed

Diffstat:
Mpackages/kyc-ui/src/forms/accept-tos.ts | 2+-
Mpackages/kyc-ui/src/hooks/form.ts | 1-
Mpackages/kyc-ui/src/pages/FillForm.tsx | 72+++++++++++++++++++++++++++++++++++++++---------------------------------
Mpackages/web-util/src/forms/InputArray.tsx | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/web-util/src/forms/InputChoiceStacked.tsx | 3+--
Mpackages/web-util/src/forms/InputLine.tsx | 11+----------
Mpackages/web-util/src/forms/forms.ts | 15++++++++-------
Apackages/web-util/src/hooks/useForm.ts | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 450 insertions(+), 85 deletions(-)

diff --git a/packages/kyc-ui/src/forms/accept-tos.ts b/packages/kyc-ui/src/forms/accept-tos.ts @@ -37,7 +37,7 @@ export const acceptTos = (i18n: InternationalizationAPI, context?: any): DoubleC } : undefined, { type: "choiceHorizontal", - id: "asd" as UIHandlerId, + id: "ACCEPTED_TERMS_OF_SERVICE" as UIHandlerId, required: true, label: i18n.str`Do you accept terms of service`, choices: [ diff --git a/packages/kyc-ui/src/hooks/form.ts b/packages/kyc-ui/src/hooks/form.ts @@ -92,7 +92,6 @@ function constructFormHandler<T>( const path = fieldId.split("."); function updater(newValue: unknown) { - console.log("----",path, newValue) updateForm(setValueDeeper(form, path, newValue)); } diff --git a/packages/kyc-ui/src/pages/FillForm.tsx b/packages/kyc-ui/src/pages/FillForm.tsx @@ -45,6 +45,7 @@ import { } from "../hooks/form.js"; import { undefinedIfEmpty } from "./Start.js"; import { useUiFormsContext } from "../context/ui-forms.js"; +import { usePreferences } from "../context/preferences.js"; type Props = { token: AccessToken; @@ -78,6 +79,7 @@ export function FillForm({ const { config, lib } = useExchangeApiContext(); // const { forms } = useUiFormsContext(); const [notification, withErrorHandler] = useLocalNotificationHandler(); + const [preferences] = usePreferences(); const customForm = requirement.context && "form" in requirement.context @@ -183,44 +185,48 @@ 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"> - {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, - form, - getConverterById, - )} - /> - </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, + form, + getConverterById, + )} + /> </div> </div> </div> - ); - })} + </div> + ); + })} </div> - <pre>{JSON.stringify(state.result, undefined, 2)}</pre> + {preferences.showDebugInfo ? ( + <pre>{JSON.stringify(state.result, undefined, 2)}</pre> + ) : ( + <Fragment /> + )} <div class="mt-6 flex items-center justify-end gap-x-6"> <button onClick={onComplete} diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx @@ -1,11 +1,23 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { FormProvider, UIFormProps } from "./FormProvider.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { + convertUiField, + RenderAllFieldsByUiConfig, + UIFormField, +} from "./forms.js"; import { useField } from "./useField.js"; import { UIFormElementConfig, UIHandlerId } from "./ui-form.js"; +import { + FormErrors, + undefinedIfEmpty, + useFormState, + useFormStateFromConfig, + validateRequiredFields, +} from "../hooks/useForm.js"; +import { getConverterById, useTranslationContext } from "../index.browser.js"; function Option({ label, @@ -80,9 +92,30 @@ export function noHandlerPropsAndNoContextForField( ); } -function getShapeFromFields( - fields: UIFormElementConfig[], -): Array<UIHandlerId> { +// function getRequiredFields(fields: UIFormField[]): Array<UIHandlerId> { +// const shape: Array<UIHandlerId> = []; +// fields.forEach((field) => { +// if ("name" in field.properties) { +// // FIXME: this should be a validation when loading the form +// // consistency check +// if (shape.indexOf(field.properties.name) !== -1) { +// throw Error(`already present: ${field.properties.name}`); +// } +// if (!field.properties.required) { +// return; +// } +// shape.push(field.properties.name); +// } else if (field.type === "group") { +// Array.prototype.push.apply( +// shape, +// getRequiredFields(field.properties.fields), +// ); +// } +// }); +// return shape; +// } + +function getRequiredFields(fields: UIFormElementConfig[]): Array<UIHandlerId> { const shape: Array<UIHandlerId> = []; fields.forEach((field) => { if ("id" in field) { @@ -91,32 +124,66 @@ function getShapeFromFields( 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, - getShapeFromFields(field.fields), - ); + Array.prototype.push.apply(shape, getRequiredFields(field.fields)); } }); return shape; } +// function getShapeFromFields(fields: UIFormField[]): Array<UIHandlerId> { +// const shape: Array<UIHandlerId> = []; +// fields.forEach((field) => { +// if ("name" in field.properties) { +// // FIXME: this should be a validation when loading the form +// // consistency check +// if (shape.indexOf(field.properties.name) !== -1) { +// throw Error(`already present: ${field.properties.name}`); +// } +// shape.push(field.properties.name); +// } else if (field.type === "group") { +// Array.prototype.push.apply( +// shape, +// getShapeFromFields(field.properties.fields), +// ); +// } +// }); +// return shape; +// } + +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; +} + +type FormType = {}; + export function InputArray<T extends object, K extends keyof T>( props: { - fields: UIFormField[]; + fields: UIFormElementConfig[]; labelField: string; } & UIFormProps<T, K>, ): VNode { const { fields, labelField, name, label, required, tooltip } = props; - // const { value, onChange, state } = useField<T, K>(name); - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); - if (!props.handler && !fieldCtx) { - throw Error(""); - } + const { value, onChange, state } = - props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + props.handler ?? noHandlerPropsAndNoContextForField(props.name); const list = (value ?? []) as Array<Record<string, string | undefined>>; const [selectedIndex, setSelectedIndex] = useState<number | undefined>( @@ -125,15 +192,48 @@ export function InputArray<T extends object, K extends keyof T>( const selected = selectedIndex === undefined ? undefined : list[selectedIndex]; - const shape: Array<UIHandlerId> = []; - const requiredFields: Array<UIHandlerId> = []; + // const shape: Array<UIHandlerId> = []; + // const requiredFields: Array<UIHandlerId> = []; + // Array.prototype.push.apply(shape, getShapeFromFields(fields)); + // Array.prototype.push.apply(requiredFields, getRequiredFields(fields)); - Array.prototype.push.apply(shape, getShapeFromFields(fields)); - Array.prototype.push.apply( - requiredFields, - getRequiredFields(fields), - ); - + const [form, formState] = useFormStateFromConfig<FormType>( + fields, + selected ?? {}, + ); + // const [form, formState] = useFormState<FormType>( + // shape, + // selected ?? {}, + // (st) => { + // const partialErrors = undefinedIfEmpty<FormErrors<FormType>>({}); + + // const errors = undefinedIfEmpty<FormErrors<FormType> | undefined>( + // validateRequiredFields(partialErrors, st, requiredFields), + // ); + + // if (errors === undefined) { + // return { + // status: "ok", + // result: st as any, + // errors: undefined, + // }; + // } + + // return { + // status: "fail", + // result: st as any, + // errors, + // }; + // }, + // ); + + useEffect(() => { + if (selectedIndex === undefined) return; + const newValue = [...list]; + newValue.splice(selectedIndex, 1, formState.result); + onChange(newValue as any); + }, [formState.result, selectedIndex]); + const { i18n } = useTranslationContext(); return ( <div class="sm:col-span-6"> @@ -210,11 +310,13 @@ export function InputArray<T extends object, K extends keyof T>( // onChange(newValue as any); // }} // > - <div class="px-4 py-6"> - <div class="grid grid-cols-1 gap-y-8 "> - <RenderAllFieldsByUiConfig fields={fields} /> - </div> + <div class="px-4 py-6"> + <div class="grid grid-cols-1 gap-y-8 "> + <RenderAllFieldsByUiConfig + fields={convertUiField(i18n, fields, form, getConverterById)} + /> </div> + </div> // </FormProvider> )} {selectedIndex !== undefined && ( @@ -226,7 +328,7 @@ export function InputArray<T extends object, K extends keyof T>( }} class="block px-3 py-2 text-sm font-semibold leading-6 text-gray-900" > - Close + <i18n.Translate>Close</i18n.Translate> </button> <button @@ -240,7 +342,7 @@ export function InputArray<T extends object, K extends keyof T>( }} class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " > - Remove + <i18n.Translate>Remove</i18n.Translate> </button> </div> )} diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx @@ -30,9 +30,8 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( } = props; //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); const { value, onChange, state } = - props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + props.handler ?? noHandlerPropsAndNoContextForField(props.name); if (state.hidden) { return <Fragment />; diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx @@ -161,23 +161,14 @@ export function InputLine<T extends object, K extends keyof T>( props: { type: InputType } & UIFormProps<T, K>, ): VNode { const { name, placeholder, before, after, converter, type, disabled } = props; - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); + const { value, onChange, state, error } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); - // const [text, setText] = useState(""); const fromString: (s: string) => any = converter?.fromStringUI ?? defaultFromString; const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; - // useEffect(() => { - // const newValue = toString(value); - // if (newValue) { - // setText(newValue); - // } - // }, [value]); - if (state.hidden) return <div />; let clazz = diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts @@ -212,12 +212,13 @@ export function convertUiField( ...converBaseFieldsProps(i18n_, config), ...converInputFieldsProps(form, config, getConverterById), labelField: config.labelFieldId, - fields: convertUiField( - i18n_, - config.fields, - (form as any)[config.id].value ?? {}, - getConverterById, - ), + fields: config.fields, + // convertUiField( + // i18n_, + // config.fields, + // (form as any)[config.id].value ?? {}, + // getConverterById, + // ), }, } as UIFormField; } @@ -349,7 +350,7 @@ function converInputFieldsProps( getConverterById: GetConverterById, ) { const names = p.id.split("."); - console.log("NAMES", names, getValueDeeper2(form, names), form !== undefined) + console.log("NAMES", names, getValueDeeper2(form, names), form) return { converter: getConverterById(p.converterId, p), handler: getValueDeeper2(form, names), diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -0,0 +1,267 @@ +/* + 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, + TalerExchangeApi, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { + UIFieldHandler, + UIFormElementConfig, + UIHandlerId, +} from "@gnu-taler/web-util/browser"; +import { useState } from "preact/hooks"; + +export type FormHandler<T> = { + [k in keyof T]?: T[k] extends string + ? UIFieldHandler + : T[k] extends AmountJson + ? UIFieldHandler + : T[k] extends TalerExchangeApi.AmlState + ? UIFieldHandler + : FormHandler<T[k]>; +}; + +export type FormValues<T> = { + [k in keyof T]: T[k] extends string ? string | undefined : FormValues<T[k]>; +}; + +export type RecursivePartial<T> = { + [k in keyof T]?: T[k] extends string + ? string + : T[k] extends AmountJson + ? AmountJson + : T[k] extends TalerExchangeApi.AmlState + ? TalerExchangeApi.AmlState + : RecursivePartial<T[k]>; +}; + +export type FormErrors<T> = { + [k in keyof T]?: T[k] extends string + ? TranslatedString + : T[k] extends AmountJson + ? TranslatedString + : T[k] extends AbsoluteTime + ? TranslatedString + : T[k] extends TalerExchangeApi.AmlState + ? TranslatedString + : FormErrors<T[k]>; +}; + +export type FormStatus<T> = + | { + status: "ok"; + result: T; + errors: undefined; + } + | { + status: "fail"; + result: RecursivePartial<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; +} + +export function useFormStateFromConfig<T>( + fields: Array<UIFormElementConfig>, + defaultValue: RecursivePartial<FormValues<T>>, + check?: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, +): [FormHandler<T>, FormStatus<T>] { + const shape: Array<UIHandlerId> = []; + const requiredFields: Array<UIHandlerId> = []; + Array.prototype.push.apply(shape, getShapeFromFields(fields)); + Array.prototype.push.apply(requiredFields, getRequiredFields(fields)); + + const [form, updateForm] = + useState<RecursivePartial<FormValues<T>>>(defaultValue); + + function defaultCheckAllRequired(st: RecursivePartial<FormValues<T>>) { + const partialErrors = undefinedIfEmpty<FormErrors<T>>({}); + + const errors = undefinedIfEmpty<FormErrors<T> | undefined>( + validateRequiredFields(partialErrors, st, requiredFields), + ); + + if (errors !== undefined) { + return { + status: "fail" as const, + result: st as any, + errors, + }; + } + + return undefined; + } + // check required fields + const requiredCheckResult = requiredFields.length > 0 ? defaultCheckAllRequired(form) : undefined; + // verify if there is a custom check function and all required fields are ok + // if there no custom check return "ok" + const status = requiredCheckResult ?? (check ? check(form) : {status: "ok" as const, result: form as any, errors: undefined}) + const handler = constructFormHandler(shape, form, updateForm, requiredCheckResult?.errors); + + return [handler, status]; +} + +/** + * @deprecated use `useFormStateFromConfig` + * + * @param defaultValue + * @param check + * @returns + */ +export function useFormState<T>( + shape: Array<UIHandlerId>, + defaultValue: RecursivePartial<FormValues<T>>, + check: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, +): [FormHandler<T>, 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> {} + +export function getValueDeeper<T>( + object: Tree<T> | undefined, + names: string[], + notFoundValue?: T, +): T | undefined { + if (names.length === 0) return object as T; + const [head, ...rest] = names; + if (!head) { + return getValueDeeper(object, rest, notFoundValue); + } + if (object === undefined) { + return notFoundValue; + } + return getValueDeeper(object[head] as Tree<T>, rest, notFoundValue); +} + +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 undefinedIfEmpty({ [head]: setValueDeeper({}, rest, value) }); + } + return undefinedIfEmpty({ + ...object, + [head]: setValueDeeper(object[head] ?? {}, rest, value), + }); +} + +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, + ) + ? obj + : undefined; +} + +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; +}