diff --git a/packages/web-util/package.json b/packages/web-util/package.json index ac85fe8eb..2c1b697d8 100644 --- a/packages/web-util/package.json +++ b/packages/web-util/package.json @@ -35,6 +35,8 @@ "@babel/preset-react": "^7.22.3", "@babel/preset-typescript": "^7.21.5", "@gnu-taler/taler-util": "workspace:*", + "@heroicons/react": "^2.0.17", + "date-fns": "2.29.3", "@linaria/babel-preset": "4.4.5", "@linaria/core": "4.2.10", "@linaria/esbuild": "4.2.11", diff --git a/packages/web-util/src/forms/Caption.tsx b/packages/web-util/src/forms/Caption.tsx new file mode 100644 index 000000000..8facddec3 --- /dev/null +++ b/packages/web-util/src/forms/Caption.tsx @@ -0,0 +1,32 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { VNode, h } from "preact"; +import { + LabelWithTooltipMaybeRequired +} from "./InputLine.js"; + +interface Props { + label: TranslatedString; + tooltip?: TranslatedString; + help?: TranslatedString; + before?: VNode; + after?: VNode; +} + +export function Caption({ before, after, label, tooltip, help }: Props): VNode { + return ( +
+ {before !== undefined && ( + {before} + )} + + {after !== undefined && ( + {after} + )} + {help && ( +

+ {help} +

+ )} +
+ ); +} diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx new file mode 100644 index 000000000..92c379459 --- /dev/null +++ b/packages/web-util/src/forms/DefaultForm.tsx @@ -0,0 +1,65 @@ + +import { ComponentChildren, Fragment, h } from "preact"; +import { FormProvider, FormState } from "./FormProvider.js"; +import { DoubleColumnForm, RenderAllFieldsByUiConfig } from "./forms.js"; + + +export interface FlexibleForm { + versionId: string; + design: DoubleColumnForm; + behavior: (form: Partial) => FormState; +} + +export function DefaultForm({ + initial, + onUpdate, + form, + onSubmit, + children, +}: { + children?: ComponentChildren; + initial: Partial; + onSubmit?: (v: Partial) => void; + form: FlexibleForm; + onUpdate?: (d: Partial) => void; +}) { + return ( + +
+ {form.design.map((section, i) => { + if (!section) return ; + return ( +
+
+

+ {section.title} +

+ {section.description && ( +

+ {section.description} +

+ )} +
+
+
+
+ +
+
+
+
+ ); + })} +
+ {children} +
+ ); +} diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx new file mode 100644 index 000000000..3da2a4f07 --- /dev/null +++ b/packages/web-util/src/forms/FormProvider.tsx @@ -0,0 +1,99 @@ +import { + AbsoluteTime, + AmountJson, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { ComponentChildren, VNode, createContext, h } from "preact"; +import { + MutableRef, + StateUpdater, + useEffect, + useRef, + useState, +} from "preact/hooks"; + +export interface FormType { + value: MutableRef>; + initialValue?: Partial; + onUpdate?: StateUpdater; + computeFormState?: (v: T) => FormState; +} + +//@ts-ignore +export const FormContext = createContext>({}); + +export type FormState = { + [field in keyof T]?: T[field] extends AbsoluteTime + ? Partial + : T[field] extends AmountJson + ? Partial + : T[field] extends Array + ? Partial> + : T[field] extends (object | undefined) + ? FormState + : Partial; +}; + +export interface InputFieldState { + /* should show the error */ + error?: TranslatedString; + /* should not allow to edit */ + readonly: boolean; + /* should show as disable */ + disabled: boolean; + /* should not show */ + hidden: boolean; +} + +export interface InputArrayFieldState extends InputFieldState { + elements: FormState[]; +} + +export function FormProvider({ + children, + initialValue, + onUpdate: notify, + onSubmit, + computeFormState, +}: { + initialValue?: Partial; + onUpdate?: (v: Partial) => void; + onSubmit?: (v: Partial, s: FormState | undefined) => void; + computeFormState?: (v: Partial) => FormState; + children: ComponentChildren; +}): VNode { + // const value = useRef(initialValue ?? {}); + // useEffect(() => { + // return function onUnload() { + // value.current = initialValue ?? {}; + // }; + // }); + // const onUpdate = notify + const [state, setState] = useState>(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 ( + +
{ + e.preventDefault(); + //@ts-ignore + if (onSubmit) + onSubmit( + value.current, + !computeFormState ? undefined : computeFormState(value.current), + ); + }} + > + {children} +
+
+ ); +} diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx new file mode 100644 index 000000000..0645f6d97 --- /dev/null +++ b/packages/web-util/src/forms/Group.tsx @@ -0,0 +1,41 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; +import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; + +interface Props { + before?: TranslatedString; + after?: TranslatedString; + tooltipBefore?: TranslatedString; + tooltipAfter?: TranslatedString; + fields: UIFormField[]; +} + +export function Group({ + before, + after, + tooltipAfter, + tooltipBefore, + fields, +}: Props): VNode { + return ( +
+
+ {before && ( + + )} +
+
+ +
+
+ {after && ( + + )} +
+
+ ); +} diff --git a/packages/web-util/src/forms/InputAmount.tsx b/packages/web-util/src/forms/InputAmount.tsx new file mode 100644 index 000000000..9be9dd4d0 --- /dev/null +++ b/packages/web-util/src/forms/InputAmount.tsx @@ -0,0 +1,34 @@ +import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; +import { VNode, h } from "preact"; +import { InputLine, UIFormProps } from "./InputLine.js"; +import { useField } from "./useField.js"; + +export function InputAmount( + props: { currency?: string } & UIFormProps, +): VNode { + const { value } = useField(props.name); + const currency = + !value || !(value as any).currency + ? props.currency + : (value as any).currency; + return ( + + type="text" + before={{ + type: "text", + text: currency as TranslatedString, + }} + converter={{ + //@ts-ignore + fromStringUI: (v): AmountJson => { + return Amounts.parseOrThrow(`${currency}:${v}`); + }, + //@ts-ignore + toStringUI: (v: AmountJson) => { + return v === undefined ? "" : Amounts.stringifyValue(v); + }, + }} + {...props} + /> + ); +} diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx new file mode 100644 index 000000000..00379bed6 --- /dev/null +++ b/packages/web-util/src/forms/InputArray.tsx @@ -0,0 +1,183 @@ +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 { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { useField } from "./useField.js"; +import { TranslatedString } from "@gnu-taler/taler-util"; + +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 ( + + ); +} + +export function InputArray( + props: { + fields: UIFormField[]; + labelField: string; + } & UIFormProps, +): VNode { + const { fields, labelField, name, label, required, tooltip } = props; + const { value, onChange, state } = useField(name); + const list = (value ?? []) as Array>; + const [selectedIndex, setSelected] = useState(undefined); + const selected = + selectedIndex === undefined ? undefined : list[selectedIndex]; + + return ( +
+ + +
+ {list.map((v, idx) => { + return ( +
+ {selectedIndex !== undefined && ( + /** + * This form provider act as a substate of the parent form + * Consider creating an InnerFormProvider since not every feature is expected + */ + { + // 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]; + }} + onSubmit={(v) => { + const newValue = [...list]; + newValue.splice(selectedIndex, 1, v); + onChange(newValue as T[K]); + setSelected(undefined); + }} + onUpdate={(v) => { + const newValue = [...list]; + newValue.splice(selectedIndex, 1, v); + onChange(newValue as T[K]); + }} + > +
+
+ +
+
+
+ )} + {selectedIndex !== undefined && ( +
+
+ {selected !== undefined && ( + + )} +
+
+ )} +
+ ); +} diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.tsx new file mode 100644 index 000000000..5c909b5d7 --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceHorizontal.tsx @@ -0,0 +1,82 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { useField } from "./useField.js"; +import { Choice } from "./InputChoiceStacked.js"; + +export function InputChoiceHorizontal( + props: { + choices: Choice[]; + } & UIFormProps, +): VNode { + const { + choices, + name, + label, + tooltip, + help, + placeholder, + required, + before, + after, + converter, + } = props; + const { value, onChange, state, isDirty } = useField(name); + if (state.hidden) { + return ; + } + + return ( +
+ +
+
+ {choices.map((choice, idx) => { + 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 (choice.value === 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 ( + + ); + })} +
+
+ {help && ( +

+ {help} +

+ )} +
+ ); +} diff --git a/packages/web-util/src/forms/InputChoiceStacked.tsx b/packages/web-util/src/forms/InputChoiceStacked.tsx new file mode 100644 index 000000000..c37984368 --- /dev/null +++ b/packages/web-util/src/forms/InputChoiceStacked.tsx @@ -0,0 +1,111 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { useField } from "./useField.js"; + +export interface Choice { + label: TranslatedString; + description?: TranslatedString; + value: V; +} + +export function InputChoiceStacked( + props: { + choices: Choice[]; + } & UIFormProps, +): VNode { + const { + choices, + name, + label, + tooltip, + help, + placeholder, + required, + before, + after, + converter, + } = props; + const { value, onChange, state, isDirty } = useField(name); + if (state.hidden) { + return ; + } + + return ( +
+ +
+
+ {choices.map((choice) => { + // 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 ( + + ); + })} +
+
+ {help && ( +

+ {help} +

+ )} +
+ ); +} diff --git a/packages/web-util/src/forms/InputDate.tsx b/packages/web-util/src/forms/InputDate.tsx new file mode 100644 index 000000000..1fd81aad9 --- /dev/null +++ b/packages/web-util/src/forms/InputDate.tsx @@ -0,0 +1,37 @@ +import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { InputLine, UIFormProps } from "./InputLine.js"; +import { CalendarIcon } from "@heroicons/react/24/outline"; +import { VNode, h } from "preact"; +import { format, parse } from "date-fns"; + +export function InputDate( + props: { pattern?: string } & UIFormProps, +): VNode { + const pattern = props.pattern ?? "dd/MM/yyyy"; + return ( + + type="text" + after={{ + type: "icon", + icon: , + }} + converter={{ + //@ts-ignore + fromStringUI: (v): AbsoluteTime => { + if (!v) return AbsoluteTime.never(); + const t_ms = parse(v, pattern, Date.now()).getTime(); + return AbsoluteTime.fromMilliseconds(t_ms); + }, + //@ts-ignore + toStringUI: (v: AbsoluteTime) => { + return !v || !v.t_ms + ? "" + : v.t_ms === "never" + ? "never" + : format(v.t_ms, pattern); + }, + }} + {...props} + /> + ); +} diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx new file mode 100644 index 000000000..0d89a98a3 --- /dev/null +++ b/packages/web-util/src/forms/InputFile.tsx @@ -0,0 +1,101 @@ +import { Fragment, VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { useField } from "./useField.js"; + +export function InputFile( + props: { maxBites: number; accept?: string } & UIFormProps, +): VNode { + const { + name, + label, + placeholder, + tooltip, + required, + help, + maxBites, + accept, + } = props; + const { value, onChange, state } = useField(name); + + if (state.hidden) { + return
; + } + return ( +
+ + {!value || !(value as string).startsWith("data:image/") ? ( +
+
+ +
+ + {/*

or drag and drop

*/} +
+
+
+ ) : ( +
+ + +
{ + onChange(undefined!); + }} + > + Clear +
+
+ )} + {help &&

{help}

} +
+ ); +} diff --git a/packages/web-util/src/forms/InputInteger.tsx b/packages/web-util/src/forms/InputInteger.tsx new file mode 100644 index 000000000..fb04e3852 --- /dev/null +++ b/packages/web-util/src/forms/InputInteger.tsx @@ -0,0 +1,23 @@ +import { VNode, h } from "preact"; +import { InputLine, UIFormProps } from "./InputLine.js"; + +export function InputInteger( + props: UIFormProps, +): VNode { + return ( + { + 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 new file mode 100644 index 000000000..9448ef5e4 --- /dev/null +++ b/packages/web-util/src/forms/InputLine.tsx @@ -0,0 +1,282 @@ +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 { + toStringUI: (v?: T) => string; + fromStringUI: (v?: string) => T; +} + +export interface UIFormProps { + name: K; + label: TranslatedString; + placeholder?: TranslatedString; + tooltip?: TranslatedString; + help?: TranslatedString; + before?: Addon; + after?: Addon; + required?: boolean; + converter?: StringConverter; +} + +export type FormErrors = { + [P in keyof T]?: string | FormErrors; +}; + +//@ts-ignore +const TooltipIcon = ( + + + +); + +export function LabelWithTooltipMaybeRequired({ + label, + required, + tooltip, +}: { + label: TranslatedString; + required?: boolean; + tooltip?: TranslatedString; +}): VNode { + const Label = ( + +
+ +
+
+ ); + const WithTooltip = tooltip ? ( +
+ {Label} + + {TooltipIcon} + + +
+ ) : ( + Label + ); + if (required) { + return ( +
+ {WithTooltip} + * +
+ ); + } + return WithTooltip; +} + +function InputWrapper({ + children, + label, + tooltip, + before, + after, + help, + error, + required, +}: { error?: string; children: ComponentChildren } & UIFormProps): VNode { + return ( +
+ +
+ {before && + (before.type === "text" ? ( + + {before.text} + + ) : before.type === "icon" ? ( +
+ {before.icon} +
+ ) : before.type === "button" ? ( + + ) : undefined)} + + {children} + + {after && + (after.type === "text" ? ( + + {after.text} + + ) : after.type === "icon" ? ( +
+ {after.icon} +
+ ) : after.type === "button" ? ( + + ) : undefined)} +
+ {error && ( +

+ {error} +

+ )} + {help && ( +

+ {help} +

+ )} +
+ ); +} + +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( + props: { type: InputType } & UIFormProps, +): VNode { + const { name, placeholder, before, after, converter, type } = props; + const { value, onChange, state, isDirty } = useField(name); + + if (state.hidden) return
; + + 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 = isDirty && state.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"; + } + const fromString: (s: string) => any = + converter?.fromStringUI ?? defaultFromString; + const toString: (s: any) => string = converter?.toStringUI ?? defaultToString; + + if (type === "text-area") { + return ( + + {...props} + error={showError ? state.error : undefined} + > +