taler-typescript-core

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

commit e9e12687488be1350f708daa7dc3f51bc4907df2
parent b5217432044d03224f905d5894058297fd354d8b
Author: Sebastian <sebasjm@gmail.com>
Date:   Wed,  5 Feb 2025 12:10:16 -0300

errors summary

Diffstat:
Mpackages/web-util/src/forms/FormProvider.tsx | 2++
Mpackages/web-util/src/forms/fields/InputArray.tsx | 9++++++++-
Mpackages/web-util/src/forms/fields/InputChoiceHorizontal.tsx | 1+
Mpackages/web-util/src/forms/fields/InputChoiceStacked.tsx | 1+
Mpackages/web-util/src/forms/fields/InputFile.tsx | 1+
Mpackages/web-util/src/forms/fields/InputLine.tsx | 6+++++-
Mpackages/web-util/src/forms/fields/InputSelectMultiple.tsx | 1+
Mpackages/web-util/src/forms/fields/InputSelectOne.tsx | 1+
Mpackages/web-util/src/forms/fields/InputToggle.tsx | 1+
Mpackages/web-util/src/forms/forms-ui.tsx | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mpackages/web-util/src/forms/gana/GLS_Onboarding.ts | 6+++---
Mpackages/web-util/src/hooks/useForm.ts | 90+++++++++++++++++++++++++++++++++++--------------------------------------------
12 files changed, 219 insertions(+), 67 deletions(-)

diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx @@ -41,6 +41,8 @@ export type FormState<T extends object | undefined> = { /** * Properties that can be defined by design or by computing state + * + * @deprecated */ export type FieldUIOptions = { /* instruction to be shown in the field */ diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx @@ -30,12 +30,14 @@ function ArrayForm({ onClose, onRemove, onConfirm, + name, }: { fields: UIFormElementConfig[]; selected: Record<string, string | undefined> | undefined; onClose: () => void; onRemove: () => void; onConfirm: (r: RecursivePartial<FormType>) => void; + name: string; }): VNode { const { i18n } = useTranslationContext(); const form = useForm<FormType>( @@ -49,7 +51,11 @@ function ArrayForm({ return ( <div class="px-4 py-6"> <div class="grid grid-cols-1 gap-y-8 "> - <SingleColumnFormSectionUI fields={fields} handler={form.handler} /> + <SingleColumnFormSectionUI + fields={fields} + handler={form.handler} + name={name} + /> </div> {/* <pre>{JSON.stringify(form.status, undefined, 2)}</pre> */} @@ -165,6 +171,7 @@ export function InputArray<T extends object, K extends keyof T>( </div> {selectedIndex !== undefined && ( <ArrayForm + name={props.name as string} fields={fields} onRemove={() => { const newValue = [...list]; diff --git a/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx @@ -27,6 +27,7 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( label={label} required={required} tooltip={tooltip} + name={props.name as string} /> <fieldset class="mt-2"> <div class="isolate inline-flex rounded-md shadow-sm"> diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.tsx @@ -41,6 +41,7 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( label={label} required={required} tooltip={tooltip} + name={props.name as string} /> <fieldset class="mt-2"> <div class="space-y-4"> diff --git a/packages/web-util/src/forms/fields/InputFile.tsx b/packages/web-util/src/forms/fields/InputFile.tsx @@ -34,6 +34,7 @@ export function InputFile<T extends object, K extends keyof T>( label={label} tooltip={tooltip} required={required} + name={props.name as string} /> {!dataUri ? ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx @@ -25,16 +25,18 @@ export function LabelWithTooltipMaybeRequired({ label, required, tooltip, + name, }: { label: TranslatedString; required?: boolean; tooltip?: TranslatedString; + name?: string; }): VNode { const Label = ( <Fragment> <div class="flex justify-between"> <label - htmlFor="email" + for={name} class="block text-sm font-medium leading-6 text-gray-900" > {label} @@ -120,6 +122,7 @@ export function InputWrapper<T extends object, K extends keyof T>({ error, disabled, required, + name, }: { error?: string; disabled: boolean; @@ -131,6 +134,7 @@ export function InputWrapper<T extends object, K extends keyof T>({ label={label} required={required} tooltip={tooltip} + name={name as string} /> <div class="relative mt-2 flex rounded-md shadow-sm"> {before && <RenderAddon disabled={disabled} addon={before} />} diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx @@ -52,6 +52,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( label={label} required={required} tooltip={tooltip} + name={props.name as string} /> {list.map((v, idx) => { return ( diff --git a/packages/web-util/src/forms/fields/InputSelectOne.tsx b/packages/web-util/src/forms/fields/InputSelectOne.tsx @@ -36,6 +36,7 @@ export function InputSelectOne<T extends object, K extends keyof T>( label={label} required={required} tooltip={tooltip} + name={props.name as string} /> {value ? ( <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 font-medium text-gray-600"> diff --git a/packages/web-util/src/forms/fields/InputToggle.tsx b/packages/web-util/src/forms/fields/InputToggle.tsx @@ -31,6 +31,7 @@ export function InputToggle<T extends object, K extends keyof T>( label={label} required={required} tooltip={tooltip} + name={props.name as string} /> <button type="button" diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -1,5 +1,10 @@ import { Fragment, h, h as create, VNode } from "preact"; -import { FormHandler, useForm } from "../hooks/useForm.js"; +import { + ErrorAndLabel, + FormErrors, + FormHandler, + useForm, +} from "../hooks/useForm.js"; // import { getConverterById, useTranslationContext } from "../index.browser.js"; import { convertFormConfigToUiField } from "./forms-utils.js"; import { @@ -13,6 +18,7 @@ import { UIFormField, } from "./field-types.js"; import { useTranslationContext } from "../index.browser.js"; +import { useState } from "preact/hooks"; export function DefaultForm<T>({ design, @@ -31,27 +37,26 @@ export function DefaultForm<T>({ {JSON.stringify(status.result ?? {}, undefined, 2)} </pre> ) : ( - <Fragment> - <h1>form validation </h1> - <pre class="break-all whitespace-pre-wrap bg-red-200 border border-red-500 w-max p-4"> - {JSON.stringify(status.errors, undefined, 2)} - </pre> - </Fragment> + <ErrorsSummary errors={status.errors} /> )} </div> ); } +export const DEFAULT_FORM_UI_NAME = "form-ui"; + /** * FIXME: formDesign should be embedded in formHandler * @param param0 * @returns */ export function FormUI<T>({ + name = DEFAULT_FORM_UI_NAME, design, handler, focus, }: { + name?: string; design: FormDesign; handler: FormHandler<T>; focus?: boolean; @@ -62,6 +67,7 @@ export function FormUI<T>({ if (!section) return <Fragment />; return ( <DoubleColumnFormSectionUI + name={name} section={section} handler={handler} focus={focus} @@ -73,6 +79,7 @@ export function FormUI<T>({ case "single-column": { return ( <SingleColumnFormSectionUI + name={name} fields={design.fields} handler={handler} focus={focus} @@ -84,16 +91,21 @@ export function FormUI<T>({ export function DoubleColumnFormSectionUI<T>({ section, + name, focus, handler, }: { + name: string; handler: FormHandler<T>; section: DoubleColumnFormSection; focus?: boolean; }): VNode { const { i18n } = useTranslationContext(); return ( - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> + <form + name={name} + 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} @@ -114,21 +126,26 @@ export function DoubleColumnFormSectionUI<T>({ </div> </div> </div> - </div> + </form> ); } export function SingleColumnFormSectionUI<T>({ fields, + name, handler, focus, }: { + name: string; handler: FormHandler<T>; fields: UIFormElementConfig[]; focus?: boolean; }): VNode { const { i18n } = useTranslationContext(); return ( - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> + <form + name={name} + 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 @@ -137,7 +154,7 @@ export function SingleColumnFormSectionUI<T>({ /> </div> </div> - </div> + </form> ); } @@ -156,7 +173,133 @@ export function RenderAllFieldsByUiConfig({ field.type ] as FieldComponentFunction<any>; const p = { ...field.properties, focus: !!focus && i === 0 }; - return Component(p); + return <Component {...p} />; }), ); } + +function ErrorsSummary<T>({ + errors, + formName = DEFAULT_FORM_UI_NAME, + startOpen, + fixed, +}: { + errors: FormErrors<T>; + formName?: string; + startOpen?: boolean; + fixed?: boolean; +}): VNode { + const { i18n } = useTranslationContext(); + const [opened, setOpened] = useState(startOpen ?? false); + + function Header() { + return ( + <div + data-fixed={!!fixed} + class="p-2 relative bg-gray-200 flex justify-between data-[fixed=false]:cursor-pointer" + onClick={() => { + if (!fixed) { + setOpened((o) => !o); + } + }} + > + <div class="px-4 sm:px-0"> + <h3 class="text-base/7 font-semibold text-gray-900"> + <i18n.Translate>Errors summary</i18n.Translate> + </h3> + </div> + + <div class="flex shrink-0 items-center gap-x-4"> + <div class="flex rounded-md shadow-sm border-0 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600"></div> + {fixed ? ( + <Fragment /> + ) : ( + <div class="rounded-full bg-gray-50 p-2"> + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="size-4" + > + {opened ? ( + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m19.5 8.25-7.5 7.5-7.5-7.5" + /> + ) : ( + <path + stroke-linecap="round" + stroke-linejoin="round" + d="m4.5 15.75 7.5-7.5 7.5 7.5" + /> + )} + </svg> + </div> + )} + </div> + </div> + ); + } + if (!opened) { + return ( + <div class="overflow-hidden border border-gray-800 rounded-xl"> + <Header /> + </div> + ); + } + + return ( + <div class="overflow-hidden border border-gray-800 rounded-xl"> + <Header /> + + <div class="border-t border-gray-100"> + <dl class="divide-y divide-gray-100"> + {Object.entries(errors).map(([fieldName, handler]) => { + const errHandler = handler as ErrorAndLabel; + //FIXME: don't rely on DOM to find the element + // use preact REF + const el = document.querySelector( + `form[name=${formName}] label[for=${fieldName}]`, + ) as HTMLElement; + return ( + <span + href="#" + onClick={(e) => { + e.preventDefault(); + if (el) { + el.focus({ preventScroll: true }); + el.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + + el.classList.add("animate-pulse"); + el.classList.add("border-b"); + el.classList.add("border-red-700"); + setTimeout(() => { + el.classList.remove("animate-pulse"); + el.classList.remove("border-b"); + el.classList.remove("border-red-700"); + }, 5000); + } + }} + class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0 odd:bg-white even:bg-gray-100 cursor-pointer" + > + <dt class="underline pl-4 text-sm/6 font-medium text-gray-900"> + {errHandler.label} + </dt> + <dd class="underline flex text-sm/6 text-red-700 sm:col-span-2 sm:mt-0"> + {errHandler.message} + </dd> + </span> + ); + })} + </dl> + </div> + </div> + ); +} diff --git a/packages/web-util/src/forms/gana/GLS_Onboarding.ts b/packages/web-util/src/forms/gana/GLS_Onboarding.ts @@ -36,7 +36,7 @@ export function GLS_Onboarding( id: "PERSON_DATE_OF_BIRTH" satisfies keyof TalerFormAttributes.GLS_Onboarding as UIHandlerId, label: i18n.str`Date of birth`, // gana_type: "ISO8601Date", - type: "absoluteTimeText", + type: "isoTimeText", placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, @@ -107,7 +107,7 @@ export function GLS_Onboarding( id: "BUSINESS_REGISTRATION_DATE" satisfies keyof TalerFormAttributes.GLS_Onboarding as UIHandlerId, label: i18n.str`Registration date`, // gana_type: "ISO8601Date", - type: "absoluteTimeText", + type: "isoTimeText", placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, @@ -151,7 +151,7 @@ export function GLS_Onboarding( id: "GLS_REPRESENTATIVE_DATE_OF_BIRTH" satisfies keyof TalerFormAttributes.GLS_BusinessRepresentative as UIHandlerId, label: i18n.str`Date of birth`, // gana_type: "ISO8601Date", - type: "absoluteTimeText", + type: "isoTimeText", placeholder: "dd/MM/yyyy", pattern: "dd/MM/yyyy", required: true, diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -21,7 +21,7 @@ import { TalerExchangeApi, TranslatedString, } from "@gnu-taler/taler-util"; -import { useEffect, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { FormDesign, UIFieldHandler, @@ -52,15 +52,19 @@ export type RecursivePartial<T> = { : RecursivePartial<T[k]>; }; +export type ErrorAndLabel = { + message: TranslatedString; + label: TranslatedString; +}; export type FormErrors<T> = { [k in keyof T]?: T[k] extends string - ? TranslatedString + ? ErrorAndLabel : T[k] extends AmountJson - ? TranslatedString + ? ErrorAndLabel : T[k] extends AbsoluteTime - ? TranslatedString + ? ErrorAndLabel : T[k] extends TalerExchangeApi.AmlState - ? TranslatedString + ? ErrorAndLabel : FormErrors<T[k]>; }; @@ -82,26 +86,6 @@ export type FormState<T> = { update: (f: FormValues<T>) => void; }; -function checkAllRequirements<T>( - st: RecursivePartial<FormValues<T>>, - check: (f: RecursivePartial<FormValues<T>>) => FormErrors<T> | undefined, -): FormStatus<T> { - const errors = undefinedIfEmpty<FormErrors<T> | undefined>( - // validateRequiredFields(st, config), - check(st), - ); - - if (errors !== undefined) { - return { - status: "fail" as const, - result: st as any, - errors, - }; - } - - return { status: "ok" as const, result: st as any, errors: undefined }; -} - /** * * @param fields form fields @@ -112,20 +96,22 @@ function checkAllRequirements<T>( export function useForm<T>( design: FormDesign<T>, initialValue: RecursivePartial<FormValues<T>>, - check?: (f: RecursivePartial<FormValues<T>>) => FormErrors<T> | undefined, ): FormState<T> { const [formValue, formUpdateHandler] = useState<RecursivePartial<FormValues<T>>>(initialValue); - const status = checkAllRequirements<T>(formValue, (v) => { - // FIXME: checks should be by fields - // FIXME: iterate only once and satify all checks, here we are potentially iterating more than once - const required = validateRequiredFields(v, design); - if (!required && check) { - return check(v); - } - return required; - }); + const errors = undefinedIfEmpty<FormErrors<T> | undefined>( + validateRequiredFields(formValue, design), + ); + + const status = + errors === undefined + ? { status: "ok" as const, result: formValue as any, errors: undefined } + : { + status: "fail" as const, + result: formValue as any, + errors, + }; const handler = constructFormHandler( design, @@ -204,7 +190,7 @@ export function undefinedIfEmpty<T extends object | undefined>( : undefined; } -export function validateRequiredFields<FormType>( +function validateRequiredFields<FormType>( form: object, config: FormDesign, ): FormErrors<FormType> | undefined { @@ -219,17 +205,21 @@ export function validateRequiredFields<FormType>( } const path = formElement.id.split("."); const v = getValueFromPath(form as any, path); - if (formElement.required) { - result = setValueIntoPath( - result, - path, - v === undefined ? "required" : undefined, - ); + if (formElement.required && v === undefined) { + const e: ErrorAndLabel = { + label: formElement.label as TranslatedString, + message: "required" as TranslatedString, // FIXME: should be translated + }; + result = setValueIntoPath(result, path, e); } if (formElement.validator) { - const error = formElement.validator(v as any); - if (error !== undefined) { - result = setValueIntoPath(result, path, error); + const message = formElement.validator(v as any); + if (message !== undefined) { + const e: ErrorAndLabel = { + label: formElement.label as TranslatedString, + message, + }; + result = setValueIntoPath(result, path, e); } } } @@ -261,7 +251,7 @@ function constructFormHandler<T>( ): FormHandler<T> { let formHandler: FormHandler<T> = {}; - function notifyUpdateOnFieldChange(formElement: UIFormElementConfig): void { + function createFieldHandler(formElement: UIFormElementConfig): void { if ("fields" in formElement) { // formElement.fields.forEach(notifyUpdateOnFieldChange); } @@ -280,13 +270,13 @@ function constructFormHandler<T>( path, undefined, ); - const currentError = getValueFromPath<TranslatedString>( + const currentError = getValueFromPath<ErrorAndLabel>( errors as any, path, undefined, ); const field: UIFieldHandler = { - error: currentError, + error: currentError?.message, value: currentValue, onChange: updater, state: {}, //FIXME: add the state of the field (hidden, ) @@ -298,12 +288,12 @@ function constructFormHandler<T>( switch (design.type) { case "double-column": { design.sections.forEach((sec) => { - sec.fields.forEach(notifyUpdateOnFieldChange); + sec.fields.forEach(createFieldHandler); }); break; } case "single-column": { - design.fields.forEach(notifyUpdateOnFieldChange); + design.fields.forEach(createFieldHandler); break; } default: {