taler-typescript-core

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

commit 19c8f7c9ccd0eed51aa47c6a3b9efb8a6040e4df
parent e5f6e43e1a23c75a0a44484b7393ac055e26a048
Author: Florian Dold <dold@taler.net>
Date:   Sun, 23 Aug 2026 12:56:11 +0200

web-util: harden form state and input handling

Diffstat:
Mpackages/web-util/src/forms/Calendar.tsx | 4++--
Mpackages/web-util/src/forms/FormProvider.tsx | 2+-
Mpackages/web-util/src/forms/fields/ExternalLink.tsx | 15+++++++++++++--
Mpackages/web-util/src/forms/fields/InputAbsoluteTime.tsx | 14++++++--------
Mpackages/web-util/src/forms/fields/InputAmount.tsx | 8+++-----
Mpackages/web-util/src/forms/fields/InputArray.tsx | 43+++++++++++++++++++++++++------------------
Mpackages/web-util/src/forms/fields/InputChoiceHorizontal.tsx | 2+-
Mpackages/web-util/src/forms/fields/InputChoiceStacked.tsx | 38+++++++++++++++++++++++++-------------
Mpackages/web-util/src/forms/fields/InputDownloadLink.tsx | 49+++++++++++++++++++++++++------------------------
Mpackages/web-util/src/forms/fields/InputDrilldown.tsx | 22+++++++++++++---------
Mpackages/web-util/src/forms/fields/InputDuration.tsx | 47+++++++++++++++++++++++++++++++++++++++--------
Mpackages/web-util/src/forms/fields/InputDurationText.tsx | 164+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/web-util/src/forms/fields/InputFile.tsx | 174++++++++++++++++++++++++++++++++-----------------------------------------------
Mpackages/web-util/src/forms/fields/InputInteger.tsx | 6++++--
Mpackages/web-util/src/forms/fields/InputIsoDate.tsx | 20+++++---------------
Mpackages/web-util/src/forms/fields/InputLine.tsx | 7++++---
Mpackages/web-util/src/forms/fields/InputPhone.tsx | 19++-----------------
Mpackages/web-util/src/forms/fields/InputSelectMultiple.tsx | 26++++++++++++++------------
Mpackages/web-util/src/forms/fields/InputSelectOne.tsx | 22+++++++++++-----------
Mpackages/web-util/src/forms/forms-types.ts | 41++++++++++-------------------------------
Mpackages/web-util/src/forms/forms-utils.ts | 31+++++++++++--------------------
Mpackages/web-util/src/forms/gana/personal-info.ts | 17++---------------
Mpackages/web-util/src/forms/gana/simplest.ts | 2--
Mpackages/web-util/src/hooks/useForm.ts | 45+++++++++++++++++++++++++++++++++++++++++----
24 files changed, 408 insertions(+), 410 deletions(-)

