commit b1090b6b3d4b725e05da426b523578c1603e7c6f parent e78d0f8e4dcb72d4d0df46845239dade8533885e Author: Sebastian <sebasjm@gmail.com> Date: Fri, 10 Jan 2025 15:42:28 -0300 remove duplicated form implementation Diffstat:
39 files changed, 1436 insertions(+), 1610 deletions(-)
diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx @@ -1,105 +0,0 @@ -import { TranslatedString } from "@gnu-taler/taler-util"; -import { Fragment, VNode, h } from "preact"; -import { - UIFormElementConfig, - getConverterById, - useTranslationContext, -} from "../index.browser.js"; -import { FormProvider, FormProviderProps, FormState } from "./FormProvider.js"; -import { - RenderAllFieldsByUiConfig, - UIFormField, - convertUiField, -} from "./forms.js"; -// import { FlexibleForm } from "./ui-form.js"; - -/** - * Flexible form uses a DoubleColumForm for design - * and may have a dynamic properties defined by - * behavior function. - */ -export interface FlexibleForm_Deprecated<T extends object> { - design: DoubleColumnForm_Deprecated; - behavior?: (form: Partial<T>) => FormState<T>; -} - -/** - * Double column form - * - * Form with sections, every sections have a title and may - * have a description. - * Every sections contain a set of fields. - */ -export type DoubleColumnForm_Deprecated = Array< - DoubleColumnFormSection_Deprecated | undefined ->; - -export type DoubleColumnFormSection_Deprecated = { - title: TranslatedString; - description?: TranslatedString; - fields: UIFormElementConfig[]; -}; - -/** - * Form Provider implementation that use FlexibleForm - * to defined behavior and fields. - */ -export function DefaultForm<T extends object>({ - initial, - onUpdate, - form, - onSubmit, - children, - readOnly, -}: Omit<FormProviderProps<T>, "computeFormState"> & { - form: FlexibleForm_Deprecated<T>; -}): VNode { - const { i18n } = useTranslationContext(); - return ( - <FormProvider - initial={initial} - onUpdate={onUpdate} - onSubmit={onSubmit} - readOnly={readOnly} - > - <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> - {form.design.map((section, i) => { - if (!section) return <Fragment />; - return ( - <div - key={i} - class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3" - > - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - {section.title} - </h2> - {section.description && ( - <p class="mt-1 text-sm leading-6 text-gray-600"> - {section.description} - </p> - )} - </div> - <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> - <div class="p-3"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig - key={i} - fields={convertUiField( - i18n, - section.fields, - form, - getConverterById, - )} - /> - </div> - </div> - </div> - </div> - ); - })} - </div> - {children} - </FormProvider> - ); -} diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx @@ -78,9 +78,9 @@ export interface UIFormProps<T extends object, K extends keyof T> handler?: UIFieldHandler; } -export type UIFieldHandler = { - value: string | undefined; - onChange: (s: string) => void; +export type UIFieldHandler<T = any> = { + value: T | undefined; + onChange: (s: T) => void; state: FieldUIOptions; error?: TranslatedString; }; @@ -113,38 +113,3 @@ export type FormProviderProps<T extends object> = Omit<FormType<T>, "value"> & { onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void; children?: ComponentChildren; }; - -export function FormProvider<T extends object>({ - children, - initial, - onUpdate: notify, - onSubmit, - computeFormState, - readOnly, -}: FormProviderProps<T>): VNode { - const [state, setState] = useState<Partial<T>>(initial ?? {}); - const value = { current: state }; - const onUpdate = (v: typeof state) => { - setState(v); - if (notify) notify(v); - }; - return ( - <FormContext.Provider - value={{ initial, value, onUpdate, computeFormState, readOnly }} - > - <form - onSubmit={(e) => { - e.preventDefault(); - //@ts-ignore - if (onSubmit) - onSubmit( - value.current, - !computeFormState ? undefined : computeFormState(value.current), - ); - }} - > - {children} - </form> - </FormContext.Provider> - ); -} diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx @@ -1,11 +1,9 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; +import { Addon } from "./FormProvider.js"; import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; -import { RenderAllFieldsByUiConfig, UIFormField, convertUiField } from "./forms.js"; -import { Addon, FormProvider } from "./FormProvider.js"; -import { useField } from "./useField.js"; -import { useTranslationContext } from "../index.browser.js"; -import { getConverterById } from "./converter.js"; +import { RenderAllFieldsByUiConfig } from "./forms-ui.js"; +import { UIFormField } from "./field-types.js"; interface Props { label: TranslatedString; @@ -35,9 +33,7 @@ export function Group({ </p> )} <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-6"> - <RenderAllFieldsByUiConfig - fields={fields} - /> + <RenderAllFieldsByUiConfig fields={fields} /> </div> </div> ); diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx @@ -21,12 +21,8 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; - +import { FormDesign, UIHandlerId } from "./forms-types.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; export default { title: "Input Absolute Time", }; @@ -44,8 +40,9 @@ const initial: TargetObject = { today: AbsoluteTime.now(), }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ @@ -62,5 +59,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputAbsoluteTime.tsx b/packages/web-util/src/forms/InputAbsoluteTime.tsx @@ -6,7 +6,6 @@ import { Calendar } from "./Calendar.js"; import { Dialog } from "./Dialog.js"; import { UIFormProps } from "./FormProvider.js"; import { InputLine } from "./InputLine.js"; -import { useField } from "./useField.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export function InputAbsoluteTime<T extends object, K extends keyof T>( @@ -15,10 +14,8 @@ export function InputAbsoluteTime<T extends object, K extends keyof T>( const pattern = properties.pattern ?? "dd/MM/yyyy"; const [open, setOpen] = useState(false); - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(properties.name); const { value, onChange } = - properties.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(properties.name); + properties.handler ?? noHandlerPropsAndNoContextForField(properties.name); return ( <Fragment> <InputLine<T, K> @@ -71,7 +68,7 @@ export function InputAbsoluteTime<T extends object, K extends keyof T>( {open && ( <Dialog onClose={() => setOpen(false)}> <Calendar - value={(value as AbsoluteTime) ?? AbsoluteTime.now()} + value={(value as any as AbsoluteTime) ?? AbsoluteTime.now()} onChange={(v) => { onChange(v as any); setOpen(false); diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx @@ -21,11 +21,8 @@ import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Amount", @@ -44,8 +41,9 @@ const initial: TargetObject = { amount: Amounts.parseOrThrow("USD:10"), }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ @@ -62,5 +60,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx @@ -2,16 +2,13 @@ import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; import { UIFormProps } from "./FormProvider.js"; import { InputLine } from "./InputLine.js"; -import { useField } from "./useField.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export function InputAmount<T extends object, K extends keyof T>( props: { currency?: string } & UIFormProps<T, K>, ): VNode { - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); const { value } = - props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + props.handler ?? noHandlerPropsAndNoContextForField(props.name); const currency = !value || !(value as any).currency ? props.currency diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Array", @@ -52,8 +49,9 @@ const initial: TargetObject = { ], }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, description: "to test how arrays are used" as TranslatedString, @@ -85,14 +83,14 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const FormWithArray = tests.createExample(TestedComponent, { initial, - form, + design, }); -const initial2: any = { -}; +const initial2: any = {}; -const form2: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design2: FormDesign = { + type: "double-column", + sections: [ { title: "Personal information" as TranslatedString, fields: [ @@ -135,5 +133,5 @@ const form2: FlexibleForm_Deprecated<TargetObject> = { export const NonMixingProperties = tests.createExample(TestedComponent, { initial: initial2, - form: form2, + design: design2, }); diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx @@ -1,23 +1,14 @@ import { TranslatedString } from "@gnu-taler/taler-util"; -import { Fragment, VNode, h } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { FormProvider, UIFormProps } from "./FormProvider.js"; -import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { - convertUiField, - RenderAllFieldsByUiConfig, - UIFormField, -} from "./forms.js"; -import { useField } from "./useField.js"; -import { UIFormElementConfig, UIHandlerId } from "./ui-form.js"; +import { getValueFromPath, useForm } from "../hooks/useForm.js"; import { - FormErrors, - undefinedIfEmpty, - useFormState, - useFormStateFromConfig, - validateRequiredFields, -} from "../hooks/useForm.js"; -import { getConverterById, useTranslationContext } from "../index.browser.js"; + SingleColumnFormSectionUI, + useTranslationContext, +} from "../index.browser.js"; +import { UIFormProps } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { UIFormElementConfig, UIHandlerId } from "./forms-types.js"; function Option({ label, @@ -192,47 +183,20 @@ export function InputArray<T extends object, K extends keyof T>( const selected = selectedIndex === undefined ? undefined : list[selectedIndex]; - // const shape: Array<UIHandlerId> = []; - // const requiredFields: Array<UIHandlerId> = []; - // Array.prototype.push.apply(shape, getShapeFromFields(fields)); - // Array.prototype.push.apply(requiredFields, getRequiredFields(fields)); - - const [form, formState] = useFormStateFromConfig<FormType>( - fields, + const form = useForm<FormType>( + { + type: "single-column", + fields, + }, selected ?? {}, ); - // const [form, formState] = useFormState<FormType>( - // shape, - // selected ?? {}, - // (st) => { - // const partialErrors = undefinedIfEmpty<FormErrors<FormType>>({}); - - // const errors = undefinedIfEmpty<FormErrors<FormType> | undefined>( - // validateRequiredFields(partialErrors, st, requiredFields), - // ); - - // if (errors === undefined) { - // return { - // status: "ok", - // result: st as any, - // errors: undefined, - // }; - // } - - // return { - // status: "fail", - // result: st as any, - // errors, - // }; - // }, - // ); useEffect(() => { if (selectedIndex === undefined) return; const newValue = [...list]; - newValue.splice(selectedIndex, 1, formState.result); + newValue.splice(selectedIndex, 1, form.status.result); onChange(newValue as any); - }, [formState.result, selectedIndex]); + }, [form.status.result, selectedIndex]); const { i18n } = useTranslationContext(); return ( @@ -247,7 +211,7 @@ export function InputArray<T extends object, K extends keyof T>( <div class="-space-y-px rounded-md bg-white "> {list.map((v, idx) => { const label = - getValueDeeper(v, labelField.split(".")) ?? "<<incomplete>>"; + getValueFromPath(v, labelField.split(".")) ?? "<<incomplete>>"; return ( <Option label={label as TranslatedString} @@ -312,9 +276,18 @@ export function InputArray<T extends object, K extends keyof T>( // > <div class="px-4 py-6"> <div class="grid grid-cols-1 gap-y-8 "> - <RenderAllFieldsByUiConfig - fields={convertUiField(i18n, fields, form, getConverterById)} + <SingleColumnFormSectionUI + fields={fields} + handler={form.handler} /> + {/* <RenderAllFieldsByUiConfig + fields={convertUiField( + i18n, + fields, + form.handler, + getConverterById, + )} + /> */} </div> </div> // </FormProvider> @@ -350,20 +323,3 @@ export function InputArray<T extends object, K extends keyof T>( </div> ); } - -export function getValueDeeper( - object: Record<string, any>, - names: string[], -): string { - if (names.length === 0) { - return object as any as string; - } - const [head, ...rest] = names; - if (!head) { - return getValueDeeper(object, rest); - } - if (object === undefined) { - return ""; - } - return getValueDeeper(object[head], rest); -} diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Choice Horizontal", @@ -44,8 +41,9 @@ const initial: TargetObject = { comment: "0", }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ @@ -75,5 +73,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx @@ -2,7 +2,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { UIFormProps } from "./FormProvider.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { useField } from "./useField.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export interface ChoiceH<V> { @@ -16,10 +15,8 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( } & UIFormProps<T, K>, ): VNode { const { choices, label, tooltip, help, required, converter } = props; - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); const { value, onChange, state } = - props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + props.handler ?? noHandlerPropsAndNoContextForField(props.name); if (state.hidden) { return <Fragment />; } @@ -34,7 +31,7 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( <fieldset class="mt-2"> <div class="isolate inline-flex rounded-md shadow-sm"> {choices.map((choice, idx) => { - const convertedValue = converter?.fromStringUI(choice.value as any) + const convertedValue = converter?.fromStringUI(choice.value as any); const isFirst = idx === 0; const isLast = idx === choices.length - 1; let clazz = @@ -62,7 +59,9 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( class={clazz} onClick={(e) => { onChange( - (value === choice.value ? undefined : convertedValue) as any, + (value === choice.value + ? undefined + : convertedValue) as any, ); }} > diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Choice Stacked", @@ -44,8 +41,9 @@ const initial: TargetObject = { comment: "some initial comment", }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ @@ -75,5 +73,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx @@ -2,7 +2,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { UIFormProps } from "./FormProvider.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { useField } from "./useField.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export interface ChoiceS<V> { @@ -29,7 +28,6 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( converter, } = props; - //FIXME: remove deprecated const { value, onChange, state } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input File", @@ -38,14 +35,15 @@ export namespace Simplest { } type TargetObject = { - comment: string; + file?: string; }; const initial: TargetObject = { - comment: "some initial comment", + file: undefined, }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ @@ -53,7 +51,7 @@ const form: FlexibleForm_Deprecated<TargetObject> = { type: "file", label: "label of the field" as TranslatedString, required: true, - id: "comment" as UIHandlerId, + id: "file" as UIHandlerId, accept: ".png", tooltip: "this is a very long tooltip that explain what the field does without being short" as TranslatedString, @@ -64,7 +62,7 @@ const form: FlexibleForm_Deprecated<TargetObject> = { ], }; -export const SimpleComment = tests.createExample(TestedComponent, { +export const AcceptPNG = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx @@ -2,23 +2,13 @@ import { Fragment, VNode, h } from "preact"; import { UIFormProps } from "./FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { useField } from "./useField.js"; export function InputFile<T extends object, K extends keyof T>( props: { maxBites: number; accept?: string } & UIFormProps<T, K>, ): VNode { - const { - label, - tooltip, - required, - help: propsHelp, - maxBites, - accept, - } = props; - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); + const { label, tooltip, required, help: propsHelp, maxBites, accept } = props; const { value, onChange, state } = - props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + props.handler ?? noHandlerPropsAndNoContextForField(props.name); const help = propsHelp ?? state.help; if (state.hidden) { diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Integer", @@ -38,15 +35,16 @@ const initial: TargetObject = { age: 5, }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ { type: "integer", - label: "label of the field" as TranslatedString, - id: "comment" as UIHandlerId, + label: "Age" as TranslatedString, + id: "age" as UIHandlerId, tooltip: "just numbers" as TranslatedString, }, ], @@ -56,5 +54,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx @@ -1,65 +0,0 @@ -/* - 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 { TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; - -export default { - title: "Input Line", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - comment: string; -}; -const initial: TargetObject = { - comment: "some initial comment", -}; - -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "text", - label: "label of the field" as TranslatedString, - id: "comment" as UIHandlerId, - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - form, -}); diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx @@ -2,7 +2,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { Addon, UIFormProps } from "./FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; -import { useField } from "./useField.js"; //@ts-ignore const TooltipIcon = ( diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Select Multiple", @@ -46,14 +43,15 @@ const initial: TargetObject = { things: [], }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ { type: "selectMultiple", - label: "allow diplicates" as TranslatedString, + label: "allow duplicates" as TranslatedString, id: "pets" as UIHandlerId, placeholder: "search..." as TranslatedString, choices: [ @@ -99,5 +97,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx @@ -4,7 +4,6 @@ import { UIFormProps } from "./FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { ChoiceS } from "./InputChoiceStacked.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { useField } from "./useField.js"; export function InputSelectMultiple<T extends object, K extends keyof T>( props: { @@ -13,11 +12,18 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( max?: number; } & UIFormProps<T, K>, ): VNode { - const { converter, label, choices, placeholder, tooltip, required, unique, max } = props; - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); + const { + converter, + label, + choices, + placeholder, + tooltip, + required, + unique, + max, + } = props; const { value, onChange, state } = - props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + props.handler ?? noHandlerPropsAndNoContextForField(props.name); const [filter, setFilter] = useState<string | undefined>(undefined); const regex = new RegExp(`.*${filter}.*`, "i"); diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Select One", @@ -44,8 +41,9 @@ const initial: TargetObject = { things: "one", }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ @@ -76,5 +74,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputSelectOne.tsx b/packages/web-util/src/forms/InputSelectOne.tsx @@ -1,10 +1,9 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { ChoiceS } from "./InputChoiceStacked.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { useField } from "./useField.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export function InputSelectOne<T extends object, K extends keyof T>( props: { @@ -12,11 +11,8 @@ export function InputSelectOne<T extends object, K extends keyof T>( } & UIFormProps<T, K>, ): VNode { const { label, choices, placeholder, tooltip, required } = props; - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); const { value, onChange } = - props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); - + props.handler ?? noHandlerPropsAndNoContextForField(props.name); const [filter, setFilter] = useState<string | undefined>(undefined); const regex = new RegExp(`.*${filter}.*`, "i"); diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Text", @@ -44,8 +41,9 @@ const initial: TargetObject = { comment: "some initial comment", }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ @@ -61,5 +59,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - DefaultForm as TestedComponent, - FlexibleForm_Deprecated, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Text Area", @@ -44,13 +41,14 @@ const initial: TargetObject = { comment: "some initial comment", }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ +const design: FormDesign = { + type: "double-column", + sections: [ { title: "this is a simple form" as TranslatedString, fields: [ { - type: "text", + type: "textArea", label: "label of the field" as TranslatedString, id: "comment" as UIHandlerId, }, @@ -61,5 +59,5 @@ const form: FlexibleForm_Deprecated<TargetObject> = { export const SimpleComment = tests.createExample(TestedComponent, { initial, - form, + design, }); diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx @@ -21,11 +21,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "../tests/hook.js"; -import { - FlexibleForm_Deprecated, - DefaultForm as TestedComponent, -} from "./DefaultForm.js"; -import { UIHandlerId } from "./ui-form.js"; +import { DefaultForm as TestedComponent } from "./forms-ui.js"; +import { FormDesign, UIHandlerId } from "./forms-types.js"; export default { title: "Input Toggle", @@ -38,29 +35,37 @@ export namespace Simplest { } type TargetObject = { - comment: string; + accept: boolean; }; const initial: TargetObject = { - comment: "some initial comment", + accept: true, }; -const form: FlexibleForm_Deprecated<TargetObject> = { - design: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "toggle", - label: "label of the field" as TranslatedString, - threeState: false, - id: "comment" as UIHandlerId, - }, - ], - }, - ], -}; +export const SimpleUsage = tests.createExample(TestedComponent, { + initial, + design: { + type: "single-column", + fields: [ + { + type: "toggle", + label: "do you accept?" as TranslatedString, + id: "accept" as UIHandlerId, + }, + ], + }, +}); -export const SimpleComment = tests.createExample(TestedComponent, { +export const WithThreeState = tests.createExample(TestedComponent, { initial, - form, + design: { + type: "single-column", + fields: [ + { + type: "toggle", + label: "do you accept?" as TranslatedString, + threeState: true, + id: "accept" as UIHandlerId, + }, + ], + }, }); diff --git a/packages/web-util/src/forms/InputToggle.tsx b/packages/web-util/src/forms/InputToggle.tsx @@ -2,7 +2,6 @@ import { VNode, h } from "preact"; import { UIFormProps } from "./FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { useField } from "./useField.js"; export function InputToggle<T extends object, K extends keyof T>( props: { threeState: boolean } & UIFormProps<T, K>, @@ -19,14 +18,13 @@ export function InputToggle<T extends object, K extends keyof T>( converter, threeState, } = props; - //FIXME: remove deprecated - const fieldCtx = useField<T, K>(props.name); const { value, onChange } = - props.handler ?? fieldCtx ?? noHandlerPropsAndNoContextForField(props.name); + props.handler ?? noHandlerPropsAndNoContextForField(props.name); const isOn = !!value; return ( <div class="sm:col-span-6"> + v = {JSON.stringify({ value, isOn })} <div class="flex items-center justify-between"> <LabelWithTooltipMaybeRequired label={label} @@ -51,7 +49,7 @@ export function InputToggle<T extends object, K extends keyof T>( <span data-state={isOn ? "on" : value === undefined ? "undefined" : "off"} class="translate-x-6 data-[state=off]:translate-x-0 data-[state=undefined]:translate-x-3 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> + ></span> </button> </div> </div> diff --git a/packages/web-util/src/forms/converter.ts b/packages/web-util/src/forms/converter.ts @@ -1,130 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2022-2024 Taler Systems S.A. - - GNU Taler is free software; you can redistribute it and/or modify it under the - terms of the GNU General Public License as published by the Free Software - Foundation; either version 3, or (at your option) any later version. - - GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY - WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR - A PARTICULAR PURPOSE. See the GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along with - GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> - */ - -import { - AbsoluteTime, - AmountJson, - Amounts, - TalerExchangeApi, -} from "@gnu-taler/taler-util"; -import { format, parse } from "date-fns"; -import { StringConverter } from "./FormProvider.js"; - -export const amlStateConverter = { - toStringUI: stringifyAmlState, - fromStringUI: parseAmlState, -}; - -function stringifyAmlState(s: TalerExchangeApi.AmlState | undefined): string { - if (s === undefined) return ""; - switch (s) { - case TalerExchangeApi.AmlState.normal: - return "normal"; - case TalerExchangeApi.AmlState.pending: - return "pending"; - case TalerExchangeApi.AmlState.frozen: - return "frozen"; - } -} - -function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { - switch (s) { - case "normal": - return TalerExchangeApi.AmlState.normal; - case "pending": - return TalerExchangeApi.AmlState.pending; - case "frozen": - return TalerExchangeApi.AmlState.frozen; - default: - throw Error(`unknown AML state: ${s}`); - } -} - -const nullConverter: StringConverter<string> = { - fromStringUI(v: string | undefined): string { - return v ?? ""; - }, - toStringUI(v: unknown): string { - return v as string; - }, -}; - -function amountConverter(config: any): StringConverter<AmountJson> { - const currency = config["currency"]; - if (!currency || typeof currency !== "string") { - throw Error(`amount converter needs a currency`); - } - return { - fromStringUI(v: string | undefined): AmountJson { - // FIXME: requires currency - return ( - Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency) - ); - }, - toStringUI(v: unknown): string { - return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); - }, - }; -} - -function absTimeConverter(config: any): StringConverter<AbsoluteTime> { - const pattern = config["pattern"]; - if (!pattern || typeof pattern !== "string") { - throw Error(`absTime converter needs a pattern`); - } - return { - fromStringUI(v: string | undefined): AbsoluteTime { - if (v === undefined) { - return AbsoluteTime.never(); - } - try { - const time = parse(v, pattern, new Date()); - return AbsoluteTime.fromMilliseconds(time.getTime()); - } catch (e) { - return AbsoluteTime.never(); - } - }, - toStringUI(v: unknown): string { - if (v === undefined) return ""; - const d = v as AbsoluteTime; - if (d.t_ms === "never") return "never"; - try { - return format(d.t_ms, pattern); - } catch (e) { - return ""; - } - }, - }; -} - -export function getConverterById( - id: string | undefined, - config: unknown, -): StringConverter<unknown> { - if (id === "Taler.AbsoluteTime") { - // @ts-expect-error check this - return absTimeConverter(config); - } - if (id === "Taler.Amount") { - // @ts-expect-error check this - return amountConverter(config); - } - if (id === "TalerExchangeApi.AmlState") { - // @ts-expect-error check this - return amlStateConverter; - } - return nullConverter as StringConverter<unknown>; -} diff --git a/packages/web-util/src/forms/field-types.ts b/packages/web-util/src/forms/field-types.ts @@ -0,0 +1,117 @@ +import { h as create, Fragment, VNode } from "preact"; +import { Caption } from "./Caption.js"; +import { Group } from "./Group.js"; +import { InputAbsoluteTime } from "./InputAbsoluteTime.js"; +import { InputAmount } from "./InputAmount.js"; +import { InputArray } from "./InputArray.js"; +import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js"; +import { InputChoiceStacked } from "./InputChoiceStacked.js"; +import { InputFile } from "./InputFile.js"; +import { InputInteger } from "./InputInteger.js"; +import { InputSelectMultiple } from "./InputSelectMultiple.js"; +import { InputSelectOne } from "./InputSelectOne.js"; +import { InputText } from "./InputText.js"; +import { InputTextArea } from "./InputTextArea.js"; +import { InputToggle } from "./InputToggle.js"; +import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; +import { + InternationalizationAPI, + UIFieldElementDescription, +} from "../index.browser.js"; +import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; +import { UIFormFieldBaseConfig, UIFormElementConfig } from "./forms-types.js"; +import { HtmlIframe } from "./HtmlIframe.js"; +import { DownloadLink } from "./DownloadLink.js"; +/** + * Constrain the type with the ui props + */ +type FieldType<T extends object = any, K extends keyof T = any> = { + group: Parameters<typeof Group>[0]; + caption: Parameters<typeof Caption>[0]; + "download-link": Parameters<typeof DownloadLink>[0]; + htmlIframe: Parameters<typeof HtmlIframe>[0]; + array: Parameters<typeof InputArray<T, K>>[0]; + file: Parameters<typeof InputFile<T, K>>[0]; + selectOne: Parameters<typeof InputSelectOne<T, K>>[0]; + selectMultiple: Parameters<typeof InputSelectMultiple<T, K>>[0]; + text: Parameters<typeof InputText<T, K>>[0]; + textArea: Parameters<typeof InputTextArea<T, K>>[0]; + choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0]; + choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0]; + absoluteTimeText: Parameters<typeof InputAbsoluteTime<T, K>>[0]; + integer: Parameters<typeof InputInteger<T, K>>[0]; + toggle: Parameters<typeof InputToggle<T, K>>[0]; + amount: Parameters<typeof InputAmount<T, K>>[0]; +}; + +/** + * List all the form fields so typescript can type-check the form instance + */ +export type UIFormField = + | { type: "group"; properties: FieldType["group"] } + | { type: "caption"; properties: FieldType["caption"] } + | { type: "download-link"; properties: FieldType["download-link"] } + | { type: "htmlIframe"; properties: FieldType["htmlIframe"] } + | { type: "array"; properties: FieldType["array"] } + | { type: "file"; properties: FieldType["file"] } + | { type: "amount"; properties: FieldType["amount"] } + | { type: "selectOne"; properties: FieldType["selectOne"] } + | { + type: "selectMultiple"; + properties: FieldType["selectMultiple"]; + } + | { type: "text"; properties: FieldType["text"] } + | { type: "textArea"; properties: FieldType["textArea"] } + | { + type: "choiceStacked"; + properties: FieldType["choiceStacked"]; + } + | { + type: "choiceHorizontal"; + properties: FieldType["choiceHorizontal"]; + } + | { type: "integer"; properties: FieldType["integer"] } + | { type: "toggle"; properties: FieldType["toggle"] } + | { + type: "absoluteTimeText"; + properties: FieldType["absoluteTimeText"]; + }; + +export type FieldComponentFunction<key extends keyof FieldType> = ( + props: FieldType[key], +) => VNode; + +type UIFormFieldMap = { + [key in keyof FieldType]: FieldComponentFunction<key>; +}; + +/** + * Maps input type with component implementation + */ +export const UIFormConfiguration: UIFormFieldMap = { + group: Group, + "download-link": DownloadLink, + caption: Caption, + htmlIframe: HtmlIframe, + //@ts-ignore + array: InputArray, + text: InputText, + //@ts-ignore + file: InputFile, + textArea: InputTextArea, + //@ts-ignore + absoluteTimeText: InputAbsoluteTime, + //@ts-ignore + choiceStacked: InputChoiceStacked, + //@ts-ignore + choiceHorizontal: InputChoiceHorizontal, + integer: InputInteger, + //@ts-ignore + selectOne: InputSelectOne, + //@ts-ignore + selectMultiple: InputSelectMultiple, + //@ts-ignore + toggle: InputToggle, + //@ts-ignore + amount: InputAmount, +}; diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -0,0 +1,420 @@ +import { + buildCodecForObject, + buildCodecForUnion, + Codec, + codecForBoolean, + codecForConstString, + codecForLazy, + codecForList, + codecForNumber, + codecForString, + codecForStringURL, + codecForTimestamp, + codecOptional, + codecOptionalDefault, + Integer, + TalerProtocolTimestamp, + TranslatedString, +} from "@gnu-taler/taler-util"; + +export type FormDesign = DoubleColumnFormDesign | SingleColumnFormDesign; + +/** + * form with composed by multiple sections + */ +export type DoubleColumnFormDesign = { + type: "double-column"; + sections: DoubleColumnFormSection[]; + // behavior?: (form: Partial<T>) => FormState<T>; +}; + +/** + * single section form + */ +export type SingleColumnFormDesign = { + type: "single-column"; + fields: UIFormElementConfig[]; +}; + +export type DoubleColumnFormSection = { + title: string; + description?: string; + fields: UIFormElementConfig[]; +}; + +export type UIFormElementConfig = + | UIFormElementGroup + | UIFormElementCaption + | UIFormElementDownloadLink + | UIFormElementHtmlIframe + | UIFormFieldAbsoluteTime + | UIFormFieldAmount + | UIFormFieldArray + | UIFormFieldChoiseHorizontal + | UIFormFieldChoiseStacked + | UIFormFieldFile + | UIFormFieldInteger + | UIFormFieldSelectMultiple + | UIFormFieldSelectOne + | UIFormFieldText + | UIFormFieldTextArea + | UIFormFieldToggle; + +type UIFormFieldAbsoluteTime = { + type: "absoluteTimeText"; + max?: TalerProtocolTimestamp; + min?: TalerProtocolTimestamp; + pattern: string; +} & UIFormFieldBaseConfig; + +type UIFormFieldAmount = { + type: "amount"; + max?: Integer; + min?: Integer; + currency: string; +} & UIFormFieldBaseConfig; + +type UIFormFieldArray = { + type: "array"; + // id of the field shown when the array is collapsed + labelFieldId: UIHandlerId; + fields: UIFormElementConfig[]; +} & UIFormFieldBaseConfig; + +type UIFormElementCaption = { type: "caption" } & UIFieldElementDescription; +type UIFormElementDownloadLink = { + type: "download-link"; + url: string; + media?: string; +} & UIFieldElementDescription; +type UIFormElementHtmlIframe = { + type: "htmlIframe"; + url: string; +} & UIFieldElementDescription; + +type UIFormElementGroup = { + type: "group"; + fields: UIFormElementConfig[]; +} & UIFieldElementDescription; + +type UIFormFieldChoiseHorizontal = { + type: "choiceHorizontal"; + choices: Array<SelectUiChoice>; + allowFreeForm?: boolean; +} & UIFormFieldBaseConfig; + +type UIFormFieldChoiseStacked = { + type: "choiceStacked"; + choices: Array<SelectUiChoice>; + allowFreeForm?: boolean; +} & UIFormFieldBaseConfig; + +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; +} & UIFormFieldBaseConfig; + +type UIFormFieldInteger = { + type: "integer"; + max?: Integer; + min?: Integer; +} & UIFormFieldBaseConfig; + +export interface SelectUiChoice { + label: string; + description?: string; + value: string; +} + +type UIFormFieldSelectMultiple = { + type: "selectMultiple"; + max?: Integer; + min?: Integer; + unique?: boolean; + choices: Array<SelectUiChoice>; + allowFreeForm?: boolean; +} & UIFormFieldBaseConfig; + +type UIFormFieldSelectOne = { + type: "selectOne"; + choices: Array<SelectUiChoice>; + allowFreeForm?: boolean; +} & UIFormFieldBaseConfig; +type UIFormFieldText = { type: "text" } & UIFormFieldBaseConfig; +type UIFormFieldTextArea = { type: "textArea" } & UIFormFieldBaseConfig; +type UIFormFieldToggle = { + type: "toggle"; + threeState?: boolean; +} & UIFormFieldBaseConfig; + +export type UIFieldElementDescription = { + /* label if the field, visible for the user */ + label: string; + + /* long text to be shown on user demand */ + tooltip?: string; + + /* short text to be shown close to the field, usually below and dimmer*/ + help?: string; + + /* if the field should be initially hidden */ + hidden?: boolean; + + /* ui element to show before */ + addonBeforeId?: string; + + /* ui element to show after */ + addonAfterId?: string; +}; + +export type UIFormFieldBaseConfig = UIFieldElementDescription & { + /* example to be shown inside the field */ + placeholder?: string; + + /* show a mark as required */ + required?: boolean; + + /* readonly and dim */ + disabled?: boolean; + + /* conversion id to convert the string into the value type + the id should be known to the ui impl + */ + converterId?: string; + + /* return an error message if the value is not valid, returns un undefined + if there is no error + */ + validator?: (value: string) => TranslatedString | undefined; + + /* property id of the form */ + id: UIHandlerId; +}; + +declare const __handlerId: unique symbol; +export type UIHandlerId = string & { [__handlerId]: true }; + +// FIXME: validate well formed ui field id +const codecForUiFieldId = codecForString as () => Codec<UIHandlerId>; + +const codecForUIFormFieldBaseDescriptionTemplate = < + T extends UIFieldElementDescription, +>() => + buildCodecForObject<T>() + .property("addonAfterId", codecOptional(codecForString())) + .property("addonBeforeId", codecOptional(codecForString())) + .property("hidden", codecOptional(codecForBoolean())) + .property("help", codecOptional(codecForString())) + .property("label", codecForString()) + .property("tooltip", codecOptional(codecForString())); + +const codecForUIFormFieldBaseConfigTemplate = < + T extends UIFormFieldBaseConfig, +>() => + 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> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldAbsoluteTime>() + .property("type", codecForConstString("absoluteTimeText")) + .property("pattern", codecForString()) + .property("max", codecOptional(codecForTimestamp)) + .property("min", codecOptional(codecForTimestamp)) + .build("UIFormFieldAbsoluteTime"); + +const codecForUiFormFieldAmount = (): Codec<UIFormFieldAmount> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldAmount>() + .property("type", codecForConstString("amount")) + .property("currency", codecForString()) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .build("UIFormFieldAmount"); + +const codecForUiFormFieldArray = (): Codec<UIFormFieldArray> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldArray>() + .property("type", codecForConstString("array")) + .property("labelFieldId", codecForUiFieldId()) + .property("tooltip", codecOptional(codecForString())) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .property("fields", codecForList(codecForUiFormField())) + .build("UIFormFieldArray"); + +const codecForUiFormFieldCaption = (): Codec<UIFormElementCaption> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementCaption>() + .property("type", codecForConstString("caption")) + .build("UIFormFieldCaption"); + +const codecForUIFormElementLink = (): Codec<UIFormElementDownloadLink> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementDownloadLink>() + .property("type", codecForConstString("download-link")) + .property("url", codecForString()) + .property("media", codecOptional(codecForString())) + .build("UIFormElementLink"); + +const codecForUiFormFieldHtmlIFrame = (): Codec<UIFormElementHtmlIframe> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementHtmlIframe>() + .property("type", codecForConstString("htmlIframe")) + .property("url", codecForStringURL()) + .build("codecForUiFormFieldHtmlIFrame"); + +const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => + buildCodecForObject<SelectUiChoice>() + .property("description", codecOptional(codecForString())) + .property("label", codecForString()) + .property("value", codecForString()) + .build("SelectUiChoice"); + +const codecForUiFormFieldChoiceHorizontal = + (): Codec<UIFormFieldChoiseHorizontal> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseHorizontal>() + .property("type", codecForConstString("choiceHorizontal")) + .property("allowFreeForm", codecOptional(codecForBoolean())) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldChoiseHorizontal"); + +const codecForUiFormFieldChoiceStacked = (): Codec<UIFormFieldChoiseStacked> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseStacked>() + .property("type", codecForConstString("choiceStacked")) + .property("allowFreeForm", codecOptional(codecForBoolean())) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldChoiseStacked"); + +const codecForUiFormFieldFile = (): Codec<UIFormFieldFile> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldFile>() + .property("type", codecForConstString("file")) + .property("accept", codecOptional(codecForString())) + .property("maxBytes", codecOptional(codecForNumber())) + .property("minBytes", codecOptional(codecForNumber())) + .build("UIFormFieldFile"); + +const codecForUiFormFieldGroup = (): Codec<UIFormElementGroup> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementGroup>() + .property("type", codecForConstString("group")) + // eslint-disable-next-line @typescript-eslint/no-use-before-define + .property("fields", codecForList(codecForUiFormField())) + .build("UiFormFieldGroup"); + +const codecForUiFormFieldInteger = (): Codec<UIFormFieldInteger> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldInteger>() + .property("type", codecForConstString("integer")) + // .property("properties", codecForUIFormFieldBaseConfig()) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .build("UIFormFieldInteger"); + +const codecForUiFormFieldSelectMultiple = + (): Codec<UIFormFieldSelectMultiple> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectMultiple>() + .property("type", codecForConstString("selectMultiple")) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .property("unique", codecOptional(codecForBoolean())) + .property("allowFreeForm", codecOptional(codecForBoolean())) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UiFormFieldSelectMultiple"); + +const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldSelectOne> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectOne>() + .property("type", codecForConstString("selectOne")) + .property("allowFreeForm", codecOptional(codecForBoolean())) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldSelectOne"); + +const codecForUiFormFieldText = (): Codec<UIFormFieldText> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldText>() + .property("type", codecForConstString("text")) + .build("UIFormFieldText"); + +const codecForUiFormFieldTextArea = (): Codec<UIFormFieldTextArea> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldTextArea>() + .property("type", codecForConstString("textArea")) + .build("UIFormFieldTextArea"); + +const codecForUiFormFieldToggle = (): Codec<UIFormFieldToggle> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldToggle>() + .property("threeState", codecOptionalDefault(codecForBoolean(), false)) + .property("type", codecForConstString("toggle")) + .build("UIFormFieldToggle"); + +const codecForUiFormField = (): Codec<UIFormElementConfig> => + buildCodecForUnion<UIFormElementConfig>() + .discriminateOn("type") + .alternative("array", codecForLazy(codecForUiFormFieldArray)) + .alternative("group", codecForLazy(codecForUiFormFieldGroup)) + .alternative("download-link", codecForUIFormElementLink()) + .alternative("absoluteTimeText", codecForUiFormFieldAbsoluteTime()) + .alternative("amount", codecForUiFormFieldAmount()) + .alternative("caption", codecForUiFormFieldCaption()) + .alternative("htmlIframe", codecForUiFormFieldHtmlIFrame()) + .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) + .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) + .alternative("file", codecForUiFormFieldFile()) + .alternative("integer", codecForUiFormFieldInteger()) + .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) + .alternative("selectOne", codecForUiFormFieldSelectOne()) + .alternative("text", codecForUiFormFieldText()) + .alternative("textArea", codecForUiFormFieldTextArea()) + .alternative("toggle", codecForUiFormFieldToggle()) + .build("UIFormField"); + +const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => + buildCodecForObject<DoubleColumnFormSection>() + .property("title", codecForString()) + .property("description", codecOptional(codecForString())) + .property("fields", codecForList(codecForUiFormField())) + .build("DoubleColumnFormSection"); + +const codecForDoubleColumnFormDesign = (): Codec<DoubleColumnFormDesign> => + buildCodecForObject<DoubleColumnFormDesign>() + .property("type", codecForConstString("double-column")) + .property("sections", codecForList(codecForDoubleColumnFormSection())) + .build("DoubleColumnFormDesign"); + +const codecForSingleColumnFormDesign = (): Codec<SingleColumnFormDesign> => + buildCodecForObject<SingleColumnFormDesign>() + .property("type", codecForConstString("single-column")) + .property("fields", codecForList(codecForUiFormField())) + .build("SingleColumnFormDesign"); + +const codecForFormDesign = (): Codec<FormDesign> => + buildCodecForUnion<FormDesign>() + .discriminateOn("type") + .alternative("double-column", codecForDoubleColumnFormDesign()) + .alternative("single-column", codecForSingleColumnFormDesign()) + .build<FormDesign>("FormDesign"); + +const codecForFormMetadata = (): Codec<FormMetadata> => + buildCodecForObject<FormMetadata>() + .property("label", codecForString()) + .property("description", codecOptional(codecForString())) + .property("id", codecForString()) + .property("version", codecForNumber()) + .property("config", codecForFormDesign()) + .build("FormMetadata"); + +export const codecForUIForms = (): Codec<UiForms> => + buildCodecForObject<UiForms>() + .property("forms", codecForList(codecForFormMetadata())) + .build("UiForms"); + +export type FormMetadata = { + label: string; + description?: string; + id: string; + version: number; + config: FormDesign; +}; + +export interface UiForms { + // Where libeufin backend is localted + // default: window.origin without "webui/" + forms: Array<FormMetadata>; +} diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -0,0 +1,129 @@ +import { Fragment, h, h as create, VNode } from "preact"; +import { FormHandler, useForm } from "../hooks/useForm.js"; +// import { getConverterById, useTranslationContext } from "../index.browser.js"; +import { convertFormConfigToUiField } from "./forms-utils.js"; +import { + DoubleColumnFormSection, + FormDesign, + UIFormElementConfig, +} from "./forms-types.js"; +import { + FieldComponentFunction, + UIFormConfiguration, + UIFormField, +} from "./field-types.js"; +import { useTranslationContext } from "../index.browser.js"; + +export function DefaultForm<T>({ + design, + initial, +}: { + design: FormDesign; + initial: object; +}): VNode { + const { handler, status } = useForm(design, initial); + + return ( + <div> + <FormUI design={design} handler={handler} /> + <pre class="break-all whitespace-pre-wrap"> + {JSON.stringify({ status }, undefined, 2)} + </pre> + </div> + ); +} + +export function FormUI<T>({ + design, + handler, +}: { + design: FormDesign; + handler: FormHandler<T>; +}): VNode { + switch (design.type) { + case "double-column": { + const ui = design.sections.map((section, i) => { + if (!section) return <Fragment />; + return ( + <DoubleColumnFormSectionUI section={section} handler={handler} /> + ); + }); + return <Fragment>{ui}</Fragment>; + } + case "single-column": { + return ( + <SingleColumnFormSectionUI fields={design.fields} handler={handler} /> + ); + } + } +} + +export function DoubleColumnFormSectionUI<T>({ + section, + handler, +}: { + handler: FormHandler<T>; + section: DoubleColumnFormSection; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + {section.title} + </h2> + {section.description && ( + <p class="mt-1 text-sm leading-6 text-gray-600"> + {section.description} + </p> + )} + </div> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> + <div class="p-3"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <RenderAllFieldsByUiConfig + fields={convertFormConfigToUiField(i18n, section.fields, handler)} + /> + </div> + </div> + </div> + </div> + ); +} +export function SingleColumnFormSectionUI<T>({ + fields, + handler, +}: { + handler: FormHandler<T>; + fields: UIFormElementConfig[]; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> + <div class="p-3"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <RenderAllFieldsByUiConfig + fields={convertFormConfigToUiField(i18n, fields, handler)} + /> + </div> + </div> + </div> + ); +} + +export function RenderAllFieldsByUiConfig({ + fields, +}: { + fields: UIFormField[]; +}): VNode { + return create( + Fragment, + {}, + fields.map((field, i) => { + const Component = UIFormConfiguration[ + field.type + ] as FieldComponentFunction<any>; + return Component(field.properties); + }), + ); +} diff --git a/packages/web-util/src/forms/forms-utils.ts b/packages/web-util/src/forms/forms-utils.ts @@ -0,0 +1,382 @@ +import { + AbsoluteTime, + AmountJson, + Amounts, + assertUnreachable, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { + InternationalizationAPI, + UIFieldElementDescription, +} from "../index.browser.js"; +import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; +import { UIFormElementConfig, UIFormFieldBaseConfig } from "./forms-types.js"; +import { UIFormField } from "./field-types.js"; +import { format, parse } from "date-fns"; + +/** + * convert field configuration to render function + * FIXME: change this mapping for something not so insane + * + * @param i18n_ + * @param fieldConfig + * @param form + * @returns + */ +export function convertFormConfigToUiField( + i18n_: InternationalizationAPI, + fieldConfig: UIFormElementConfig[], + form: object, +): UIFormField[] { + return fieldConfig.map((config) => { + // NON input fields + switch (config.type) { + case "caption": { + const resp: UIFormField = { + type: config.type, + properties: converBaseFieldsProps(i18n_, config), + }; + return resp; + } + case "download-link": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config), + label: i18n_.str`${config.label}`, + url: config.url, + media: config.media, + }, + }; + return resp; + } + case "htmlIframe": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config), + url: config.url, + }, + }; + return resp; + } + case "group": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config), + fields: convertFormConfigToUiField(i18n_, config.fields, form), + }, + }; + return resp; + } + } + // Input Fields + switch (config.type) { + case "array": { + return { + type: "array", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + labelField: config.labelFieldId, + fields: config.fields, + // convertFormConfigToUiField( + // i18n_, + // config.fields, + // (form as any)[config.id].value ?? {}, + // getConverterByFieldType, + // ), + }, + } as UIFormField; + } + case "absoluteTimeText": { + return { + type: "absoluteTimeText", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + }, + } as UIFormField; + } + case "amount": { + return { + type: "amount", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + currency: config.currency, + }, + } as UIFormField; + } + case "choiceHorizontal": { + return { + type: "choiceHorizontal", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + choices: config.choices, + }, + } as UIFormField; + } + case "choiceStacked": { + return { + type: "choiceStacked", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + choices: config.choices, + }, + } as UIFormField; + } + case "file": { + return { + type: "file", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + accept: config.accept, + maxBites: config.maxBytes, + }, + } as UIFormField; + } + case "integer": { + return { + type: "integer", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + }, + } as UIFormField; + } + case "selectMultiple": { + return { + type: "selectMultiple", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + choices: config.choices, + unique: config.unique, + }, + } as UIFormField; + } + case "selectOne": { + return { + type: "selectOne", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + choices: config.choices, + }, + } as UIFormField; + } + case "text": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + }, + } as UIFormField; + } + case "textArea": { + return { + type: "textArea", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + }, + } as UIFormField; + } + case "toggle": { + return { + type: "toggle", + properties: { + ...converBaseFieldsProps(i18n_, config), + ...converInputFieldsProps( + form, + config, + getConverterByFieldType(config.type, config), + ), + threeState: config.threeState, + }, + } as UIFormField; + } + default: { + assertUnreachable(config); + } + } + }); +} + +function getAddonById(_id: string | undefined): Addon { + return undefined!; +} + +function getConverterByFieldType( + fieldType: string | undefined, + config: unknown, +): StringConverter<unknown> { + if (fieldType === "absoluteTimeText") { + // @ts-expect-error check this + return absTimeConverter(config); + } + if (fieldType === "amount") { + // @ts-expect-error check this + return amountConverter(config); + } + if (fieldType === "TalerExchangeApi.AmlState") { + // @ts-expect-error check this + return amlStateConverter; + } + return nullConverter as StringConverter<unknown>; +} + +function converInputFieldsProps( + form: object, + p: UIFormFieldBaseConfig, + converter: StringConverter<unknown>, +) { + const names = p.id.split("."); + // console.log("NAMES", names, getValueDeeper2(form, names), form) + return { + converter, + handler: getValueDeeper2(form, names), + required: p.required, + disabled: p.disabled, + name: names[names.length - 1], + help: p.help, + placeholder: p.placeholder, + tooltip: p.tooltip, + label: p.label as TranslatedString, + }; +} + +function converBaseFieldsProps( + i18n_: InternationalizationAPI, + p: UIFieldElementDescription, +) { + return { + after: getAddonById(p.addonAfterId), + before: getAddonById(p.addonBeforeId), + hidden: p.hidden, + help: i18n_.str`${p.help}`, + label: i18n_.str`${p.label}`, + tooltip: i18n_.str`${p.tooltip}`, + }; +} + +function getValueDeeper2( + object: Record<string, any>, + names: string[], +): UIFieldHandler { + if (names.length === 0) return object as UIFieldHandler; + const [head, ...rest] = names; + if (!head) { + return getValueDeeper2(object, rest); + } + if (object === undefined) { + throw Error("handler not found"); + } + return getValueDeeper2(object[head], rest); +} + +const nullConverter: StringConverter<string> = { + fromStringUI(v: string | undefined): string { + return v ?? ""; + }, + toStringUI(v: unknown): string { + return v as string; + }, +}; + +function amountConverter(config: any): StringConverter<AmountJson> { + const currency = config["currency"]; + if (!currency || typeof currency !== "string") { + throw Error(`amount converter needs a currency`); + } + return { + fromStringUI(v: string | undefined): AmountJson { + return ( + Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency) + ); + }, + toStringUI(v: unknown): string { + return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); + }, + }; +} + +function absTimeConverter(config: any): StringConverter<AbsoluteTime> { + const pattern = config["pattern"]; + if (!pattern || typeof pattern !== "string") { + throw Error(`absTime converter needs a pattern`); + } + return { + fromStringUI(v: string | undefined): AbsoluteTime { + if (v === undefined) { + return AbsoluteTime.never(); + } + try { + const time = parse(v, pattern, new Date()); + return AbsoluteTime.fromMilliseconds(time.getTime()); + } catch (e) { + return AbsoluteTime.never(); + } + }, + toStringUI(v: unknown): string { + if (v === undefined) return ""; + const d = v as AbsoluteTime; + if (d.t_ms === "never") return "never"; + try { + return format(d.t_ms, pattern); + } catch (e) { + return ""; + } + }, + }; +} diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts @@ -1,411 +0,0 @@ -import { h as create, Fragment, VNode } from "preact"; -import { Caption } from "./Caption.js"; -import { Group } from "./Group.js"; -import { InputAbsoluteTime } from "./InputAbsoluteTime.js"; -import { InputAmount } from "./InputAmount.js"; -import { InputArray } from "./InputArray.js"; -import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js"; -import { InputChoiceStacked } from "./InputChoiceStacked.js"; -import { InputFile } from "./InputFile.js"; -import { InputInteger } from "./InputInteger.js"; -import { InputSelectMultiple } from "./InputSelectMultiple.js"; -import { InputSelectOne } from "./InputSelectOne.js"; -import { InputText } from "./InputText.js"; -import { InputTextArea } from "./InputTextArea.js"; -import { InputToggle } from "./InputToggle.js"; -import { Addon, StringConverter, UIFieldHandler } from "./FormProvider.js"; -import { - InternationalizationAPI, - UIFieldElementDescription, -} from "../index.browser.js"; -import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; -import { UIFormFieldBaseConfig, UIFormElementConfig } from "./ui-form.js"; -import { HtmlIframe } from "./HtmlIframe.js"; -import { DownloadLink } from "./DownloadLink.js"; -/** - * Constrain the type with the ui props - */ -type FieldType<T extends object = any, K extends keyof T = any> = { - group: Parameters<typeof Group>[0]; - caption: Parameters<typeof Caption>[0]; - "download-link": Parameters<typeof DownloadLink>[0]; - htmlIframe: Parameters<typeof HtmlIframe>[0]; - array: Parameters<typeof InputArray<T, K>>[0]; - file: Parameters<typeof InputFile<T, K>>[0]; - selectOne: Parameters<typeof InputSelectOne<T, K>>[0]; - selectMultiple: Parameters<typeof InputSelectMultiple<T, K>>[0]; - text: Parameters<typeof InputText<T, K>>[0]; - textArea: Parameters<typeof InputTextArea<T, K>>[0]; - choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0]; - choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0]; - absoluteTimeText: Parameters<typeof InputAbsoluteTime<T, K>>[0]; - integer: Parameters<typeof InputInteger<T, K>>[0]; - toggle: Parameters<typeof InputToggle<T, K>>[0]; - amount: Parameters<typeof InputAmount<T, K>>[0]; -}; - -/** - * List all the form fields so typescript can type-check the form instance - */ -export type UIFormField = - | { type: "group"; properties: FieldType["group"] } - | { type: "caption"; properties: FieldType["caption"] } - | { type: "download-link"; properties: FieldType["download-link"] } - | { type: "htmlIframe"; properties: FieldType["htmlIframe"] } - | { type: "array"; properties: FieldType["array"] } - | { type: "file"; properties: FieldType["file"] } - | { type: "amount"; properties: FieldType["amount"] } - | { type: "selectOne"; properties: FieldType["selectOne"] } - | { - type: "selectMultiple"; - properties: FieldType["selectMultiple"]; - } - | { type: "text"; properties: FieldType["text"] } - | { type: "textArea"; properties: FieldType["textArea"] } - | { - type: "choiceStacked"; - properties: FieldType["choiceStacked"]; - } - | { - type: "choiceHorizontal"; - properties: FieldType["choiceHorizontal"]; - } - | { type: "integer"; properties: FieldType["integer"] } - | { type: "toggle"; properties: FieldType["toggle"] } - | { - type: "absoluteTimeText"; - properties: FieldType["absoluteTimeText"]; - }; - -type FieldComponentFunction<key extends keyof FieldType> = ( - props: FieldType[key], -) => VNode; - -type UIFormFieldMap = { - [key in keyof FieldType]: FieldComponentFunction<key>; -}; - -/** - * Maps input type with component implementation - */ -const UIFormConfiguration: UIFormFieldMap = { - group: Group, - "download-link": DownloadLink, - caption: Caption, - htmlIframe: HtmlIframe, - //@ts-ignore - array: InputArray, - text: InputText, - //@ts-ignore - file: InputFile, - textArea: InputTextArea, - //@ts-ignore - absoluteTimeText: InputAbsoluteTime, - //@ts-ignore - choiceStacked: InputChoiceStacked, - //@ts-ignore - choiceHorizontal: InputChoiceHorizontal, - integer: InputInteger, - //@ts-ignore - selectOne: InputSelectOne, - //@ts-ignore - selectMultiple: InputSelectMultiple, - //@ts-ignore - toggle: InputToggle, - //@ts-ignore - amount: InputAmount, -}; - -export function RenderAllFieldsByUiConfig({ - fields, -}: { - fields: UIFormField[]; -}): VNode { - return create( - Fragment, - {}, - fields.map((field, i) => { - const Component = UIFormConfiguration[ - field.type - ] as FieldComponentFunction<any>; - return Component(field.properties); - }), - ); -} - -// type FormSet<T extends object> = { -// Provider: typeof FormProvider<T>; -// InputLine: <K extends keyof T>() => typeof InputLine<T, K>; -// InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<T, K>; -// }; - -/** - * Helper function that created a typed object. - * - * @returns - */ -// export function createNewForm<T extends object>() { -// const res: FormSet<T> = { -// Provider: FormProvider, -// InputLine: () => InputLine, -// InputChoiceHorizontal: () => InputChoiceHorizontal, -// }; -// return { -// Provider: res.Provider, -// InputLine: res.InputLine(), -// InputChoiceHorizontal: res.InputChoiceHorizontal(), -// }; -// } - -/** - * convert field configuration to render function - * - * @param i18n_ - * @param fieldConfig - * @param form - * @returns - */ -export function convertUiField( - i18n_: InternationalizationAPI, - fieldConfig: UIFormElementConfig[], - form: object, - getConverterById: GetConverterById, -): UIFormField[] { - return fieldConfig.map((config) => { - // NON input fields - switch (config.type) { - case "caption": { - const resp: UIFormField = { - type: config.type, - properties: converBaseFieldsProps(i18n_, config), - }; - return resp; - } - case "download-link": { - const resp: UIFormField = { - type: config.type, - properties: { - ...converBaseFieldsProps(i18n_, config), - label: i18n_.str`${config.label}`, - url: config.url, - media: config.media, - }, - }; - return resp; - } - case "htmlIframe": { - const resp: UIFormField = { - type: config.type, - properties: { - ...converBaseFieldsProps(i18n_, config), - url: config.url, - }, - }; - return resp; - } - case "group": { - const resp: UIFormField = { - type: config.type, - properties: { - ...converBaseFieldsProps(i18n_, config), - fields: convertUiField( - i18n_, - config.fields, - form, - getConverterById, - ), - }, - }; - return resp; - } - } - // Input Fields - switch (config.type) { - case "array": { - return { - type: "array", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - labelField: config.labelFieldId, - fields: config.fields, - // convertUiField( - // i18n_, - // config.fields, - // (form as any)[config.id].value ?? {}, - // getConverterById, - // ), - }, - } as UIFormField; - } - case "absoluteTimeText": { - return { - type: "absoluteTimeText", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - }, - } as UIFormField; - } - case "amount": { - return { - type: "amount", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - currency: config.currency, - }, - } as UIFormField; - } - case "choiceHorizontal": { - return { - type: "choiceHorizontal", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - choices: config.choices, - }, - } as UIFormField; - } - case "choiceStacked": { - return { - type: "choiceStacked", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - choices: config.choices, - }, - } as UIFormField; - } - case "file": { - return { - type: "file", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - accept: config.accept, - maxBites: config.maxBytes, - }, - } as UIFormField; - } - case "integer": { - return { - type: "integer", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - }, - } as UIFormField; - } - case "selectMultiple": { - return { - type: "selectMultiple", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - choices: config.choices, - }, - } as UIFormField; - } - case "selectOne": { - return { - type: "selectOne", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - choices: config.choices, - }, - } as UIFormField; - } - case "text": { - return { - type: "text", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - }, - } as UIFormField; - } - case "textArea": { - return { - type: "textArea", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - }, - } as UIFormField; - } - case "toggle": { - return { - type: "toggle", - properties: { - ...converBaseFieldsProps(i18n_, config), - ...converInputFieldsProps(form, config, getConverterById), - threeState: config.threeState, - }, - } as UIFormField; - } - default: { - assertUnreachable(config); - } - } - }); -} - -function getAddonById(_id: string | undefined): Addon { - return undefined!; -} - -type GetConverterById = ( - id: string | undefined, - config: unknown, -) => StringConverter<unknown>; - -function converInputFieldsProps( - form: object, - p: UIFormFieldBaseConfig, - getConverterById: GetConverterById, -) { - const names = p.id.split("."); - // console.log("NAMES", names, getValueDeeper2(form, names), form) - return { - converter: getConverterById(p.converterId, p), - handler: getValueDeeper2(form, names), - required: p.required, - disabled: p.disabled, - name: names[names.length - 1], - help: p.help, - placeholder: p.placeholder, - tooltip: p.tooltip, - label: p.label as TranslatedString, - }; -} - -function converBaseFieldsProps( - i18n_: InternationalizationAPI, - p: UIFieldElementDescription, -) { - return { - after: getAddonById(p.addonAfterId), - before: getAddonById(p.addonBeforeId), - hidden: p.hidden, - help: i18n_.str`${p.help}`, - label: i18n_.str`${p.label}`, - tooltip: i18n_.str`${p.tooltip}`, - }; -} - -export function getValueDeeper2( - object: Record<string, any>, - names: string[], -): UIFieldHandler { - if (names.length === 0) return object as UIFieldHandler; - const [head, ...rest] = names; - if (!head) { - return getValueDeeper2(object, rest); - } - if (object === undefined) { - throw Error("handler not found"); - } - return getValueDeeper2(object[head], rest); -} diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts @@ -5,7 +5,6 @@ export * as a4 from "./InputChoiceStacked.stories.js"; export * as a5 from "./InputAbsoluteTime.stories.js"; export * as a6 from "./InputFile.stories.js"; export * as a7 from "./InputInteger.stories.js"; -export * as a8 from "./InputLine.stories.js"; export * as a9 from "./InputSelectMultiple.stories.js"; export * as a10 from "./InputSelectOne.stories.js"; export * as a11 from "./InputText.stories.js"; diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts @@ -1,26 +1,23 @@ -export * from "./Calendar.js" -export * from "./Caption.js" -export * from "./HtmlIframe.js" -export * from "./DefaultForm.js" -export * from "./Dialog.js" -export * from "./FormProvider.js" -export * from "./Group.js" -export * from "./InputAbsoluteTime.js" -export * from "./InputAmount.js" -export * from "./InputArray.js" -export * from "./InputChoiceHorizontal.js" -export * from "./InputChoiceStacked.js" -export * from "./InputFile.js" -export * from "./InputInteger.js" -export * from "./InputLine.js" -export * from "./InputSelectMultiple.js" -export * from "./InputSelectOne.js" -export * from "./InputText.js" -export * from "./InputTextArea.js" -export * from "./InputToggle.js" -export * from "./TimePicker.js" -export * from "./forms.js" -export * from "./ui-form.js" -export * from "./converter.js" -export * from "./useField.js" - +export * from "./Calendar.js"; +export * from "./Caption.js"; +export * from "./HtmlIframe.js"; +export * from "./Dialog.js"; +export * from "./FormProvider.js"; +export * from "./Group.js"; +export * from "./InputAbsoluteTime.js"; +export * from "./InputAmount.js"; +export * from "./InputArray.js"; +export * from "./InputChoiceHorizontal.js"; +export * from "./InputChoiceStacked.js"; +export * from "./InputFile.js"; +export * from "./InputInteger.js"; +export * from "./InputLine.js"; +export * from "./InputSelectMultiple.js"; +export * from "./InputSelectOne.js"; +export * from "./InputText.js"; +export * from "./InputTextArea.js"; +export * from "./InputToggle.js"; +export * from "./TimePicker.js"; +export * from "./field-types.js"; +export * from "./forms-types.js"; +export * from "./forms-ui.js"; diff --git a/packages/web-util/src/forms/ui-form.ts b/packages/web-util/src/forms/ui-form.ts @@ -1,421 +0,0 @@ -import { - buildCodecForObject, - buildCodecForUnion, - Codec, - codecForBoolean, - codecForCanonBaseUrl, - codecForConstString, - codecForLazy, - codecForList, - codecForNumber, - codecForString, - codecForStringURL, - codecForTimestamp, - codecOptional, - codecOptionalDefault, - Integer, - TalerProtocolTimestamp, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { InternationalizationAPI } from "../index.browser.js"; - -export type FormConfiguration = DoubleColumnForm | SingleColumnForm; - -export type DoubleColumnForm = { - type: "double-column"; - design: DoubleColumnFormSection[]; - // behavior?: (form: Partial<T>) => FormState<T>; -}; - -export type SingleColumnForm = { - type: "single-column"; - fields: UIFormElementConfig[]; -}; - -export type DoubleColumnFormSection = { - title: string; - description?: string; - fields: UIFormElementConfig[]; -}; - -// export interface BaseForm { -// state: TalerExchangeApi.AmlState; -// threshold: AmountJson; -// } - -export type UIFormElementConfig = - | UIFormElementGroup - | UIFormElementCaption - | UIFormElementDownloadLink - | UIFormElementHtmlIframe - | UIFormFieldAbsoluteTime - | UIFormFieldAmount - | UIFormFieldArray - | UIFormFieldChoiseHorizontal - | UIFormFieldChoiseStacked - | UIFormFieldFile - | UIFormFieldInteger - | UIFormFieldSelectMultiple - | UIFormFieldSelectOne - | UIFormFieldText - | UIFormFieldTextArea - | UIFormFieldToggle; - -type UIFormFieldAbsoluteTime = { - type: "absoluteTimeText"; - max?: TalerProtocolTimestamp; - min?: TalerProtocolTimestamp; - pattern: string; -} & UIFormFieldBaseConfig; - -type UIFormFieldAmount = { - type: "amount"; - max?: Integer; - min?: Integer; - currency: string; -} & UIFormFieldBaseConfig; - -type UIFormFieldArray = { - type: "array"; - // id of the field shown when the array is collapsed - labelFieldId: UIHandlerId; - fields: UIFormElementConfig[]; -} & UIFormFieldBaseConfig; - -type UIFormElementCaption = { type: "caption" } & UIFieldElementDescription; -type UIFormElementDownloadLink = { - type: "download-link"; - url: string; - media?: string; -} & UIFieldElementDescription; -type UIFormElementHtmlIframe = { - type: "htmlIframe"; - url: string; -} & UIFieldElementDescription; - -type UIFormElementGroup = { - type: "group"; - fields: UIFormElementConfig[]; -} & UIFieldElementDescription; - -type UIFormFieldChoiseHorizontal = { - type: "choiceHorizontal"; - choices: Array<SelectUiChoice>; - allowFreeForm?: boolean; -} & UIFormFieldBaseConfig; - -type UIFormFieldChoiseStacked = { - type: "choiceStacked"; - choices: Array<SelectUiChoice>; - allowFreeForm?: boolean; -} & UIFormFieldBaseConfig; - -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; -} & UIFormFieldBaseConfig; - -type UIFormFieldInteger = { - type: "integer"; - max?: Integer; - min?: Integer; -} & UIFormFieldBaseConfig; - -export interface SelectUiChoice { - label: string; - description?: string; - value: string; -} - -type UIFormFieldSelectMultiple = { - type: "selectMultiple"; - max?: Integer; - min?: Integer; - unique?: boolean; - choices: Array<SelectUiChoice>; - allowFreeForm?: boolean; -} & UIFormFieldBaseConfig; - -type UIFormFieldSelectOne = { - type: "selectOne"; - choices: Array<SelectUiChoice>; - allowFreeForm?: boolean; -} & UIFormFieldBaseConfig; -type UIFormFieldText = { type: "text" } & UIFormFieldBaseConfig; -type UIFormFieldTextArea = { type: "textArea" } & UIFormFieldBaseConfig; -type UIFormFieldToggle = { - type: "toggle"; - threeState?: boolean; -} & UIFormFieldBaseConfig; - -export type UIFieldElementDescription = { - /* label if the field, visible for the user */ - label: string; - - /* long text to be shown on user demand */ - tooltip?: string; - - /* short text to be shown close to the field, usually below and dimmer*/ - help?: string; - - /* if the field should be initially hidden */ - hidden?: boolean; - - /* ui element to show before */ - addonBeforeId?: string; - - /* ui element to show after */ - addonAfterId?: string; -}; - -export type UIFormFieldBaseConfig = UIFieldElementDescription & { - /* example to be shown inside the field */ - placeholder?: string; - - /* show a mark as required */ - required?: boolean; - - /* readonly and dim */ - disabled?: boolean; - - /* conversion id to convert the string into the value type - the id should be known to the ui impl - */ - converterId?: string; - - /* return an error message if the value is not valid, returns un undefined - if there is no error - */ - validator?: (value: string) => TranslatedString | undefined; - - /* property id of the form */ - id: UIHandlerId; -}; - -declare const __handlerId: unique symbol; -export type UIHandlerId = string & { [__handlerId]: true }; - -// FIXME: validate well formed ui field id -const codecForUiFieldId = codecForString as () => Codec<UIHandlerId>; - -const codecForUIFormFieldBaseDescriptionTemplate = < - T extends UIFieldElementDescription, ->() => - buildCodecForObject<T>() - .property("addonAfterId", codecOptional(codecForString())) - .property("addonBeforeId", codecOptional(codecForString())) - .property("hidden", codecOptional(codecForBoolean())) - .property("help", codecOptional(codecForString())) - .property("label", codecForString()) - .property("tooltip", codecOptional(codecForString())); - -const codecForUIFormFieldBaseConfigTemplate = < - T extends UIFormFieldBaseConfig, ->() => - 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> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldAbsoluteTime>() - .property("type", codecForConstString("absoluteTimeText")) - .property("pattern", codecForString()) - .property("max", codecOptional(codecForTimestamp)) - .property("min", codecOptional(codecForTimestamp)) - .build("UIFormFieldAbsoluteTime"); - -const codecForUiFormFieldAmount = (): Codec<UIFormFieldAmount> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldAmount>() - .property("type", codecForConstString("amount")) - .property("currency", codecForString()) - .property("max", codecOptional(codecForNumber())) - .property("min", codecOptional(codecForNumber())) - .build("UIFormFieldAmount"); - -const codecForUiFormFieldArray = (): Codec<UIFormFieldArray> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldArray>() - .property("type", codecForConstString("array")) - .property("labelFieldId", codecForUiFieldId()) - .property("tooltip", codecOptional(codecForString())) - // eslint-disable-next-line @typescript-eslint/no-use-before-define - .property("fields", codecForList(codecForUiFormField())) - .build("UIFormFieldArray"); - -const codecForUiFormFieldCaption = (): Codec<UIFormElementCaption> => - codecForUIFormFieldBaseDescriptionTemplate<UIFormElementCaption>() - .property("type", codecForConstString("caption")) - .build("UIFormFieldCaption"); - -const codecForUIFormElementLink = (): Codec<UIFormElementDownloadLink> => - codecForUIFormFieldBaseDescriptionTemplate<UIFormElementDownloadLink>() - .property("type", codecForConstString("download-link")) - .property("url", codecForString()) - .property("media", codecOptional(codecForString())) - .build("UIFormElementLink"); - -const codecForUiFormFieldHtmlIFrame = (): Codec<UIFormElementHtmlIframe> => - codecForUIFormFieldBaseDescriptionTemplate<UIFormElementHtmlIframe>() - .property("type", codecForConstString("htmlIframe")) - .property("url", codecForStringURL()) - .build("codecForUiFormFieldHtmlIFrame"); - -const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => - buildCodecForObject<SelectUiChoice>() - .property("description", codecOptional(codecForString())) - .property("label", codecForString()) - .property("value", codecForString()) - .build("SelectUiChoice"); - -const codecForUiFormFieldChoiceHorizontal = - (): Codec<UIFormFieldChoiseHorizontal> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseHorizontal>() - .property("type", codecForConstString("choiceHorizontal")) - .property("allowFreeForm", codecOptional(codecForBoolean())) - .property("choices", codecForList(codecForUiFormSelectUiChoice())) - .build("UIFormFieldChoiseHorizontal"); - -const codecForUiFormFieldChoiceStacked = (): Codec<UIFormFieldChoiseStacked> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseStacked>() - .property("type", codecForConstString("choiceStacked")) - .property("allowFreeForm", codecOptional(codecForBoolean())) - .property("choices", codecForList(codecForUiFormSelectUiChoice())) - .build("UIFormFieldChoiseStacked"); - -const codecForUiFormFieldFile = (): Codec<UIFormFieldFile> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldFile>() - .property("type", codecForConstString("file")) - .property("accept", codecOptional(codecForString())) - .property("maxBytes", codecOptional(codecForNumber())) - .property("minBytes", codecOptional(codecForNumber())) - .build("UIFormFieldFile"); - -const codecForUiFormFieldGroup = (): Codec<UIFormElementGroup> => - codecForUIFormFieldBaseDescriptionTemplate<UIFormElementGroup>() - .property("type", codecForConstString("group")) - // eslint-disable-next-line @typescript-eslint/no-use-before-define - .property("fields", codecForList(codecForUiFormField())) - .build("UiFormFieldGroup"); - -const codecForUiFormFieldInteger = (): Codec<UIFormFieldInteger> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldInteger>() - .property("type", codecForConstString("integer")) - // .property("properties", codecForUIFormFieldBaseConfig()) - .property("max", codecOptional(codecForNumber())) - .property("min", codecOptional(codecForNumber())) - .build("UIFormFieldInteger"); - -const codecForUiFormFieldSelectMultiple = - (): Codec<UIFormFieldSelectMultiple> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectMultiple>() - .property("type", codecForConstString("selectMultiple")) - .property("max", codecOptional(codecForNumber())) - .property("min", codecOptional(codecForNumber())) - .property("unique", codecOptional(codecForBoolean())) - .property("allowFreeForm", codecOptional(codecForBoolean())) - .property("choices", codecForList(codecForUiFormSelectUiChoice())) - .build("UiFormFieldSelectMultiple"); - -const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldSelectOne> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectOne>() - .property("type", codecForConstString("selectOne")) - .property("allowFreeForm", codecOptional(codecForBoolean())) - .property("choices", codecForList(codecForUiFormSelectUiChoice())) - .build("UIFormFieldSelectOne"); - -const codecForUiFormFieldText = (): Codec<UIFormFieldText> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldText>() - .property("type", codecForConstString("text")) - .build("UIFormFieldText"); - -const codecForUiFormFieldTextArea = (): Codec<UIFormFieldTextArea> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldTextArea>() - .property("type", codecForConstString("textArea")) - .build("UIFormFieldTextArea"); - -const codecForUiFormFieldToggle = (): Codec<UIFormFieldToggle> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldToggle>() - .property("threeState", codecOptionalDefault(codecForBoolean(), false)) - .property("type", codecForConstString("toggle")) - .build("UIFormFieldToggle"); - -const codecForUiFormField = (): Codec<UIFormElementConfig> => - buildCodecForUnion<UIFormElementConfig>() - .discriminateOn("type") - .alternative("array", codecForLazy(codecForUiFormFieldArray)) - .alternative("group", codecForLazy(codecForUiFormFieldGroup)) - .alternative("download-link", codecForUIFormElementLink()) - .alternative("absoluteTimeText", codecForUiFormFieldAbsoluteTime()) - .alternative("amount", codecForUiFormFieldAmount()) - .alternative("caption", codecForUiFormFieldCaption()) - .alternative("htmlIframe", codecForUiFormFieldHtmlIFrame()) - .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) - .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) - .alternative("file", codecForUiFormFieldFile()) - .alternative("integer", codecForUiFormFieldInteger()) - .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) - .alternative("selectOne", codecForUiFormFieldSelectOne()) - .alternative("text", codecForUiFormFieldText()) - .alternative("textArea", codecForUiFormFieldTextArea()) - .alternative("toggle", codecForUiFormFieldToggle()) - .build("UIFormField"); - -const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => - buildCodecForObject<DoubleColumnFormSection>() - .property("title", codecForString()) - .property("description", codecOptional(codecForString())) - .property("fields", codecForList(codecForUiFormField())) - .build("DoubleColumnFormSection"); - -const codecForDoubleColumnForm = (): Codec<DoubleColumnForm> => - buildCodecForObject<DoubleColumnForm>() - .property("type", codecForConstString("double-column")) - .property("design", codecForList(codecForDoubleColumnFormSection())) - .build("DoubleColumnForm"); - -const codecForSingleColumnForm = (): Codec<SingleColumnForm> => - buildCodecForObject<SingleColumnForm>() - .property("type", codecForConstString("single-column")) - .property("fields", codecForList(codecForUiFormField())) - .build("SingleColumnForm"); - -const codecForFormConfiguration = (): Codec<FormConfiguration> => - buildCodecForUnion<FormConfiguration>() - .discriminateOn("type") - .alternative("double-column", codecForDoubleColumnForm()) - .alternative("single-column", codecForSingleColumnForm()) - .build<FormConfiguration>("FormConfiguration"); - -const codecForFormMetadata = (): Codec<FormMetadata> => - buildCodecForObject<FormMetadata>() - .property("label", codecForString()) - .property("description", codecOptional(codecForString())) - .property("id", codecForString()) - .property("version", codecForNumber()) - .property("config", codecForFormConfiguration()) - .build("FormMetadata"); - -export const codecForUIForms = (): Codec<UiForms> => - buildCodecForObject<UiForms>() - .property("forms", codecForList(codecForFormMetadata())) - .build("UiForms"); - -export type FormMetadata = { - label: string; - description?: string; - id: string; - version: number; - config: FormConfiguration; -}; - -export interface UiForms { - // Where libeufin backend is localted - // default: window.origin without "webui/" - forms: Array<FormMetadata>; -} diff --git a/packages/web-util/src/forms/useField.ts b/packages/web-util/src/forms/useField.ts @@ -11,10 +11,10 @@ export interface InputFieldHandler<Type> { /** * @deprecated removing this so we don't depend on context to create a form - * @param name - * @returns + * @param name + * @returns */ -export function useField<T extends object, K extends keyof T>( +export function useField_deprecated<T extends object, K extends keyof T>( name: K, ): InputFieldHandler<T[K]> | undefined { const ctx = useContext(FormContext); @@ -27,7 +27,7 @@ export function useField<T extends object, K extends keyof T>( computeFormState, onUpdate: notifyUpdate, readOnly: readOnlyForm, - } = ctx + } = ctx; type P = typeof name; type V = T[P]; @@ -40,7 +40,7 @@ export function useField<T extends object, K extends keyof T>( //compute default state const state = { - disabled: readOnlyForm ? true : (fieldState.disabled ?? false), + disabled: readOnlyForm ? true : fieldState.disabled ?? false, hidden: fieldState.hidden ?? false, help: fieldState.help, elements: "elements" in fieldState ? fieldState.elements ?? [] : [], @@ -48,7 +48,7 @@ export function useField<T extends object, K extends keyof T>( function onChange(value: V): void { // setCurrentValue(value); - formValue.current = setValueDeeper( + formValue.current = setValueDeeper_toberemoved( formValue.current, String(name).split("."), value, @@ -72,20 +72,24 @@ export function useField<T extends object, K extends keyof T>( * @param name * @returns */ -function readField<T>( - object: any, - name: string, -): T | undefined { +function readField<T>(object: any, name: string): T | undefined { return name.split(".").reduce((prev, current) => { return prev ? prev[current] : undefined; }, object); } -function setValueDeeper(object: any, names: string[], value: any): any { +function setValueDeeper_toberemoved( + object: any, + names: string[], + value: any, +): any { if (names.length === 0) return value; const [head, ...rest] = names; if (object === undefined) { - return { [head]: setValueDeeper({}, rest, value) }; + return { [head]: setValueDeeper_toberemoved({}, rest, value) }; } - return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) }; + return { + ...object, + [head]: setValueDeeper_toberemoved(object[head] ?? {}, rest, value), + }; } diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts @@ -1,7 +1,13 @@ export { useLang } from "./useLang.js"; -export { useLocalStorage, buildStorageKey, StorageKey, StorageState } from "./useLocalStorage.js"; +export { + useLocalStorage, + buildStorageKey, + StorageKey, + StorageState, +} from "./useLocalStorage.js"; export { useMemoryStorage } from "./useMemoryStorage.js"; export * from "./useNotifications.js"; +export { useForm } from "./useForm.js"; export { useAsyncAsHook, HookError, diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -17,10 +17,12 @@ import { AbsoluteTime, AmountJson, + assertUnreachable, TalerExchangeApi, TranslatedString, } from "@gnu-taler/taler-util"; import { + FormDesign, UIFieldHandler, UIFormElementConfig, UIHandlerId, @@ -75,139 +77,113 @@ export type FormStatus<T> = errors: FormErrors<T>; }; -function constructFormHandler<T>( - shape: Array<UIHandlerId>, - form: RecursivePartial<FormValues<T>>, - updateForm: (d: RecursivePartial<FormValues<T>>) => void, - errors: FormErrors<T> | undefined, -): FormHandler<T> { - const handler = shape.reduce((handleForm, fieldId) => { - const path = fieldId.split("."); +export type FormState<T> = { + handler: FormHandler<T>; + status: FormStatus<T>; +}; - function updater(newValue: unknown) { - updateForm(setValueDeeper(form, path, newValue)); - } +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), + ); - const currentValue = getValueDeeper<string>(form as any, path, undefined); - const currentError = getValueDeeper<TranslatedString>( - errors as any, - path, - undefined, - ); - const field: UIFieldHandler = { - error: currentError, - value: currentValue, - onChange: updater, - state: {}, //FIXME: add the state of the field (hidden, ) + if (errors !== undefined) { + return { + status: "fail" as const, + result: st as any, + errors, }; + } - return setValueDeeper(handleForm, path, field); - }, {} as FormHandler<T>); - - return handler; + return { status: "ok" as const, result: st as any, errors: undefined }; } -export function useFormStateFromConfig<T>( - fields: Array<UIFormElementConfig>, - defaultValue: RecursivePartial<FormValues<T>>, - check?: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, -): [FormHandler<T>, FormStatus<T>] { - const shape: Array<UIHandlerId> = []; - const requiredFields: Array<UIHandlerId> = []; - Array.prototype.push.apply(shape, getShapeFromFields(fields)); - Array.prototype.push.apply(requiredFields, getRequiredFields(fields)); - - const [form, updateForm] = - useState<RecursivePartial<FormValues<T>>>(defaultValue); - - function defaultCheckAllRequired(st: RecursivePartial<FormValues<T>>) { - const partialErrors = undefinedIfEmpty<FormErrors<T>>({}); - - const errors = undefinedIfEmpty<FormErrors<T> | undefined>( - validateRequiredFields(partialErrors, st, requiredFields), - ); +/** + * + * @param fields form fields + * @param initialValue initial value + * @param check validation chain + * @returns + */ +export function useForm<T>( + design: FormDesign, + initialValue: RecursivePartial<FormValues<T>>, + check?: (f: RecursivePartial<FormValues<T>>) => FormErrors<T> | undefined, +): FormState<T> { + const [formValue, formUpdateHandler] = + useState<RecursivePartial<FormValues<T>>>(initialValue); - if (errors !== undefined) { - return { - status: "fail" as const, - result: st as any, - errors, - }; + 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; + }); - return undefined; - } - // check required fields - const requiredCheckResult = - requiredFields.length > 0 ? defaultCheckAllRequired(form) : undefined; - // verify if there is a custom check function and all required fields are ok - // if there no custom check return "ok" - const status = - requiredCheckResult ?? - (check - ? check(form) - : { status: "ok" as const, result: form as any, errors: undefined }); const handler = constructFormHandler( - shape, - form, - updateForm, - requiredCheckResult?.errors, + design, + formValue, + formUpdateHandler, + status?.errors, ); - return [handler, status]; + return { handler, status }; } +interface Tree<T> extends Record<string, Tree<T> | T> {} + /** - * @deprecated use `useFormStateFromConfig` + * Use $path to get the value of $object + * return $noFoundValue if the target property is undefined * - * @param defaultValue - * @param check + * @param object + * @param path + * @param notFoundValue * @returns */ -export function useFormState<T>( - shape: Array<UIHandlerId>, - defaultValue: RecursivePartial<FormValues<T>>, - check: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, -): [FormHandler<T>, FormStatus<T>] { - const [form, updateForm] = - useState<RecursivePartial<FormValues<T>>>(defaultValue); - - const status = check(form); - const handler = constructFormHandler(shape, form, updateForm, status.errors); - - return [handler, status]; -} - -interface Tree<T> extends Record<string, Tree<T> | T> {} - -export function getValueDeeper<T>( +export function getValueFromPath<T>( object: Tree<T> | undefined, - names: string[], + path: string[], notFoundValue?: T, ): T | undefined { - if (names.length === 0) return object as T; - const [head, ...rest] = names; + if (path.length === 0) return object as T; + const [head, ...rest] = path; if (!head) { - return getValueDeeper(object, rest, notFoundValue); + return getValueFromPath(object, rest, notFoundValue); } if (object === undefined) { return notFoundValue; } - return getValueDeeper(object[head] as Tree<T>, rest, notFoundValue); + return getValueFromPath(object[head] as Tree<T>, rest, notFoundValue); } -export function setValueDeeper(object: any, names: string[], value: any): any { - if (names.length === 0) return value; - const [head, ...rest] = names; +/** + * Use $path to set the value $value into $object + * Don't modify $object, returns a new value + * @param object + * @param path + * @param value + * @returns + */ +function setValueIntoPath(object: any, path: string[], value: any): any { + if (path.length === 0) return value; + const [head, ...rest] = path; if (!head) { - return setValueDeeper(object, rest, value); + return setValueIntoPath(object, rest, value); } if (object === undefined) { - return undefinedIfEmpty({ [head]: setValueDeeper({}, rest, value) }); + return undefinedIfEmpty({ [head]: setValueIntoPath({}, rest, value) }); } return undefinedIfEmpty({ ...object, - [head]: setValueDeeper(object[head] ?? {}, rest, value), + [head]: setValueIntoPath(object[head] ?? {}, rest, value), }); } @@ -222,60 +198,105 @@ export function undefinedIfEmpty<T extends object | undefined>( : undefined; } -export function getShapeFromFields( - fields: UIFormElementConfig[], -): Array<UIHandlerId> { - const shape: Array<UIHandlerId> = []; - fields.forEach((field) => { - if ("id" in field) { - // FIXME: this should be a validation when loading the form - // consistency check - // if (shape.indexOf(field.id) !== -1) { - // throw Error(`already present: ${field.id}`); - // } - shape.push(field.id); - } else if (field.type === "group") { - Array.prototype.push.apply(shape, getShapeFromFields(field.fields)); - } - }); - return shape; -} - -export function getRequiredFields( - fields: UIFormElementConfig[], -): Array<UIHandlerId> { - const shape: Array<UIHandlerId> = []; - fields.forEach((field) => { - if ("id" in field) { - // FIXME: this should be a validation when loading the form - // consistency check - // if (shape.indexOf(field.id) !== -1) { - // throw Error(`already present: ${field.id}`); - // } - if (!field.required) { - return; - } - shape.push(field.id); - } else if (field.type === "group") { - Array.prototype.push.apply(shape, getRequiredFields(field.fields)); - } - }); - return shape; -} export function validateRequiredFields<FormType>( - errors: FormErrors<FormType> | undefined, form: object, - fields: Array<UIHandlerId>, + config: FormDesign, ): FormErrors<FormType> | undefined { - let result: FormErrors<FormType> | undefined = errors; - fields.forEach((f) => { - const path = f.split("."); - const v = getValueDeeper(form as any, path); - result = setValueDeeper( + let result: FormErrors<FormType> | undefined = undefined; + + function checkIfRequiredFieldHasValue(formElement: UIFormElementConfig) { + if ("fields" in formElement) { + formElement.fields.forEach(checkIfRequiredFieldHasValue); + } + if (!("id" in formElement)) { + return; + } + if (!formElement.required) return; + const path = formElement.id.split("."); + const v = getValueFromPath(form as any, path); + result = setValueIntoPath( result, path, v === undefined ? "required" : undefined, ); - }); + } + + switch (config.type) { + case "double-column": { + config.sections.forEach((sec) => { + sec.fields.forEach(checkIfRequiredFieldHasValue); + }); + break; + } + case "single-column": { + config.fields.forEach(checkIfRequiredFieldHasValue); + break; + } + default: { + assertUnreachable(config); + } + } + return result; } + +function constructFormHandler<T>( + design: FormDesign, + value: RecursivePartial<FormValues<T>>, + onValueChange: (d: RecursivePartial<FormValues<T>>) => void, + errors: FormErrors<T> | undefined, +): FormHandler<T> { + let formHandler: FormHandler<T> = {}; + + function notifyUpdateOnFieldChange(formElement: UIFormElementConfig): void { + if ("fields" in formElement) { + formElement.fields.forEach(notifyUpdateOnFieldChange); + } + if (!("id" in formElement)) { + return; + } + const path = formElement.id.split("."); + + function updater(newValue: unknown) { + const updated = setValueIntoPath(value, path, newValue); + onValueChange(updated); + } + + const currentValue = getValueFromPath<string>( + value as any, + path, + undefined, + ); + const currentError = getValueFromPath<TranslatedString>( + errors as any, + path, + undefined, + ); + const field: UIFieldHandler = { + error: currentError, + value: currentValue, + onChange: updater, + state: {}, //FIXME: add the state of the field (hidden, ) + }; + + formHandler = setValueIntoPath(formHandler, path, field); + } + + switch (design.type) { + case "double-column": { + design.sections.forEach((sec) => { + sec.fields.forEach(notifyUpdateOnFieldChange); + }); + break; + } + case "single-column": { + design.fields.forEach(notifyUpdateOnFieldChange); + break; + } + default: { + assertUnreachable(design); + } + } + + return formHandler; +} diff --git a/packages/web-util/src/stories.html b/packages/web-util/src/stories.html @@ -1,4 +1,4 @@ -<!DOCTYPE html> +<!doctype html> <html> <head> <title>WebUtils: Stories</title> @@ -13,6 +13,10 @@ href="__EXAMPLES_CSS_FILE_LOCATION__" /> <script type="module" src="__EXAMPLES_JS_FILE_LOCATION__"></script> + <!-- FIXME: remove this --> + <!-- this is an easy setup of tailwind to test out the form fields --> + <!-- the css must be build locally and prevent the requirement of internet access for development --> + <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio,line-clamp,container-queries"></script> </head> <body> <taler-stories id="container"></taler-stories>