taler-typescript-core

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

commit 411dcd6b32501c1c1c91727b428ae0e46c76460b
parent 6dc70f3df6ec6c431137ef83f2975de9a987de3f
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 13 Jan 2025 18:07:40 -0300

input duration

Diffstat:
Mpackages/web-util/src/forms/field-types.ts | 9++++++++-
Apackages/web-util/src/forms/fields/InputDuration.stories.tsx | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputDuration.tsx | 183+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/fields/InputLine.tsx | 2+-
Mpackages/web-util/src/forms/fields/InputSecret.stories.tsx | 2+-
Mpackages/web-util/src/forms/forms-types.ts | 15++++++++++++++-
Mpackages/web-util/src/forms/forms-utils.ts | 13+++++++++++++
Mpackages/web-util/src/forms/index.stories.ts | 1+
Mpackages/web-util/src/hooks/useForm.ts | 2+-
9 files changed, 284 insertions(+), 5 deletions(-)

diff --git a/packages/web-util/src/forms/field-types.ts b/packages/web-util/src/forms/field-types.ts @@ -9,9 +9,10 @@ import { InputChoiceStacked } from "./fields/InputChoiceStacked.js"; import { InputFile } from "./fields/InputFile.js"; import { InputInteger } from "./fields/InputInteger.js"; import { InputSelectMultiple } from "./fields/InputSelectMultiple.js"; +import { InputDuration } from "./fields/InputDuration.js"; +import { InputSecret } from "./fields/InputSecret.js"; import { InputSelectOne } from "./fields/InputSelectOne.js"; import { InputText } from "./fields/InputText.js"; -import { InputSecret } from "./fields/InputSecret.js"; import { InputTextArea } from "./fields/InputTextArea.js"; import { InputToggle } from "./fields/InputToggle.js"; import { Group } from "./Group.js"; @@ -37,6 +38,7 @@ type FieldType<T extends object = any, K extends keyof T = any> = { secret: Parameters<typeof InputSecret<T, K>>[0]; toggle: Parameters<typeof InputToggle<T, K>>[0]; amount: Parameters<typeof InputAmount<T, K>>[0]; + duration: Parameters<typeof InputDuration<T, K>>[0]; }; /** @@ -71,6 +73,10 @@ export type UIFormField = | { type: "absoluteTimeText"; properties: FieldType["absoluteTimeText"]; + } + | { + type: "duration"; + properties: FieldType["duration"]; }; export type FieldComponentFunction<key extends keyof FieldType> = ( @@ -111,4 +117,5 @@ export const UIFormConfiguration: UIFormFieldMap = { toggle: InputToggle, //@ts-ignore amount: InputAmount, + duration: InputDuration, }; diff --git a/packages/web-util/src/forms/fields/InputDuration.stories.tsx b/packages/web-util/src/forms/fields/InputDuration.stories.tsx @@ -0,0 +1,62 @@ +/* + This file is part of GNU Taler + (C) 2022 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/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { Duration, TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "../../tests/hook.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { DefaultForm as TestedComponent } from "../forms-ui.js"; + +export default { + title: "Input duration", +}; + +type TargetObject = { + time: Duration; +}; +const initial: TargetObject = { + time: Duration.fromSpec({ + days: 1, + hours: 2, + minutes: 3, + seconds: 4, + }), +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "duration", + label: "How long?" as TranslatedString, + id: "time" as UIHandlerId, + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputDuration.tsx b/packages/web-util/src/forms/fields/InputDuration.tsx @@ -0,0 +1,183 @@ +import { Fragment, VNode, h } from "preact"; +import { useTranslationContext } from "../../index.browser.js"; +import { UIFormProps } from "../FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { InputWrapper } from "./InputLine.js"; +import { Duration } from "@gnu-taler/taler-util"; +import { useEffect, useState } from "preact/hooks"; + +export function InputDuration<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + const { name, placeholder, before, after, converter, disabled } = props; + const { i18n } = useTranslationContext(); + const { value, onChange, state, error } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + + const sd = Duration.toSpec(value as Duration); + const [days, setDays] = useState(sd?.days ?? 0); + const [hours, setHours] = useState(sd?.hours ?? 0); + const [minutes, setMinutes] = useState(sd?.minutes ?? 0); + const [seconds, setSeconds] = useState(sd?.seconds ?? 0); + + useEffect(() => { + onChange( + Duration.fromSpec({ + days, + hours, + minutes, + seconds, + }), + ); + }, [days, hours, minutes, seconds]); + const fromString: (s: string) => any = + converter?.fromStringUI ?? defaultFromString; + const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; + + if (state.hidden) return <div />; + + let clazz = + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200"; + // if (before) { + // switch (before.type) { + // case "icon": { + // clazz += " pl-10"; + // break; + // } + // case "button": { + // clazz += " rounded-none rounded-r-md "; + // break; + // } + // case "text": { + clazz += " min-w-0 flex-1 rounded-r-md rounded-none "; + // break; + // } + // } + // } + if (after) { + switch (after.type) { + case "icon": { + clazz += " pr-10"; + break; + } + case "button": { + clazz += " rounded-none rounded-l-md"; + break; + } + case "text": { + clazz += " min-w-0 flex-1 rounded-l-md rounded-none "; + break; + } + } + } + const showError = value !== undefined && error; + if (showError) { + clazz += + " text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500"; + } else { + clazz += + " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600"; + } + return ( + <InputWrapper<T, K> + {...props} + help={props.help ?? state.help} + disabled={disabled ?? false} + error={showError ? error : undefined} + > + <div class="flex flex-col gap-1"> + <div class="flex"> + <span class="ml-2 inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> + <i18n.Translate>days</i18n.Translate> + </span> + <input + name={String(name)} + type="number" + onChange={(e) => { + setDays(fromString(e.currentTarget.value)); + }} + placeholder={placeholder ? placeholder : undefined} + value={toString(sd?.days) ?? ""} + // onBlur={() => { + // onChange(fromString(value as any)); + // }} + // defaultValue={toString(value)} + disabled={disabled ?? false} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + <span class="ml-2 inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> + <i18n.Translate>hours</i18n.Translate> + </span> + <input + name={String(name)} + type="number" + onChange={(e) => { + setHours(fromString(e.currentTarget.value)); + }} + placeholder={placeholder ? placeholder : undefined} + value={toString(sd?.hours) ?? ""} + // onBlur={() => { + // onChange(fromString(value as any)); + // }} + // defaultValue={toString(value)} + disabled={disabled ?? false} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + </div> + <div class="flex"> + <span class="ml-2 inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> + <i18n.Translate>minutes</i18n.Translate> + </span> + <input + name={String(name)} + type="number" + onChange={(e) => { + setMinutes(fromString(e.currentTarget.value)); + }} + placeholder={placeholder ? placeholder : undefined} + value={toString(sd?.minutes) ?? ""} + // onBlur={() => { + // onChange(fromString(value as any)); + // }} + // defaultValue={toString(value)} + disabled={disabled ?? false} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + <span class="ml-2 inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> + <i18n.Translate>seconds</i18n.Translate> + </span> + <input + name={String(name)} + type="number" + onChange={(e) => { + setSeconds(fromString(e.currentTarget.value)); + }} + placeholder={placeholder ? placeholder : undefined} + value={toString(sd?.seconds) ?? ""} + // onBlur={() => { + // onChange(fromString(value as any)); + // }} + // defaultValue={toString(value)} + disabled={disabled ?? false} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + </div> + </div> + </InputWrapper> + ); +} + +function defaultToString(v: unknown) { + return v === undefined ? "" : typeof v !== "object" ? String(v) : ""; +} +function defaultFromString(v: string) { + return v; +} diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx @@ -104,7 +104,7 @@ export function RenderAddon({ } } -function InputWrapper<T extends object, K extends keyof T>({ +export function InputWrapper<T extends object, K extends keyof T>({ children, label, tooltip, diff --git a/packages/web-util/src/forms/fields/InputSecret.stories.tsx b/packages/web-util/src/forms/fields/InputSecret.stories.tsx @@ -32,7 +32,7 @@ type TargetObject = { pwd: string; }; const initial: TargetObject = { - pwd: 5, + pwd: "5", }; const design: FormDesign = { diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -17,7 +17,9 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; -export type FormDesign = DoubleColumnFormDesign | SingleColumnFormDesign; +export type FormDesign<T = unknown> = + | DoubleColumnFormDesign + | SingleColumnFormDesign; /** * form with composed by multiple sections @@ -56,6 +58,7 @@ export type UIFormElementConfig = | UIFormFieldInteger | UIFormFieldSecret | UIFormFieldSelectMultiple + | UIFormFieldDuration | UIFormFieldSelectOne | UIFormFieldText | UIFormFieldTextArea @@ -144,6 +147,10 @@ type UIFormFieldSelectMultiple = { allowFreeForm?: boolean; } & UIFormFieldBaseConfig; +type UIFormFieldDuration = { + type: "duration"; +} & UIFormFieldBaseConfig; + type UIFormFieldSelectOne = { type: "selectOne"; choices: Array<SelectUiChoice>; @@ -320,6 +327,11 @@ const codecForUiFormFieldSecret = (): Codec<UIFormFieldSecret> => .property("type", codecForConstString("secret")) .build("UIFormFieldSecret"); +const codecForUiFormFieldDuration = (): Codec<UIFormFieldDuration> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldDuration>() + .property("type", codecForConstString("duration")) + .build("UiFormFieldDuration"); + const codecForUiFormFieldSelectMultiple = (): Codec<UIFormFieldSelectMultiple> => codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectMultiple>() @@ -370,6 +382,7 @@ const codecForUiFormField = (): Codec<UIFormElementConfig> => .alternative("integer", codecForUiFormFieldInteger()) .alternative("secret", codecForUiFormFieldSecret()) .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) + .alternative("duration", codecForUiFormFieldDuration()) .alternative("selectOne", codecForUiFormFieldSelectOne()) .alternative("text", codecForUiFormFieldText()) .alternative("textArea", codecForUiFormFieldTextArea()) diff --git a/packages/web-util/src/forms/forms-utils.ts b/packages/web-util/src/forms/forms-utils.ts @@ -245,6 +245,19 @@ export function convertFormConfigToUiField( }, } as UIFormField; } + case "duration": { + return { + type: "duration", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + }, + } as UIFormField; + } case "toggle": { return { type: "toggle", diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts @@ -11,3 +11,4 @@ export * as a11 from "./fields/InputText.stories.js"; export * as a12 from "./fields/InputTextArea.stories.js"; export * as a13 from "./fields/InputToggle.stories.js"; export * as a14 from "./fields/InputSecret.stories.js"; +export * as a15 from "./fields/InputDuration.stories.js"; diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -110,7 +110,7 @@ function checkAllRequirements<T>( * @returns */ export function useForm<T>( - design: FormDesign, + design: FormDesign<T>, initialValue: RecursivePartial<FormValues<T>>, check?: (f: RecursivePartial<FormValues<T>>) => FormErrors<T> | undefined, ): FormState<T> {