diff options
Diffstat (limited to 'packages')
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"; |