From cb535460350bd510dd4b2b7d6bc3c6ec5f5bcdf1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 10 May 2023 00:53:37 -0300 Subject: almost first document --- .../src/forms/FormProvider.tsx | 54 ++++ .../src/forms/InputArray.tsx | 166 +++++++++++++ .../src/forms/InputChoice.tsx | 119 +++++++++ .../exchange-backoffice-ui/src/forms/InputDate.tsx | 36 +++ .../src/forms/InputInteger.tsx | 19 ++ .../exchange-backoffice-ui/src/forms/InputLine.tsx | 273 +++++++++++++++++++++ .../src/forms/InputSelectMultiple.tsx | 144 +++++++++++ .../exchange-backoffice-ui/src/forms/InputText.tsx | 6 + .../src/forms/InputTextArea.tsx | 6 + packages/exchange-backoffice-ui/src/forms/forms.ts | 87 +++++++ .../exchange-backoffice-ui/src/forms/useField.ts | 72 ++++++ 11 files changed, 982 insertions(+) create mode 100644 packages/exchange-backoffice-ui/src/forms/FormProvider.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputArray.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputChoice.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputDate.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputInteger.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputLine.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputSelectMultiple.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputText.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/InputTextArea.tsx create mode 100644 packages/exchange-backoffice-ui/src/forms/forms.ts create mode 100644 packages/exchange-backoffice-ui/src/forms/useField.ts (limited to 'packages/exchange-backoffice-ui/src/forms') diff --git a/packages/exchange-backoffice-ui/src/forms/FormProvider.tsx b/packages/exchange-backoffice-ui/src/forms/FormProvider.tsx new file mode 100644 index 000000000..c9b6783e6 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/FormProvider.tsx @@ -0,0 +1,54 @@ +import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; +import { ComponentChildren, VNode, createContext, h } from "preact"; +import { StateUpdater, useMemo } from "preact/hooks"; + +export interface FormType { + initialValue: Partial; + value: Partial; + onUpdate: StateUpdater; + computeFormState?: (v: T) => FormState; +} + +//@ts-ignore +export const FormContext = createContext>({}); + +type FormState = { + [field in keyof T]?: T[field] extends AbsoluteTime + ? Partial + : T[field] extends object + ? 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 function FormProvider({ + children, + state, + computeFormState, +}: { + state: [Partial, StateUpdater]; + computeFormState?: (v: T) => FormState; + children: ComponentChildren; +}): VNode { + const [value, onUpdate] = state; + const initialValue = useMemo(() => value, []); + const contextValue = useMemo( + () => ({ initialValue, value, onUpdate, computeFormState }), + [value, onUpdate, computeFormState], + ); + return ( + +
{children}
+
+ ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputArray.tsx b/packages/exchange-backoffice-ui/src/forms/InputArray.tsx new file mode 100644 index 000000000..f60ed4160 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputArray.tsx @@ -0,0 +1,166 @@ +import { Fragment, VNode, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { FormProvider } from "./FormProvider.js"; +import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; +import { useField } from "./useField.js"; + +export function InputArray( + props: { + fields: UIFormField[]; + labelField: string; + } & UIFormProps>, +): VNode { + const { fields, labelField, name, label, required, tooltip } = props; + const { value, onChange } = useField<{ [s: string]: Array }>(name); + const list = value ?? []; + const [selectedIndex, setSelected] = useState(undefined); + const selected = + selectedIndex === undefined ? undefined : list[selectedIndex]; + const formState = useState(selected ?? {}); + useEffect(() => { + const [, update] = formState; + update(selected); + }, [selected]); + return ( +
+ + +
+
+ {selectedIndex !== undefined && ( + + )} +
+
+ {selectedIndex === undefined && ( + + )} +
+
+
+ {list.map((v, idx) => { + const isFirst = idx === 0; + const isLast = idx === list.length - 1; + const isSelected = selectedIndex === idx; + const disabled = selectedIndex !== undefined && !isSelected; + 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 ( + + ); + })} +
+ {selectedIndex !== undefined && ( + +
+
+ +
+
+
+ )} + {selectedIndex !== undefined && ( +
+
+ {selected !== undefined && ( + + )} +
+
+ +
+
+ )} +
+ ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputChoice.tsx b/packages/exchange-backoffice-ui/src/forms/InputChoice.tsx new file mode 100644 index 000000000..dae5ff34a --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputChoice.tsx @@ -0,0 +1,119 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; +import { useField } from "./useField.js"; + +function classNames(...classes: string[]): string { + return classes.filter(Boolean).join(" "); +} +const memoryOptions = [ + { name: "4 GB", inStock: true }, + { name: "8 GB", inStock: true }, + { name: "16 GB", inStock: true }, + { name: "32 GB", inStock: true }, + { name: "64 GB", inStock: true }, + { name: "128 GB", inStock: false }, +]; + +export interface Choice { + label: TranslatedString; + description?: TranslatedString; + value: string; +} + +export function InputChoiceStacked( + props: { + choices: Choice[]; + } & UIFormProps, +): VNode { + const { + choices, + name, + label, + tooltip, + placeholder, + required, + before, + after, + converter, + } = props; + const { value, onChange, state, isDirty } = useField(name); + if (state.hidden) { + return ; + } + + return ( +
+ +
+ {value !== undefined && !required && ( +
+
+ +
+
+ )} + +
+ {choices.map((choice) => { + 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 ( + + ); + })} +
+
+
+ ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputDate.tsx b/packages/exchange-backoffice-ui/src/forms/InputDate.tsx new file mode 100644 index 000000000..c9e1421f8 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputDate.tsx @@ -0,0 +1,36 @@ +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={{ + fromStringUI: (v) => { + if (!v) return { t_ms: "never" }; + console.log("from", v); + const t_ms = parse(v, pattern, Date.now()).getTime(); + return { t_ms }; + }, + toStringUI: (v) => { + return v === undefined + ? "" + : v.t_ms === "never" + ? "never" + : format(v.t_ms, pattern); + }, + }} + {...props} + /> + ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputInteger.tsx b/packages/exchange-backoffice-ui/src/forms/InputInteger.tsx new file mode 100644 index 000000000..49e6973fc --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputInteger.tsx @@ -0,0 +1,19 @@ +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); + }, + toStringUI: (v?: number) => { + return v === undefined ? "" : String(v); + }, + }} + {...props} + /> + ); +} diff --git a/packages/exchange-backoffice-ui/src/forms/InputLine.tsx b/packages/exchange-backoffice-ui/src/forms/InputLine.tsx new file mode 100644 index 000000000..0870e885a --- /dev/null +++ b/packages/exchange-backoffice-ui/src/forms/InputLine.tsx @@ -0,0 +1,273 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; +import { useField } from "./useField.js"; + +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: string; + 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; +} + +export function InputLine(props: { type: string } & 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}> +