taler-typescript-core

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

commit a4973dc237a9c775d23d151db065c8f29699fa18
parent 9c6146309dfe84743f437287985cf93834b865a3
Author: Sebastian <sebasjm@gmail.com>
Date:   Fri, 10 Jan 2025 16:28:23 -0300

take the right imports, remove unused code, move gana forms to web-utils

Diffstat:
Dpackages/aml-backoffice-ui/src/hooks/form.ts | 216-------------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/Cases.tsx | 37+------------------------------------
Mpackages/aml-backoffice-ui/src/pages/CreateAccount.tsx | 22++++------------------
Mpackages/aml-backoffice-ui/src/pages/Search.tsx | 6++++--
Mpackages/aml-backoffice-ui/src/pages/UnlockAccount.tsx | 4++--
Mpackages/kyc-ui/src/forms/index.ts | 18+++++++++---------
Dpackages/kyc-ui/src/hooks/form.ts | 228-------------------------------------------------------------------------------
Mpackages/kyc-ui/src/pages/FillForm.tsx | 9---------
Mpackages/web-util/src/forms/Caption.tsx | 5++++-
Mpackages/web-util/src/forms/DownloadLink.tsx | 2+-
Mpackages/web-util/src/forms/Group.tsx | 5++++-
Mpackages/web-util/src/forms/HtmlIframe.tsx | 2+-
Dpackages/web-util/src/forms/InputAbsoluteTime.stories.tsx | 63---------------------------------------------------------------
Dpackages/web-util/src/forms/InputAbsoluteTime.tsx | 91-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputAmount.stories.tsx | 64----------------------------------------------------------------
Dpackages/web-util/src/forms/InputAmount.tsx | 40----------------------------------------
Dpackages/web-util/src/forms/InputArray.stories.tsx | 137-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputArray.tsx | 325-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputChoiceHorizontal.stories.tsx | 77-----------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputChoiceHorizontal.tsx | 81-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputChoiceStacked.stories.tsx | 77-----------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputChoiceStacked.tsx | 116-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputFile.stories.tsx | 68--------------------------------------------------------------------
Dpackages/web-util/src/forms/InputFile.tsx | 129-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputInteger.stories.tsx | 58----------------------------------------------------------
Dpackages/web-util/src/forms/InputInteger.tsx | 24------------------------
Dpackages/web-util/src/forms/InputLine.tsx | 268-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputSelectMultiple.stories.tsx | 101-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputSelectMultiple.tsx | 172-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputSelectOne.stories.tsx | 78------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputSelectOne.tsx | 140-------------------------------------------------------------------------------
Dpackages/web-util/src/forms/InputText.stories.tsx | 63---------------------------------------------------------------
Dpackages/web-util/src/forms/InputText.tsx | 9---------
Dpackages/web-util/src/forms/InputTextArea.stories.tsx | 63---------------------------------------------------------------
Dpackages/web-util/src/forms/InputTextArea.tsx | 9---------
Dpackages/web-util/src/forms/InputToggle.stories.tsx | 71-----------------------------------------------------------------------
Dpackages/web-util/src/forms/InputToggle.tsx | 57---------------------------------------------------------
Mpackages/web-util/src/forms/field-types.ts | 35++++++++++++++---------------------
Apackages/web-util/src/forms/fields/InputAbsoluteTime.stories.tsx | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputAbsoluteTime.tsx | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputAmount.stories.tsx | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputAmount.tsx | 40++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputArray.stories.tsx | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputArray.tsx | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputChoiceHorizontal.stories.tsx | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputChoiceHorizontal.tsx | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputChoiceStacked.stories.tsx | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputChoiceStacked.tsx | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputFile.stories.tsx | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputFile.tsx | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputInteger.stories.tsx | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputInteger.tsx | 24++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputLine.tsx | 268+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputSelectMultiple.tsx | 172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputSelectOne.stories.tsx | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputSelectOne.tsx | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputText.stories.tsx | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputText.tsx | 9+++++++++
Apackages/web-util/src/forms/fields/InputTextArea.stories.tsx | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputTextArea.tsx | 9+++++++++
Apackages/web-util/src/forms/fields/InputToggle.stories.tsx | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/web-util/src/forms/fields/InputToggle.tsx | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rpackages/kyc-ui/src/forms/VQF_902_1.ts -> packages/web-util/src/forms/gana/VQF_902_1.ts | 0
Rpackages/kyc-ui/src/forms/VQF_902_11.ts -> packages/web-util/src/forms/gana/VQF_902_11.ts | 0
Rpackages/kyc-ui/src/forms/VQF_902_12.ts -> packages/web-util/src/forms/gana/VQF_902_12.ts | 0
Rpackages/kyc-ui/src/forms/VQF_902_13.ts -> packages/web-util/src/forms/gana/VQF_902_13.ts | 0
Rpackages/kyc-ui/src/forms/VQF_902_14.ts -> packages/web-util/src/forms/gana/VQF_902_14.ts | 0
Rpackages/kyc-ui/src/forms/VQF_902_15.ts -> packages/web-util/src/forms/gana/VQF_902_15.ts | 0
Rpackages/kyc-ui/src/forms/VQF_902_4.ts -> packages/web-util/src/forms/gana/VQF_902_4.ts | 0
Rpackages/kyc-ui/src/forms/VQF_902_5.ts -> packages/web-util/src/forms/gana/VQF_902_5.ts | 0
Rpackages/kyc-ui/src/forms/VQF_902_9.ts -> packages/web-util/src/forms/gana/VQF_902_9.ts | 0
Rpackages/kyc-ui/src/forms/taler_form_attributes.ts -> packages/web-util/src/forms/gana/taler_form_attributes.ts | 0
Mpackages/web-util/src/forms/index.stories.ts | 24++++++++++++------------
Mpackages/web-util/src/forms/index.ts | 36+++++++++++++++++++++++-------------
Dpackages/web-util/src/forms/useField.ts | 95-------------------------------------------------------------------------------
Mpackages/web-util/src/hooks/index.ts | 8+++++++-
77 files changed, 2467 insertions(+), 3047 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/hooks/form.ts b/packages/aml-backoffice-ui/src/hooks/form.ts @@ -1,216 +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, - TalerExchangeApi, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { - UIFieldHandler, - UIFormElementConfig, - UIHandlerId, -} from "@gnu-taler/web-util/browser"; -import { useState } from "preact/hooks"; -import { undefinedIfEmpty } from "../pages/CreateAccount.js"; - -// export type UIField = { -// value: string | undefined; -// onUpdate: (s: string) => void; -// error: TranslatedString | undefined; -// }; - -export type FormHandler<T> = { - [k in keyof T]?: T[k] extends string - ? UIFieldHandler - : T[k] extends AmountJson - ? UIFieldHandler - : T[k] extends TalerExchangeApi.AmlState - ? UIFieldHandler - : FormHandler<T[k]>; -}; - -export type FormValues<T> = { - [k in keyof T]: T[k] extends string ? string | undefined : FormValues<T[k]>; -}; - -export type RecursivePartial<T> = { - [k in keyof T]?: T[k] extends string - ? string - : T[k] extends AmountJson - ? AmountJson - : T[k] extends TalerExchangeApi.AmlState - ? TalerExchangeApi.AmlState - : RecursivePartial<T[k]>; -}; - -export type FormErrors<T> = { - [k in keyof T]?: T[k] extends string - ? TranslatedString - : T[k] extends AmountJson - ? TranslatedString - : T[k] extends AbsoluteTime - ? TranslatedString - : T[k] extends TalerExchangeApi.AmlState - ? TranslatedString - : FormErrors<T[k]>; -}; - -export type FormStatus<T> = - | { - status: "ok"; - result: T; - errors: undefined; - } - | { - status: "fail"; - result: RecursivePartial<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("."); - -// function updater(newValue: unknown) { -// updateForm(setValueDeeper(form, path, newValue)); -// } - -// 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, ) -// }; - -// return setValueDeeper(handleForm, path, field); -// }, {} as FormHandler<T>); - -// return handler; -// } - -// export function useFormState<T>( -// shape: Array<UIHandlerId>, -// defaultValue: RecursivePartial<FormValues<T>>, -// check: (f: RecursivePartial<FormValues<T>>) => FormStatus<T>, -// ): { handler: FormHandler<T>; status: 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>( - object: Tree<T> | undefined, - names: string[], - notFoundValue?: T, -): T | undefined { - if (names.length === 0) return object as T; - const [head, ...rest] = names; - if (!head) { - return getValueDeeper(object, rest, notFoundValue); - } - if (object === undefined) { - return notFoundValue; - } - return getValueDeeper(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; - if (!head) { - return setValueDeeper(object, rest, value); - } - if (object === undefined) { - return undefinedIfEmpty({ [head]: setValueDeeper({}, rest, value) }); - } - return undefinedIfEmpty({ - ...object, - [head]: setValueDeeper(object[head] ?? {}, rest, value), - }); -} - -// 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>, -// ): 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(result, path, !v ? "required" : undefined); -// }); -// return result; -// } diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -31,10 +31,10 @@ import { useCurrentDecisionsUnderInvestigation, } from "../hooks/decisions.js"; +import { useState } from "preact/hooks"; import { privatePages } from "../Routing.js"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { Officer } from "./Officer.js"; -import { useState } from "preact/hooks"; type FormType = { // state: TalerExchangeApi.AmlState; @@ -100,41 +100,6 @@ export function CasesUI({ }): VNode { const { i18n } = useTranslationContext(); - // const [form, status] = useFormState<FormType>( - // [".state"] as Array<UIHandlerId>, - // { - // // state: filter, - // }, - // (state) => { - // const errors = undefinedIfEmpty<FormErrors<FormType>>({ - // state: state.state === undefined ? i18n.str`required` : undefined, - // }); - // if (errors === undefined) { - // const result: FormType = { - // state: state.state!, - // }; - // return { - // status: "ok", - // result, - // errors, - // }; - // } - // const result: RecursivePartial<FormType> = { - // state: state.state, - // }; - // return { - // status: "fail", - // result, - // errors, - // }; - // }, - // ); - // useEffect(() => { - // if (status.status === "ok" && filter !== status.result.state) { - // onChangeFilter(status.result.state); - // } - // }, [form?.state?.value]); - return ( <div> <div class="sm:flex sm:items-center"> diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx @@ -16,22 +16,19 @@ import { Button, FormDesign, + FormErrors, FormUI, - InputLine, + FormValues, InternationalizationAPI, LocalNotificationBanner, + RecursivePartial, UIHandlerId, + undefinedIfEmpty, useForm, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { - FormErrors, - FormStatus, - FormValues, - RecursivePartial, -} from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { usePreferences } from "../hooks/preferences.js"; @@ -70,17 +67,6 @@ function createFormValidator( }; } -export function undefinedIfEmpty<T extends object | undefined>( - obj: T, -): T | undefined { - if (obj === undefined) return undefined; - return Object.keys(obj).some( - (k) => (obj as Record<string, T>)[k] !== undefined, - ) - ? obj - : undefined; -} - const createAccountForm = (i18n: InternationalizationAPI): FormDesign => ({ type: "single-column", fields: [ diff --git a/packages/aml-backoffice-ui/src/pages/Search.tsx b/packages/aml-backoffice-ui/src/pages/Search.tsx @@ -31,12 +31,16 @@ import { Attention, encodeCrockForURI, FormDesign, + FormErrors, FormUI, + FormValues, InternationalizationAPI, Loading, + RecursivePartial, Time, UIFormElementConfig, UIHandlerId, + undefinedIfEmpty, useExchangeApiContext, useForm, useTranslationContext, @@ -45,11 +49,9 @@ import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useAccountDecisions } from "../hooks/decisions.js"; -import { FormErrors, FormValues, RecursivePartial } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { privatePages } from "../Routing.js"; import { Pagination, ToInvestigateIcon } from "./Cases.js"; -import { undefinedIfEmpty } from "./CreateAccount.js"; import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; export function Search() { diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx @@ -16,18 +16,18 @@ import { Button, FormDesign, + FormErrors, InputLine, InternationalizationAPI, LocalNotificationBanner, UIHandlerId, + undefinedIfEmpty, useForm, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { FormErrors } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; -import { undefinedIfEmpty } from "./CreateAccount.js"; type FormType = { password: string; diff --git a/packages/kyc-ui/src/forms/index.ts b/packages/kyc-ui/src/forms/index.ts @@ -16,20 +16,20 @@ import { FormMetadata, InternationalizationAPI, + VQF_902_1, + VQF_902_11, + VQF_902_12, + VQF_902_13, + VQF_902_14, + VQF_902_15, + VQF_902_4, + VQF_902_5, + VQF_902_9, } from "@gnu-taler/web-util/browser"; import { simplest } from "./simplest.js"; import { acceptTos } from "./accept-tos.js"; import { nameAndDob } from "./nameAndBirthdate.js"; import { personalInfo } from "./personal-info.js"; -import { VQF_902_15 } from "./VQF_902_15.js"; -import { VQF_902_14 } from "./VQF_902_14.js"; -import { VQF_902_13 } from "./VQF_902_13.js"; -import { VQF_902_12 } from "./VQF_902_12.js"; -import { VQF_902_11 } from "./VQF_902_11.js"; -import { VQF_902_9 } from "./VQF_902_9.js"; -import { VQF_902_5 } from "./VQF_902_5.js"; -import { VQF_902_4 } from "./VQF_902_4.js"; -import { VQF_902_1 } from "./VQF_902_1.js"; export const preloadedForms: ( i18n: InternationalizationAPI, diff --git a/packages/kyc-ui/src/hooks/form.ts b/packages/kyc-ui/src/hooks/form.ts @@ -1,228 +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, - TalerExchangeApi, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { - UIFieldHandler, - UIFormElementConfig, - UIHandlerId, -} from "@gnu-taler/web-util/browser"; -import { useState } from "preact/hooks"; -import { undefinedIfEmpty } from "../pages/Start.js"; - -// export type UIField = { -// value: string | undefined; -// onUpdate: (s: string) => void; -// error: TranslatedString | undefined; -// }; - -export type FormHandler<T> = { - [k in keyof T]?: T[k] extends string - ? UIFieldHandler - : T[k] extends AmountJson - ? UIFieldHandler - : T[k] extends TalerExchangeApi.AmlState - ? UIFieldHandler - : FormHandler<T[k]>; -}; - -export type FormValues<T> = { - [k in keyof T]: T[k] extends string ? string | undefined : FormValues<T[k]>; -}; - -export type RecursivePartial<T> = { - [k in keyof T]?: T[k] extends string - ? string - : T[k] extends AmountJson - ? AmountJson - : T[k] extends TalerExchangeApi.AmlState - ? TalerExchangeApi.AmlState - : RecursivePartial<T[k]>; -}; - -export type FormErrors<T> = { - [k in keyof T]?: T[k] extends string - ? TranslatedString - : T[k] extends AmountJson - ? TranslatedString - : T[k] extends AbsoluteTime - ? TranslatedString - : T[k] extends TalerExchangeApi.AmlState - ? TranslatedString - : FormErrors<T[k]>; -}; - -export type FormStatus<T> = - | { - status: "ok"; - result: T; - errors: undefined; - } - | { - status: "fail"; - result: RecursivePartial<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("."); - - function updater(newValue: unknown) { - updateForm(setValueDeeper(form, path, newValue)); - } - - 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, ) - }; - - return setValueDeeper(handleForm, path, field); - }, {} as FormHandler<T>); - - return handler; -} - -/** - * FIXME: Consider sending this to web-utils - * - * - * @param defaultValue - * @param check - * @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>( - object: Tree<T> | undefined, - names: string[], - notFoundValue?: T, -): T | undefined { - if (names.length === 0) return object as T; - const [head, ...rest] = names; - if (!head) { - return getValueDeeper(object, rest, notFoundValue); - } - if (object === undefined) { - return notFoundValue; - } - return getValueDeeper(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; - if (!head) { - return setValueDeeper(object, rest, value); - } - if (object === undefined) { - return undefinedIfEmpty({ [head]: setValueDeeper({}, rest, value) }); - } - return undefinedIfEmpty({ - ...object, - [head]: setValueDeeper(object[head] ?? {}, rest, value), - }); -} - -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>, -): 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( - result, - path, - v === undefined ? "required" : undefined, - ); - }); - return result; -} diff --git a/packages/kyc-ui/src/pages/FillForm.tsx b/packages/kyc-ui/src/pages/FillForm.tsx @@ -105,15 +105,6 @@ export function FillForm({ ? undefined : withErrorHandler( async () => { - const information: KycForm = { - header: { - id: theForm.id, - version: theForm.version, - when: AbsoluteTime.now(), - }, - payload: validatedForm, - }; - // const data = new FormData() // data.set("header", JSON.stringify(information.header)) // data.set("payload", JSON.stringify(information.payload)) diff --git a/packages/web-util/src/forms/Caption.tsx b/packages/web-util/src/forms/Caption.tsx @@ -1,6 +1,9 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { + LabelWithTooltipMaybeRequired, + RenderAddon, +} from "./fields/InputLine.js"; import { Addon } from "./FormProvider.js"; interface Props { diff --git a/packages/web-util/src/forms/DownloadLink.tsx b/packages/web-util/src/forms/DownloadLink.tsx @@ -1,6 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { RenderAddon } from "./fields/InputLine.js"; import { Addon } from "./FormProvider.js"; interface Props { diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx @@ -1,7 +1,10 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; import { Addon } from "./FormProvider.js"; -import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { + LabelWithTooltipMaybeRequired, + RenderAddon, +} from "./fields/InputLine.js"; import { RenderAllFieldsByUiConfig } from "./forms-ui.js"; import { UIFormField } from "./field-types.js"; diff --git a/packages/web-util/src/forms/HtmlIframe.tsx b/packages/web-util/src/forms/HtmlIframe.tsx @@ -1,6 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { RenderAddon } from "./fields/InputLine.js"; import { Addon } from "./FormProvider.js"; interface Props { diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx @@ -1,63 +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 { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "../tests/hook.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; -import { DefaultForm as TestedComponent } from "./forms-ui.js"; -export default { - title: "Input Absolute Time", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - today: AbsoluteTime; -}; -const initial: TargetObject = { - today: AbsoluteTime.now(), -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "absoluteTimeText", - label: "label of the field" as TranslatedString, - id: "today" as UIHandlerId, - pattern: "dd/MM/yyyy HH:mm", - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputAbsoluteTime.tsx b/packages/web-util/src/forms/InputAbsoluteTime.tsx @@ -1,91 +0,0 @@ -import { AbsoluteTime } from "@gnu-taler/taler-util"; -import { format, parse } from "date-fns"; -import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { Calendar } from "./Calendar.js"; -import { Dialog } from "./Dialog.js"; -import { UIFormProps } from "./FormProvider.js"; -import { InputLine } from "./InputLine.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; - -export function InputAbsoluteTime<T extends object, K extends keyof T>( - properties: { pattern?: string } & UIFormProps<T, K>, -): VNode { - const pattern = properties.pattern ?? "dd/MM/yyyy"; - const [open, setOpen] = useState(false); - - const { value, onChange } = - properties.handler ?? noHandlerPropsAndNoContextForField(properties.name); - return ( - <Fragment> - <InputLine<T, K> - type="text" - after={{ - type: "button", - onClick: () => { - setOpen(true); - }, - // icon: <CalendarIcon class="h-6 w-6" />, - children: ( - <svg - xmlns="http://www.w3.org/2000/svg" - fill="none" - viewBox="0 0 24 24" - stroke-width="1.5" - stroke="currentColor" - class="w-6 h-6" - > - <path - stroke-linecap="round" - stroke-linejoin="round" - d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" - /> - </svg> - ), - }} - converter={{ - //@ts-ignore - fromStringUI: (v): AbsoluteTime | undefined => { - if (!v) return undefined; - try { - const t_ms = parse(v, pattern, Date.now()).getTime(); - return AbsoluteTime.fromMilliseconds(t_ms); - } catch (e) { - return undefined; - } - }, - //@ts-ignore - toStringUI: (v: AbsoluteTime | undefined) => { - return !v || !v.t_ms - ? undefined - : v.t_ms === "never" - ? "never" - : format(v.t_ms, pattern); - }, - }} - {...properties} - /> - {open && ( - <Dialog onClose={() => setOpen(false)}> - <Calendar - value={(value as any as AbsoluteTime) ?? AbsoluteTime.now()} - onChange={(v) => { - onChange(v as any); - setOpen(false); - }} - /> - </Dialog> - )} - {/* {open && - <Dialog onClose={() => setOpen(false)} > - <TimePicker value={value as AbsoluteTime ?? AbsoluteTime.now()} - onChange={(v) => { - onChange(v as any) - }} - onConfirm={() => { - setOpen(false) - }} /> - </Dialog>} */} - </Fragment> - ); -} diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx @@ -1,64 +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 { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; -import * as tests from "../tests/hook.js"; -import { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Amount", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - amount: AmountJson; -}; -const initial: TargetObject = { - amount: Amounts.parseOrThrow("USD:10"), -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "amount", - label: "label of the field" as TranslatedString, - id: "amount" as UIHandlerId, - currency: "ARS", - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx @@ -1,40 +0,0 @@ -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 { noHandlerPropsAndNoContextForField } from "./InputArray.js"; - -export function InputAmount<T extends object, K extends keyof T>( - props: { currency?: string } & UIFormProps<T, K>, -): VNode { - const { value } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); - const currency = - !value || !(value as any).currency - ? props.currency - : (value as any).currency; - return ( - <InputLine<T, K> - {...props} - type="text" - before={{ - type: "text", - text: currency as TranslatedString, - }} - //@ts-ignore - converter={ - props.converter ?? { - fromStringUI: (v): AmountJson => { - return ( - Amounts.parse(`${currency}:${v}`) ?? - Amounts.zeroOfCurrency(currency) - ); - }, - toStringUI: (v: AmountJson) => { - return v === undefined ? "" : Amounts.stringifyValue(v); - }, - } - } - /> - ); -} diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx @@ -1,137 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Array", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - people: { - name: string; - age: number; - }[]; -}; -const initial: TargetObject = { - people: [ - { - name: "me", - age: 17, - }, - ], -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - description: "to test how arrays are used" as TranslatedString, - fields: [ - { - type: "array", - label: "People" as TranslatedString, - fields: [ - { - id: "name" as UIHandlerId, - type: "text", - required: true, - label: "Name" as TranslatedString, - }, - { - id: "age" as UIHandlerId, - type: "integer", - required: true, - label: "Age" as TranslatedString, - }, - ], - id: "people" as UIHandlerId, - labelFieldId: "name" as UIHandlerId, - }, - ], - }, - ], -}; - -export const FormWithArray = tests.createExample(TestedComponent, { - initial, - design, -}); - -const initial2: any = {}; - -const design2: FormDesign = { - type: "double-column", - sections: [ - { - title: "Personal information" as TranslatedString, - fields: [ - { - type: "text", - id: "PERSON_FULL_NAME" as UIHandlerId, - label: "Full name", - required: true, - }, - ], - }, - { - title: "Representatives" as TranslatedString, - fields: [ - { - type: "array", - id: "BUSINESS_LEGAL_REPRESENTATIVES" as UIHandlerId, - labelFieldId: "PERSON_FULL_NAME" as UIHandlerId, - label: - "List of natural persons that are legal representatives or shareholders", - fields: [ - { - type: "text", - id: "PERSON_FULL_NAME" as UIHandlerId, - label: "Name", - required: true, - }, - { - type: "text", - id: "PERSON_DATE_OF_BIRTH" as UIHandlerId, - label: "Date of birth", - required: true, - }, - ], - }, - ], - }, - ], -}; - -export const NonMixingProperties = tests.createExample(TestedComponent, { - initial: initial2, - design: design2, -}); diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx @@ -1,325 +0,0 @@ -import { TranslatedString } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { getValueFromPath, useForm } from "../hooks/useForm.js"; -import { - 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, - disabled, - isFirst, - isLast, - isSelected, - onClick, -}: { - label: TranslatedString; - isFirst?: boolean; - isLast?: boolean; - isSelected?: boolean; - disabled?: boolean; - onClick: () => void; -}): VNode { - let clazz = "relative flex border p-4 focus:outline-none disabled:text-grey"; - if (isFirst) { - clazz += " rounded-tl-md rounded-tr-md "; - } - if (isLast) { - clazz += " rounded-bl-md rounded-br-md "; - } - if (isSelected) { - clazz += " z-10 border-indigo-200 bg-indigo-50 "; - } else { - clazz += " border-gray-200"; - } - if (disabled) { - clazz += - " cursor-not-allowed bg-gray-50 text-gray-500 ring-gray-200 text-gray"; - } else { - clazz += " cursor-pointer"; - } - return ( - <label class={clazz}> - <input - type="radio" - name="privacy-setting" - checked={isSelected} - disabled={disabled} - onClick={onClick} - class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600" - aria-labelledby="privacy-setting-0-label" - aria-describedby="privacy-setting-0-description" - /> - <span class="ml-3 flex flex-col"> - <span - id="privacy-setting-0-label" - disabled - class="block text-sm font-medium" - > - {label} - </span> - {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */} - {/* <span - id="privacy-setting-0-description" - class="block text-sm" - > - This project would be available to anyone who has the link - </span> */} - </span> - </label> - ); -} - -export function noHandlerPropsAndNoContextForField( - field: string | number | symbol, -): never { - throw Error( - `Field ${field.toString()} doesn't have handler and is not in a form provider context.`, - ); -} - -// function getRequiredFields(fields: UIFormField[]): Array<UIHandlerId> { -// const shape: Array<UIHandlerId> = []; -// fields.forEach((field) => { -// if ("name" in field.properties) { -// // FIXME: this should be a validation when loading the form -// // consistency check -// if (shape.indexOf(field.properties.name) !== -1) { -// throw Error(`already present: ${field.properties.name}`); -// } -// if (!field.properties.required) { -// return; -// } -// shape.push(field.properties.name); -// } else if (field.type === "group") { -// Array.prototype.push.apply( -// shape, -// getRequiredFields(field.properties.fields), -// ); -// } -// }); -// return shape; -// } - -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; -} - -// function getShapeFromFields(fields: UIFormField[]): Array<UIHandlerId> { -// const shape: Array<UIHandlerId> = []; -// fields.forEach((field) => { -// if ("name" in field.properties) { -// // FIXME: this should be a validation when loading the form -// // consistency check -// if (shape.indexOf(field.properties.name) !== -1) { -// throw Error(`already present: ${field.properties.name}`); -// } -// shape.push(field.properties.name); -// } else if (field.type === "group") { -// Array.prototype.push.apply( -// shape, -// getShapeFromFields(field.properties.fields), -// ); -// } -// }); -// return shape; -// } - -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; -} - -type FormType = {}; - -export function InputArray<T extends object, K extends keyof T>( - props: { - fields: UIFormElementConfig[]; - labelField: string; - } & UIFormProps<T, K>, -): VNode { - const { fields, labelField, name, label, required, tooltip } = props; - - const { value, onChange, state } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); - - const list = (value ?? []) as Array<Record<string, string | undefined>>; - const [selectedIndex, setSelectedIndex] = useState<number | undefined>( - undefined, - ); - const selected = - selectedIndex === undefined ? undefined : list[selectedIndex]; - - const form = useForm<FormType>( - { - type: "single-column", - fields, - }, - selected ?? {}, - ); - - useEffect(() => { - if (selectedIndex === undefined) return; - const newValue = [...list]; - newValue.splice(selectedIndex, 1, form.status.result); - onChange(newValue as any); - }, [form.status.result, selectedIndex]); - const { i18n } = useTranslationContext(); - - return ( - <div class="sm:col-span-6"> - <LabelWithTooltipMaybeRequired - label={label} - required={required} - tooltip={tooltip} - /> - - <div class="overflow-hidden ring-1 ring-gray-900/5 rounded-xl p-4"> - <div class="-space-y-px rounded-md bg-white "> - {list.map((v, idx) => { - const label = - getValueFromPath(v, labelField.split(".")) ?? "<<incomplete>>"; - return ( - <Option - label={label as TranslatedString} - key={idx} - isSelected={selectedIndex === idx} - isLast={idx === list.length - 1} - disabled={selectedIndex !== undefined && selectedIndex !== idx} - isFirst={idx === 0} - onClick={() => { - setSelectedIndex(selectedIndex === idx ? undefined : idx); - }} - /> - ); - })} - {!state.disabled && ( - <div class="pt-2"> - <Option - label={"Add new..." as TranslatedString} - isSelected={selectedIndex === list.length} - isLast - isFirst - disabled={ - selectedIndex !== undefined && selectedIndex !== list.length - } - onClick={() => { - setSelectedIndex( - selectedIndex === list.length ? undefined : list.length, - ); - }} - /> - </div> - )} - </div> - {selectedIndex !== undefined && ( - /** - * This form provider act as a substate of the parent form - * Consider creating an InnerFormProvider since not every feature is expected - */ - // <FormProvider - // initial={selected ?? {}} - // readOnly={state.disabled} - // computeFormState={(v) => { - // // current state is ignored - // // the state is defined by the parent form - - // // elements should be present in the state object since this is expected to be an array - // //@ts-ignore - // // return state.elements[selectedIndex]; - // return {}; - // }} - // onSubmit={(v) => { - // const newValue = [...list]; - // newValue.splice(selectedIndex, 1, v); - // onChange(newValue as any); - // setSelectedIndex(undefined); - // }} - // onUpdate={(v) => { - // const newValue = [...list]; - // newValue.splice(selectedIndex, 1, v); - // onChange(newValue as any); - // }} - // > - <div class="px-4 py-6"> - <div class="grid grid-cols-1 gap-y-8 "> - <SingleColumnFormSectionUI - fields={fields} - handler={form.handler} - /> - {/* <RenderAllFieldsByUiConfig - fields={convertUiField( - i18n, - fields, - form.handler, - getConverterById, - )} - /> */} - </div> - </div> - // </FormProvider> - )} - {selectedIndex !== undefined && ( - <div class="flex items-center justify-end gap-x-6"> - <button - type="button" - onClick={() => { - setSelectedIndex(undefined); - }} - class="block px-3 py-2 text-sm font-semibold leading-6 text-gray-900" - > - <i18n.Translate>Close</i18n.Translate> - </button> - - <button - type="button" - disabled={selected !== undefined} - onClick={() => { - const newValue = [...list]; - newValue.splice(selectedIndex, 1); - onChange(newValue as any); - setSelectedIndex(undefined); - }} - class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " - > - <i18n.Translate>Remove</i18n.Translate> - </button> - </div> - )} - </div> - </div> - ); -} diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx @@ -1,77 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Choice Horizontal", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - comment: string; -}; -const initial: TargetObject = { - comment: "0", -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "choiceHorizontal", - label: "label of the field" as TranslatedString, - id: "comment" as UIHandlerId, - choices: [ - { - label: "first choice" as TranslatedString, - value: "1", - }, - { - label: "second choice" as TranslatedString, - value: "2", - }, - { - label: "third choice" as TranslatedString, - value: "3", - }, - ], - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx @@ -1,81 +0,0 @@ -import { TranslatedString } from "@gnu-taler/taler-util"; -import { Fragment, VNode, h } from "preact"; -import { UIFormProps } from "./FormProvider.js"; -import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; - -export interface ChoiceH<V> { - label: TranslatedString; - value: V; -} - -export function InputChoiceHorizontal<T extends object, K extends keyof T>( - props: { - choices: ChoiceH<string>[]; - } & UIFormProps<T, K>, -): VNode { - const { choices, label, tooltip, help, required, converter } = props; - const { value, onChange, state } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); - if (state.hidden) { - return <Fragment />; - } - - return ( - <div class="sm:col-span-6"> - <LabelWithTooltipMaybeRequired - label={label} - required={required} - tooltip={tooltip} - /> - <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 isFirst = idx === 0; - const isLast = idx === choices.length - 1; - let clazz = - "relative inline-flex items-center px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:z-10"; - if (convertedValue !== undefined && convertedValue === value) { - clazz += - " text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500"; - } else { - clazz += " hover:bg-gray-100 border-gray-300"; - } - if (isFirst) { - clazz += " rounded-l-md"; - } else { - clazz += " -ml-px"; - } - if (isLast) { - clazz += " rounded-r-md"; - } - return ( - <button - type="button" - key={idx} - disabled={state.disabled} - label={choice.label} - class={clazz} - onClick={(e) => { - onChange( - (value === choice.value - ? undefined - : convertedValue) as any, - ); - }} - > - {choice.label} - </button> - ); - })} - </div> - </fieldset> - {help && ( - <p class="mt-2 text-sm text-gray-500" id="email-description"> - {help} - </p> - )} - </div> - ); -} diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx @@ -1,77 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Choice Stacked", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - comment: string; -}; -const initial: TargetObject = { - comment: "some initial comment", -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "choiceStacked", - label: "label of the field" as TranslatedString, - id: "comment" as UIHandlerId, - choices: [ - { - label: "first choice" as TranslatedString, - value: "1", - }, - { - label: "second choice" as TranslatedString, - value: "2", - }, - { - label: "third choice" as TranslatedString, - value: "3", - }, - ], - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx @@ -1,116 +0,0 @@ -import { TranslatedString } from "@gnu-taler/taler-util"; -import { Fragment, VNode, h } from "preact"; -import { UIFormProps } from "./FormProvider.js"; -import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; - -export interface ChoiceS<V> { - label: TranslatedString; - description?: TranslatedString; - value: V; -} - -export function InputChoiceStacked<T extends object, K extends keyof T>( - props: { - choices: ChoiceS<T[K]>[]; - } & UIFormProps<T, K>, -): VNode { - const { - choices, - name, - label, - tooltip, - help, - placeholder, - required, - before, - after, - converter, - } = props; - - const { value, onChange, state } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); - - if (state.hidden) { - return <Fragment />; - } - - return ( - <div class="sm:col-span-6"> - <LabelWithTooltipMaybeRequired - label={label} - required={required} - tooltip={tooltip} - /> - <fieldset class="mt-2"> - <div class="space-y-4"> - {choices.map((choice, idx) => { - // const currentValue = !converter - // ? choice.value - // : converter.fromStringUI(choice.value) ?? ""; - - let clazz = - "border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between"; - if (choice.value === value) { - clazz += - " border-transparent border-indigo-600 ring-2 ring-indigo-600"; - } else { - clazz += " border-gray-300"; - } - - return ( - <label key={idx} class={clazz}> - <input - type="radio" - name="server-size" - // defaultValue={choice.value} - disabled={state.disabled} - value={ - (!converter - ? (choice.value as string) - : converter?.toStringUI(choice.value)) ?? "" - } - onClick={(e) => { - onChange( - (value === choice.value - ? undefined - : choice.value) as any, - ); - }} - class="sr-only" - aria-labelledby="server-size-0-label" - aria-describedby="server-size-0-description-0 server-size-0-description-1" - /> - <span class="flex items-center"> - <span class="flex flex-col text-sm"> - <span - id="server-size-0-label" - class="font-medium text-gray-900" - > - {choice.label} - </span> - {choice.description !== undefined && ( - <span - id="server-size-0-description-0" - class="text-gray-500" - > - <span class="block sm:inline"> - {choice.description} - </span> - </span> - )} - </span> - </span> - </label> - ); - })} - </div> - </fieldset> - {help && ( - <p class="mt-2 text-sm text-gray-500" id="email-description"> - {help} - </p> - )} - </div> - ); -} diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx @@ -1,68 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input File", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - file?: string; -}; -const initial: TargetObject = { - file: undefined, -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "file", - label: "label of the field" as TranslatedString, - required: true, - id: "file" as UIHandlerId, - accept: ".png", - tooltip: - "this is a very long tooltip that explain what the field does without being short" as TranslatedString, - help: "Max size of 2 mega bytes" as TranslatedString, - }, - ], - }, - ], -}; - -export const AcceptPNG = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx @@ -1,129 +0,0 @@ -import { Fragment, VNode, h } from "preact"; -import { UIFormProps } from "./FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; -import { LabelWithTooltipMaybeRequired } from "./InputLine.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; - const { value, onChange, state } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); - - const help = propsHelp ?? state.help; - if (state.hidden) { - return <div />; - } - - const valueStr = !value ? "" : value.toString(); - const firstColon = valueStr.indexOf(";"); - - const { fileName, dataUri } = valueStr.startsWith("file:") - ? { - fileName: valueStr.substring(5, firstColon), - dataUri: valueStr.substring(firstColon + 1), - } - : { - fileName: "", - dataUri: valueStr, - }; - - return ( - <div class="col-span-full"> - <LabelWithTooltipMaybeRequired - label={label} - tooltip={tooltip} - required={required} - /> - {!dataUri ? ( - <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> - <div class="text-center"> - <svg - class="mx-auto h-12 w-12 text-gray-300" - viewBox="0 0 24 24" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" - clip-rule="evenodd" - /> - </svg> - {!state.disabled && ( - <div class="my-2 flex text-sm leading-6 text-gray-600"> - <label - for={String(props.name)} - class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" - > - <span>Upload a file</span> - <input - id={String(props.name)} - type="file" - class="sr-only" - accept={accept} - onChange={(e) => { - const f: FileList | null = e.currentTarget.files; - if (!f || f.length != 1) { - return onChange(undefined!); - } - if (f[0].size > maxBites) { - return onChange(undefined!); - } - const fileName = f[0].name; - return f[0].arrayBuffer().then((b) => { - const b64 = window.btoa( - new Uint8Array(b).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - if (fileName) { - return onChange( - `file:${fileName};data:${f[0].type};base64,${b64}` as any, - ); - } else { - return onChange( - `data:${f[0].type};base64,${b64}` as any, - ); - } - }); - }} - /> - </label> - {/* <p class="pl-1">or drag and drop</p> */} - </div> - )} - </div> - </div> - ) : ( - <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> - {(dataUri as string).startsWith("data:image/") ? ( - <img src={dataUri} class=" h-24 w-full object-cover relative" /> - ) : ( - <div /> - )} - {fileName ? ( - <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> - {fileName} - </div> - ) : ( - <Fragment /> - )} - - {!state.disabled && ( - <div - class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer " - onClick={() => { - onChange(undefined!); - }} - > - Clear - </div> - )} - </div> - )} - {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>} - </div> - ); -} diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx @@ -1,58 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Integer", -}; - -type TargetObject = { - age: number; -}; -const initial: TargetObject = { - age: 5, -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "integer", - label: "Age" as TranslatedString, - id: "age" as UIHandlerId, - tooltip: "just numbers" as TranslatedString, - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputInteger.tsx b/packages/web-util/src/forms/InputInteger.tsx @@ -1,24 +0,0 @@ -import { VNode, h } from "preact"; -import { InputLine } from "./InputLine.js"; -import { UIFormProps } from "./FormProvider.js"; - -export function InputInteger<T extends object, K extends keyof T>( - props: UIFormProps<T, K>, -): VNode { - return ( - <InputLine - type="number" - converter={{ - //@ts-ignore - fromStringUI: (v): number => { - return !v ? 0 : Number.parseInt(v, 10); - }, - //@ts-ignore - toStringUI: (v?: number): string => { - return v === undefined ? "" : String(v); - }, - }} - {...props} - /> - ); -} diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx @@ -1,268 +0,0 @@ -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"; - -//@ts-ignore -const TooltipIcon = ( - <svg - class="w-5 h-5" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 20 20" - fill="currentColor" - > - <path - fill-rule="evenodd" - d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" - clip-rule="evenodd" - /> - </svg> -); - -export function LabelWithTooltipMaybeRequired({ - label, - required, - tooltip, -}: { - label: TranslatedString; - required?: boolean; - tooltip?: TranslatedString; -}): VNode { - const Label = ( - <Fragment> - <div class="flex justify-between"> - <label - htmlFor="email" - class="block text-sm font-medium leading-6 text-gray-900" - > - {label} - </label> - </div> - </Fragment> - ); - const WithTooltip = tooltip ? ( - <div class="relative flex flex-grow items-stretch focus-within:z-10"> - {Label} - <span class="relative flex items-center group pl-2"> - {TooltipIcon} - <div class="absolute bottom-0 -ml-10 hidden flex-col items-center mb-6 group-hover:flex w-28"> - <div class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg"> - {tooltip} - </div> - <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div> - </div> - </span> - </div> - ) : ( - Label - ); - if (required) { - return ( - <div class="flex justify-between w-fit"> - {WithTooltip} - <span class="text-sm leading-6 text-red-600 pl-2">*</span> - </div> - ); - } - return WithTooltip; -} - -export function RenderAddon({ - disabled, - addon, -}: { - disabled?: boolean; - addon: Addon; -}): VNode { - switch (addon.type) { - case "text": { - return ( - <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> - {addon.text} - </span> - ); - } - case "icon": { - return ( - <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> - {addon.icon} - </div> - ); - } - case "button": { - return ( - <button - type="button" - disabled={disabled} - onClick={addon.onClick} - class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" - > - {addon.children} - </button> - ); - } - } -} - -function InputWrapper<T extends object, K extends keyof T>({ - children, - label, - tooltip, - before, - after, - help, - error, - disabled, - required, -}: { - error?: string; - disabled: boolean; - children: ComponentChildren; -} & UIFormProps<T, K>): VNode { - return ( - <div class="sm:col-span-6 "> - <LabelWithTooltipMaybeRequired - label={label} - required={required} - tooltip={tooltip} - /> - <div class="relative mt-2 flex rounded-md shadow-sm"> - {before && <RenderAddon disabled={disabled} addon={before} />} - - {children} - - {after && <RenderAddon disabled={disabled} addon={after} />} - </div> - {error && ( - <p class="mt-2 text-sm text-red-600" id="email-error"> - {error} - </p> - )} - {help && ( - <p class="mt-2 text-sm text-gray-500" id="email-description"> - {help} - </p> - )} - </div> - ); -} - -function defaultToString(v: unknown) { - return v === undefined ? "" : typeof v !== "object" ? String(v) : ""; -} -function defaultFromString(v: string) { - return v; -} - -type InputType = "text" | "text-area" | "password" | "email" | "number"; - -export function InputLine<T extends object, K extends keyof T>( - props: { type: InputType } & UIFormProps<T, K>, -): VNode { - const { name, placeholder, before, after, converter, type, disabled } = props; - - const { value, onChange, state, error } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); - - const fromString: (s: string) => any = - converter?.fromStringUI ?? defaultFromString; - const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; - - if (state.hidden) return <div />; - - let clazz = - "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200"; - if (before) { - switch (before.type) { - case "icon": { - clazz += " pl-10"; - break; - } - case "button": { - clazz += " rounded-none rounded-r-md "; - break; - } - case "text": { - clazz += " min-w-0 flex-1 rounded-r-md rounded-none "; - break; - } - } - } - if (after) { - switch (after.type) { - case "icon": { - clazz += " pr-10"; - break; - } - case "button": { - clazz += " rounded-none rounded-l-md"; - break; - } - case "text": { - clazz += " min-w-0 flex-1 rounded-l-md rounded-none "; - break; - } - } - } - const showError = value !== undefined && error; - if (showError) { - clazz += - " text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500"; - } else { - clazz += - " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600"; - } - - if (type === "text-area") { - return ( - <InputWrapper<T, K> - {...props} - help={props.help ?? state.help} - disabled={disabled ?? false} - error={showError ? error : undefined} - > - <textarea - rows={4} - name={String(name)} - onChange={(e) => { - onChange(fromString(e.currentTarget.value)); - }} - placeholder={placeholder ? placeholder : undefined} - value={toString(value) ?? ""} - // defaultValue={toString(value)} - disabled={disabled ?? false} - aria-invalid={showError} - // aria-describedby="email-error" - class={clazz} - /> - </InputWrapper> - ); - } - - return ( - <InputWrapper<T, K> - {...props} - help={props.help ?? state.help} - disabled={disabled ?? false} - error={showError ? error : undefined} - > - <input - name={String(name)} - type={type} - onChange={(e) => { - onChange(fromString(e.currentTarget.value)); - }} - placeholder={placeholder ? placeholder : undefined} - value={toString(value) ?? ""} - // onBlur={() => { - // onChange(fromString(value as any)); - // }} - // defaultValue={toString(value)} - disabled={disabled ?? false} - aria-invalid={showError} - // aria-describedby="email-error" - class={clazz} - /> - </InputWrapper> - ); -} diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx @@ -1,101 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Select Multiple", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - pets: string[]; - things: string[]; -}; -const initial: TargetObject = { - pets: [], - things: [], -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "selectMultiple", - label: "allow duplicates" as TranslatedString, - id: "pets" as UIHandlerId, - placeholder: "search..." as TranslatedString, - choices: [ - { - label: "one label" as TranslatedString, - value: "one", - }, - { - label: "two label" as TranslatedString, - value: "two", - }, - { - label: "five label" as TranslatedString, - value: "five", - }, - ], - }, - { - type: "selectMultiple", - label: "unique values" as TranslatedString, - id: "things" as UIHandlerId, - unique: true, - placeholder: "search..." as TranslatedString, - choices: [ - { - label: "one label" as TranslatedString, - value: "one", - }, - { - label: "two label" as TranslatedString, - value: "two", - }, - { - label: "five label" as TranslatedString, - value: "five", - }, - ], - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx @@ -1,172 +0,0 @@ -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"; - -export function InputSelectMultiple<T extends object, K extends keyof T>( - props: { - choices: ChoiceS<T[K]>[]; - unique?: boolean; - max?: number; - } & UIFormProps<T, K>, -): VNode { - const { - converter, - label, - choices, - placeholder, - tooltip, - required, - unique, - max, - } = props; - const { value, onChange, state } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); - - const [filter, setFilter] = useState<string | undefined>(undefined); - const regex = new RegExp(`.*${filter}.*`, "i"); - const choiceMap = choices.reduce( - (prev, curr) => { - return { ...prev, [curr.value as string]: curr.label }; - }, - {} as Record<string, string>, - ); - - const list = (value ?? []) as string[]; - const filteredChoices = - filter === undefined - ? undefined - : choices.filter((v) => { - return regex.test(v.label); - }); - return ( - <div class="sm:col-span-6"> - <LabelWithTooltipMaybeRequired - label={label} - required={required} - tooltip={tooltip} - /> - {list.map((v, idx) => { - return ( - <span - key={idx} - class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600" - > - {choiceMap[v]} - <button - type="button" - disabled={state.disabled} - onClick={() => { - const newValue = [...list]; - newValue.splice(idx, 1); - onChange(newValue as any); - setFilter(undefined); - }} - class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" - > - <span class="sr-only">Remove</span> - <svg - viewBox="0 0 14 14" - class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75" - > - <path d="M4 4l6 6m0-6l-6 6" /> - </svg> - <span class="absolute -inset-1"></span> - </button> - </span> - ); - })} - - {!state.disabled && ( - <div class="relative mt-2"> - <input - id="combobox" - type="text" - value={filter ?? ""} - onChange={(e) => { - setFilter(e.currentTarget.value); - }} - placeholder={placeholder} - class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - role="combobox" - aria-controls="options" - aria-expanded="false" - /> - <button - type="button" - disabled={state.disabled} - onClick={() => { - setFilter(filter === undefined ? "" : undefined); - }} - class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" - > - <svg - class="h-5 w-5 text-gray-400" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" - clip-rule="evenodd" - /> - </svg> - </button> - - {filteredChoices !== undefined && ( - <ul - class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" - id="options" - role="listbox" - > - {filteredChoices.map((v, idx) => { - return ( - <li - key={idx} - class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" - id="option-0" - role="option" - onClick={() => { - setFilter(undefined); - if (unique && list.indexOf(v.value as string) !== -1) { - return; - } - if (max !== undefined && list.length >= max) { - return; - } - const newValue = [...list]; - newValue.splice(0, 0, v.value as string); - onChange(newValue as any); - }} - - // tabindex="-1" - > - {/* <!-- Selected: "font-semibold" --> */} - <span class="block truncate">{v.label}</span> - - {/* <!-- - Checkmark, only display for selected option. - - Active: "text-white", Not Active: "text-indigo-600" - --> */} - </li> - ); - })} - - {/* <!-- - Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. - - Active: "text-white bg-indigo-600", Not Active: "text-gray-900" - --> */} - - {/* <!-- More items... --> */} - </ul> - )} - </div> - )} - </div> - ); -} diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx @@ -1,78 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Select One", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - things: string; -}; -const initial: TargetObject = { - things: "one", -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "selectOne", - label: "label of the field" as TranslatedString, - id: "things" as UIHandlerId, - placeholder: "search..." as TranslatedString, - choices: [ - { - label: "one label" as TranslatedString, - value: "one", - }, - { - label: "two label" as TranslatedString, - value: "two", - }, - { - label: "five label" as TranslatedString, - value: "five", - }, - ], - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputSelectOne.tsx b/packages/web-util/src/forms/InputSelectOne.tsx @@ -1,140 +0,0 @@ -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"; - -export function InputSelectOne<T extends object, K extends keyof T>( - props: { - choices: ChoiceS<T[K]>[]; - } & UIFormProps<T, K>, -): VNode { - const { label, choices, placeholder, tooltip, required } = props; - const { value, onChange } = - props.handler ?? noHandlerPropsAndNoContextForField(props.name); - - const [filter, setFilter] = useState<string | undefined>(undefined); - const regex = new RegExp(`.*${filter}.*`, "i"); - const choiceMap = choices.reduce( - (prev, curr) => { - return { ...prev, [curr.value as string]: curr.label }; - }, - {} as Record<string, string>, - ); - - const filteredChoices = - filter === undefined - ? undefined - : choices.filter((v) => { - return regex.test(v.label); - }); - return ( - <div class="sm:col-span-6"> - <LabelWithTooltipMaybeRequired - label={label} - required={required} - tooltip={tooltip} - /> - {value ? ( - <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 font-medium text-gray-600"> - {choiceMap[value as string]} - <button - type="button" - onClick={() => { - onChange(undefined!); - }} - class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" - > - <span class="sr-only">Remove</span> - <svg - viewBox="0 0 14 14" - class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75" - > - <path d="M4 4l6 6m0-6l-6 6" /> - </svg> - <span class="absolute -inset-1"></span> - </button> - </span> - ) : ( - <div class="relative mt-2"> - <input - id="combobox" - type="text" - value={filter ?? ""} - onChange={(e) => { - setFilter(e.currentTarget.value); - }} - placeholder={placeholder} - class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - role="combobox" - aria-controls="options" - aria-expanded="false" - /> - <button - type="button" - onClick={() => { - setFilter(filter === undefined ? "" : undefined); - }} - class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" - > - <svg - class="h-5 w-5 text-gray-400" - viewBox="0 0 20 20" - fill="currentColor" - aria-hidden="true" - > - <path - fill-rule="evenodd" - d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" - clip-rule="evenodd" - /> - </svg> - </button> - - {filteredChoices !== undefined && ( - <ul - class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" - id="options" - role="listbox" - > - {filteredChoices.map((v, idx) => { - return ( - <li - key={idx} - class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" - id="option-0" - role="option" - onClick={() => { - setFilter(undefined); - onChange(v.value as any); - }} - - // tabindex="-1" - > - {/* <!-- Selected: "font-semibold" --> */} - <span class="block truncate">{v.label}</span> - - {/* <!-- - Checkmark, only display for selected option. - - Active: "text-white", Not Active: "text-indigo-600" - --> */} - </li> - ); - })} - - {/* <!-- - Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. - - Active: "text-white bg-indigo-600", Not Active: "text-gray-900" - --> */} - - {/* <!-- More items... --> */} - </ul> - )} - </div> - )} - </div> - ); -} diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx @@ -1,63 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Text", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - comment: string; -}; -const initial: TargetObject = { - comment: "some initial comment", -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - 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, - design, -}); diff --git a/packages/web-util/src/forms/InputText.tsx b/packages/web-util/src/forms/InputText.tsx @@ -1,9 +0,0 @@ -import { VNode, h } from "preact"; -import { UIFormProps } from "./FormProvider.js"; -import { InputLine } from "./InputLine.js"; - -export function InputText<T extends object, K extends keyof T>( - props: UIFormProps<T, K>, -): VNode { - return <InputLine type="text" {...props} />; -} diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx @@ -1,63 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Text Area", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - comment: string; -}; -const initial: TargetObject = { - comment: "some initial comment", -}; - -const design: FormDesign = { - type: "double-column", - sections: [ - { - title: "this is a simple form" as TranslatedString, - fields: [ - { - type: "textArea", - label: "label of the field" as TranslatedString, - id: "comment" as UIHandlerId, - }, - ], - }, - ], -}; - -export const SimpleComment = tests.createExample(TestedComponent, { - initial, - design, -}); diff --git a/packages/web-util/src/forms/InputTextArea.tsx b/packages/web-util/src/forms/InputTextArea.tsx @@ -1,9 +0,0 @@ -import { VNode, h } from "preact"; -import { InputLine } from "./InputLine.js"; -import { UIFormProps } from "./FormProvider.js"; - -export function InputTextArea<T extends object, K extends keyof T>( - props: UIFormProps<T, K>, -): VNode { - return <InputLine type="text-area" {...props} />; -} diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx @@ -1,71 +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 { DefaultForm as TestedComponent } from "./forms-ui.js"; -import { FormDesign, UIHandlerId } from "./forms-types.js"; - -export default { - title: "Input Toggle", -}; - -export namespace Simplest { - export interface Form { - comment: string; - } -} - -type TargetObject = { - accept: boolean; -}; -const initial: TargetObject = { - accept: true, -}; - -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 WithThreeState = tests.createExample(TestedComponent, { - initial, - 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 @@ -1,57 +0,0 @@ -import { VNode, h } from "preact"; -import { UIFormProps } from "./FormProvider.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; -import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; - -export function InputToggle<T extends object, K extends keyof T>( - props: { threeState: boolean } & UIFormProps<T, K>, -): VNode { - const { - name, - label, - tooltip, - help, - placeholder, - required, - before, - after, - converter, - threeState, - } = props; - const { value, onChange } = - 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} - required={required} - tooltip={tooltip} - /> - <button - type="button" - data-state={isOn ? "on" : value === undefined ? "undefined" : "off"} - class="bg-indigo-600 data-[state=off]:bg-gray-200 data-[state=undefined]:bg-gray-200 relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - if (value === false && threeState) { - return onChange(undefined as any); - } else { - return onChange(!isOn as any); - } - }} - > - <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> - </button> - </div> - </div> - ); -} diff --git a/packages/web-util/src/forms/field-types.ts b/packages/web-util/src/forms/field-types.ts @@ -1,27 +1,20 @@ -import { h as create, Fragment, VNode } from "preact"; +import { VNode } from "preact"; import { Caption } from "./Caption.js"; +import { DownloadLink } from "./DownloadLink.js"; +import { InputAbsoluteTime } from "./fields/InputAbsoluteTime.js"; +import { InputAmount } from "./fields/InputAmount.js"; +import { InputArray } from "./fields/InputArray.js"; +import { InputChoiceHorizontal } from "./fields/InputChoiceHorizontal.js"; +import { InputChoiceStacked } from "./fields/InputChoiceStacked.js"; +import { InputFile } from "./fields/InputFile.js"; +import { InputInteger } from "./fields/InputInteger.js"; +import { InputSelectMultiple } from "./fields/InputSelectMultiple.js"; +import { InputSelectOne } from "./fields/InputSelectOne.js"; +import { InputText } from "./fields/InputText.js"; +import { InputTextArea } from "./fields/InputTextArea.js"; +import { InputToggle } from "./fields/InputToggle.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 */ diff --git a/packages/web-util/src/forms/fields/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/fields/InputAbsoluteTime.stories.tsx @@ -0,0 +1,63 @@ +/* + 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 { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "../../tests/hook.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; +import { DefaultForm as TestedComponent } from "../forms-ui.js"; +export default { + title: "Input Absolute Time", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + today: AbsoluteTime; +}; +const initial: TargetObject = { + today: AbsoluteTime.now(), +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "absoluteTimeText", + label: "label of the field" as TranslatedString, + id: "today" as UIHandlerId, + pattern: "dd/MM/yyyy HH:mm", + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputAbsoluteTime.tsx b/packages/web-util/src/forms/fields/InputAbsoluteTime.tsx @@ -0,0 +1,91 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { format, parse } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Calendar } from "../Calendar.js"; +import { Dialog } from "../Dialog.js"; +import { UIFormProps } from "../FormProvider.js"; +import { InputLine } from "./InputLine.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export function InputAbsoluteTime<T extends object, K extends keyof T>( + properties: { pattern?: string } & UIFormProps<T, K>, +): VNode { + const pattern = properties.pattern ?? "dd/MM/yyyy"; + const [open, setOpen] = useState(false); + + const { value, onChange } = + properties.handler ?? noHandlerPropsAndNoContextForField(properties.name); + return ( + <Fragment> + <InputLine<T, K> + type="text" + after={{ + type: "button", + onClick: () => { + setOpen(true); + }, + // icon: <CalendarIcon class="h-6 w-6" />, + children: ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + stroke-width="1.5" + stroke="currentColor" + class="w-6 h-6" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" + /> + </svg> + ), + }} + converter={{ + //@ts-ignore + fromStringUI: (v): AbsoluteTime | undefined => { + if (!v) return undefined; + try { + const t_ms = parse(v, pattern, Date.now()).getTime(); + return AbsoluteTime.fromMilliseconds(t_ms); + } catch (e) { + return undefined; + } + }, + //@ts-ignore + toStringUI: (v: AbsoluteTime | undefined) => { + return !v || !v.t_ms + ? undefined + : v.t_ms === "never" + ? "never" + : format(v.t_ms, pattern); + }, + }} + {...properties} + /> + {open && ( + <Dialog onClose={() => setOpen(false)}> + <Calendar + value={(value as any as AbsoluteTime) ?? AbsoluteTime.now()} + onChange={(v) => { + onChange(v as any); + setOpen(false); + }} + /> + </Dialog> + )} + {/* {open && + <Dialog onClose={() => setOpen(false)} > + <TimePicker value={value as AbsoluteTime ?? AbsoluteTime.now()} + onChange={(v) => { + onChange(v as any) + }} + onConfirm={() => { + setOpen(false) + }} /> + </Dialog>} */} + </Fragment> + ); +} diff --git a/packages/web-util/src/forms/fields/InputAmount.stories.tsx b/packages/web-util/src/forms/fields/InputAmount.stories.tsx @@ -0,0 +1,64 @@ +/* + 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 { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "../../tests/hook.js"; +import { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Amount", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + amount: AmountJson; +}; +const initial: TargetObject = { + amount: Amounts.parseOrThrow("USD:10"), +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "amount", + label: "label of the field" as TranslatedString, + id: "amount" as UIHandlerId, + currency: "ARS", + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputAmount.tsx b/packages/web-util/src/forms/fields/InputAmount.tsx @@ -0,0 +1,40 @@ +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 { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export function InputAmount<T extends object, K extends keyof T>( + props: { currency?: string } & UIFormProps<T, K>, +): VNode { + const { value } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + const currency = + !value || !(value as any).currency + ? props.currency + : (value as any).currency; + return ( + <InputLine<T, K> + {...props} + type="text" + before={{ + type: "text", + text: currency as TranslatedString, + }} + //@ts-ignore + converter={ + props.converter ?? { + fromStringUI: (v): AmountJson => { + return ( + Amounts.parse(`${currency}:${v}`) ?? + Amounts.zeroOfCurrency(currency) + ); + }, + toStringUI: (v: AmountJson) => { + return v === undefined ? "" : Amounts.stringifyValue(v); + }, + } + } + /> + ); +} diff --git a/packages/web-util/src/forms/fields/InputArray.stories.tsx b/packages/web-util/src/forms/fields/InputArray.stories.tsx @@ -0,0 +1,137 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Array", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + people: { + name: string; + age: number; + }[]; +}; +const initial: TargetObject = { + people: [ + { + name: "me", + age: 17, + }, + ], +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + description: "to test how arrays are used" as TranslatedString, + fields: [ + { + type: "array", + label: "People" as TranslatedString, + fields: [ + { + id: "name" as UIHandlerId, + type: "text", + required: true, + label: "Name" as TranslatedString, + }, + { + id: "age" as UIHandlerId, + type: "integer", + required: true, + label: "Age" as TranslatedString, + }, + ], + id: "people" as UIHandlerId, + labelFieldId: "name" as UIHandlerId, + }, + ], + }, + ], +}; + +export const FormWithArray = tests.createExample(TestedComponent, { + initial, + design, +}); + +const initial2: any = {}; + +const design2: FormDesign = { + type: "double-column", + sections: [ + { + title: "Personal information" as TranslatedString, + fields: [ + { + type: "text", + id: "PERSON_FULL_NAME" as UIHandlerId, + label: "Full name", + required: true, + }, + ], + }, + { + title: "Representatives" as TranslatedString, + fields: [ + { + type: "array", + id: "BUSINESS_LEGAL_REPRESENTATIVES" as UIHandlerId, + labelFieldId: "PERSON_FULL_NAME" as UIHandlerId, + label: + "List of natural persons that are legal representatives or shareholders", + fields: [ + { + type: "text", + id: "PERSON_FULL_NAME" as UIHandlerId, + label: "Name", + required: true, + }, + { + type: "text", + id: "PERSON_DATE_OF_BIRTH" as UIHandlerId, + label: "Date of birth", + required: true, + }, + ], + }, + ], + }, + ], +}; + +export const NonMixingProperties = tests.createExample(TestedComponent, { + initial: initial2, + design: design2, +}); diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx @@ -0,0 +1,325 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { getValueFromPath, useForm } from "../../hooks/useForm.js"; +import { + 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, + disabled, + isFirst, + isLast, + isSelected, + onClick, +}: { + label: TranslatedString; + isFirst?: boolean; + isLast?: boolean; + isSelected?: boolean; + disabled?: boolean; + onClick: () => void; +}): VNode { + let clazz = "relative flex border p-4 focus:outline-none disabled:text-grey"; + if (isFirst) { + clazz += " rounded-tl-md rounded-tr-md "; + } + if (isLast) { + clazz += " rounded-bl-md rounded-br-md "; + } + if (isSelected) { + clazz += " z-10 border-indigo-200 bg-indigo-50 "; + } else { + clazz += " border-gray-200"; + } + if (disabled) { + clazz += + " cursor-not-allowed bg-gray-50 text-gray-500 ring-gray-200 text-gray"; + } else { + clazz += " cursor-pointer"; + } + return ( + <label class={clazz}> + <input + type="radio" + name="privacy-setting" + checked={isSelected} + disabled={disabled} + onClick={onClick} + class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600" + aria-labelledby="privacy-setting-0-label" + aria-describedby="privacy-setting-0-description" + /> + <span class="ml-3 flex flex-col"> + <span + id="privacy-setting-0-label" + disabled + class="block text-sm font-medium" + > + {label} + </span> + {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */} + {/* <span + id="privacy-setting-0-description" + class="block text-sm" + > + This project would be available to anyone who has the link + </span> */} + </span> + </label> + ); +} + +export function noHandlerPropsAndNoContextForField( + field: string | number | symbol, +): never { + throw Error( + `Field ${field.toString()} doesn't have handler and is not in a form provider context.`, + ); +} + +// function getRequiredFields(fields: UIFormField[]): Array<UIHandlerId> { +// const shape: Array<UIHandlerId> = []; +// fields.forEach((field) => { +// if ("name" in field.properties) { +// // FIXME: this should be a validation when loading the form +// // consistency check +// if (shape.indexOf(field.properties.name) !== -1) { +// throw Error(`already present: ${field.properties.name}`); +// } +// if (!field.properties.required) { +// return; +// } +// shape.push(field.properties.name); +// } else if (field.type === "group") { +// Array.prototype.push.apply( +// shape, +// getRequiredFields(field.properties.fields), +// ); +// } +// }); +// return shape; +// } + +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; +} + +// function getShapeFromFields(fields: UIFormField[]): Array<UIHandlerId> { +// const shape: Array<UIHandlerId> = []; +// fields.forEach((field) => { +// if ("name" in field.properties) { +// // FIXME: this should be a validation when loading the form +// // consistency check +// if (shape.indexOf(field.properties.name) !== -1) { +// throw Error(`already present: ${field.properties.name}`); +// } +// shape.push(field.properties.name); +// } else if (field.type === "group") { +// Array.prototype.push.apply( +// shape, +// getShapeFromFields(field.properties.fields), +// ); +// } +// }); +// return shape; +// } + +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; +} + +type FormType = {}; + +export function InputArray<T extends object, K extends keyof T>( + props: { + fields: UIFormElementConfig[]; + labelField: string; + } & UIFormProps<T, K>, +): VNode { + const { fields, labelField, name, label, required, tooltip } = props; + + const { value, onChange, state } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + + const list = (value ?? []) as Array<Record<string, string | undefined>>; + const [selectedIndex, setSelectedIndex] = useState<number | undefined>( + undefined, + ); + const selected = + selectedIndex === undefined ? undefined : list[selectedIndex]; + + const form = useForm<FormType>( + { + type: "single-column", + fields, + }, + selected ?? {}, + ); + + useEffect(() => { + if (selectedIndex === undefined) return; + const newValue = [...list]; + newValue.splice(selectedIndex, 1, form.status.result); + onChange(newValue as any); + }, [form.status.result, selectedIndex]); + const { i18n } = useTranslationContext(); + + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + + <div class="overflow-hidden ring-1 ring-gray-900/5 rounded-xl p-4"> + <div class="-space-y-px rounded-md bg-white "> + {list.map((v, idx) => { + const label = + getValueFromPath(v, labelField.split(".")) ?? "<<incomplete>>"; + return ( + <Option + label={label as TranslatedString} + key={idx} + isSelected={selectedIndex === idx} + isLast={idx === list.length - 1} + disabled={selectedIndex !== undefined && selectedIndex !== idx} + isFirst={idx === 0} + onClick={() => { + setSelectedIndex(selectedIndex === idx ? undefined : idx); + }} + /> + ); + })} + {!state.disabled && ( + <div class="pt-2"> + <Option + label={"Add new..." as TranslatedString} + isSelected={selectedIndex === list.length} + isLast + isFirst + disabled={ + selectedIndex !== undefined && selectedIndex !== list.length + } + onClick={() => { + setSelectedIndex( + selectedIndex === list.length ? undefined : list.length, + ); + }} + /> + </div> + )} + </div> + {selectedIndex !== undefined && ( + /** + * This form provider act as a substate of the parent form + * Consider creating an InnerFormProvider since not every feature is expected + */ + // <FormProvider + // initial={selected ?? {}} + // readOnly={state.disabled} + // computeFormState={(v) => { + // // current state is ignored + // // the state is defined by the parent form + + // // elements should be present in the state object since this is expected to be an array + // //@ts-ignore + // // return state.elements[selectedIndex]; + // return {}; + // }} + // onSubmit={(v) => { + // const newValue = [...list]; + // newValue.splice(selectedIndex, 1, v); + // onChange(newValue as any); + // setSelectedIndex(undefined); + // }} + // onUpdate={(v) => { + // const newValue = [...list]; + // newValue.splice(selectedIndex, 1, v); + // onChange(newValue as any); + // }} + // > + <div class="px-4 py-6"> + <div class="grid grid-cols-1 gap-y-8 "> + <SingleColumnFormSectionUI + fields={fields} + handler={form.handler} + /> + {/* <RenderAllFieldsByUiConfig + fields={convertUiField( + i18n, + fields, + form.handler, + getConverterById, + )} + /> */} + </div> + </div> + // </FormProvider> + )} + {selectedIndex !== undefined && ( + <div class="flex items-center justify-end gap-x-6"> + <button + type="button" + onClick={() => { + setSelectedIndex(undefined); + }} + class="block px-3 py-2 text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Close</i18n.Translate> + </button> + + <button + type="button" + disabled={selected !== undefined} + onClick={() => { + const newValue = [...list]; + newValue.splice(selectedIndex, 1); + onChange(newValue as any); + setSelectedIndex(undefined); + }} + class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " + > + <i18n.Translate>Remove</i18n.Translate> + </button> + </div> + )} + </div> + </div> + ); +} diff --git a/packages/web-util/src/forms/fields/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/fields/InputChoiceHorizontal.stories.tsx @@ -0,0 +1,77 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Choice Horizontal", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +}; +const initial: TargetObject = { + comment: "0", +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "choiceHorizontal", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + choices: [ + { + label: "first choice" as TranslatedString, + value: "1", + }, + { + label: "second choice" as TranslatedString, + value: "2", + }, + { + label: "third choice" as TranslatedString, + value: "3", + }, + ], + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/fields/InputChoiceHorizontal.tsx @@ -0,0 +1,81 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { UIFormProps } from "../FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export interface ChoiceH<V> { + label: TranslatedString; + value: V; +} + +export function InputChoiceHorizontal<T extends object, K extends keyof T>( + props: { + choices: ChoiceH<string>[]; + } & UIFormProps<T, K>, +): VNode { + const { choices, label, tooltip, help, required, converter } = props; + const { value, onChange, state } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + if (state.hidden) { + return <Fragment />; + } + + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <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 isFirst = idx === 0; + const isLast = idx === choices.length - 1; + let clazz = + "relative inline-flex items-center px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:z-10"; + if (convertedValue !== undefined && convertedValue === value) { + clazz += + " text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500"; + } else { + clazz += " hover:bg-gray-100 border-gray-300"; + } + if (isFirst) { + clazz += " rounded-l-md"; + } else { + clazz += " -ml-px"; + } + if (isLast) { + clazz += " rounded-r-md"; + } + return ( + <button + type="button" + key={idx} + disabled={state.disabled} + label={choice.label} + class={clazz} + onClick={(e) => { + onChange( + (value === choice.value + ? undefined + : convertedValue) as any, + ); + }} + > + {choice.label} + </button> + ); + })} + </div> + </fieldset> + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.stories.tsx @@ -0,0 +1,77 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Choice Stacked", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +}; +const initial: TargetObject = { + comment: "some initial comment", +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "choiceStacked", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + choices: [ + { + label: "first choice" as TranslatedString, + value: "1", + }, + { + label: "second choice" as TranslatedString, + value: "2", + }, + { + label: "third choice" as TranslatedString, + value: "3", + }, + ], + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputChoiceStacked.tsx b/packages/web-util/src/forms/fields/InputChoiceStacked.tsx @@ -0,0 +1,116 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { UIFormProps } from "../FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; + +export interface ChoiceS<V> { + label: TranslatedString; + description?: TranslatedString; + value: V; +} + +export function InputChoiceStacked<T extends object, K extends keyof T>( + props: { + choices: ChoiceS<T[K]>[]; + } & UIFormProps<T, K>, +): VNode { + const { + choices, + name, + label, + tooltip, + help, + placeholder, + required, + before, + after, + converter, + } = props; + + const { value, onChange, state } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + + if (state.hidden) { + return <Fragment />; + } + + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <fieldset class="mt-2"> + <div class="space-y-4"> + {choices.map((choice, idx) => { + // const currentValue = !converter + // ? choice.value + // : converter.fromStringUI(choice.value) ?? ""; + + let clazz = + "border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between"; + if (choice.value === value) { + clazz += + " border-transparent border-indigo-600 ring-2 ring-indigo-600"; + } else { + clazz += " border-gray-300"; + } + + return ( + <label key={idx} class={clazz}> + <input + type="radio" + name="server-size" + // defaultValue={choice.value} + disabled={state.disabled} + value={ + (!converter + ? (choice.value as string) + : converter?.toStringUI(choice.value)) ?? "" + } + onClick={(e) => { + onChange( + (value === choice.value + ? undefined + : choice.value) as any, + ); + }} + class="sr-only" + aria-labelledby="server-size-0-label" + aria-describedby="server-size-0-description-0 server-size-0-description-1" + /> + <span class="flex items-center"> + <span class="flex flex-col text-sm"> + <span + id="server-size-0-label" + class="font-medium text-gray-900" + > + {choice.label} + </span> + {choice.description !== undefined && ( + <span + id="server-size-0-description-0" + class="text-gray-500" + > + <span class="block sm:inline"> + {choice.description} + </span> + </span> + )} + </span> + </span> + </label> + ); + })} + </div> + </fieldset> + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/fields/InputFile.stories.tsx b/packages/web-util/src/forms/fields/InputFile.stories.tsx @@ -0,0 +1,68 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input File", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + file?: string; +}; +const initial: TargetObject = { + file: undefined, +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "file", + label: "label of the field" as TranslatedString, + required: true, + id: "file" as UIHandlerId, + accept: ".png", + tooltip: + "this is a very long tooltip that explain what the field does without being short" as TranslatedString, + help: "Max size of 2 mega bytes" as TranslatedString, + }, + ], + }, + ], +}; + +export const AcceptPNG = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputFile.tsx b/packages/web-util/src/forms/fields/InputFile.tsx @@ -0,0 +1,129 @@ +import { Fragment, VNode, h } from "preact"; +import { UIFormProps } from "../FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.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; + const { value, onChange, state } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + + const help = propsHelp ?? state.help; + if (state.hidden) { + return <div />; + } + + const valueStr = !value ? "" : value.toString(); + const firstColon = valueStr.indexOf(";"); + + const { fileName, dataUri } = valueStr.startsWith("file:") + ? { + fileName: valueStr.substring(5, firstColon), + dataUri: valueStr.substring(firstColon + 1), + } + : { + fileName: "", + dataUri: valueStr, + }; + + return ( + <div class="col-span-full"> + <LabelWithTooltipMaybeRequired + label={label} + tooltip={tooltip} + required={required} + /> + {!dataUri ? ( + <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> + <div class="text-center"> + <svg + class="mx-auto h-12 w-12 text-gray-300" + viewBox="0 0 24 24" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" + clip-rule="evenodd" + /> + </svg> + {!state.disabled && ( + <div class="my-2 flex text-sm leading-6 text-gray-600"> + <label + for={String(props.name)} + class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" + > + <span>Upload a file</span> + <input + id={String(props.name)} + type="file" + class="sr-only" + accept={accept} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) { + return onChange(undefined!); + } + if (f[0].size > maxBites) { + return onChange(undefined!); + } + const fileName = f[0].name; + return f[0].arrayBuffer().then((b) => { + const b64 = window.btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + if (fileName) { + return onChange( + `file:${fileName};data:${f[0].type};base64,${b64}` as any, + ); + } else { + return onChange( + `data:${f[0].type};base64,${b64}` as any, + ); + } + }); + }} + /> + </label> + {/* <p class="pl-1">or drag and drop</p> */} + </div> + )} + </div> + </div> + ) : ( + <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> + {(dataUri as string).startsWith("data:image/") ? ( + <img src={dataUri} class=" h-24 w-full object-cover relative" /> + ) : ( + <div /> + )} + {fileName ? ( + <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> + {fileName} + </div> + ) : ( + <Fragment /> + )} + + {!state.disabled && ( + <div + class="opacity-0 hover:opacity-70 duration-300 absolute rounded-lg border inset-0 z-10 flex justify-center text-xl items-center bg-black text-white cursor-pointer " + onClick={() => { + onChange(undefined!); + }} + > + Clear + </div> + )} + </div> + )} + {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>} + </div> + ); +} diff --git a/packages/web-util/src/forms/fields/InputInteger.stories.tsx b/packages/web-util/src/forms/fields/InputInteger.stories.tsx @@ -0,0 +1,58 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Integer", +}; + +type TargetObject = { + age: number; +}; +const initial: TargetObject = { + age: 5, +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "integer", + label: "Age" as TranslatedString, + id: "age" as UIHandlerId, + tooltip: "just numbers" as TranslatedString, + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputInteger.tsx b/packages/web-util/src/forms/fields/InputInteger.tsx @@ -0,0 +1,24 @@ +import { VNode, h } from "preact"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "../FormProvider.js"; + +export function InputInteger<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + return ( + <InputLine + type="number" + converter={{ + //@ts-ignore + fromStringUI: (v): number => { + return !v ? 0 : Number.parseInt(v, 10); + }, + //@ts-ignore + toStringUI: (v?: number): string => { + return v === undefined ? "" : String(v); + }, + }} + {...props} + /> + ); +} diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx @@ -0,0 +1,268 @@ +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"; + +//@ts-ignore +const TooltipIcon = ( + <svg + class="w-5 h-5" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 20 20" + fill="currentColor" + > + <path + fill-rule="evenodd" + d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" + clip-rule="evenodd" + /> + </svg> +); + +export function LabelWithTooltipMaybeRequired({ + label, + required, + tooltip, +}: { + label: TranslatedString; + required?: boolean; + tooltip?: TranslatedString; +}): VNode { + const Label = ( + <Fragment> + <div class="flex justify-between"> + <label + htmlFor="email" + class="block text-sm font-medium leading-6 text-gray-900" + > + {label} + </label> + </div> + </Fragment> + ); + const WithTooltip = tooltip ? ( + <div class="relative flex flex-grow items-stretch focus-within:z-10"> + {Label} + <span class="relative flex items-center group pl-2"> + {TooltipIcon} + <div class="absolute bottom-0 -ml-10 hidden flex-col items-center mb-6 group-hover:flex w-28"> + <div class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg"> + {tooltip} + </div> + <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div> + </div> + </span> + </div> + ) : ( + Label + ); + if (required) { + return ( + <div class="flex justify-between w-fit"> + {WithTooltip} + <span class="text-sm leading-6 text-red-600 pl-2">*</span> + </div> + ); + } + return WithTooltip; +} + +export function RenderAddon({ + disabled, + addon, +}: { + disabled?: boolean; + addon: Addon; +}): VNode { + switch (addon.type) { + case "text": { + return ( + <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm"> + {addon.text} + </span> + ); + } + case "icon": { + return ( + <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3"> + {addon.icon} + </div> + ); + } + case "button": { + return ( + <button + type="button" + disabled={disabled} + onClick={addon.onClick} + class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-l-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" + > + {addon.children} + </button> + ); + } + } +} + +function InputWrapper<T extends object, K extends keyof T>({ + children, + label, + tooltip, + before, + after, + help, + error, + disabled, + required, +}: { + error?: string; + disabled: boolean; + children: ComponentChildren; +} & UIFormProps<T, K>): VNode { + return ( + <div class="sm:col-span-6 "> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <div class="relative mt-2 flex rounded-md shadow-sm"> + {before && <RenderAddon disabled={disabled} addon={before} />} + + {children} + + {after && <RenderAddon disabled={disabled} addon={after} />} + </div> + {error && ( + <p class="mt-2 text-sm text-red-600" id="email-error"> + {error} + </p> + )} + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + </div> + ); +} + +function defaultToString(v: unknown) { + return v === undefined ? "" : typeof v !== "object" ? String(v) : ""; +} +function defaultFromString(v: string) { + return v; +} + +type InputType = "text" | "text-area" | "password" | "email" | "number"; + +export function InputLine<T extends object, K extends keyof T>( + props: { type: InputType } & UIFormProps<T, K>, +): VNode { + const { name, placeholder, before, after, converter, type, disabled } = props; + + const { value, onChange, state, error } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + + const fromString: (s: string) => any = + converter?.fromStringUI ?? defaultFromString; + const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; + + if (state.hidden) return <div />; + + let clazz = + "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200"; + if (before) { + switch (before.type) { + case "icon": { + clazz += " pl-10"; + break; + } + case "button": { + clazz += " rounded-none rounded-r-md "; + break; + } + case "text": { + clazz += " min-w-0 flex-1 rounded-r-md rounded-none "; + break; + } + } + } + if (after) { + switch (after.type) { + case "icon": { + clazz += " pr-10"; + break; + } + case "button": { + clazz += " rounded-none rounded-l-md"; + break; + } + case "text": { + clazz += " min-w-0 flex-1 rounded-l-md rounded-none "; + break; + } + } + } + const showError = value !== undefined && error; + if (showError) { + clazz += + " text-red-900 ring-red-300 placeholder:text-red-300 focus:ring-red-500"; + } else { + clazz += + " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600"; + } + + if (type === "text-area") { + return ( + <InputWrapper<T, K> + {...props} + help={props.help ?? state.help} + disabled={disabled ?? false} + error={showError ? error : undefined} + > + <textarea + rows={4} + name={String(name)} + onChange={(e) => { + onChange(fromString(e.currentTarget.value)); + }} + placeholder={placeholder ? placeholder : undefined} + value={toString(value) ?? ""} + // defaultValue={toString(value)} + disabled={disabled ?? false} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + </InputWrapper> + ); + } + + return ( + <InputWrapper<T, K> + {...props} + help={props.help ?? state.help} + disabled={disabled ?? false} + error={showError ? error : undefined} + > + <input + name={String(name)} + type={type} + onChange={(e) => { + onChange(fromString(e.currentTarget.value)); + }} + placeholder={placeholder ? placeholder : undefined} + value={toString(value) ?? ""} + // onBlur={() => { + // onChange(fromString(value as any)); + // }} + // defaultValue={toString(value)} + disabled={disabled ?? false} + aria-invalid={showError} + // aria-describedby="email-error" + class={clazz} + /> + </InputWrapper> + ); +} diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.stories.tsx @@ -0,0 +1,101 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Select Multiple", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + pets: string[]; + things: string[]; +}; +const initial: TargetObject = { + pets: [], + things: [], +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "selectMultiple", + label: "allow duplicates" as TranslatedString, + id: "pets" as UIHandlerId, + placeholder: "search..." as TranslatedString, + choices: [ + { + label: "one label" as TranslatedString, + value: "one", + }, + { + label: "two label" as TranslatedString, + value: "two", + }, + { + label: "five label" as TranslatedString, + value: "five", + }, + ], + }, + { + type: "selectMultiple", + label: "unique values" as TranslatedString, + id: "things" as UIHandlerId, + unique: true, + placeholder: "search..." as TranslatedString, + choices: [ + { + label: "one label" as TranslatedString, + value: "one", + }, + { + label: "two label" as TranslatedString, + value: "two", + }, + { + label: "five label" as TranslatedString, + value: "five", + }, + ], + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputSelectMultiple.tsx b/packages/web-util/src/forms/fields/InputSelectMultiple.tsx @@ -0,0 +1,172 @@ +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"; + +export function InputSelectMultiple<T extends object, K extends keyof T>( + props: { + choices: ChoiceS<T[K]>[]; + unique?: boolean; + max?: number; + } & UIFormProps<T, K>, +): VNode { + const { + converter, + label, + choices, + placeholder, + tooltip, + required, + unique, + max, + } = props; + const { value, onChange, state } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + + const [filter, setFilter] = useState<string | undefined>(undefined); + const regex = new RegExp(`.*${filter}.*`, "i"); + const choiceMap = choices.reduce( + (prev, curr) => { + return { ...prev, [curr.value as string]: curr.label }; + }, + {} as Record<string, string>, + ); + + const list = (value ?? []) as string[]; + const filteredChoices = + filter === undefined + ? undefined + : choices.filter((v) => { + return regex.test(v.label); + }); + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + {list.map((v, idx) => { + return ( + <span + key={idx} + class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600" + > + {choiceMap[v]} + <button + type="button" + disabled={state.disabled} + onClick={() => { + const newValue = [...list]; + newValue.splice(idx, 1); + onChange(newValue as any); + setFilter(undefined); + }} + class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" + > + <span class="sr-only">Remove</span> + <svg + viewBox="0 0 14 14" + class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75" + > + <path d="M4 4l6 6m0-6l-6 6" /> + </svg> + <span class="absolute -inset-1"></span> + </button> + </span> + ); + })} + + {!state.disabled && ( + <div class="relative mt-2"> + <input + id="combobox" + type="text" + value={filter ?? ""} + onChange={(e) => { + setFilter(e.currentTarget.value); + }} + placeholder={placeholder} + class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + role="combobox" + aria-controls="options" + aria-expanded="false" + /> + <button + type="button" + disabled={state.disabled} + onClick={() => { + setFilter(filter === undefined ? "" : undefined); + }} + class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + > + <svg + class="h-5 w-5 text-gray-400" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" + clip-rule="evenodd" + /> + </svg> + </button> + + {filteredChoices !== undefined && ( + <ul + class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + id="options" + role="listbox" + > + {filteredChoices.map((v, idx) => { + return ( + <li + key={idx} + class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" + id="option-0" + role="option" + onClick={() => { + setFilter(undefined); + if (unique && list.indexOf(v.value as string) !== -1) { + return; + } + if (max !== undefined && list.length >= max) { + return; + } + const newValue = [...list]; + newValue.splice(0, 0, v.value as string); + onChange(newValue as any); + }} + + // tabindex="-1" + > + {/* <!-- Selected: "font-semibold" --> */} + <span class="block truncate">{v.label}</span> + + {/* <!-- + Checkmark, only display for selected option. + + Active: "text-white", Not Active: "text-indigo-600" + --> */} + </li> + ); + })} + + {/* <!-- + Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + + Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + --> */} + + {/* <!-- More items... --> */} + </ul> + )} + </div> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/fields/InputSelectOne.stories.tsx b/packages/web-util/src/forms/fields/InputSelectOne.stories.tsx @@ -0,0 +1,78 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Select One", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + things: string; +}; +const initial: TargetObject = { + things: "one", +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "selectOne", + label: "label of the field" as TranslatedString, + id: "things" as UIHandlerId, + placeholder: "search..." as TranslatedString, + choices: [ + { + label: "one label" as TranslatedString, + value: "one", + }, + { + label: "two label" as TranslatedString, + value: "two", + }, + { + label: "five label" as TranslatedString, + value: "five", + }, + ], + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputSelectOne.tsx b/packages/web-util/src/forms/fields/InputSelectOne.tsx @@ -0,0 +1,140 @@ +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"; + +export function InputSelectOne<T extends object, K extends keyof T>( + props: { + choices: ChoiceS<T[K]>[]; + } & UIFormProps<T, K>, +): VNode { + const { label, choices, placeholder, tooltip, required } = props; + const { value, onChange } = + props.handler ?? noHandlerPropsAndNoContextForField(props.name); + + const [filter, setFilter] = useState<string | undefined>(undefined); + const regex = new RegExp(`.*${filter}.*`, "i"); + const choiceMap = choices.reduce( + (prev, curr) => { + return { ...prev, [curr.value as string]: curr.label }; + }, + {} as Record<string, string>, + ); + + const filteredChoices = + filter === undefined + ? undefined + : choices.filter((v) => { + return regex.test(v.label); + }); + return ( + <div class="sm:col-span-6"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + {value ? ( + <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 font-medium text-gray-600"> + {choiceMap[value as string]} + <button + type="button" + onClick={() => { + onChange(undefined!); + }} + class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20" + > + <span class="sr-only">Remove</span> + <svg + viewBox="0 0 14 14" + class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75" + > + <path d="M4 4l6 6m0-6l-6 6" /> + </svg> + <span class="absolute -inset-1"></span> + </button> + </span> + ) : ( + <div class="relative mt-2"> + <input + id="combobox" + type="text" + value={filter ?? ""} + onChange={(e) => { + setFilter(e.currentTarget.value); + }} + placeholder={placeholder} + class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + role="combobox" + aria-controls="options" + aria-expanded="false" + /> + <button + type="button" + onClick={() => { + setFilter(filter === undefined ? "" : undefined); + }} + class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none" + > + <svg + class="h-5 w-5 text-gray-400" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z" + clip-rule="evenodd" + /> + </svg> + </button> + + {filteredChoices !== undefined && ( + <ul + class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm" + id="options" + role="listbox" + > + {filteredChoices.map((v, idx) => { + return ( + <li + key={idx} + class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600" + id="option-0" + role="option" + onClick={() => { + setFilter(undefined); + onChange(v.value as any); + }} + + // tabindex="-1" + > + {/* <!-- Selected: "font-semibold" --> */} + <span class="block truncate">{v.label}</span> + + {/* <!-- + Checkmark, only display for selected option. + + Active: "text-white", Not Active: "text-indigo-600" + --> */} + </li> + ); + })} + + {/* <!-- + Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation. + + Active: "text-white bg-indigo-600", Not Active: "text-gray-900" + --> */} + + {/* <!-- More items... --> */} + </ul> + )} + </div> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/fields/InputText.stories.tsx b/packages/web-util/src/forms/fields/InputText.stories.tsx @@ -0,0 +1,63 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Text", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +}; +const initial: TargetObject = { + comment: "some initial comment", +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + 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, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputText.tsx b/packages/web-util/src/forms/fields/InputText.tsx @@ -0,0 +1,9 @@ +import { VNode, h } from "preact"; +import { UIFormProps } from "../FormProvider.js"; +import { InputLine } from "./InputLine.js"; + +export function InputText<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + return <InputLine type="text" {...props} />; +} diff --git a/packages/web-util/src/forms/fields/InputTextArea.stories.tsx b/packages/web-util/src/forms/fields/InputTextArea.stories.tsx @@ -0,0 +1,63 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Text Area", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +}; +const initial: TargetObject = { + comment: "some initial comment", +}; + +const design: FormDesign = { + type: "double-column", + sections: [ + { + title: "this is a simple form" as TranslatedString, + fields: [ + { + type: "textArea", + label: "label of the field" as TranslatedString, + id: "comment" as UIHandlerId, + }, + ], + }, + ], +}; + +export const SimpleComment = tests.createExample(TestedComponent, { + initial, + design, +}); diff --git a/packages/web-util/src/forms/fields/InputTextArea.tsx b/packages/web-util/src/forms/fields/InputTextArea.tsx @@ -0,0 +1,9 @@ +import { VNode, h } from "preact"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "../FormProvider.js"; + +export function InputTextArea<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + return <InputLine type="text-area" {...props} />; +} diff --git a/packages/web-util/src/forms/fields/InputToggle.stories.tsx b/packages/web-util/src/forms/fields/InputToggle.stories.tsx @@ -0,0 +1,71 @@ +/* + 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 { DefaultForm as TestedComponent } from "../forms-ui.js"; +import { FormDesign, UIHandlerId } from "../forms-types.js"; + +export default { + title: "Input Toggle", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + accept: boolean; +}; +const initial: TargetObject = { + accept: true, +}; + +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 WithThreeState = tests.createExample(TestedComponent, { + initial, + 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/fields/InputToggle.tsx b/packages/web-util/src/forms/fields/InputToggle.tsx @@ -0,0 +1,57 @@ +import { VNode, h } from "preact"; +import { UIFormProps } from "../FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; + +export function InputToggle<T extends object, K extends keyof T>( + props: { threeState: boolean } & UIFormProps<T, K>, +): VNode { + const { + name, + label, + tooltip, + help, + placeholder, + required, + before, + after, + converter, + threeState, + } = props; + const { value, onChange } = + 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} + required={required} + tooltip={tooltip} + /> + <button + type="button" + data-state={isOn ? "on" : value === undefined ? "undefined" : "off"} + class="bg-indigo-600 data-[state=off]:bg-gray-200 data-[state=undefined]:bg-gray-200 relative inline-flex h-6 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + if (value === false && threeState) { + return onChange(undefined as any); + } else { + return onChange(!isOn as any); + } + }} + > + <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> + </button> + </div> + </div> + ); +} diff --git a/packages/kyc-ui/src/forms/VQF_902_1.ts b/packages/web-util/src/forms/gana/VQF_902_1.ts diff --git a/packages/kyc-ui/src/forms/VQF_902_11.ts b/packages/web-util/src/forms/gana/VQF_902_11.ts diff --git a/packages/kyc-ui/src/forms/VQF_902_12.ts b/packages/web-util/src/forms/gana/VQF_902_12.ts diff --git a/packages/kyc-ui/src/forms/VQF_902_13.ts b/packages/web-util/src/forms/gana/VQF_902_13.ts diff --git a/packages/kyc-ui/src/forms/VQF_902_14.ts b/packages/web-util/src/forms/gana/VQF_902_14.ts diff --git a/packages/kyc-ui/src/forms/VQF_902_15.ts b/packages/web-util/src/forms/gana/VQF_902_15.ts diff --git a/packages/kyc-ui/src/forms/VQF_902_4.ts b/packages/web-util/src/forms/gana/VQF_902_4.ts diff --git a/packages/kyc-ui/src/forms/VQF_902_5.ts b/packages/web-util/src/forms/gana/VQF_902_5.ts diff --git a/packages/kyc-ui/src/forms/VQF_902_9.ts b/packages/web-util/src/forms/gana/VQF_902_9.ts diff --git a/packages/kyc-ui/src/forms/taler_form_attributes.ts b/packages/web-util/src/forms/gana/taler_form_attributes.ts diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts @@ -1,12 +1,12 @@ -export * as a1 from "./InputAmount.stories.js"; -export * as a2 from "./InputArray.stories.js"; -export * as a3 from "./InputChoiceHorizontal.stories.js"; -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 a9 from "./InputSelectMultiple.stories.js"; -export * as a10 from "./InputSelectOne.stories.js"; -export * as a11 from "./InputText.stories.js"; -export * as a12 from "./InputTextArea.stories.js"; -export * as a13 from "./InputToggle.stories.js"; +export * as a1 from "./fields/InputAmount.stories.js"; +export * as a2 from "./fields/InputArray.stories.js"; +export * as a3 from "./fields/InputChoiceHorizontal.stories.js"; +export * as a4 from "./fields/InputChoiceStacked.stories.js"; +export * as a5 from "./fields/InputAbsoluteTime.stories.js"; +export * as a6 from "./fields/InputFile.stories.js"; +export * as a7 from "./fields/InputInteger.stories.js"; +export * as a9 from "./fields/InputSelectMultiple.stories.js"; +export * as a10 from "./fields/InputSelectOne.stories.js"; +export * as a11 from "./fields/InputText.stories.js"; +export * as a12 from "./fields/InputTextArea.stories.js"; +export * as a13 from "./fields/InputToggle.stories.js"; diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts @@ -4,19 +4,29 @@ 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 "./gana/VQF_902_1.js"; +export * from "./gana/VQF_902_4.js"; +export * from "./gana/VQF_902_5.js"; +export * from "./gana/VQF_902_9.js"; +export * from "./gana/VQF_902_11.js"; +export * from "./gana/VQF_902_12.js"; +export * from "./gana/VQF_902_13.js"; +export * from "./gana/VQF_902_14.js"; +export * from "./gana/VQF_902_15.js"; +export * from "./gana/taler_form_attributes.js"; +export * from "./fields/InputAbsoluteTime.js"; +export * from "./fields/InputAmount.js"; +export * from "./fields/InputArray.js"; +export * from "./fields/InputChoiceHorizontal.js"; +export * from "./fields/InputChoiceStacked.js"; +export * from "./fields/InputFile.js"; +export * from "./fields/InputInteger.js"; +export * from "./fields/InputLine.js"; +export * from "./fields/InputSelectMultiple.js"; +export * from "./fields/InputSelectOne.js"; +export * from "./fields/InputText.js"; +export * from "./fields/InputTextArea.js"; +export * from "./fields/InputToggle.js"; export * from "./TimePicker.js"; export * from "./field-types.js"; export * from "./forms-types.js"; diff --git a/packages/web-util/src/forms/useField.ts b/packages/web-util/src/forms/useField.ts @@ -1,95 +0,0 @@ -import { useContext } from "preact/compat"; -import { FieldUIOptions, FormContext } from "./FormProvider.js"; -import { TranslatedString } from "@gnu-taler/taler-util"; - -export interface InputFieldHandler<Type> { - value: Type; - onChange: (s: Type) => void; - state: FieldUIOptions; - error?: TranslatedString | undefined; -} - -/** - * @deprecated removing this so we don't depend on context to create a form - * @param name - * @returns - */ -export function useField_deprecated<T extends object, K extends keyof T>( - name: K, -): InputFieldHandler<T[K]> | undefined { - const ctx = useContext(FormContext); - if (!ctx) { - //no context, can't be used - return undefined; - } - const { - value: formValue, - computeFormState, - onUpdate: notifyUpdate, - readOnly: readOnlyForm, - } = ctx; - - type P = typeof name; - type V = T[P]; - const formState = computeFormState ? computeFormState(formValue.current) : {}; - - const fieldValue = readField(formValue.current, String(name)) as V; - - const fieldState = - readField<Partial<FieldUIOptions>>(formState, String(name)) ?? {}; - - //compute default state - const state = { - disabled: readOnlyForm ? true : fieldState.disabled ?? false, - hidden: fieldState.hidden ?? false, - help: fieldState.help, - elements: "elements" in fieldState ? fieldState.elements ?? [] : [], - }; - - function onChange(value: V): void { - // setCurrentValue(value); - formValue.current = setValueDeeper_toberemoved( - formValue.current, - String(name).split("."), - value, - ); - if (notifyUpdate) { - notifyUpdate(formValue.current); - } - } - - return { - value: fieldValue, - onChange, - state, - }; -} - -/** - * read the field of an object an support accessing it using '.' - * - * @param object - * @param name - * @returns - */ -function readField<T>(object: any, name: string): T | undefined { - return name.split(".").reduce((prev, current) => { - return prev ? prev[current] : undefined; - }, object); -} - -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_toberemoved({}, 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 @@ -7,7 +7,13 @@ export { } from "./useLocalStorage.js"; export { useMemoryStorage } from "./useMemoryStorage.js"; export * from "./useNotifications.js"; -export { useForm } from "./useForm.js"; +export { + useForm, + RecursivePartial, + FormValues, + FormErrors, + undefinedIfEmpty, +} from "./useForm.js"; export { useAsyncAsHook, HookError,