commit e9e12687488be1350f708daa7dc3f51bc4907df2
parent b5217432044d03224f905d5894058297fd354d8b
Author: Sebastian <sebasjm@gmail.com>
Date: Wed, 5 Feb 2025 12:10:16 -0300
errors summary
Diffstat:
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: {