diff --git a/packages/web-util/src/forms/Calendar.tsx b/packages/web-util/src/forms/Calendar.tsx @@ -49,8 +49,8 @@ export function Calendar({ input.current.value = !year ? "" : String(year); }, [year]); - const start = startOfWeek(startOfMonth(showingDate)); - const end = endOfWeek(endOfMonth(showingDate)); + const start = startOfWeek(startOfMonth(showingDate), { weekStartsOn: 1 }); + const end = endOfWeek(endOfMonth(showingDate), { weekStartsOn: 1 }); const daysInMonth = eachDayOfInterval({ start, end }); const { i18n } = useTranslationContext(); const monthNames = [ diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx @@ -110,5 +110,5 @@ export interface StringConverter<T> { /** * Convert a UI string to a form attribute value. */ - fromStringUI: (v?: string) => T; + fromStringUI: (v?: string) => T | undefined; } diff --git a/packages/web-util/src/forms/fields/ExternalLink.tsx b/packages/web-util/src/forms/fields/ExternalLink.tsx @@ -24,8 +24,10 @@ export function ExternalLink({ handler, name, help, + required, + disabled, }: Props & UIFormProps<boolean>): VNode { - const { value, onChange, error } = + const { onChange, error } = handler ?? noHandlerPropsAndNoContextForField(name); return ( <div class="sm:col-span-6"> @@ -35,18 +37,27 @@ export function ExternalLink({ class="underline text-blue-600 hover:text-blue-900 visited:text-purple-600" target="_blank" rel="noreferrer" - onClick={() => { + aria-disabled={disabled} + onClick={(event) => { + if (disabled) { + event.preventDefault(); + return; + } onChange(true); }} > {label} </a> + {required && ( + <span class="text-xl bold leading-6 text-red-600 pl-2">*</span> + )} {after !== undefined && <RenderAddon addon={after} />} {help && ( <p class="mt-2 text-sm text-gray-500" id="email-description"> {help} </p> )} + {error && <p class="mt-2 text-sm text-red-600">{error}</p>} </div> ); } diff --git a/packages/web-util/src/forms/fields/InputAbsoluteTime.tsx b/packages/web-util/src/forms/fields/InputAbsoluteTime.tsx @@ -1,5 +1,5 @@ import { AbsoluteTime } from "@gnu-taler/taler-util"; -import { format, parse } from "date-fns"; +import { format, isValid, parse } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Calendar } from "../Calendar.js"; @@ -48,16 +48,14 @@ export function InputAbsoluteTime( //@ts-ignore fromStringUI: (v): AbsoluteTime | undefined => { if (!v) return undefined; - try { - const t_ms = parse(v, pattern, Date.now()).getTime(); - return AbsoluteTime.fromMilliseconds(t_ms); - } catch (e) { - return undefined; - } + const parsed = parse(v, pattern, Date.now()); + return isValid(parsed) + ? AbsoluteTime.fromMilliseconds(parsed.getTime()) + : undefined; }, //@ts-ignore toStringUI: (v: AbsoluteTime | undefined) => { - return !v || !v.t_ms + return !v ? undefined : v.t_ms === "never" ? "never" diff --git a/packages/web-util/src/forms/fields/InputAmount.tsx b/packages/web-util/src/forms/fields/InputAmount.tsx @@ -17,11 +17,9 @@ export function InputAmount( //@ts-ignore converter={ props.converter ?? { - fromStringUI: (v): AmountJson => { - return ( - Amounts.parse(`${props.currency}:${v}`) ?? - Amounts.zeroOfCurrency(props.currency) - ); + fromStringUI: (v): AmountJson | undefined => { + if (!v) return undefined; + return Amounts.parse(`${props.currency}:${v}`) ?? undefined; }, toStringUI: (v: AmountJson) => { return v === undefined ? "" : Amounts.stringifyValue(v); diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx @@ -1,6 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; +import { useId, useMemo, useState } from "preact/hooks"; import { getValueFromPath, RecursivePartial, @@ -21,6 +21,7 @@ export function noHandlerPropsAndNoContextForField( } type FormType = {}; +const EMPTY_FORM = {}; function ArrayForm({ fields, @@ -38,13 +39,11 @@ function ArrayForm({ name: string; }): VNode { const { i18n } = useTranslationContext(); - const form = useForm<FormType>( - { - type: "single-column", - fields, - }, - selected ?? {}, + const design = useMemo( + () => ({ type: "single-column", fields }) as const, + [fields], ); + const form = useForm<FormType>(design, selected ?? EMPTY_FORM); return ( <div class="px-4 py-6"> @@ -116,6 +115,7 @@ export function InputArray( const [selectedIndex, setSelectedIndex] = useState<number | undefined>( undefined, ); + const optionGroup = `array-${useId()}`; if (hidden) { return <Fragment />; @@ -153,11 +153,16 @@ export function InputArray( : labelValue; return ( <Option + id={`${optionGroup}-${idx}`} + groupName={optionGroup} label={label as TranslatedString} key={idx} isSelected={selectedIndex === idx} isLast={idx === list.length - 1} - disabled={selectedIndex !== undefined && selectedIndex !== idx} + disabled={ + props.disabled || + (selectedIndex !== undefined && selectedIndex !== idx) + } isFirst={idx === 0} onClick={() => { setSelectedIndex(selectedIndex === idx ? undefined : idx); @@ -168,6 +173,8 @@ export function InputArray( {!props.disabled && ( <div class="pt-2"> <Option + id={`${optionGroup}-new`} + groupName={optionGroup} label={i18n.str`Add new...`} isSelected={selectedIndex === list.length} isLast @@ -184,7 +191,7 @@ export function InputArray( </div> )} </div> - {selectedIndex !== undefined && ( + {!props.disabled && selectedIndex !== undefined && ( <ArrayForm name={props.name as string} fields={fields} @@ -215,6 +222,8 @@ export function InputArray( } function Option({ + id, + groupName, label, disabled, isFirst, @@ -222,6 +231,8 @@ function Option({ isSelected, onClick, }: { + id: string; + groupName: string; label: TranslatedString; isFirst?: boolean; isLast?: boolean; @@ -248,23 +259,19 @@ function Option({ clazz += " cursor-pointer"; } return ( - <label class={clazz}> + <label class={clazz} for={id}> <input + id={id} type="radio" - name="privacy-setting" + name={groupName} checked={isSelected} disabled={disabled} onClick={onClick} class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600" - aria-labelledby="privacy-setting-0-label" - aria-describedby="privacy-setting-0-description" + aria-labelledby={`${id}-label`} /> <span class="ml-3 flex flex-col"> - <span - id="privacy-setting-0-label" - disabled - class="block text-sm font-medium" - > + <span id={`${id}-label`} disabled class="block text-sm font-medium"> {label} </span> {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */} diff --git a/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx @@ -60,7 +60,7 @@ export function InputChoiceHorizontal<ChoiceVal>( class={clazz} onClick={(e) => { onChange( - (value === choice.value + (value === convertedValue ? undefined : convertedValue) as any, ); diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.tsx @@ -1,6 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { useEffect } from "preact/hooks"; +import { useEffect, useId } from "preact/hooks"; import { UIFormProps } from "../FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; @@ -27,10 +27,7 @@ export function InputChoiceStacked<Choices>( const { value, onChange } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); - - if (hidden) { - return <Fragment />; - } + const groupName = `choice-${useId()}`; useEffect(() => { // Reset choice if value is set to a choices that's @@ -40,8 +37,12 @@ export function InputChoiceStacked<Choices>( return; } } - onChange(undefined); - }, []); + if (value !== undefined) onChange(undefined); + }, [choices, value]); + + if (hidden) { + return <Fragment />; + } return ( <div class="sm:col-span-6"> @@ -54,6 +55,7 @@ export function InputChoiceStacked<Choices>( <fieldset class="mt-2"> <div class="space-y-4"> {choices.map((choice, idx) => { + const optionId = `${groupName}-${idx}`; let clazz = "border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between data-[disabled=true]:cursor-not-allowed data-[disabled=true]:bg-gray-50 data-[disabled=true]:text-gray-500 "; if (choice.value === value) { @@ -64,10 +66,16 @@ export function InputChoiceStacked<Choices>( } return ( - <label key={idx} class={clazz} data-disabled={props.disabled}> + <label + key={idx} + for={optionId} + class={clazz} + data-disabled={props.disabled} + > <input + id={optionId} type="radio" - name="server-size" + name={groupName} disabled={props.disabled} value={ (!converter @@ -82,20 +90,24 @@ export function InputChoiceStacked<Choices>( ); }} class="sr-only" - aria-labelledby="server-size-0-label" - aria-describedby="server-size-0-description-0 server-size-0-description-1" + aria-labelledby={`${optionId}-label`} + aria-describedby={ + choice.description === undefined + ? undefined + : `${optionId}-description` + } /> <span class="flex items-center"> <span class="flex flex-col text-sm"> <span - id="server-size-0-label" + id={`${optionId}-label`} class="font-medium text-gray-900" > {choice.label} </span> {choice.description !== undefined && ( <span - id="server-size-0-description-0" + id={`${optionId}-description`} class="text-gray-500" > <span class="block sm:inline"> diff --git a/packages/web-util/src/forms/fields/InputDownloadLink.tsx b/packages/web-util/src/forms/fields/InputDownloadLink.tsx @@ -38,35 +38,35 @@ export function InputDownloadLink(props: Props & UIFormProps<boolean>): VNode { <a href="#" class="underline text-blue-600 hover:text-blue-900 visited:text-purple-600" - onClick={(e) => { + aria-disabled={disabled} + onClick={async (e) => { e.preventDefault(); - onChange(true); - return ( - fetch(url, { + if (disabled) return; + try { + const response = await fetch(url, { headers: { "Content-Type": media ?? "text/html", }, cache: "no-cache", - }) - // .then((r) => r.text()) - .then((r) => r.arrayBuffer()) - .then((r) => { - const b64 = window.btoa( - new Uint8Array(r).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - - const a = document.createElement("a"); - a.href = `data:${media ?? "text/html"};base64,${b64}`; - a.download = fileName ?? ""; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - return; - }) - ); + }); + if (!response.ok) throw Error(`HTTP ${response.status}`); + const objectUrl = URL.createObjectURL(await response.blob()); + const anchor = document.createElement("a"); + anchor.href = objectUrl; + anchor.download = fileName ?? ""; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(objectUrl); + // Mark the document as read only after it was fetched. + onChange(true); + } catch (downloadError) { + console.error( + "Could not download linked form document", + downloadError, + ); + onChange(undefined); + } }} media={media} download @@ -84,6 +84,7 @@ export function InputDownloadLink(props: Props & UIFormProps<boolean>): VNode { {help} </p> )} + {error && <p class="mt-2 text-sm text-red-600">{error}</p>} </div> ); } diff --git a/packages/web-util/src/forms/fields/InputDrilldown.tsx b/packages/web-util/src/forms/fields/InputDrilldown.tsx @@ -1,6 +1,7 @@ -import { i18n, TranslatedString } from "@gnu-taler/taler-util"; +import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; +import { useTranslationContext } from "../../context/translation.js"; import { UIFormProps } from "../FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { InputSelectOne } from "./InputSelectOne.js"; @@ -12,18 +13,20 @@ export interface ChoiceH<V> { export function InputDrilldown( props: { - choices: any; + choices?: any; } & UIFormProps<any>, ): VNode { - const { hidden, choices, label, tooltip, help, required, converter } = props; - const { value, onChange } = + const { hidden, choices, required } = props; + const { onChange } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); - if (hidden) { + const [choiceStack, setChoiceStack] = useState<string[]>([]); + const { i18n: localI18n } = useTranslationContext(); + useEffect(() => setChoiceStack([]), [choices]); + + if (hidden || !choices || typeof choices !== "object") { return <Fragment />; } - const [choiceStack, setChoiceStack] = useState<string[]>([]); - let ch = props.choices; let inputs = []; @@ -35,8 +38,9 @@ export function InputDrilldown( inputs.push( <InputSelectOne name={props.name} - label={i18n.str`${props.label} (Classification level ${lvl + 1})`} + label={localI18n.str`${props.label} (Classification level ${lvl + 1})`} required={required} + disabled={props.disabled} handler={{ name: choiceStack.join(","), onChange(x) { diff --git a/packages/web-util/src/forms/fields/InputDuration.tsx b/packages/web-util/src/forms/fields/InputDuration.tsx @@ -12,7 +12,15 @@ export function InputDuration(props: UIFormProps<Duration>): VNode { const { value, onChange, error } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); - const specDuration = !value ? undefined : Duration.toSpec(value as Duration); + const forever = !!value && Duration.isForever(value as Duration); + const specDuration = + !value || forever ? undefined : Duration.toSpec(value as Duration); + const lastFiniteValue = useRef<Duration>(Duration.fromSpec({})); + useEffect(() => { + if (value && !Duration.isForever(value as Duration)) { + lastFiniteValue.current = value as Duration; + } + }, [value]); // const [seconds, setSeconds] = useState(sd?.seconds ?? 0); // const [hours, setHours] = useState(sd?.hours ?? 0); // const [minutes, setMinutes] = useState(sd?.minutes ?? 0); @@ -168,7 +176,8 @@ export function InputDuration(props: UIFormProps<Duration>): VNode { // onChange(fromString(value as any)); // }} // defaultValue={toString(value)} - disabled={disabled ?? false} + disabled={disabled || forever} + min="0" aria-invalid={showError} // aria-describedby="email-error" class={clazz} @@ -194,7 +203,8 @@ export function InputDuration(props: UIFormProps<Duration>): VNode { // onChange(fromString(value as any)); // }} // defaultValue={toString(value)} - disabled={disabled ?? false} + disabled={disabled || forever} + min="0" aria-invalid={showError} // aria-describedby="email-error" class={clazz} @@ -222,7 +232,8 @@ export function InputDuration(props: UIFormProps<Duration>): VNode { // onChange(fromString(value as any)); // }} // defaultValue={toString(value)} - disabled={disabled ?? false} + disabled={disabled || forever} + min="0" aria-invalid={showError} // aria-describedby="email-error" class={clazz} @@ -248,7 +259,8 @@ export function InputDuration(props: UIFormProps<Duration>): VNode { // onChange(fromString(value as any)); // }} // defaultValue={toString(value)} - disabled={disabled ?? false} + disabled={disabled || forever} + min="0" aria-invalid={showError} // aria-describedby="email-error" class={clazz} @@ -276,7 +288,8 @@ export function InputDuration(props: UIFormProps<Duration>): VNode { // onChange(fromString(value as any)); // }} // defaultValue={toString(value)} - disabled={disabled ?? false} + disabled={disabled || forever} + min="0" aria-invalid={showError} // aria-describedby="email-error" class={clazz} @@ -303,12 +316,28 @@ export function InputDuration(props: UIFormProps<Duration>): VNode { // onChange(fromString(value as any)); // }} // defaultValue={toString(value)} - disabled={disabled ?? false} + disabled={disabled || forever} + min="0" aria-invalid={showError} // aria-describedby="email-error" class={clazz} /> </div> + <label class="mt-2 flex items-center gap-2 text-sm text-gray-700"> + <input + type="checkbox" + checked={forever} + disabled={disabled} + onChange={(event) => { + onChange( + event.currentTarget.checked + ? Duration.getForever() + : lastFiniteValue.current, + ); + }} + /> + {i18n.str`No expiration`} + </label> </div> </InputWrapper> ); @@ -319,5 +348,7 @@ function defaultToString(v: unknown) { } function defaultFromString(v: string) { - return v; + if (!v) return undefined; + const value = Number(v); + return Number.isSafeInteger(value) && value >= 0 ? value : undefined; } diff --git a/packages/web-util/src/forms/fields/InputDurationText.tsx b/packages/web-util/src/forms/fields/InputDurationText.tsx @@ -1,102 +1,98 @@ import { Duration } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; +import { useEffect, useRef } from "preact/hooks"; +import { useTranslationContext } from "../../context/translation.js"; import { UIFormProps } from "../FormProvider.js"; import { InputLine } from "./InputLine.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; const PATTERN = /^(?<value>[0-9]+)(?<unit>[smhDMY])$/; -const UNIT_GROUP = "unit"; -const VALUE_GROUP = "value"; - type DurationUnit = "s" | "m" | "h" | "D" | "M" | "Y"; type DurationSpec = Parameters<typeof Duration.fromSpec>[0]; -type DurationValue = { - unit: DurationUnit; - value: number; -}; - -function updateSpec(spec: DurationSpec, value: DurationValue): void { - switch (value.unit) { - case "s": { - spec.seconds = value.value; - break; - } - case "m": { - spec.minutes = value.value; - break; - } - case "h": { - spec.hours = value.value; - break; - } - case "D": { - spec.days = value.value; - break; - } - case "M": { - spec.months = value.value; - break; - } - case "Y": { - spec.years = value.value; - break; +function parseDuration(text: string | undefined): Duration | undefined { + if (!text?.trim()) return undefined; + const spec: DurationSpec = {}; + for (const token of text.trim().split(/\s+/)) { + const match = PATTERN.exec(token); + if (!match?.groups) return undefined; + const value = Number.parseInt(match.groups.value, 10); + switch (match.groups.unit as DurationUnit) { + case "s": + spec.seconds = value; + break; + case "m": + spec.minutes = value; + break; + case "h": + spec.hours = value; + break; + case "D": + spec.days = value; + break; + case "M": + spec.months = value; + break; + case "Y": + spec.years = value; + break; } } + return Duration.fromSpecOrUndefined(spec); } -function parseDurationValue(str: string): DurationValue | undefined { - const r = PATTERN.exec(str); - if (!r) return undefined; - const value = Number.parseInt(r.groups![VALUE_GROUP], 10); - const unit = r.groups![UNIT_GROUP] as DurationUnit; - return { value, unit }; +function formatDuration(value?: Duration): string { + if (!value || Duration.isForever(value)) return ""; + const spec = Duration.toSpec(value); + return [ + spec?.years ? `${spec.years}Y` : undefined, + spec?.months ? `${spec.months}M` : undefined, + spec?.days ? `${spec.days}D` : undefined, + spec?.hours ? `${spec.hours}h` : undefined, + spec?.minutes ? `${spec.minutes}m` : undefined, + spec?.seconds ? `${spec.seconds}s` : undefined, + ] + .filter((part): part is string => part !== undefined) + .join(" "); } -export function InputDurationText(props: UIFormProps<string>): VNode { +export function InputDurationText(props: UIFormProps<Duration>): VNode { + const { i18n } = useTranslationContext(); + const handler = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const forever = !!handler.value && Duration.isForever(handler.value); + const lastFiniteValue = useRef<Duration>(Duration.fromSpec({})); + + useEffect(() => { + if (handler.value && !Duration.isForever(handler.value)) { + lastFiniteValue.current = handler.value; + } + }, [handler.value]); + return ( - <InputLine - type="text" - {...props} - converter={{ - //@ts-ignore - fromStringUI: (v): Duration | undefined => { - if (!v) return Duration.getForever(); - const spec = v.split(" ").reduce((prev, cur) => { - const v = parseDurationValue(cur); - if (v) { - updateSpec(prev, v); - } - return prev; - }, {} as DurationSpec); - return Duration.fromSpecOrUndefined(spec); - }, - //@ts-ignore - toStringUI: (v?: Duration): string => { - if (v === undefined) return ""; - // return v! as any; - const spec = Duration.toSpec(v); - let result = ""; - if (spec?.years) { - result += `${spec.years}Y `; - } - if (spec?.months) { - result += `${spec.months}M `; - } - if (spec?.days) { - result += `${spec.days}D `; - } - if (spec?.hours) { - result += `${spec.hours}h `; - } - if (spec?.minutes) { - result += `${spec.minutes}m `; - } - if (spec?.seconds) { - result += `${spec.seconds}s `; - } - return result.trimEnd(); - }, - }} - /> + <div class="sm:col-span-6"> + <InputLine + {...props} + handler={handler} + disabled={props.disabled || forever} + type="text" + converter={{ fromStringUI: parseDuration, toStringUI: formatDuration }} + /> + <label class="mt-2 flex items-center gap-2 text-sm text-gray-700"> + <input + type="checkbox" + checked={forever} + disabled={props.disabled} + onChange={(event) => { + handler.onChange( + event.currentTarget.checked + ? Duration.getForever() + : lastFiniteValue.current, + ); + }} + /> + {i18n.str`No expiration`} + </label> + </div> ); } diff --git a/packages/web-util/src/forms/fields/InputFile.tsx b/packages/web-util/src/forms/fields/InputFile.tsx @@ -1,18 +1,11 @@ /* This file is part of GNU Taler - (C) 2025 Taler Systems S.A. + (C) 2025-2026 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 { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -23,49 +16,56 @@ import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; export function InputFile( - props: { maxBites: number; accept?: string } & UIFormProps<FileFieldData>, + props: { maxBytes?: number; accept?: string } & UIFormProps<FileFieldData>, ): VNode { const { i18n } = useTranslationContext(); - const { label, tooltip, required, help: propsHelp, maxBites, accept } = props; - const { value, onChange } = + const { label, tooltip, required, help, maxBytes, accept, disabled, hidden } = + props; + const { value, onChange, error } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const [fileError, setFileError] = useState<string>(); + + if (hidden) return <Fragment />; + + const dataUri = !value + ? undefined + : `data:${value.MIME_TYPE ?? "application/octet-stream"};base64,${value.CONTENTS}`; - const help = propsHelp; - if (props.hidden) { - return <Fragment />; + function clearFile(): void { + setFileError(undefined); + onChange(undefined); } - const [dataUri, setDataUri] = useState<string | undefined>(() => { - if (!value) { - return undefined; - } - if (value.ENCODING != "base64") { - throw Error("unsupported file storage type"); + async function selectFile(file: File | undefined): Promise<void> { + if (!file) { + clearFile(); + return; } - return `data:${value.MIME_TYPE ?? "application/octet-stream"};base64,${ - value.CONTENTS - }`; - }); - - const handleFile = ( - contentsBase64?: string, - mimeType?: string, - filename?: string, - ) => { - // console.log(`handleFile`, contentsBase64, mimeType, filename); - if (contentsBase64 == null) { - setDataUri(undefined); + if (maxBytes !== undefined && file.size > maxBytes) { onChange(undefined); + setFileError(i18n.str`The selected file is too large.`); return; } - setDataUri(`data:${mimeType}};base64,${contentsBase64}`); - onChange({ - CONTENTS: contentsBase64, - ENCODING: "base64", - FILENAME: filename, - MIME_TYPE: mimeType, - }); - }; + try { + const contents = window.btoa( + new Uint8Array(await file.arrayBuffer()).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + setFileError(undefined); + onChange({ + CONTENTS: contents, + ENCODING: "base64", + FILENAME: file.name, + MIME_TYPE: file.type, + }); + } catch (readError) { + console.error("Could not read selected file", readError); + onChange(undefined); + setFileError(i18n.str`The selected file could not be read.`); + } + } return ( <div class="col-span-full"> @@ -73,7 +73,7 @@ export function InputFile( label={label} tooltip={tooltip} required={required} - name={props.name as string} + name={String(props.name)} /> {!value ? ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> @@ -90,44 +90,26 @@ export function InputFile( clip-rule="evenodd" /> </svg> - {!props.disabled && ( + {!disabled && ( <div class="my-2 flex text-sm leading-6 text-gray-600"> <label for={String(props.name)} class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" > - <span> - <i18n.Translate>Upload a file</i18n.Translate> - </span> + <span>{i18n.str`Upload a file`}</span> <input id={String(props.name)} type="file" class="sr-only" accept={accept} - onChange={(e) => { - const f: FileList | null = e.currentTarget.files; - if (!f || f.length != 1) { - handleFile(undefined); - return; - } - if (f[0].size > maxBites) { - handleFile(undefined); - return; - } - const fileName = f[0].name; - return f[0].arrayBuffer().then((b) => { - const b64 = window.btoa( - new Uint8Array(b).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - handleFile(b64, f[0].type, fileName); - }); + onChange={(event) => { + const files = event.currentTarget.files; + void selectFile( + files && files.length === 1 ? files[0] : undefined, + ); }} /> </label> - {/* <p class="pl-1">or drag and drop</p> */} </div> )} </div> @@ -136,19 +118,14 @@ export function InputFile( <Fragment> <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> {value.MIME_TYPE?.startsWith("image/") ? ( - <Fragment> - <img src={dataUri} class=" h-24 w-full object-cover relative" /> - {value.FILENAME ? ( - <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> - {value.FILENAME} - </div> - ) : ( - <Fragment /> - )} - </Fragment> + <img + src={dataUri} + alt={value.FILENAME ?? ""} + class="h-24 w-full object-cover relative" + /> ) : ( - <div class="h-24 w-full object-cover relative p-2"> - <div class="flex flex-row"> + <div class="h-24 w-full p-2"> + <div class="flex flex-row items-center gap-2"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" @@ -160,45 +137,36 @@ export function InputFile( <path stroke-linecap="round" stroke-linejoin="round" - d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" + d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9Z" /> </svg> - - {value.FILENAME ? ( - <div class=" flex justify-center text-xl items-center "> - {value.FILENAME} - </div> - ) : ( - <div /> - )} + {value.FILENAME} </div> </div> )} - - {!props.disabled && ( - <div - class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer " - onClick={() => { - handleFile(undefined); - }} + {!disabled && ( + <button + type="button" + class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer" + onClick={clearFile} > - Clear - </div> + {i18n.str`Clear`} + </button> )} </div> <a class="font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" - href={`data:${value.MIME_TYPE};${value.ENCODING},${value.CONTENTS}`} + href={dataUri} download={value.FILENAME} - onClick={(e) => { - return false; - }} > - <i18n.Translate>Download a copy.</i18n.Translate> + {i18n.str`Download a copy.`} </a> </Fragment> )} {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>} + {(fileError || error) && ( + <p class="mt-2 text-sm text-red-600">{fileError ?? error}</p> + )} </div> ); } diff --git a/packages/web-util/src/forms/fields/InputInteger.tsx b/packages/web-util/src/forms/fields/InputInteger.tsx @@ -8,8 +8,10 @@ export function InputInteger(props: UIFormProps<number>): VNode { type="number" converter={{ //@ts-ignore - fromStringUI: (v): number => { - return !v ? 0 : Number.parseInt(v, 10); + fromStringUI: (v): number | undefined => { + if (!v) return undefined; + const number = Number(v); + return Number.isSafeInteger(number) ? number : undefined; }, //@ts-ignore toStringUI: (v?: number): string => { diff --git a/packages/web-util/src/forms/fields/InputIsoDate.tsx b/packages/web-util/src/forms/fields/InputIsoDate.tsx @@ -15,7 +15,7 @@ */ import { AbsoluteTime } from "@gnu-taler/taler-util"; -import { format, parse, parseISO } from "date-fns"; +import { format, isValid, parse, parseISO } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { Calendar } from "../Calendar.js"; @@ -104,25 +104,15 @@ export function InputIsoDate( if (!v || typeof v !== "string") { return ""; } - try { - const d = parse(v, "yyyy-MM-dd", Date.now()); - return format(d, pattern); - } catch (e) { - console.error(`toStringUI: failed to convert ${v}: ${e}`); - return ""; - } + const date = parse(v, "yyyy-MM-dd", Date.now()); + return isValid(date) ? format(date, pattern) : ""; }, fromStringUI: (v: string | undefined): string => { if (!v) { return ""; } - try { - const t_ms = parse(v, pattern, Date.now()).getTime(); - return format(t_ms, "yyyy-MM-dd"); - } catch (e) { - console.error(`fromStringUI: failed to convert ${v}`); - return ""; - } + const date = parse(v, pattern, Date.now()); + return isValid(date) ? format(date, "yyyy-MM-dd") : ""; }, }} /> diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx @@ -167,8 +167,8 @@ function defaultFromString(v: string) { type InputType = "text" | "text-area" | "password" | "email" | "number" | "tel"; -export function InputLine( - props: { type: InputType; defaultValue?: string } & UIFormProps<string>, +export function InputLine<Value = string>( + props: { type: InputType; defaultValue?: string } & UIFormProps<Value>, ): VNode { const { name, @@ -192,7 +192,8 @@ export function InputLine( useEffect(() => { if (!input.current) return; if (input.current === document.activeElement) return; - input.current.value = !value ? "" : toString(value); + input.current.value = + value === undefined || value === null ? "" : toString(value); }, [value]); // useHiddenHandler(name as string, hidden ?? false, value, onChange); diff --git a/packages/web-util/src/forms/fields/InputPhone.tsx b/packages/web-util/src/forms/fields/InputPhone.tsx @@ -2,21 +2,6 @@ import { VNode, h } from "preact"; import { InputLine } from "./InputLine.js"; import { UIFormProps } from "../FormProvider.js"; -export function InputPhone(props: UIFormProps<number>): VNode { - return ( - <InputLine - type="tel" - converter={{ - //@ts-ignore - fromStringUI: (v): number => { - return !v ? 0 : Number.parseInt(v, 10); - }, - //@ts-ignore - toStringUI: (v?: number): string => { - return v === undefined ? "" : String(v); - }, - }} - {...props} - /> - ); +export function InputPhone(props: UIFormProps<string>): VNode { + return <InputLine type="tel" {...props} />; } diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx @@ -1,5 +1,5 @@ import { Fragment, VNode, h } from "preact"; -import { useRef, useState } from "preact/hooks"; +import { useId, useRef, useState } from "preact/hooks"; import { useTranslationContext } from "../../context/translation.js"; import { UIFormProps } from "../FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; @@ -17,7 +17,6 @@ export function InputSelectMultiple<ChoiceVal>( } & UIFormProps<ChoiceVal>, ): VNode { const { - converter, label, choices, placeholder, @@ -29,16 +28,18 @@ export function InputSelectMultiple<ChoiceVal>( max, } = props; const { i18n } = useTranslationContext(); - const { value, onChange } = + const { value, onChange, error } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); const [filter, setFilter] = useState<string | undefined>(undefined); const [dirty, setDirty] = useState<boolean>(); + const id = `select-many-${useId()}`; + const inputRef = useRef<HTMLInputElement>(null); if (hidden) { return <Fragment />; } - const regex = new RegExp(`.*${filter}.*`, "i"); + const normalizedFilter = (filter ?? "").toLocaleLowerCase(); const choiceMap = choices.reduce( (prev, curr) => { return { ...prev, [curr.value as string]: curr.label }; @@ -46,14 +47,12 @@ export function InputSelectMultiple<ChoiceVal>( {} as Record<string, string>, ); - const inputRef = useRef<HTMLInputElement>(null); - const list = (value ?? []) as string[]; const filteredChoices = filter === undefined ? undefined : choices.filter((v) => { - const match = regex.test(v.label); + const match = v.label.toLocaleLowerCase().includes(normalizedFilter); if (!unique) return match; return match && list.indexOf(v.value as string) === -1; }); @@ -70,7 +69,7 @@ export function InputSelectMultiple<ChoiceVal>( <div class="relative mt-2"> <input ref={inputRef} - id="combobox" + id={id} type="text" value={filter ?? ""} autoComplete="off" @@ -90,7 +89,7 @@ export function InputSelectMultiple<ChoiceVal>( placeholder={placeholder} class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" role="combobox" - aria-controls="options" + aria-controls={`${id}-options`} aria-expanded="false" /> <button @@ -125,7 +124,7 @@ export function InputSelectMultiple<ChoiceVal>( !filteredChoices.length ? ( <ul class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" - id="options" + id={`${id}-options`} role="listbox" > <li class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"> @@ -137,7 +136,7 @@ export function InputSelectMultiple<ChoiceVal>( ) : ( <ul class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" - id="options" + id={`${id}-options`} role="listbox" > {filteredChoices.map((v, idx) => { @@ -145,7 +144,7 @@ export function InputSelectMultiple<ChoiceVal>( <li key={idx} class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" - id="option-0" + id={`${id}-option-${idx}`} role="option" onMouseDown={(e) => { // Input element should not lose focus @@ -209,6 +208,9 @@ export function InputSelectMultiple<ChoiceVal>( {help} </p> )} + {dirty !== undefined && error && ( + <p class="mt-2 text-sm text-red-600">{error}</p> + )} </div> ); } diff --git a/packages/web-util/src/forms/fields/InputSelectOne.tsx b/packages/web-util/src/forms/fields/InputSelectOne.tsx @@ -1,6 +1,6 @@ import { i18n } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { useRef, useState } from "preact/hooks"; +import { useId, useRef, useState } from "preact/hooks"; import { UIFormProps } from "../FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { ChoiceS } from "./InputChoiceStacked.js"; @@ -21,7 +21,9 @@ export function InputSelectOne<Choices>( const [filter, setFilter] = useState<string | undefined>(undefined); const [dirty, setDirty] = useState<boolean>(); // FIXME: dirty state should come from handler - const regex = new RegExp(`.*${filter}.*`, "i"); + const id = `select-one-${useId()}`; + const inputRef = useRef<HTMLInputElement>(null); + const normalizedFilter = (filter ?? "").toLocaleLowerCase(); const choiceMap = choices.reduce( (prev, curr) => { return { ...prev, [curr.value as string]: curr.label }; @@ -43,15 +45,13 @@ export function InputSelectOne<Choices>( ), ); - const inputRef = useRef<HTMLInputElement>(null); - const sortedChoices = [...prefChoices, ...normalChoices]; let filteredChoices = filter === undefined ? undefined : sortedChoices.filter((v) => { - return regex.test(v.label); + return v.label.toLocaleLowerCase().includes(normalizedFilter); }); const noItems = @@ -66,7 +66,7 @@ export function InputSelectOne<Choices>( tooltip={tooltip} name={props.name as string} /> - {value ? ( + {value !== undefined ? ( <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 font-medium text-gray-600"> {choiceMap[value as string]} <button @@ -90,7 +90,7 @@ export function InputSelectOne<Choices>( ) : ( <div class="relative mt-2"> <input - id="combobox" + id={id} autocomplete="off" ref={inputRef} type="text" @@ -109,7 +109,7 @@ export function InputSelectOne<Choices>( placeholder={placeholder} class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" role="combobox" - aria-controls="options" + aria-controls={`${id}-options`} aria-expanded="false" /> <button @@ -141,7 +141,7 @@ export function InputSelectOne<Choices>( {noItems && ( <ul class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" - id="options" + id={`${id}-options`} role="listbox" > <li class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"> @@ -154,7 +154,7 @@ export function InputSelectOne<Choices>( {!noItems && filteredChoices && ( <ul class="absolute overflow-y-scroll z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" - id="options" + id={`${id}-options`} role="listbox" > {filteredChoices.map((v, idx) => { @@ -162,7 +162,7 @@ export function InputSelectOne<Choices>( <li key={idx} class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" - id="option-0" + id={`${id}-option-${idx}`} role="option" onMouseDown={(e) => { // Input element should not lose focus diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -192,7 +192,6 @@ type UIFormFieldChoiceStacked = { type UIFormFieldFile = { type: "file"; maxBytes?: Integer; - minBytes?: Integer; // comma-separated list of one or more file types // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers accept?: string; @@ -277,14 +276,6 @@ export type ComputableFieldConfig = { * readonly and dim */ disabled?: boolean; - - /* - update field config based on form state - */ - updateProps?: ( - value: any, - root?: any, - ) => Partial<ComputableFieldConfig> | undefined; }; export type UIFieldElementDescription = ComputableFieldConfig & { @@ -297,12 +288,6 @@ export type UIFieldElementDescription = ComputableFieldConfig & { /* short text to be shown close to the field, usually below and dimmer*/ help?: string; - /* ui element to show before */ - addonBeforeId?: string; - - /* ui element to show after */ - addonAfterId?: string; - /** * Return if the field should be hidden. * Receives the value after conversion and the root of the form. @@ -315,12 +300,6 @@ export type UIFormFieldBaseConfig = UIFieldElementDescription & { placeholder?: string; /** - * Conversion id to convert the string into the value type. - * The id should be known to the ui implementation. - */ - converterId?: string; - - /** * Return an error message if the value is not valid, * undefined otherwise. */ @@ -364,9 +343,9 @@ const codecForUIFormFieldBaseDescriptionTemplate = < T extends UIFieldElementDescription, >() => buildCodecForObject<T>() - .property("addonAfterId", codecOptional(codecForString())) - .property("addonBeforeId", codecOptional(codecForString())) .property("hidden", codecOptional(codecForBoolean())) + .property("disabled", codecOptional(codecForBoolean())) + .property("required", codecOptional(codecForBoolean())) .property("help", codecOptional(codecForString())) .property("label", codecForString()) .property("tooltip", codecOptional(codecForString())); @@ -376,9 +355,6 @@ const codecForUIFormFieldBaseConfigTemplate = < >() => codecForUIFormFieldBaseDescriptionTemplate<T>() .property("id", codecForUiFieldId()) - .property("converterId", codecOptional(codecForString())) - .property("disabled", codecOptional(codecForBoolean())) - .property("required", codecOptional(codecForBoolean())) .property("placeholder", codecOptional(codecForString())); const codecForUiFormFieldAbsoluteTime = (): Codec<UIFormFieldAbsoluteTime> => @@ -393,6 +369,8 @@ const codecForUiFormFieldIsoDate = (): Codec<UIFormFieldIsoDate> => codecForUIFormFieldBaseConfigTemplate<UIFormFieldIsoDate>() .property("type", codecForConstString("isoDateText")) .property("pattern", codecForString()) + .property("defaultValue", codecOptional(codecForString())) + .property("defaultCalendarValue", codecOptional(codecForString())) .property("max", codecOptional(codecForTimestamp)) .property("min", codecOptional(codecForTimestamp)) .build("UIFormFieldIsoTime"); @@ -444,10 +422,7 @@ const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => buildCodecForObject<SelectUiChoice>() .property("description", codecOptional(codecForString())) .property("label", codecForString()) - .property( - "value", - codecForEither(codecForString(), codecForBoolean()), - ) + .property("value", codecForEither(codecForString(), codecForBoolean())) .build("SelectUiChoice"); const codecForUiFormFieldChoiceHorizontal = @@ -475,7 +450,6 @@ const codecForUiFormFieldFile = (): Codec<UIFormFieldFile> => .property("type", codecForConstString("file")) .property("accept", codecOptional(codecForString())) .property("maxBytes", codecOptional(codecForNumber())) - .property("minBytes", codecOptional(codecForNumber())) .build("UIFormFieldFile"); const codecForUiFormFieldGroup = (): Codec<UIFormElementGroup> => @@ -531,6 +505,10 @@ const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldSelectOne> => codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectOne>() .property("type", codecForConstString("selectOne")) .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .property( + "preferredChoiceVals", + codecOptional(codecForList(codecForString())), + ) .build("UIFormFieldSelectOne"); const codecForUiFormFieldText = (): Codec<UIFormFieldText> => @@ -606,6 +584,7 @@ const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => const codecForDoubleColumnFormDesign = (): Codec<DoubleColumnFormDesign> => buildCodecForObject<DoubleColumnFormDesign>() .property("type", codecForConstString("double-column")) + .property("title", codecOptional(codecForString())) .property("sections", codecForList(codecForDoubleColumnFormSection())) .build("DoubleColumnFormDesign"); diff --git a/packages/web-util/src/forms/forms-utils.ts b/packages/web-util/src/forms/forms-utils.ts @@ -10,7 +10,7 @@ import { FormModel } from "../hooks/useForm.js"; import { InternationalizationAPI } from "../context/translation.js"; import { UIFieldElementDescription } from "./forms-types.js"; import { UIFormField } from "./field-types.js"; -import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; +import { StringConverter, UIFieldHandler } from "./FormProvider.js"; import { UIFormElementConfig, UIFormFieldBaseConfig } from "./forms-types.js"; /** @@ -237,7 +237,7 @@ export function convertFormConfigToUiField( ), hidden, accept: config.accept, - maxBites: config.maxBytes, + maxBytes: config.maxBytes, }, } as UIFormField; } @@ -407,10 +407,6 @@ export function convertFormConfigToUiField( return result.filter((v): v is UIFormField => !!v); } -function getAddonById(_id: string | undefined): Addon { - return undefined!; -} - function getConverterByFieldType( fieldType: string | undefined, config: unknown, @@ -461,8 +457,6 @@ function convertBaseFieldsProps( p: UIFieldElementDescription, ) { return { - after: getAddonById(p.addonAfterId), - before: getAddonById(p.addonBeforeId), help: i18n_.str`${p.help}`, label: i18n_.str`${p.label}`, tooltip: i18n_.str`${p.tooltip}`, @@ -484,10 +478,9 @@ function amountConverter(config: any): StringConverter<AmountJson> { throw Error(`amount converter needs a currency`); } return { - fromStringUI(v: string | undefined): AmountJson { - return ( - Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency) - ); + fromStringUI(v: string | undefined): AmountJson | undefined { + if (!v) return undefined; + return Amounts.parse(`${currency}:${v}`) ?? undefined; }, toStringUI(v: unknown): string { return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); @@ -501,16 +494,14 @@ function absTimeConverter(config: any): StringConverter<AbsoluteTime> { throw Error(`absTime converter needs a pattern`); } return { - fromStringUI(v: string | undefined): AbsoluteTime { + fromStringUI(v: string | undefined): AbsoluteTime | undefined { if (v === undefined) { - return AbsoluteTime.never(); - } - try { - const time = parse(v, pattern, new Date()); - return AbsoluteTime.fromMilliseconds(time.getTime()); - } catch (e) { - return AbsoluteTime.never(); + return undefined; } + const time = parse(v, pattern, new Date()); + return Number.isNaN(time.getTime()) + ? undefined + : AbsoluteTime.fromMilliseconds(time.getTime()); }, toStringUI(v: unknown): string { if (v === undefined) return ""; diff --git a/packages/web-util/src/forms/gana/personal-info.ts b/packages/web-util/src/forms/gana/personal-info.ts @@ -14,11 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import type { - DoubleColumnFormDesign, - InternationalizationAPI, - UIHandlerId, -} from "@gnu-taler/web-util/browser"; +import type { InternationalizationAPI } from "../../context/translation.js"; +import type { DoubleColumnFormDesign, UIHandlerId } from "../forms-types.js"; export const personalInfo = ( i18n: InternationalizationAPI, @@ -28,15 +25,6 @@ export const personalInfo = ( { title: i18n.str`Simple form`, fields: [ - // { - // type: "absoluteTimeText", - // name: "dateOfDeath", - // label: i18n.str`Date of death`, - // pattern: "dd/MM/yyyy", - // // help: i18n.str`if deceased. format 'dd/MM/yyyy'`, - // help: i18n.str`if deceased'`, - // id: ".birthdate" as UIHandlerId, - // }, { type: "choiceStacked", id: "trucker" as UIHandlerId, @@ -57,7 +45,6 @@ export const personalInfo = ( type: "amount", id: "money" as UIHandlerId, currency: "YEIN", - converterId: "Taler.Amount", label: i18n.str`How much cash are you carrying?`, }, ], diff --git a/packages/web-util/src/forms/gana/simplest.ts b/packages/web-util/src/forms/gana/simplest.ts @@ -50,7 +50,6 @@ export function resolutionSection( type: "choiceHorizontal", id: "state" as UIHandlerId, label: i18n.str`New state`, - converterId: "TalerExchangeApi.AmlState", choices: [ { value: "frozen", @@ -70,7 +69,6 @@ export function resolutionSection( type: "amount", id: "threshold" as UIHandlerId, currency: "NETZBON", - converterId: "Taler.Amount", label: i18n.str`New threshold`, }, ], diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -167,8 +167,19 @@ export function useForm<T>( initialValue: RecursivePartial<FormValues<T>>, ): FormState<T> { const { i18n } = useTranslationContext(); - const [formValue, formUpdateHandler] = - useState<RecursivePartial<FormValues<T>>>(initialValue); + const resetKey = stableFormKey(design, initialValue); + const [storedForm, setStoredForm] = useState<{ + key: string; + value: RecursivePartial<FormValues<T>>; + }>({ key: resetKey, value: initialValue }); + // Callers frequently construct equivalent form objects inline. A stable + // structural key switches to the new initial record during the same render, + // without erasing edits merely because object identity changed. + const formValue = + storedForm.key === resetKey ? storedForm.value : initialValue; + const formUpdateHandler = (value: RecursivePartial<FormValues<T>>) => { + setStoredForm({ key: resetKey, value }); + }; const { model, result, errors } = constructFormHandler( design, @@ -246,7 +257,7 @@ export function undefinedIfEmpty<T extends object | undefined>( function checkFormFieldIsValid( formElement: UIFormElementConfig, - currentValue: string | undefined, + currentValue: unknown, i18n: InternationalizationAPI, secitonTitle: string | undefined, form: any, @@ -255,7 +266,7 @@ function checkFormFieldIsValid( return undefined; } - if (formElement.required && currentValue === undefined) { + if (formElement.required && isEmptyRequiredValue(currentValue)) { return { label: formElement.label as TranslatedString, message: i18n.str`required`, @@ -287,6 +298,32 @@ function checkFormFieldIsValid( return undefined; } +function isEmptyRequiredValue(value: unknown): boolean { + if (value === undefined || value === null) return true; + if (typeof value === "string") return value.trim().length === 0; + if (Array.isArray(value)) return value.length === 0; + if ( + typeof value === "object" && + Object.getPrototypeOf(value) === Object.prototype + ) { + return Object.keys(value).length === 0; + } + return false; +} + +function stableFormKey(design: FormDesign, initialValue: unknown): string { + const seen = new WeakSet<object>(); + return JSON.stringify([design, initialValue], (_key, value: unknown) => { + if (typeof value === "function") return value.toString(); + if (typeof value === "bigint") return `${value}n`; + if (typeof value === "object" && value !== null) { + if (seen.has(value)) return "[circular]"; + seen.add(value); + } + return value; + }); +} + /** * @param formValue Plain, unprocessed form contents. */