more ui stuff, moved forms to util
This commit is contained in:
parent
a5406c5a5d
commit
fdbe623e10
@ -35,6 +35,8 @@
|
|||||||
"@babel/preset-react": "^7.22.3",
|
"@babel/preset-react": "^7.22.3",
|
||||||
"@babel/preset-typescript": "^7.21.5",
|
"@babel/preset-typescript": "^7.21.5",
|
||||||
"@gnu-taler/taler-util": "workspace:*",
|
"@gnu-taler/taler-util": "workspace:*",
|
||||||
|
"@heroicons/react": "^2.0.17",
|
||||||
|
"date-fns": "2.29.3",
|
||||||
"@linaria/babel-preset": "4.4.5",
|
"@linaria/babel-preset": "4.4.5",
|
||||||
"@linaria/core": "4.2.10",
|
"@linaria/core": "4.2.10",
|
||||||
"@linaria/esbuild": "4.2.11",
|
"@linaria/esbuild": "4.2.11",
|
||||||
|
32
packages/web-util/src/forms/Caption.tsx
Normal file
32
packages/web-util/src/forms/Caption.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
65
packages/web-util/src/forms/DefaultForm.tsx
Normal file
65
packages/web-util/src/forms/DefaultForm.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
99
packages/web-util/src/forms/FormProvider.tsx
Normal file
99
packages/web-util/src/forms/FormProvider.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
41
packages/web-util/src/forms/Group.tsx
Normal file
41
packages/web-util/src/forms/Group.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
34
packages/web-util/src/forms/InputAmount.tsx
Normal file
34
packages/web-util/src/forms/InputAmount.tsx
Normal file
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
183
packages/web-util/src/forms/InputArray.tsx
Normal file
183
packages/web-util/src/forms/InputArray.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
82
packages/web-util/src/forms/InputChoiceHorizontal.tsx
Normal file
82
packages/web-util/src/forms/InputChoiceHorizontal.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
111
packages/web-util/src/forms/InputChoiceStacked.tsx
Normal file
111
packages/web-util/src/forms/InputChoiceStacked.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
37
packages/web-util/src/forms/InputDate.tsx
Normal file
37
packages/web-util/src/forms/InputDate.tsx
Normal file
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
101
packages/web-util/src/forms/InputFile.tsx
Normal file
101
packages/web-util/src/forms/InputFile.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
23
packages/web-util/src/forms/InputInteger.tsx
Normal file
23
packages/web-util/src/forms/InputInteger.tsx
Normal file
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
282
packages/web-util/src/forms/InputLine.tsx
Normal file
282
packages/web-util/src/forms/InputLine.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
151
packages/web-util/src/forms/InputSelectMultiple.tsx
Normal file
151
packages/web-util/src/forms/InputSelectMultiple.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
134
packages/web-util/src/forms/InputSelectOne.tsx
Normal file
134
packages/web-util/src/forms/InputSelectOne.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
8
packages/web-util/src/forms/InputText.tsx
Normal file
8
packages/web-util/src/forms/InputText.tsx
Normal file
@ -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} />;
|
||||||
|
}
|
8
packages/web-util/src/forms/InputTextArea.tsx
Normal file
8
packages/web-util/src/forms/InputTextArea.tsx
Normal file
@ -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} />;
|
||||||
|
}
|
135
packages/web-util/src/forms/forms.ts
Normal file
135
packages/web-util/src/forms/forms.ts
Normal file
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
19
packages/web-util/src/forms/index.ts
Normal file
19
packages/web-util/src/forms/index.ts
Normal file
@ -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"
|
93
packages/web-util/src/forms/useField.ts
Normal file
93
packages/web-util/src/forms/useField.ts
Normal file
@ -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) };
|
||||||
|
}
|
@ -5,6 +5,9 @@ export {
|
|||||||
useNotifications,
|
useNotifications,
|
||||||
notifyError,
|
notifyError,
|
||||||
notifyInfo,
|
notifyInfo,
|
||||||
|
notify,
|
||||||
|
ErrorNotification,
|
||||||
|
InfoNotification
|
||||||
} from "./useNotifications.js";
|
} from "./useNotifications.js";
|
||||||
export {
|
export {
|
||||||
useAsyncAsHook,
|
useAsyncAsHook,
|
||||||
|
@ -4,13 +4,13 @@ import { memoryMap } from "../index.browser.js";
|
|||||||
|
|
||||||
export type NotificationMessage = ErrorNotification | InfoNotification;
|
export type NotificationMessage = ErrorNotification | InfoNotification;
|
||||||
|
|
||||||
interface ErrorNotification {
|
export interface ErrorNotification {
|
||||||
type: "error";
|
type: "error";
|
||||||
title: TranslatedString;
|
title: TranslatedString;
|
||||||
description?: TranslatedString;
|
description?: TranslatedString;
|
||||||
debug?: string;
|
debug?: string;
|
||||||
}
|
}
|
||||||
interface InfoNotification {
|
export interface InfoNotification {
|
||||||
type: "info";
|
type: "info";
|
||||||
title: TranslatedString;
|
title: TranslatedString;
|
||||||
}
|
}
|
||||||
@ -18,30 +18,29 @@ interface InfoNotification {
|
|||||||
const storage = memoryMap<Map<string, NotificationMessage>>();
|
const storage = memoryMap<Map<string, NotificationMessage>>();
|
||||||
const NOTIFICATION_KEY = "notification";
|
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(
|
export function notifyError(
|
||||||
title: TranslatedString,
|
title: TranslatedString,
|
||||||
description: TranslatedString | undefined,
|
description: TranslatedString | undefined,
|
||||||
debug?: any,
|
debug?: any,
|
||||||
) {
|
) {
|
||||||
const currentState: Map<string, NotificationMessage> =
|
notify({
|
||||||
storage.get(NOTIFICATION_KEY) ?? new Map();
|
|
||||||
|
|
||||||
const notif = {
|
|
||||||
type: "error" as const,
|
type: "error" as const,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
debug,
|
debug,
|
||||||
};
|
});
|
||||||
const newState = currentState.set(hash(notif), notif);
|
|
||||||
storage.set(NOTIFICATION_KEY, newState);
|
|
||||||
}
|
}
|
||||||
export function notifyInfo(title: TranslatedString) {
|
export function notifyInfo(title: TranslatedString) {
|
||||||
const currentState: Map<string, NotificationMessage> =
|
notify({
|
||||||
storage.get(NOTIFICATION_KEY) ?? new Map();
|
type: "info" as const,
|
||||||
|
title,
|
||||||
const notif = { type: "info" as const, title };
|
});
|
||||||
const newState = currentState.set(hash(notif), notif);
|
|
||||||
storage.set(NOTIFICATION_KEY, newState);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Notification = {
|
type Notification = {
|
||||||
|
@ -5,4 +5,5 @@ export * from "./utils/http-impl.sw.js";
|
|||||||
export * from "./utils/observable.js";
|
export * from "./utils/observable.js";
|
||||||
export * from "./context/index.js";
|
export * from "./context/index.js";
|
||||||
export * from "./components/index.js";
|
export * from "./components/index.js";
|
||||||
|
export * from "./forms/index.js";
|
||||||
export { renderStories, parseGroupImport } from "./stories.js";
|
export { renderStories, parseGroupImport } from "./stories.js";
|
||||||
|
Loading…
Reference in New Issue
Block a user