aboutsummaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/web-util/package.json2
-rw-r--r--packages/web-util/src/forms/Caption.tsx32
-rw-r--r--packages/web-util/src/forms/DefaultForm.tsx65
-rw-r--r--packages/web-util/src/forms/FormProvider.tsx99
-rw-r--r--packages/web-util/src/forms/Group.tsx41
-rw-r--r--packages/web-util/src/forms/InputAmount.tsx34
-rw-r--r--packages/web-util/src/forms/InputArray.tsx183
-rw-r--r--packages/web-util/src/forms/InputChoiceHorizontal.tsx82
-rw-r--r--packages/web-util/src/forms/InputChoiceStacked.tsx111
-rw-r--r--packages/web-util/src/forms/InputDate.tsx37
-rw-r--r--packages/web-util/src/forms/InputFile.tsx101
-rw-r--r--packages/web-util/src/forms/InputInteger.tsx23
-rw-r--r--packages/web-util/src/forms/InputLine.tsx282
-rw-r--r--packages/web-util/src/forms/InputSelectMultiple.tsx151
-rw-r--r--packages/web-util/src/forms/InputSelectOne.tsx134
-rw-r--r--packages/web-util/src/forms/InputText.tsx8
-rw-r--r--packages/web-util/src/forms/InputTextArea.tsx8
-rw-r--r--packages/web-util/src/forms/forms.ts135
-rw-r--r--packages/web-util/src/forms/index.ts19
-rw-r--r--packages/web-util/src/forms/useField.ts93
-rw-r--r--packages/web-util/src/hooks/index.ts3
-rw-r--r--packages/web-util/src/hooks/useNotifications.ts29
-rw-r--r--packages/web-util/src/index.browser.ts1
23 files changed, 1658 insertions, 15 deletions
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 (
+ <div class="sm:col-span-6 flex">
+ {before !== undefined && (
+ <span class="pointer-events-none flex items-center pr-2">{before}</span>
+ )}
+ <LabelWithTooltipMaybeRequired label={label} tooltip={tooltip} />
+ {after !== undefined && (
+ <span class="pointer-events-none flex items-center pl-2">{after}</span>
+ )}
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
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<T extends object> {
+ versionId: string;
+ design: DoubleColumnForm;
+ behavior: (form: Partial<T>) => FormState<T>;
+}
+
+export function DefaultForm<T extends object>({
+ initial,
+ onUpdate,
+ form,
+ onSubmit,
+ children,
+}: {
+ children?: ComponentChildren;
+ initial: Partial<T>;
+ onSubmit?: (v: Partial<T>) => void;
+ form: FlexibleForm<T>;
+ onUpdate?: (d: Partial<T>) => void;
+}) {
+ return (
+ <FormProvider
+ initialValue={initial}
+ onUpdate={onUpdate}
+ onSubmit={onSubmit}
+ 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/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<T> {
+ value: MutableRef<Partial<T>>;
+ initialValue?: Partial<T>;
+ onUpdate?: StateUpdater<T>;
+ computeFormState?: (v: T) => FormState<T>;
+}
+
+//@ts-ignore
+export const FormContext = createContext<FormType<any>>({});
+
+export type FormState<T> = {
+ [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>;
+};
+
+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<T> extends InputFieldState {
+ elements: FormState<T>[];
+}
+
+export function FormProvider<T>({
+ children,
+ initialValue,
+ onUpdate: notify,
+ onSubmit,
+ computeFormState,
+}: {
+ initialValue?: Partial<T>;
+ onUpdate?: (v: Partial<T>) => void;
+ onSubmit?: (v: Partial<T>, s: FormState<T> | undefined) => void;
+ computeFormState?: (v: Partial<T>) => FormState<T>;
+ 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 }}
+ >
+ <form
+ onSubmit={(e) => {
+ e.preventDefault();
+ //@ts-ignore
+ if (onSubmit)
+ onSubmit(
+ value.current,
+ !computeFormState ? undefined : computeFormState(value.current),
+ );
+ }}
+ >
+ {children}
+ </form>
+ </FormContext.Provider>
+ );
+}
diff --git a/packages/web-util/src/forms/Group.tsx b/packages/web-util/src/forms/Group.tsx
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 (
+ <div class="sm:col-span-6 p-4 rounded-lg border-r-2 border-2 bg-gray-50">
+ <div class="pb-4">
+ {before && (
+ <LabelWithTooltipMaybeRequired
+ label={before}
+ tooltip={tooltipBefore}
+ />
+ )}
+ </div>
+ <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-6">
+ <RenderAllFieldsByUiConfig fields={fields} />
+ </div>
+ <div class="pt-4">
+ {after && (
+ <LabelWithTooltipMaybeRequired label={after} tooltip={tooltipAfter} />
+ )}
+ </div>
+ </div>
+ );
+}
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<T extends object, K extends keyof T>(
+ props: { currency?: string } & UIFormProps<T, K>,
+): VNode {
+ const { value } = useField<T, K>(props.name);
+ const currency =
+ !value || !(value as any).currency
+ ? props.currency
+ : (value as any).currency;
+ return (
+ <InputLine<T, K>
+ 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 (
+ <label class={clazz}>
+ <input
+ type="radio"
+ name="privacy-setting"
+ checked={isSelected}
+ disabled={disabled}
+ onClick={onClick}
+ class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600"
+ aria-labelledby="privacy-setting-0-label"
+ aria-describedby="privacy-setting-0-description"
+ />
+ <span class="ml-3 flex flex-col">
+ <span
+ id="privacy-setting-0-label"
+ disabled
+ class="block text-sm font-medium"
+ >
+ {label}
+ </span>
+ {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */}
+ {/* <span
+ id="privacy-setting-0-description"
+ class="block text-sm"
+ >
+ This project would be available to anyone who has the link
+ </span> */}
+ </span>
+ </label>
+ );
+}
+
+export function InputArray<T extends object, K extends keyof T>(
+ props: {
+ fields: UIFormField[];
+ labelField: string;
+ } & UIFormProps<T, K>,
+): VNode {
+ const { fields, labelField, name, label, required, tooltip } = props;
+ const { value, onChange, state } = useField<T, K>(name);
+ const list = (value ?? []) as Array<Record<string, string | undefined>>;
+ const [selectedIndex, setSelected] = useState<number | undefined>(undefined);
+ const selected =
+ selectedIndex === undefined ? undefined : list[selectedIndex];
+
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+
+ <div class="-space-y-px rounded-md bg-white ">
+ {list.map((v, idx) => {
+ return (
+ <Option
+ label={v[labelField] as TranslatedString}
+ isSelected={selectedIndex === idx}
+ isLast={idx === list.length - 1}
+ disabled={selectedIndex !== undefined && selectedIndex !== idx}
+ isFirst={idx === 0}
+ onClick={() => {
+ setSelected(selectedIndex === idx ? undefined : idx);
+ }}
+ />
+ );
+ })}
+ <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 && (
+ /**
+ * This form provider act as a substate of the parent form
+ * Consider creating an InnerFormProvider since not every feature is expected
+ */
+ <FormProvider
+ initialValue={selected}
+ computeFormState={(v) => {
+ // current state is ignored
+ // the state is defined by the parent form
+
+ // elements should be present in the state object since this is expected to be an array
+ //@ts-ignore
+ return state.elements[selectedIndex];
+ }}
+ 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]);
+ }}
+ >
+ <div class="px-4 py-6">
+ <div class="grid grid-cols-1 gap-y-8 ">
+ <RenderAllFieldsByUiConfig fields={fields} />
+ </div>
+ </div>
+ </FormProvider>
+ )}
+ {selectedIndex !== undefined && (
+ <div class="flex items-center pt-3">
+ <div class="flex-auto">
+ {selected !== undefined && (
+ <button
+ type="button"
+ onClick={() => {
+ const newValue = [...list];
+ newValue.splice(selectedIndex, 1);
+ onChange(newValue as T[K]);
+ setSelected(undefined);
+ }}
+ class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 "
+ >
+ Remove
+ </button>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
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<T extends object, K extends keyof T>(
+ props: {
+ choices: Choice<T[K]>[];
+ } & UIFormProps<T, K>,
+): VNode {
+ const {
+ choices,
+ name,
+ label,
+ tooltip,
+ help,
+ placeholder,
+ required,
+ before,
+ after,
+ converter,
+ } = props;
+ const { value, onChange, state, isDirty } = useField<T, K>(name);
+ if (state.hidden) {
+ return <Fragment />;
+ }
+
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <fieldset class="mt-2">
+ <div class="isolate inline-flex rounded-md shadow-sm">
+ {choices.map((choice, idx) => {
+ const 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 (
+ <button
+ type="button"
+ class={clazz}
+ onClick={(e) => {
+ onChange(
+ (value === choice.value ? undefined : choice.value) as T[K],
+ );
+ }}
+ >
+ {(!converter
+ ? (choice.value as string)
+ : converter?.toStringUI(choice.value)) ?? ""}
+ </button>
+ );
+ })}
+ </div>
+ </fieldset>
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputChoiceStacked.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<V> {
+ label: TranslatedString;
+ description?: TranslatedString;
+ value: V;
+}
+
+export function InputChoiceStacked<T extends object, K extends keyof T>(
+ props: {
+ choices: Choice<T[K]>[];
+ } & UIFormProps<T, K>,
+): VNode {
+ const {
+ choices,
+ name,
+ label,
+ tooltip,
+ help,
+ placeholder,
+ required,
+ before,
+ after,
+ converter,
+ } = props;
+ const { value, onChange, state, isDirty } = useField<T, K>(name);
+ if (state.hidden) {
+ return <Fragment />;
+ }
+
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <fieldset class="mt-2">
+ <div class="space-y-4">
+ {choices.map((choice) => {
+ // const currentValue = !converter
+ // ? choice.value
+ // : converter.fromStringUI(choice.value) ?? "";
+
+ let clazz =
+ "border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between";
+ if (choice.value === value) {
+ clazz +=
+ " border-transparent border-indigo-600 ring-2 ring-indigo-600";
+ } else {
+ clazz += " border-gray-300";
+ }
+
+ return (
+ <label class={clazz}>
+ <input
+ type="radio"
+ name="server-size"
+ // defaultValue={choice.value}
+ value={
+ (!converter
+ ? (choice.value as string)
+ : converter?.toStringUI(choice.value)) ?? ""
+ }
+ onClick={(e) => {
+ onChange(
+ (value === choice.value
+ ? undefined
+ : choice.value) as T[K],
+ );
+ }}
+ class="sr-only"
+ aria-labelledby="server-size-0-label"
+ aria-describedby="server-size-0-description-0 server-size-0-description-1"
+ />
+ <span class="flex items-center">
+ <span class="flex flex-col text-sm">
+ <span
+ id="server-size-0-label"
+ class="font-medium text-gray-900"
+ >
+ {choice.label}
+ </span>
+ {choice.description !== undefined && (
+ <span
+ id="server-size-0-description-0"
+ class="text-gray-500"
+ >
+ <span class="block sm:inline">
+ {choice.description}
+ </span>
+ </span>
+ )}
+ </span>
+ </span>
+ </label>
+ );
+ })}
+ </div>
+ </fieldset>
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/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<T extends object, K extends keyof T>(
+ props: { pattern?: string } & UIFormProps<T, K>,
+): VNode {
+ const pattern = props.pattern ?? "dd/MM/yyyy";
+ return (
+ <InputLine<T, K>
+ type="text"
+ after={{
+ type: "icon",
+ icon: <CalendarIcon class="h-6 w-6" />,
+ }}
+ 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<T extends object, K extends keyof T>(
+ props: { maxBites: number; accept?: string } & UIFormProps<T, K>,
+): VNode {
+ const {
+ name,
+ label,
+ placeholder,
+ tooltip,
+ required,
+ help,
+ maxBites,
+ accept,
+ } = props;
+ const { value, onChange, state } = useField<T, K>(name);
+
+ if (state.hidden) {
+ return <div />;
+ }
+ return (
+ <div class="col-span-full">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ tooltip={tooltip}
+ required={required}
+ />
+ {!value || !(value as string).startsWith("data:image/") ? (
+ <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1">
+ <div class="text-center">
+ <svg
+ class="mx-auto h-12 w-12 text-gray-300"
+ viewBox="0 0 24 24"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ <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>
+ ) : (
+ <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative">
+ <img
+ src={value as string}
+ 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>
+ </div>
+ )}
+ {help && <p class="text-xs leading-5 text-gray-600 mt-2">{help}</p>}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputInteger.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<T extends object, K extends keyof T>(
+ props: UIFormProps<T, K>,
+): VNode {
+ return (
+ <InputLine
+ type="number"
+ converter={{
+ //@ts-ignore
+ fromStringUI: (v): number => {
+ return !v ? 0 : Number.parseInt(v, 10);
+ },
+ //@ts-ignore
+ toStringUI: (v?: number): string => {
+ return v === undefined ? "" : String(v);
+ },
+ }}
+ {...props}
+ />
+ );
+}
diff --git a/packages/web-util/src/forms/InputLine.tsx b/packages/web-util/src/forms/InputLine.tsx
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<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]>;
+};
+
+//@ts-ignore
+const TooltipIcon = (
+ <svg
+ class="w-5 h-5"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+ </svg>
+);
+
+export function LabelWithTooltipMaybeRequired({
+ label,
+ required,
+ tooltip,
+}: {
+ label: TranslatedString;
+ required?: boolean;
+ tooltip?: TranslatedString;
+}): VNode {
+ const Label = (
+ <Fragment>
+ <div class="flex justify-between">
+ <label
+ htmlFor="email"
+ class="block text-sm font-medium leading-6 text-gray-900"
+ >
+ {label}
+ </label>
+ </div>
+ </Fragment>
+ );
+ const WithTooltip = tooltip ? (
+ <div class="relative flex flex-grow items-stretch focus-within:z-10">
+ {Label}
+ <span class="relative flex items-center group pl-2">
+ {TooltipIcon}
+ <div class="absolute bottom-0 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">
+ {tooltip}
+ </span>
+ <div class="w-3 h-3 -mt-2 rotate-45 bg-black"></div>
+ </div>
+ </span>
+ </div>
+ ) : (
+ Label
+ );
+ if (required) {
+ return (
+ <div class="flex justify-between">
+ {WithTooltip}
+ <span class="text-sm leading-6 text-red-600">*</span>
+ </div>
+ );
+ }
+ return WithTooltip;
+}
+
+function InputWrapper<T extends object, K extends keyof T>({
+ children,
+ label,
+ tooltip,
+ before,
+ after,
+ help,
+ error,
+ required,
+}: { error?: string; children: ComponentChildren } & UIFormProps<T, K>): VNode {
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <div class="relative mt-2 flex rounded-md shadow-sm">
+ {before &&
+ (before.type === "text" ? (
+ <span class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
+ {before.text}
+ </span>
+ ) : before.type === "icon" ? (
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ {before.icon}
+ </div>
+ ) : before.type === "button" ? (
+ <button
+ type="button"
+ 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"
+ >
+ {before.children}
+ </button>
+ ) : undefined)}
+
+ {children}
+
+ {after &&
+ (after.type === "text" ? (
+ <span class="inline-flex items-center rounded-r-md border border-l-0 border-gray-300 px-3 text-gray-500 sm:text-sm">
+ {after.text}
+ </span>
+ ) : after.type === "icon" ? (
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
+ {after.icon}
+ </div>
+ ) : after.type === "button" ? (
+ <button
+ type="button"
+ 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"
+ >
+ {after.children}
+ </button>
+ ) : undefined)}
+ </div>
+ {error && (
+ <p class="mt-2 text-sm text-red-600" id="email-error">
+ {error}
+ </p>
+ )}
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
+
+function defaultToString(v: unknown) {
+ return v === undefined ? "" : typeof v !== "object" ? String(v) : "";
+}
+function defaultFromString(v: string) {
+ return v;
+}
+
+type InputType = "text" | "text-area" | "password" | "email" | "number";
+
+export function InputLine<T extends object, K extends keyof T>(
+ props: { type: InputType } & UIFormProps<T, K>,
+): VNode {
+ const { name, placeholder, before, after, converter, type } = props;
+ const { value, onChange, state, isDirty } = useField<T, K>(name);
+
+ if (state.hidden) return <div />;
+
+ let clazz =
+ "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200";
+ if (before) {
+ switch (before.type) {
+ case "icon": {
+ clazz += " pl-10";
+ break;
+ }
+ case "button": {
+ clazz += " rounded-none rounded-r-md ";
+ break;
+ }
+ case "text": {
+ clazz += " min-w-0 flex-1 rounded-r-md rounded-none ";
+ break;
+ }
+ }
+ }
+ if (after) {
+ switch (after.type) {
+ case "icon": {
+ clazz += " pr-10";
+ break;
+ }
+ case "button": {
+ clazz += " rounded-none rounded-l-md";
+ break;
+ }
+ case "text": {
+ clazz += " min-w-0 flex-1 rounded-l-md rounded-none ";
+ break;
+ }
+ }
+ }
+ const showError = 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 (
+ <InputWrapper<T, K>
+ {...props}
+ error={showError ? state.error : undefined}
+ >
+ <textarea
+ rows={4}
+ name={String(name)}
+ onChange={(e) => {
+ onChange(fromString(e.currentTarget.value));
+ }}
+ placeholder={placeholder ? placeholder : undefined}
+ value={toString(value) ?? ""}
+ // defaultValue={toString(value)}
+ disabled={state.disabled}
+ aria-invalid={showError}
+ // aria-describedby="email-error"
+ class={clazz}
+ />
+ </InputWrapper>
+ );
+ }
+
+ return (
+ <InputWrapper<T, K> {...props} error={showError ? state.error : undefined}>
+ <input
+ name={String(name)}
+ type={type}
+ onChange={(e) => {
+ onChange(fromString(e.currentTarget.value));
+ }}
+ placeholder={placeholder ? placeholder : undefined}
+ value={toString(value) ?? ""}
+ // defaultValue={toString(value)}
+ disabled={state.disabled}
+ aria-invalid={showError}
+ // aria-describedby="email-error"
+ class={clazz}
+ />
+ </InputWrapper>
+ );
+}
diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx
new file mode 100644
index 000000000..8116bdc03
--- /dev/null
+++ b/packages/web-util/src/forms/InputSelectMultiple.tsx
@@ -0,0 +1,151 @@
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Choice } from "./InputChoiceStacked.js";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputSelectMultiple<T extends object, K extends keyof T>(
+ props: {
+ choices: Choice<T[K]>[];
+ unique?: boolean;
+ max?: number;
+ } & UIFormProps<T, K>,
+): VNode {
+ const { name, label, choices, placeholder, tooltip, required, unique, max } =
+ props;
+ const { value, onChange } = useField<T, K>(name);
+
+ const [filter, setFilter] = useState<string | undefined>(undefined);
+ const regex = new RegExp(`.*${filter}.*`, "i");
+ const choiceMap = choices.reduce((prev, curr) => {
+ return { ...prev, [curr.value as string]: curr.label };
+ }, {} as Record<string, string>);
+
+ const list = (value ?? []) as string[];
+ const filteredChoices =
+ filter === undefined
+ ? undefined
+ : choices.filter((v) => {
+ return regex.test(v.label);
+ });
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ {list.map((v, idx) => {
+ return (
+ <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 text-xs font-medium text-gray-600">
+ {choiceMap[v]}
+ <button
+ type="button"
+ onClick={() => {
+ const newValue = [...list];
+ newValue.splice(idx, 1);
+ onChange(newValue as T[K]);
+ setFilter(undefined);
+ }}
+ class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
+ >
+ <span class="sr-only">Remove</span>
+ <svg
+ viewBox="0 0 14 14"
+ class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75"
+ >
+ <path d="M4 4l6 6m0-6l-6 6" />
+ </svg>
+ <span class="absolute -inset-1"></span>
+ </button>
+ </span>
+ );
+ })}
+
+ <div class="relative mt-2">
+ <input
+ id="combobox"
+ type="text"
+ value={filter ?? ""}
+ onChange={(e) => {
+ setFilter(e.currentTarget.value);
+ }}
+ placeholder={placeholder}
+ class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ role="combobox"
+ aria-controls="options"
+ aria-expanded="false"
+ />
+ <button
+ type="button"
+ onClick={() => {
+ setFilter(filter === undefined ? "" : undefined);
+ }}
+ class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
+ >
+ <svg
+ class="h-5 w-5 text-gray-400"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
+
+ {filteredChoices !== undefined && (
+ <ul
+ class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
+ id="options"
+ role="listbox"
+ >
+ {filteredChoices.map((v, idx) => {
+ return (
+ <li
+ class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
+ id="option-0"
+ role="option"
+ onClick={() => {
+ setFilter(undefined);
+ if (unique && list.indexOf(v.value as string) !== -1) {
+ return;
+ }
+ if (max !== undefined && list.length >= max) {
+ return;
+ }
+ const newValue = [...list];
+ newValue.splice(0, 0, v.value as string);
+ onChange(newValue as T[K]);
+ }}
+
+ // tabindex="-1"
+ >
+ {/* <!-- Selected: "font-semibold" --> */}
+ <span class="block truncate">{v.label}</span>
+
+ {/* <!--
+ Checkmark, only display for selected option.
+
+ Active: "text-white", Not Active: "text-indigo-600"
+ --> */}
+ </li>
+ );
+ })}
+
+ {/* <!--
+ Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation.
+
+ Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
+ --> */}
+
+ {/* <!-- More items... --> */}
+ </ul>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputSelectOne.tsx b/packages/web-util/src/forms/InputSelectOne.tsx
new file mode 100644
index 000000000..7bef1058b
--- /dev/null
+++ b/packages/web-util/src/forms/InputSelectOne.tsx
@@ -0,0 +1,134 @@
+import { Fragment, VNode, h } from "preact";
+import { useState } from "preact/hooks";
+import { Choice } from "./InputChoiceStacked.js";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputSelectOne<T extends object, K extends keyof T>(
+ props: {
+ choices: Choice<T[K]>[];
+ } & UIFormProps<T, K>,
+): VNode {
+ const { name, label, choices, placeholder, tooltip, required } = props;
+ const { value, onChange } = useField<T, K>(name);
+
+ const [filter, setFilter] = useState<string | undefined>(undefined);
+ const regex = new RegExp(`.*${filter}.*`, "i");
+ const choiceMap = choices.reduce((prev, curr) => {
+ return { ...prev, [curr.value as string]: curr.label };
+ }, {} as Record<string, string>);
+
+ const filteredChoices =
+ filter === undefined
+ ? undefined
+ : choices.filter((v) => {
+ return regex.test(v.label);
+ });
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ {value ? (
+ <span class="inline-flex items-center gap-x-0.5 rounded-md bg-gray-100 p-1 mr-2 font-medium text-gray-600">
+ {choiceMap[value as string]}
+ <button
+ type="button"
+ onClick={() => {
+ onChange(undefined!);
+ }}
+ class="group relative h-5 w-5 rounded-sm hover:bg-gray-500/20"
+ >
+ <span class="sr-only">Remove</span>
+ <svg
+ viewBox="0 0 14 14"
+ class="h-5 w-5 stroke-gray-700/50 group-hover:stroke-gray-700/75"
+ >
+ <path d="M4 4l6 6m0-6l-6 6" />
+ </svg>
+ <span class="absolute -inset-1"></span>
+ </button>
+ </span>
+ ) : (
+ <div class="relative mt-2">
+ <input
+ id="combobox"
+ type="text"
+ value={filter ?? ""}
+ onChange={(e) => {
+ setFilter(e.currentTarget.value);
+ }}
+ placeholder={placeholder}
+ class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
+ role="combobox"
+ aria-controls="options"
+ aria-expanded="false"
+ />
+ <button
+ type="button"
+ onClick={() => {
+ setFilter(filter === undefined ? "" : undefined);
+ }}
+ class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none"
+ >
+ <svg
+ class="h-5 w-5 text-gray-400"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ </button>
+
+ {filteredChoices !== undefined && (
+ <ul
+ class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
+ id="options"
+ role="listbox"
+ >
+ {filteredChoices.map((v, idx) => {
+ return (
+ <li
+ class="relative cursor-pointer select-none py-2 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-indigo-600"
+ id="option-0"
+ role="option"
+ onClick={() => {
+ setFilter(undefined);
+ onChange(v.value as T[K]);
+ }}
+
+ // tabindex="-1"
+ >
+ {/* <!-- Selected: "font-semibold" --> */}
+ <span class="block truncate">{v.label}</span>
+
+ {/* <!--
+ Checkmark, only display for selected option.
+
+ Active: "text-white", Not Active: "text-indigo-600"
+ --> */}
+ </li>
+ );
+ })}
+
+ {/* <!--
+ Combobox option, manage highlight styles based on mouseenter/mouseleave and keyboard navigation.
+
+ Active: "text-white bg-indigo-600", Not Active: "text-gray-900"
+ --> */}
+
+ {/* <!-- More items... --> */}
+ </ul>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/packages/web-util/src/forms/InputText.tsx b/packages/web-util/src/forms/InputText.tsx
new file mode 100644
index 000000000..1b37ee6fb
--- /dev/null
+++ b/packages/web-util/src/forms/InputText.tsx
@@ -0,0 +1,8 @@
+import { VNode, h } from "preact";
+import { InputLine, UIFormProps } from "./InputLine.js";
+
+export function InputText<T extends object, K extends keyof T>(
+ props: UIFormProps<T, K>,
+): VNode {
+ return <InputLine type="text" {...props} />;
+}
diff --git a/packages/web-util/src/forms/InputTextArea.tsx b/packages/web-util/src/forms/InputTextArea.tsx
new file mode 100644
index 000000000..45229951e
--- /dev/null
+++ b/packages/web-util/src/forms/InputTextArea.tsx
@@ -0,0 +1,8 @@
+import { VNode, h } from "preact";
+import { InputLine, UIFormProps } from "./InputLine.js";
+
+export function InputTextArea<T extends object, K extends keyof T>(
+ props: UIFormProps<T, K>,
+): VNode {
+ return <InputLine type="text-area" {...props} />;
+}
diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts
new file mode 100644
index 000000000..2c90a69ed
--- /dev/null
+++ b/packages/web-util/src/forms/forms.ts
@@ -0,0 +1,135 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { InputText } from "./InputText.js";
+import { InputDate } from "./InputDate.js";
+import { InputInteger } from "./InputInteger.js";
+import { h as create, Fragment, VNode } from "preact";
+import { InputChoiceStacked } from "./InputChoiceStacked.js";
+import { InputArray } from "./InputArray.js";
+import { InputSelectMultiple } from "./InputSelectMultiple.js";
+import { InputTextArea } from "./InputTextArea.js";
+import { InputFile } from "./InputFile.js";
+import { Caption } from "./Caption.js";
+import { Group } from "./Group.js";
+import { InputSelectOne } from "./InputSelectOne.js";
+import { FormProvider } from "./FormProvider.js";
+import { InputLine } from "./InputLine.js";
+import { InputAmount } from "./InputAmount.js";
+import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js";
+
+export type DoubleColumnForm = Array<DoubleColumnFormSection | undefined>;
+
+export type DoubleColumnFormSection = {
+ title: TranslatedString;
+ description?: TranslatedString;
+ fields: UIFormField[];
+};
+
+/**
+ * Constrain the type with the ui props
+ */
+type FieldType<T extends object = any, K extends keyof T = any> = {
+ group: Parameters<typeof Group>[0];
+ caption: Parameters<typeof Caption>[0];
+ array: Parameters<typeof InputArray<T, K>>[0];
+ file: Parameters<typeof InputFile<T, K>>[0];
+ selectOne: Parameters<typeof InputSelectOne<T, K>>[0];
+ selectMultiple: Parameters<typeof InputSelectMultiple<T, K>>[0];
+ text: Parameters<typeof InputText<T, K>>[0];
+ textArea: Parameters<typeof InputTextArea<T, K>>[0];
+ choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0];
+ choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0];
+ date: Parameters<typeof InputDate<T, K>>[0];
+ integer: Parameters<typeof InputInteger<T, K>>[0];
+ amount: Parameters<typeof InputAmount<T, K>>[0];
+};
+
+/**
+ * List all the form fields so typescript can type-check the form instance
+ */
+export type UIFormField =
+ | { type: "group"; props: FieldType["group"] }
+ | { type: "caption"; props: FieldType["caption"] }
+ | { type: "array"; props: FieldType["array"] }
+ | { type: "file"; props: FieldType["file"] }
+ | { type: "amount"; props: FieldType["amount"] }
+ | { type: "selectOne"; props: FieldType["selectOne"] }
+ | { type: "selectMultiple"; props: FieldType["selectMultiple"] }
+ | { type: "text"; props: FieldType["text"] }
+ | { type: "textArea"; props: FieldType["textArea"] }
+ | { type: "choiceStacked"; props: FieldType["choiceStacked"] }
+ | { type: "choiceHorizontal"; props: FieldType["choiceHorizontal"] }
+ | { type: "integer"; props: FieldType["integer"] }
+ | { type: "date"; props: FieldType["date"] };
+
+type FieldComponentFunction<key extends keyof FieldType> = (
+ props: FieldType[key],
+) => VNode;
+
+type UIFormFieldMap = {
+ [key in keyof FieldType]: FieldComponentFunction<key>;
+};
+
+/**
+ * Maps input type with component implementation
+ */
+const UIFormConfiguration: UIFormFieldMap = {
+ group: Group,
+ caption: Caption,
+ //@ts-ignore
+ array: InputArray,
+ text: InputText,
+ //@ts-ignore
+ file: InputFile,
+ textArea: InputTextArea,
+ //@ts-ignore
+ date: InputDate,
+ //@ts-ignore
+ choiceStacked: InputChoiceStacked,
+ //@ts-ignore
+ choiceHorizontal: InputChoiceHorizontal,
+ integer: InputInteger,
+ //@ts-ignore
+ selectOne: InputSelectOne,
+ //@ts-ignore
+ selectMultiple: InputSelectMultiple,
+ //@ts-ignore
+ amount: InputAmount,
+};
+
+export function RenderAllFieldsByUiConfig({
+ fields,
+}: {
+ fields: UIFormField[];
+}): VNode {
+ return create(
+ Fragment,
+ {},
+ fields.map((field, i) => {
+ const Component = UIFormConfiguration[
+ field.type
+ ] as FieldComponentFunction<any>;
+ return Component(field.props);
+ }),
+ );
+}
+
+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
+ >;
+};
+export function createNewForm<T extends object>() {
+ const res: FormSet<T> = {
+ Provider: FormProvider,
+ InputLine: () => InputLine,
+ InputChoiceHorizontal: () => InputChoiceHorizontal,
+ };
+ return {
+ Provider: res.Provider,
+ InputLine: res.InputLine(),
+ InputChoiceHorizontal: res.InputChoiceHorizontal(),
+ };
+}
diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts
new file mode 100644
index 000000000..08bb9ee77
--- /dev/null
+++ b/packages/web-util/src/forms/index.ts
@@ -0,0 +1,19 @@
+export * from "./Caption.js"
+export * from "./FormProvider.js"
+export * from "./forms.js"
+export * from "./Group.js"
+export * from "./index.js"
+export * from "./InputAmount.js"
+export * from "./InputArray.js"
+export * from "./InputChoiceHorizontal.js"
+export * from "./InputChoiceStacked.js"
+export * from "./InputDate.js"
+export * from "./InputFile.js"
+export * from "./InputInteger.js"
+export * from "./InputLine.js"
+export * from "./InputSelectMultiple.js"
+export * from "./InputSelectOne.js"
+export * from "./InputTextArea.js"
+export * from "./InputText.js"
+export * from "./useField.js"
+export * from "./DefaultForm.js"
diff --git a/packages/web-util/src/forms/useField.ts b/packages/web-util/src/forms/useField.ts
new file mode 100644
index 000000000..bf94d2f5d
--- /dev/null
+++ b/packages/web-util/src/forms/useField.ts
@@ -0,0 +1,93 @@
+import { useContext, useState } from "preact/compat";
+import { FormContext, InputFieldState } from "./FormProvider.js";
+
+export interface InputFieldHandler<Type> {
+ value: Type;
+ onChange: (s: Type) => void;
+ state: InputFieldState;
+ isDirty: boolean;
+}
+
+export function useField<T extends object, K extends keyof T>(
+ name: K,
+): InputFieldHandler<T[K]> {
+ const {
+ initialValue,
+ value: formValue,
+ computeFormState,
+ onUpdate: notifyUpdate,
+ } = useContext(FormContext);
+
+ type P = typeof name;
+ type V = T[P];
+ const formState = computeFormState ? computeFormState(formValue.current) : {};
+
+ const fieldValue = readField(formValue.current, String(name)) as V;
+ // console.log("USE FIELD", String(name), formValue.current, fieldValue);
+ const [currentValue, setCurrentValue] = useState<any | undefined>(fieldValue);
+ const fieldState =
+ readField<Partial<InputFieldState>>(formState, String(name)) ?? {};
+
+ //compute default state
+ const state = {
+ disabled: fieldState.disabled ?? false,
+ readonly: fieldState.readonly ?? false,
+ hidden: fieldState.hidden ?? false,
+ error: fieldState.error,
+ elements: "elements" in fieldState ? fieldState.elements ?? [] : [],
+ };
+
+ function onChange(value: V): void {
+ setCurrentValue(value);
+ formValue.current = setValueDeeper(
+ formValue.current,
+ String(name).split("."),
+ value,
+ );
+ if (notifyUpdate) {
+ notifyUpdate(formValue.current);
+ }
+ }
+
+ return {
+ value: fieldValue,
+ onChange,
+ isDirty: currentValue !== undefined,
+ state,
+ };
+}
+
+/**
+ * read the field of an object an support accessing it using '.'
+ *
+ * @param object
+ * @param name
+ * @returns
+ */
+function readField<T>(
+ object: any,
+ name: string,
+ debug?: boolean,
+): T | undefined {
+ return name.split(".").reduce((prev, current) => {
+ if (debug) {
+ console.log(
+ "READ",
+ name,
+ prev,
+ current,
+ prev ? prev[current] : undefined,
+ );
+ }
+ return prev ? prev[current] : undefined;
+ }, object);
+}
+
+function setValueDeeper(object: any, names: string[], value: any): any {
+ if (names.length === 0) return value;
+ const [head, ...rest] = names;
+ if (object === undefined) {
+ return { [head]: setValueDeeper({}, rest, value) };
+ }
+ return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) };
+}
diff --git a/packages/web-util/src/hooks/index.ts b/packages/web-util/src/hooks/index.ts
index a3a2053e6..c29de9023 100644
--- a/packages/web-util/src/hooks/index.ts
+++ b/packages/web-util/src/hooks/index.ts
@@ -5,6 +5,9 @@ export {
useNotifications,
notifyError,
notifyInfo,
+ notify,
+ ErrorNotification,
+ InfoNotification
} from "./useNotifications.js";
export {
useAsyncAsHook,
diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts
index 733950592..52e626b38 100644
--- a/packages/web-util/src/hooks/useNotifications.ts
+++ b/packages/web-util/src/hooks/useNotifications.ts
@@ -4,13 +4,13 @@ import { memoryMap } from "../index.browser.js";
export type NotificationMessage = ErrorNotification | InfoNotification;
-interface ErrorNotification {
+export interface ErrorNotification {
type: "error";
title: TranslatedString;
description?: TranslatedString;
debug?: string;
}
-interface InfoNotification {
+export interface InfoNotification {
type: "info";
title: TranslatedString;
}
@@ -18,30 +18,29 @@ interface InfoNotification {
const storage = memoryMap<Map<string, NotificationMessage>>();
const NOTIFICATION_KEY = "notification";
+export function notify(notif: NotificationMessage): void {
+ const currentState: Map<string, NotificationMessage> =
+ storage.get(NOTIFICATION_KEY) ?? new Map();
+ const newState = currentState.set(hash(notif), notif);
+ storage.set(NOTIFICATION_KEY, newState);
+}
export function notifyError(
title: TranslatedString,
description: TranslatedString | undefined,
debug?: any,
) {
- const currentState: Map<string, NotificationMessage> =
- storage.get(NOTIFICATION_KEY) ?? new Map();
-
- const notif = {
+ notify({
type: "error" as const,
title,
description,
debug,
- };
- const newState = currentState.set(hash(notif), notif);
- storage.set(NOTIFICATION_KEY, newState);
+ });
}
export function notifyInfo(title: TranslatedString) {
- const currentState: Map<string, NotificationMessage> =
- storage.get(NOTIFICATION_KEY) ?? new Map();
-
- const notif = { type: "info" as const, title };
- const newState = currentState.set(hash(notif), notif);
- storage.set(NOTIFICATION_KEY, newState);
+ notify({
+ type: "info" as const,
+ title,
+ });
}
type Notification = {
diff --git a/packages/web-util/src/index.browser.ts b/packages/web-util/src/index.browser.ts
index 2a537b405..82c399bfd 100644
--- a/packages/web-util/src/index.browser.ts
+++ b/packages/web-util/src/index.browser.ts
@@ -5,4 +5,5 @@ export * from "./utils/http-impl.sw.js";
export * from "./utils/observable.js";
export * from "./context/index.js";
export * from "./components/index.js";
+export * from "./forms/index.js";
export { renderStories, parseGroupImport } from "./stories.js";