diff options
Diffstat (limited to 'packages')
35 files changed, 1519 insertions, 177 deletions
diff --git a/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx b/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx index b3cb7a972..b9f9f7832 100644 --- a/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx +++ b/packages/aml-backoffice-ui/src/handlers/FormProvider.tsx @@ -32,14 +32,14 @@ export const FormContext = createContext<FormType<any>>({}); * - object => recurse into * - array => behavior result and element field */ -export type FormState<T extends object> = { +export type FormState<T extends object | undefined> = { [field in keyof T]?: T[field] extends AbsoluteTime ? BehaviorResult : T[field] extends AmountJson ? BehaviorResult : T[field] extends Array<infer P extends object> ? InputArrayFieldState<P> - : T[field] extends (object) + : T[field] extends (object | undefined) ? FormState<T[field]> : BehaviorResult; }; diff --git a/packages/web-util/src/forms/Calendar.tsx b/packages/web-util/src/forms/Calendar.tsx new file mode 100644 index 000000000..e476bf6f6 --- /dev/null +++ b/packages/web-util/src/forms/Calendar.tsx @@ -0,0 +1,119 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util" +import { useTranslationContext } from "@gnu-taler/web-util/browser" +import { add as dateAdd, sub as dateSub, eachDayOfInterval, endOfMonth, endOfWeek, format, getMonth, getYear, isSameDay, isSameMonth, startOfDay, startOfMonth, startOfWeek } from "date-fns" +import { VNode, h } from "preact" +import { useState } from "preact/hooks" + +export function Calendar({ value, onChange }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void }): VNode { + const today = startOfDay(new Date()) + const selected = !value ? today : new Date(AbsoluteTime.toStampMs(value)) + const [showingDate, setShowingDate] = useState(selected) + const month = getMonth(showingDate) + const year = getYear(showingDate) + + const start = startOfWeek(startOfMonth(showingDate)); + const end = endOfWeek(endOfMonth(showingDate)); + const daysInMonth = eachDayOfInterval({ start, end }); + const { i18n } = useTranslationContext() + const monthNames = [ + i18n.str`January`, + i18n.str`February`, + i18n.str`March`, + i18n.str`April`, + i18n.str`May`, + i18n.str`June`, + i18n.str`July`, + i18n.str`August`, + i18n.str`September`, + i18n.str`October`, + i18n.str`November`, + i18n.str`December`, + ] + return <div class="text-center p-2"> + <div class="flex items-center text-gray-900"> + <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateSub(showingDate, { years: 1 })) + }}> + <span class="sr-only"> + {i18n.str`Previous year`} + </span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /> + </svg> + </button> + <div class="flex-auto text-sm font-semibold">{year}</div> + <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateAdd(showingDate, { years: 1 })) + }}> + <span class="sr-only"> + {i18n.str`Next year`} + </span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /> + </svg> + </button> + </div> + <div class="mt-4 flex items-center text-gray-900"> + <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 round-sm" + onClick={() => { + setShowingDate(dateSub(showingDate, { months: 1 })) + }}> + <span class="sr-only"> + {i18n.str`Previous month`} + </span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z" clip-rule="evenodd" /> + </svg> + </button> + <div class="flex-auto text-sm font-semibold">{monthNames[month]}</div> + <button type="button" class="flex px-4 flex-none items-center justify-center p-1.5 text-gray-400 hover:text-gray-500 ring-2 rounded-sm " + onClick={() => { + setShowingDate(dateAdd(showingDate, { months: 1 })) + }}> + <span class="sr-only"> + {i18n.str`Next month`} + </span> + <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z" clip-rule="evenodd" /> + </svg> + </button> + </div> + <div class="mt-6 grid grid-cols-7 text-xs leading-6 text-gray-500"> + <div>M</div> + <div>T</div> + <div>W</div> + <div>T</div> + <div>F</div> + <div>S</div> + <div>S</div> + </div> + <div class="isolate mt-2"> + <div class="grid grid-cols-7 gap-px rounded-lg bg-gray-200 text-sm shadow ring-1 ring-gray-200"> + {daysInMonth.map(current => ( + <button type="button" + data-month={isSameMonth(current, showingDate)} + data-today={isSameDay(current, today)} + data-selected={isSameDay(current, selected)} + onClick={() => { + onChange(AbsoluteTime.fromStampMs(current.getTime())) + }} + class="text-gray-400 hover:bg-gray-700 focus:z-10 py-1.5 + data-[month=false]:bg-gray-100 data-[month=true]:bg-white + data-[today=true]:font-semibold + data-[month=true]:text-gray-900 + data-[today=true]:bg-red-300 data-[today=true]:hover:bg-red-200 + data-[month=true]:hover:bg-gray-200 + data-[selected=true]:!bg-blue-400 data-[selected=true]:hover:!bg-blue-300 "> + <time dateTime={format(current, "yyyy-MM-dd")} + class="mx-auto flex h-7 w-7 py-4 px-5 sm:px-8 items-center justify-center rounded-full"> + {format(current, "dd")} + </time> + </button> + ))} + </div> + {daysInMonth.length < 40 ? <div class="w-7 h-7 m-1.5" /> : undefined} + </div> + </div> +} diff --git a/packages/web-util/src/forms/Dialog.tsx b/packages/web-util/src/forms/Dialog.tsx new file mode 100644 index 000000000..7b41fe487 --- /dev/null +++ b/packages/web-util/src/forms/Dialog.tsx @@ -0,0 +1,15 @@ +import { ComponentChildren, VNode, h } from "preact"; + +export function Dialog({ children, onClose }: { onClose?: () => void; children: ComponentChildren }): VNode { + return <div class="relative z-10" aria-labelledby="modal-title" role="dialog" aria-modal="true" onClick={onClose}> + <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div> + + <div class="fixed inset-0 z-10 w-screen overflow-y-auto"> + <div class="flex min-h-full items-center justify-center p-4 text-center "> + <div class="relative transform overflow-hidden rounded-lg bg-white p-1 text-left shadow-xl transition-all" onClick={(e) => e.stopPropagation()}> + {children} + </div> + </div> + </div> + </div> +} diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx index 3da2a4f07..b9f9f7832 100644 --- a/packages/web-util/src/forms/FormProvider.tsx +++ b/packages/web-util/src/forms/FormProvider.tsx @@ -7,14 +7,13 @@ import { ComponentChildren, VNode, createContext, h } from "preact"; import { MutableRef, StateUpdater, - useEffect, - useRef, - useState, + useState } from "preact/hooks"; -export interface FormType<T> { +export interface FormType<T extends object> { value: MutableRef<Partial<T>>; initialValue?: Partial<T>; + readOnly?: boolean; onUpdate?: StateUpdater<T>; computeFormState?: (v: T) => FormState<T>; } @@ -22,18 +21,31 @@ export interface FormType<T> { //@ts-ignore export const FormContext = createContext<FormType<any>>({}); -export type FormState<T> = { +/** + * Map of {[field]:BehaviorResult} + * for every field of type + * - any native (string, number, etc...) + * - absoluteTime + * - amountJson + * + * except for: + * - object => recurse into + * - array => behavior result and element field + */ +export type FormState<T extends object | undefined> = { [field in keyof T]?: T[field] extends AbsoluteTime - ? Partial<InputFieldState> - : T[field] extends AmountJson - ? Partial<InputFieldState> - : T[field] extends Array<infer P> - ? Partial<InputArrayFieldState<P>> - : T[field] extends (object | undefined) - ? FormState<T[field]> - : Partial<InputFieldState>; + ? BehaviorResult + : T[field] extends AmountJson + ? BehaviorResult + : T[field] extends Array<infer P extends object> + ? InputArrayFieldState<P> + : T[field] extends (object | undefined) + ? FormState<T[field]> + : BehaviorResult; }; +export type BehaviorResult = Partial<InputFieldState> & FieldUIOptions + export interface InputFieldState { /* should show the error */ error?: TranslatedString; @@ -45,41 +57,70 @@ export interface InputFieldState { hidden: boolean; } -export interface InputArrayFieldState<T> extends InputFieldState { - elements: FormState<T>[]; +export interface IconAddon { + type: "icon"; + icon: VNode; +} +export interface ButtonAddon { + type: "button"; + onClick: () => void; + children: ComponentChildren; +} +export interface TextAddon { + type: "text"; + text: TranslatedString; } +export type Addon = IconAddon | ButtonAddon | TextAddon; -export function FormProvider<T>({ +export interface StringConverter<T> { + toStringUI: (v?: T) => string; + fromStringUI: (v?: string) => T; +} + +type FieldUIOptions = { + placeholder?: TranslatedString; + tooltip?: TranslatedString; + help?: TranslatedString; + required?: boolean; +} + +export interface UIFormProps<T extends object, K extends keyof T> extends FieldUIOptions { + name: K; + label: TranslatedString; + before?: Addon; + after?: Addon; + converter?: StringConverter<T[K]>; +} + +export interface InputArrayFieldState<P extends object> extends BehaviorResult { + elements?: FormState<P>[]; +} + +export function FormProvider<T extends object>({ children, initialValue, onUpdate: notify, onSubmit, computeFormState, + readOnly, }: { initialValue?: Partial<T>; onUpdate?: (v: Partial<T>) => void; onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void; computeFormState?: (v: Partial<T>) => FormState<T>; + readOnly?: boolean; children: ComponentChildren; }): VNode { - // const value = useRef(initialValue ?? {}); - // useEffect(() => { - // return function onUnload() { - // value.current = initialValue ?? {}; - // }; - // }); - // const onUpdate = notify + const [state, setState] = useState<Partial<T>>(initialValue ?? {}); const value = { current: state }; - // console.log("RENDER", initialValue, value); const onUpdate = (v: typeof state) => { - // console.log("updated"); setState(v); if (notify) notify(v); }; return ( <FormContext.Provider - value={{ initialValue, value, onUpdate, computeFormState }} + value={{ initialValue, value, onUpdate, computeFormState, readOnly }} > <form onSubmit={(e) => { diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx new file mode 100644 index 000000000..54e41ffae --- /dev/null +++ b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "absoluteTime", + props: { + label: "label of the field" as TranslatedString, + name: "today", + pattern: "dd/MM/yyyy HH:mm" + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputAbsoluteTime.tsx b/packages/web-util/src/forms/InputAbsoluteTime.tsx new file mode 100644 index 000000000..0e03c5595 --- /dev/null +++ b/packages/web-util/src/forms/InputAbsoluteTime.tsx @@ -0,0 +1,77 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { InputLine } from "./InputLine.js"; +import { Fragment, VNode, h } from "preact"; +import { format, parse } from "date-fns"; +import { Dialog } from "./Dialog.js"; +import { Calendar } from "./Calendar.js"; +import { useState } from "preact/hooks"; +import { useField } from "./useField.js"; +import { UIFormProps } from "./FormProvider.js"; +import { TimePicker } from "./TimePicker.js"; + +export function InputAbsoluteTime<T extends object, K extends keyof T>( + props: { pattern?: string } & UIFormProps<T, K>, +): VNode { + const pattern = props.pattern ?? "dd/MM/yyyy"; + const [open, setOpen] = useState(true) + const { value, onChange } = useField<T, K>(props.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); + }, + }} + {...props} + /> + {/* {open && + <Dialog onClose={() => setOpen(false)}> + <Calendar value={value 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 new file mode 100644 index 000000000..872726247 --- /dev/null +++ b/packages/web-util/src/forms/InputAmount.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "amount", + props: { + label: "label of the field" as TranslatedString, + name: "amount", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx index 9be9dd4d0..29ec43525 100644 --- a/packages/web-util/src/forms/InputAmount.tsx +++ b/packages/web-util/src/forms/InputAmount.tsx @@ -1,7 +1,8 @@ import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; -import { InputLine, UIFormProps } from "./InputLine.js"; +import { InputLine } from "./InputLine.js"; import { useField } from "./useField.js"; +import { UIFormProps } from "./FormProvider.js"; export function InputAmount<T extends object, K extends keyof T>( props: { currency?: string } & UIFormProps<T, K>, @@ -21,7 +22,8 @@ export function InputAmount<T extends object, K extends keyof T>( converter={{ //@ts-ignore fromStringUI: (v): AmountJson => { - return Amounts.parseOrThrow(`${currency}:${v}`); + + return Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency); }, //@ts-ignore toStringUI: (v: AmountJson) => { diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx new file mode 100644 index 000000000..ee25d355b --- /dev/null +++ b/packages/web-util/src/forms/InputArray.stories.tsx @@ -0,0 +1,79 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "array", + props: { + label: "People" as TranslatedString, + name: "comment", + fields: [{ + type: "text", + props: { + label: "the name" as TranslatedString, + name: "name", + } + }, { + type: "integer", + props: { + label: "the age" as TranslatedString, + name: "age", + } + }], + labelField: "name" + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx index 00379bed6..38c399e66 100644 --- a/packages/web-util/src/forms/InputArray.tsx +++ b/packages/web-util/src/forms/InputArray.tsx @@ -1,10 +1,10 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { FormProvider, InputArrayFieldState } from "./FormProvider.js"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { useState } from "preact/hooks"; +import { FormProvider, UIFormProps } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; import { useField } from "./useField.js"; -import { TranslatedString } from "@gnu-taler/taler-util"; function Option({ label, @@ -107,22 +107,24 @@ export function InputArray<T extends object, K extends keyof T>( /> ); })} - <div class="pt-2"> - <Option - label={"Add..." as TranslatedString} - isSelected={selectedIndex === list.length} - isLast - isFirst - disabled={ - selectedIndex !== undefined && selectedIndex !== list.length - } - onClick={() => { - setSelected( - selectedIndex === list.length ? undefined : list.length, - ); - }} - /> - </div> + {!state.disabled && + <div class="pt-2"> + <Option + label={"Add..." as TranslatedString} + isSelected={selectedIndex === list.length} + isLast + isFirst + disabled={ + selectedIndex !== undefined && selectedIndex !== list.length + } + onClick={() => { + setSelected( + selectedIndex === list.length ? undefined : list.length, + ); + }} + /> + </div> + } </div> {selectedIndex !== undefined && ( /** @@ -131,6 +133,7 @@ export function InputArray<T extends object, K extends keyof T>( */ <FormProvider initialValue={selected} + readOnly={state.disabled} computeFormState={(v) => { // current state is ignored // the state is defined by the parent form diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx new file mode 100644 index 000000000..7872afac7 --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx @@ -0,0 +1,69 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "choiceHorizontal", + props: { + label: "label of the field" as TranslatedString, + name: "comment", + choices: [{ + label: "first choice" as TranslatedString, + value: "1" + }, { + label: "second choice" as TranslatedString, + value: "2" + }, { + label: "thrid choice" as TranslatedString, + value: "3" + },], + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx index 5c909b5d7..594b1c32e 100644 --- a/packages/web-util/src/forms/InputChoiceHorizontal.tsx +++ b/packages/web-util/src/forms/InputChoiceHorizontal.tsx @@ -1,8 +1,13 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; -import { Choice } from "./InputChoiceStacked.js"; +import { UIFormProps } from "./FormProvider.js"; + +export interface Choice<V> { + label: TranslatedString; + value: V; +} export function InputChoiceHorizontal<T extends object, K extends keyof T>( props: { @@ -57,6 +62,8 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( return ( <button type="button" + disabled={state.disabled} + label={choice.label} class={clazz} onClick={(e) => { onChange( @@ -64,9 +71,7 @@ export function InputChoiceHorizontal<T extends object, K extends keyof T>( ); }} > - {(!converter - ? (choice.value as string) - : converter?.toStringUI(choice.value)) ?? ""} + {choice.label} </button> ); })} diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx new file mode 100644 index 000000000..215418430 --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx @@ -0,0 +1,69 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "choiceStacked", + props: { + label: "label of the field" as TranslatedString, + name: "comment", + choices: [{ + label: "first choice" as TranslatedString, + value: "1" + }, { + label: "second choice" as TranslatedString, + value: "2" + }, { + label: "thrid choice" as TranslatedString, + value: "3" + },], + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx index c37984368..48d367ff2 100644 --- a/packages/web-util/src/forms/InputChoiceStacked.tsx +++ b/packages/web-util/src/forms/InputChoiceStacked.tsx @@ -1,7 +1,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; +import { UIFormProps } from "./FormProvider.js"; export interface Choice<V> { label: TranslatedString; @@ -60,6 +61,7 @@ export function InputChoiceStacked<T extends object, K extends keyof T>( type="radio" name="server-size" // defaultValue={choice.value} + disabled={state.disabled} value={ (!converter ? (choice.value as string) diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx new file mode 100644 index 000000000..8a1783bda --- /dev/null +++ b/packages/web-util/src/forms/InputFile.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 { TranslatedString } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.js"; + +export default { + title: "Input File", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "file", + props: { + label: "label of the field" as TranslatedString, + name: "comment", + required: true, + maxBites: 2 * 1024 * 1024, + 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 SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx index 0d89a98a3..bc460f370 100644 --- a/packages/web-util/src/forms/InputFile.tsx +++ b/packages/web-util/src/forms/InputFile.tsx @@ -1,6 +1,7 @@ import { Fragment, VNode, h } from "preact"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; +import { UIFormProps, BehaviorResult } from "./FormProvider.js"; export function InputFile<T extends object, K extends keyof T>( props: { maxBites: number; accept?: string } & UIFormProps<T, K>, @@ -11,12 +12,12 @@ export function InputFile<T extends object, K extends keyof T>( placeholder, tooltip, required, - help, + help: propsHelp, maxBites, accept, } = props; const { value, onChange, state } = useField<T, K>(name); - + const help = propsHelp ?? state.help if (state.hidden) { return <div />; } @@ -42,40 +43,42 @@ export function InputFile<T extends object, K extends keyof T>( clip-rule="evenodd" /> </svg> - <div class="my-2 flex text-sm leading-6 text-gray-600"> - <label - for="file-upload" - 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="file-upload" - name="file-upload" - 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!); - } - return f[0].arrayBuffer().then((b) => { - const b64 = window.btoa( - new Uint8Array(b).reduce( - (data, byte) => data + String.fromCharCode(byte), - "", - ), - ); - return onChange(`data:${f[0].type};base64,${b64}` as any); - }); - }} - /> - </label> - {/* <p class="pl-1">or drag and drop</p> */} - </div> + {!state.disabled && + <div class="my-2 flex text-sm leading-6 text-gray-600"> + <label + for="file-upload" + 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="file-upload" + name="file-upload" + 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!); + } + return f[0].arrayBuffer().then((b) => { + const b64 = window.btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + return onChange(`data:${f[0].type};base64,${b64}` as any); + }); + }} + /> + </label> + {/* <p class="pl-1">or drag and drop</p> */} + </div> + } </div> </div> ) : ( @@ -85,14 +88,16 @@ export function InputFile<T extends object, K extends keyof T>( class=" h-24 w-full object-cover relative" /> - <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> + {!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>} diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx new file mode 100644 index 000000000..344865817 --- /dev/null +++ b/packages/web-util/src/forms/InputInteger.stories.tsx @@ -0,0 +1,55 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.js"; + +export default { + title: "Input Integer", +}; + + +type TargetObject = { + age: number; +} +const initial: TargetObject = { + age: 5, +} + +const form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "integer", + props: { + label: "label of the field" as TranslatedString, + name: "age", + tooltip: "just numbers" as TranslatedString, + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputInteger.tsx b/packages/web-util/src/forms/InputInteger.tsx index fb04e3852..a6a02ad43 100644 --- a/packages/web-util/src/forms/InputInteger.tsx +++ b/packages/web-util/src/forms/InputInteger.tsx @@ -1,5 +1,6 @@ import { VNode, h } from "preact"; -import { InputLine, UIFormProps } from "./InputLine.js"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; export function InputInteger<T extends object, K extends keyof T>( props: UIFormProps<T, K>, diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx new file mode 100644 index 000000000..0d55bddf7 --- /dev/null +++ b/packages/web-util/src/forms/InputLine.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.js"; + +export default { + title: "Input Line", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + props: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx index 9448ef5e4..8c44b1ca5 100644 --- a/packages/web-util/src/forms/InputLine.tsx +++ b/packages/web-util/src/forms/InputLine.tsx @@ -1,42 +1,8 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useField } from "./useField.js"; - -export interface IconAddon { - type: "icon"; - icon: VNode; -} -interface ButtonAddon { - type: "button"; - onClick: () => void; - children: ComponentChildren; -} -interface TextAddon { - type: "text"; - text: TranslatedString; -} -type Addon = IconAddon | ButtonAddon | TextAddon; - -interface StringConverter<T> { - toStringUI: (v?: T) => string; - fromStringUI: (v?: string) => T; -} - -export interface UIFormProps<T extends object, K extends keyof T> { - name: K; - label: TranslatedString; - placeholder?: TranslatedString; - tooltip?: TranslatedString; - help?: TranslatedString; - before?: Addon; - after?: Addon; - required?: boolean; - converter?: StringConverter<T[K]>; -} - -export type FormErrors<T> = { - [P in keyof T]?: string | FormErrors<T[P]>; -}; +import { useEffect, useState } from "preact/hooks"; +import { UIFormProps } from "./FormProvider.js"; //@ts-ignore const TooltipIcon = ( @@ -80,11 +46,11 @@ export function LabelWithTooltipMaybeRequired({ {Label} <span class="relative flex items-center group pl-2"> {TooltipIcon} - <div class="absolute bottom-0 flex flex-col items-center hidden mb-6 group-hover:flex"> - <span class="relative z-10 p-2 text-xs leading-none text-white whitespace-no-wrap bg-black shadow-lg"> + <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} - </span> - <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div> + </div> + <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div> </div> </span> </div> @@ -110,8 +76,9 @@ function InputWrapper<T extends object, K extends keyof T>({ after, help, error, + disabled, required, -}: { error?: string; children: ComponentChildren } & UIFormProps<T, K>): VNode { +}: { error?: string; disabled: boolean, children: ComponentChildren } & UIFormProps<T, K>): VNode { return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -132,6 +99,7 @@ function InputWrapper<T extends object, K extends keyof T>({ ) : before.type === "button" ? ( <button type="button" + disabled={disabled} onClick={before.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" > @@ -153,6 +121,7 @@ function InputWrapper<T extends object, K extends keyof T>({ ) : after.type === "button" ? ( <button type="button" + disabled={disabled} onClick={after.onClick} class="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50" > @@ -189,6 +158,18 @@ export function InputLine<T extends object, K extends keyof T>( const { name, placeholder, before, after, converter, type } = props; const { value, onChange, state, isDirty } = useField<T, K>(name); + const [text, setText] = useState("") + const fromString: (s: string) => any = + converter?.fromStringUI ?? defaultFromString; + const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; + + useEffect(() => { + const newValue = toString(value) + if (newValue) { + setText(newValue) + } + }, [value]) + if (state.hidden) return <div />; let clazz = @@ -233,14 +214,13 @@ export function InputLine<T extends object, K extends keyof T>( clazz += " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600"; } - const fromString: (s: string) => any = - converter?.fromStringUI ?? defaultFromString; - const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; if (type === "text-area") { return ( <InputWrapper<T, K> {...props} + help={props.help ?? state.help} + disabled={state.disabled ?? false} error={showError ? state.error : undefined} > <textarea @@ -262,15 +242,21 @@ export function InputLine<T extends object, K extends keyof T>( } return ( - <InputWrapper<T, K> {...props} error={showError ? state.error : undefined}> + <InputWrapper<T, K> {...props} + help={props.help ?? state.help} + disabled={state.disabled ?? false} error={showError ? state.error : undefined} + > <input name={String(name)} type={type} onChange={(e) => { - onChange(fromString(e.currentTarget.value)); + setText(e.currentTarget.value) }} placeholder={placeholder ? placeholder : undefined} - value={toString(value) ?? ""} + value={text} + onBlur={() => { + onChange(fromString(text)); + }} // defaultValue={toString(value)} disabled={state.disabled} aria-invalid={showError} diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx new file mode 100644 index 000000000..4dac61f21 --- /dev/null +++ b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx @@ -0,0 +1,90 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "selectMultiple", + props: { + label: "allow diplicates" as TranslatedString, + name: "pets", + 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", + props: { + label: "unique values" as TranslatedString, + name: "things", + 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, form }); diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx index 8116bdc03..06eb91bb3 100644 --- a/packages/web-util/src/forms/InputSelectMultiple.tsx +++ b/packages/web-util/src/forms/InputSelectMultiple.tsx @@ -1,8 +1,9 @@ import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; import { Choice } from "./InputChoiceStacked.js"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; +import { useState } from "preact/hooks"; +import { UIFormProps } from "./FormProvider.js"; export function InputSelectMultiple<T extends object, K extends keyof T>( props: { @@ -13,7 +14,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( ): VNode { const { name, label, choices, placeholder, tooltip, required, unique, max } = props; - const { value, onChange } = useField<T, K>(name); + const { value, onChange, state } = useField<T, K>(name); const [filter, setFilter] = useState<string | undefined>(undefined); const regex = new RegExp(`.*${filter}.*`, "i"); @@ -26,8 +27,8 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( filter === undefined ? undefined : choices.filter((v) => { - return regex.test(v.label); - }); + return regex.test(v.label); + }); return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -41,6 +42,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( {choiceMap[v]} <button type="button" + disabled={state.disabled} onClick={() => { const newValue = [...list]; newValue.splice(idx, 1); @@ -62,7 +64,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( ); })} - <div class="relative mt-2"> + {!state.disabled && <div class="relative mt-2"> <input id="combobox" type="text" @@ -78,6 +80,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( /> <button type="button" + disabled={state.disabled} onClick={() => { setFilter(filter === undefined ? "" : undefined); }} @@ -122,7 +125,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( onChange(newValue as T[K]); }} - // tabindex="-1" + // tabindex="-1" > {/* <!-- Selected: "font-semibold" --> */} <span class="block truncate">{v.label}</span> @@ -145,7 +148,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( {/* <!-- More items... --> */} </ul> )} - </div> + </div>} </div> ); } diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx new file mode 100644 index 000000000..0bb871500 --- /dev/null +++ b/packages/web-util/src/forms/InputSelectOne.stories.tsx @@ -0,0 +1,70 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "selectOne", + props: { + label: "label of the field" as TranslatedString, + name: "things", + 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, form }); diff --git a/packages/web-util/src/forms/InputSelectOne.tsx b/packages/web-util/src/forms/InputSelectOne.tsx index 7bef1058b..98430306e 100644 --- a/packages/web-util/src/forms/InputSelectOne.tsx +++ b/packages/web-util/src/forms/InputSelectOne.tsx @@ -1,8 +1,9 @@ import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; import { Choice } from "./InputChoiceStacked.js"; -import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; +import { useState } from "preact/hooks"; +import { UIFormProps } from "./FormProvider.js"; export function InputSelectOne<T extends object, K extends keyof T>( props: { @@ -22,8 +23,8 @@ export function InputSelectOne<T extends object, K extends keyof T>( filter === undefined ? undefined : choices.filter((v) => { - return regex.test(v.label); - }); + return regex.test(v.label); + }); return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -104,7 +105,7 @@ export function InputSelectOne<T extends object, K extends keyof T>( onChange(v.value as T[K]); }} - // tabindex="-1" + // tabindex="-1" > {/* <!-- Selected: "font-semibold" --> */} <span class="block truncate">{v.label}</span> diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx new file mode 100644 index 000000000..9ce733d4a --- /dev/null +++ b/packages/web-util/src/forms/InputText.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + props: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputText.tsx b/packages/web-util/src/forms/InputText.tsx index 1b37ee6fb..7ad36b737 100644 --- a/packages/web-util/src/forms/InputText.tsx +++ b/packages/web-util/src/forms/InputText.tsx @@ -1,5 +1,6 @@ import { VNode, h } from "preact"; -import { InputLine, UIFormProps } from "./InputLine.js"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; export function InputText<T extends object, K extends keyof T>( props: UIFormProps<T, K>, diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx new file mode 100644 index 000000000..df35b25c4 --- /dev/null +++ b/packages/web-util/src/forms/InputTextArea.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.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 form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "text", + props: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputTextArea.tsx b/packages/web-util/src/forms/InputTextArea.tsx index 45229951e..6b76d8329 100644 --- a/packages/web-util/src/forms/InputTextArea.tsx +++ b/packages/web-util/src/forms/InputTextArea.tsx @@ -1,5 +1,6 @@ import { VNode, h } from "preact"; -import { InputLine, UIFormProps } from "./InputLine.js"; +import { InputLine } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; export function InputTextArea<T extends object, K extends keyof T>( props: UIFormProps<T, K>, diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx new file mode 100644 index 000000000..735e812f3 --- /dev/null +++ b/packages/web-util/src/forms/InputToggle.stories.tsx @@ -0,0 +1,59 @@ +/* + 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 "@gnu-taler/web-util/testing"; +import { + NiceForm as TestedComponent, +} from "./NiceForm.js"; +import { FlexibleForm } from "./forms.js"; + +export default { + title: "Input Toggle", +}; + +export namespace Simplest { + export interface Form { + comment: string; + } +} + +type TargetObject = { + comment: string; +} +const initial: TargetObject = { + comment: "some initial comment" +} + +const form: FlexibleForm<TargetObject> = { + design: [{ + title: "this is a simple form" as TranslatedString, + fields: [{ + type: "toggle", + props: { + label: "label of the field" as TranslatedString, + name: "comment", + }, + }] + }] +} + +export const SimpleComment = tests.createExample(TestedComponent, { initial, form }); diff --git a/packages/web-util/src/forms/InputToggle.tsx b/packages/web-util/src/forms/InputToggle.tsx new file mode 100644 index 000000000..1ea8699b2 --- /dev/null +++ b/packages/web-util/src/forms/InputToggle.tsx @@ -0,0 +1,38 @@ +import { VNode, h } from "preact"; +import { InputLine, LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { UIFormProps } from "./FormProvider.js"; +import { useField } from "./useField.js"; + +export function InputToggle<T extends object, K extends keyof T>( + props: UIFormProps<T, K>, +): VNode { + const { + name, + label, + tooltip, + help, + placeholder, + required, + before, + after, + converter, + } = props; + const { value, onChange, state, isDirty } = useField<T, K>(name); + + const isOn = !!value + return <div class="sm:col-span-6"> + <div class="flex items-center justify-between"> + <LabelWithTooltipMaybeRequired + label={label} + required={required} + tooltip={tooltip} + /> + <button type="button" data-enabled={isOn} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 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-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" + onClick={() => { onChange(!isOn as any); }}> + <span aria-hidden="true" data-enabled={isOn} class="translate-x-5 data-[enabled=false]:translate-x-0 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/NiceForm.tsx b/packages/web-util/src/forms/NiceForm.tsx new file mode 100644 index 000000000..d01b80b02 --- /dev/null +++ b/packages/web-util/src/forms/NiceForm.tsx @@ -0,0 +1,60 @@ +import { ComponentChildren, Fragment, h } from "preact"; +import { FormProvider } from "./FormProvider.js"; +import { FlexibleForm, RenderAllFieldsByUiConfig } from "./forms.js"; + +export function NiceForm<T extends object>({ + initial, + onUpdate, + form, + onSubmit, + children, + readOnly, +}: { + children?: ComponentChildren; + initial: Partial<T>; + onSubmit?: (v: Partial<T>) => void; + form: FlexibleForm<T>; + readOnly?: boolean; + onUpdate?: (d: Partial<T>) => void; +}) { + return ( + <FormProvider + initialValue={initial} + onUpdate={onUpdate} + onSubmit={onSubmit} + readOnly={readOnly} + computeFormState={form.behavior} + > + <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> + {form.design.map((section, i) => { + if (!section) return <Fragment />; + return ( + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + {section.title} + </h2> + {section.description && ( + <p class="mt-1 text-sm leading-6 text-gray-600"> + {section.description} + </p> + )} + </div> + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-md md:col-span-2"> + <div class="p-3"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <RenderAllFieldsByUiConfig + key={i} + fields={section.fields} + /> + </div> + </div> + </div> + </div> + ); + })} + </div> + {children} + </FormProvider> + ); +} diff --git a/packages/web-util/src/forms/TimePicker.tsx b/packages/web-util/src/forms/TimePicker.tsx new file mode 100644 index 000000000..c6dc3e794 --- /dev/null +++ b/packages/web-util/src/forms/TimePicker.tsx @@ -0,0 +1,110 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util" +import { useTranslationContext } from "@gnu-taler/web-util/browser" +import { startOfDay, getHours, getMinutes, getSeconds, setHours } from "date-fns" +import { Fragment, VNode, h } from "preact" +import { useState } from "preact/hooks" + +export function TimePicker({ value, onChange, onConfirm }: { value: AbsoluteTime | undefined, onChange: (v: AbsoluteTime) => void, onConfirm: () => void }): VNode { + const date = !value ? new Date() : new Date(AbsoluteTime.toStampMs(value)) + const hours = getHours(date) % 12 + const minutes = getMinutes(date) + const seconds = getSeconds(date) + + const { i18n } = useTranslationContext() + + return <Fragment> + <div class="flex flex-col bg-white rounded-t-sm justify-around" > + {/* time selection */} + <div id="" class="bg-[#3b71ca] dark:bg-zinc-700 h-24 rounded-t-lg p-12 flex flex-row items-center justify-center"> + <div class="flex w-full justify-evenly"> + <div class=""> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " + style="pointer-events: none;"> + {new String(hours).padStart(2, "0")} + </button> + </span> + <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " > + {new String(minutes).padStart(2, "0")} + </button> + </span> + <span type="button" class="font-light leading-[1.2] text-[3.75rem] opacity-[.54] border-none bg-transparent p-0 text-white " >:</span> + <span class="relative h-full"> + <button type="button" class="py-1 px-3 text-[3.75rem] font-light leading-[1.2] text-white opacity-[.54] border-none bg-transparent p-0 cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none " > + {new String(seconds).padStart(2, "0")} + </button> + </span> + </div> + <div class="flex flex-col justify-center text-[18px] text-[#ffffff8a] "> + <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" > + AM + </button> + <button type="button" class="py-1 px-3 bg-transparent border-none text-white cursor-pointer hover:bg-[#00000026] hover:outline-none focus:bg-[#00000026] focus:outline-none" > + PM + </button> + </div> + </div> + </div> + {/* clock */} + <div id="" class="mt-2 min-w-[310px] max-w-[325px] min-h-[305px] overflow-x-hidden h-full flex justify-center mx-auto flex-col items-center dark:bg-zinc-500" > + <div class="relative rounded-[100%] w-[260px] h-[260px] cursor-default my-0 mx-auto bg-[#00000012] dark:bg-zinc-600/50 animate-[show-up-clock_350ms_linear]" > + + <span class="top-1/2 left-1/2 w-[6px] h-[6px] -translate-y-1/2 -translate-x-1/2 rounded-[50%] bg-[#3b71ca] absolute" ></span> + <div class="bg-[#3b71ca] bottom-1/2 h-2/5 left-[calc(50%-1px)] rtl:!left-auto origin-[center_bottom_0] rtl:!origin-[50%_50%_0] w-[2px] absolute" style={{ transform: "rotateZ(60deg)", height: "calc(35% + 1px)" }}> + {/* <div class="-top-[21px] -left-[15px] w-[4px] border-[14px] border-solid border-[#3b71ca] h-[4px] box-content rounded-[100%] absolute" style="background-color: rgb(25, 118, 210);"></div> */} + </div> + + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 12).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 224px;"> + <span>0</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 1).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 209.263px;"> + <span >1</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 2).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" data-selected={true} style="left: 209.263px; bottom: 169px;" > + <span >2</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 3).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 224px; bottom: 114px;"> + <span >3</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 4).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 209.263px; bottom: 59px;"> + <span >4</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 5).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 169px; bottom: 18.7372px;"> + <span >5</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 6).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 114px; bottom: 4px;"> + <span >6</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 7).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 18.7372px;"> + <span >7</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 8).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 59px;"> + <span >8</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 9).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 4px; bottom: 114px;"> + <span >9</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 10).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 18.7372px; bottom: 169px;"> + <span >10</span> + </span> + <span onClick={() => onChange(AbsoluteTime.fromStampMs(setHours(date, 11).getTime()))} class="absolute rounded-[100%] w-[32px] h-[32px] text-center cursor-pointer text-[1.1rem] bg-transparent flex justify-center items-center font-light focus:outline-none selection:bg-transparent data-[selected=true]:text-white data-[selected=true]:bg-[#3b71ca] data-[selected=true]:font-normal" style="left: 59px; bottom: 209.263px;"> + <span >11</span> + </span> + </div> + </div> + </div> + <div id="" class="rounded-b-lg flex justify-between items-center w-full h-[56px] px-[12px] bg-white dark:bg-zinc-500"> + <div class="w-full flex justify-end"> + <button + type="submit" + onClick={onConfirm} + class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + </div> + </Fragment> +} diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts index 6e8a0e7c0..1c212fafa 100644 --- a/packages/web-util/src/forms/forms.ts +++ b/packages/web-util/src/forms/forms.ts @@ -1,6 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { InputText } from "./InputText.js"; -import { InputDate } from "./InputDate.js"; +import { InputAbsoluteTime } from "./InputAbsoluteTime.js"; import { InputInteger } from "./InputInteger.js"; import { h as create, Fragment, VNode } from "preact"; import { InputChoiceStacked } from "./InputChoiceStacked.js"; @@ -15,6 +15,7 @@ import { FormProvider, FormState } from "./FormProvider.js"; import { InputLine } from "./InputLine.js"; import { InputAmount } from "./InputAmount.js"; import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js"; +import { InputToggle } from "./InputToggle.js"; export type DoubleColumnForm = Array<DoubleColumnFormSection | undefined>; @@ -23,6 +24,10 @@ export type DoubleColumnFormSection = { description?: TranslatedString; fields: UIFormField[]; }; +export interface FlexibleForm<T extends object> { + design: DoubleColumnForm; + behavior?: (form: Partial<T>) => FormState<T>; +} /** * Constrain the type with the ui props @@ -38,8 +43,9 @@ type FieldType<T extends object = any, K extends keyof T = any> = { textArea: Parameters<typeof InputTextArea<T, K>>[0]; choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0]; choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0]; - date: Parameters<typeof InputDate<T, K>>[0]; + absoluteTime: Parameters<typeof InputAbsoluteTime<T, K>>[0]; integer: Parameters<typeof InputInteger<T, K>>[0]; + toggle: Parameters<typeof InputToggle<T, K>>[0]; amount: Parameters<typeof InputAmount<T, K>>[0]; }; @@ -59,7 +65,8 @@ export type UIFormField = | { type: "choiceStacked"; props: FieldType["choiceStacked"] } | { type: "choiceHorizontal"; props: FieldType["choiceHorizontal"] } | { type: "integer"; props: FieldType["integer"] } - | { type: "date"; props: FieldType["date"] }; + | { type: "toggle"; props: FieldType["toggle"] } + | { type: "absoluteTime"; props: FieldType["absoluteTime"] }; type FieldComponentFunction<key extends keyof FieldType> = ( props: FieldType[key], @@ -82,7 +89,7 @@ const UIFormConfiguration: UIFormFieldMap = { file: InputFile, textArea: InputTextArea, //@ts-ignore - date: InputDate, + absoluteTime: InputAbsoluteTime, //@ts-ignore choiceStacked: InputChoiceStacked, //@ts-ignore @@ -93,6 +100,8 @@ const UIFormConfiguration: UIFormFieldMap = { //@ts-ignore selectMultiple: InputSelectMultiple, //@ts-ignore + toggle: InputToggle, + //@ts-ignore amount: InputAmount, }; @@ -116,10 +125,7 @@ export function RenderAllFieldsByUiConfig({ type FormSet<T extends object> = { Provider: typeof FormProvider<T>; InputLine: <K extends keyof T>() => typeof InputLine<T, K>; - InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal< - T, - K - >; + InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<T, K>; }; export function createNewForm<T extends object>() { const res: FormSet<T> = { diff --git a/packages/web-util/src/forms/index.stories.ts b/packages/web-util/src/forms/index.stories.ts new file mode 100644 index 000000000..55878cb02 --- /dev/null +++ b/packages/web-util/src/forms/index.stories.ts @@ -0,0 +1,13 @@ +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 a8 from "./InputLine.stories.js"; +export * as a9 from "./InputSelectMultiple.stories.js"; +export * as a10 from "./InputSelectOne.stories.js"; +export * as a11 from "./InputText.stories.js"; +export * as a12 from "./InputTextArea.stories.js"; +export * as a13 from "./InputToggle.stories.js"; diff --git a/packages/web-util/src/forms/useField.ts b/packages/web-util/src/forms/useField.ts index bf94d2f5d..651778628 100644 --- a/packages/web-util/src/forms/useField.ts +++ b/packages/web-util/src/forms/useField.ts @@ -1,10 +1,10 @@ import { useContext, useState } from "preact/compat"; -import { FormContext, InputFieldState } from "./FormProvider.js"; +import { BehaviorResult, FormContext, InputFieldState } from "./FormProvider.js"; export interface InputFieldHandler<Type> { value: Type; onChange: (s: Type) => void; - state: InputFieldState; + state: BehaviorResult; isDirty: boolean; } @@ -16,6 +16,7 @@ export function useField<T extends object, K extends keyof T>( value: formValue, computeFormState, onUpdate: notifyUpdate, + readOnly: readOnlyForm, } = useContext(FormContext); type P = typeof name; @@ -26,14 +27,15 @@ export function useField<T extends object, K extends keyof T>( // console.log("USE FIELD", String(name), formValue.current, fieldValue); const [currentValue, setCurrentValue] = useState<any | undefined>(fieldValue); const fieldState = - readField<Partial<InputFieldState>>(formState, String(name)) ?? {}; + readField<Partial<BehaviorResult>>(formState, String(name)) ?? {}; //compute default state const state = { - disabled: fieldState.disabled ?? false, - readonly: fieldState.readonly ?? false, + disabled: readOnlyForm ? true : (fieldState.disabled ?? false), + readonly: readOnlyForm ? true : (fieldState.readonly ?? false), hidden: fieldState.hidden ?? false, error: fieldState.error, + help: fieldState.help, elements: "elements" in fieldState ? fieldState.elements ?? [] : [], }; |