diff options
Diffstat (limited to 'packages/merchant-backoffice-ui/src/components/form')
24 files changed, 2461 insertions, 0 deletions
diff --git a/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx b/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx new file mode 100644 index 000000000..aef410ce7 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/FormProvider.tsx @@ -0,0 +1,81 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useMemo } from "preact/hooks"; + +type Updater<S> = (value: ((prevState: S) => S) ) => void; + +export interface Props<T> { + object?: Partial<T>; + errors?: FormErrors<T>; + name?: string; + valueHandler: Updater<Partial<T>> | null; + children: ComponentChildren +} + +const noUpdater: Updater<Partial<unknown>> = () => (s: unknown) => s + +export function FormProvider<T>({ object = {}, errors = {}, name = '', valueHandler, children }: Props<T>): VNode { + const initialObject = useMemo(() => object, []); + const value = useMemo<FormType<T>>(() => ({ errors, object, initialObject, valueHandler: valueHandler ? valueHandler : noUpdater, name, toStr: {}, fromStr: {} }), [errors, object, valueHandler]); + + return <FormContext.Provider value={value}> + <form class="field" onSubmit={(e) => { + e.preventDefault(); + // if (valueHandler) valueHandler(object); + }}> + {children} + </form> + </FormContext.Provider>; +} + +export interface FormType<T> { + object: Partial<T>; + initialObject: Partial<T>; + errors: FormErrors<T>; + toStr: FormtoStr<T>; + name: string; + fromStr: FormfromStr<T>; + valueHandler: Updater<Partial<T>>; +} + +const FormContext = createContext<FormType<unknown>>(null!) + +export function useFormContext<T>() { + return useContext<FormType<T>>(FormContext) +} + +export type FormErrors<T> = { + [P in keyof T]?: string | FormErrors<T[P]> +} + +export type FormtoStr<T> = { + [P in keyof T]?: ((f?: T[P]) => string) +} + +export type FormfromStr<T> = { + [P in keyof T]?: ((f: string) => T[P]) +} + +export type FormUpdater<T> = { + [P in keyof T]?: (f: keyof T) => (v: T[P]) => void +} diff --git a/packages/merchant-backoffice-ui/src/components/form/Input.tsx b/packages/merchant-backoffice-ui/src/components/form/Input.tsx new file mode 100644 index 000000000..9a9691e9b --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/Input.tsx @@ -0,0 +1,71 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { ComponentChildren, h, VNode } from "preact"; +import { useField, InputProps } from "./useField"; + +interface Props<T> extends InputProps<T> { + inputType?: 'text' | 'number' | 'multiline' | 'password'; + expand?: boolean; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; + inputExtra?: any, + side?: ComponentChildren; + children?: ComponentChildren; +} + +const defaultToString = (f?: any): string => f || '' +const defaultFromString = (v: string): any => v as any + +const TextInput = ({ inputType, error, ...rest }: any) => inputType === 'multiline' ? + <textarea {...rest} class={error ? "textarea is-danger" : "textarea"} rows="3" /> : + <input {...rest} class={error ? "input is-danger" : "input"} type={inputType} />; + +export function Input<T>({ name, readonly, placeholder, tooltip, label, expand, help, children, inputType, inputExtra, side, fromStr = defaultFromString, toStr = defaultToString }: Props<keyof T>): VNode { + const { error, value, onChange, required } = useField<T>(name); + return <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class={expand ? "control is-expanded has-icons-right" : "control has-icons-right"}> + <TextInput error={error} {...inputExtra} + inputType={inputType} + placeholder={placeholder} readonly={readonly} + name={String(name)} value={toStr(value)} + onChange={(e: h.JSX.TargetedEvent<HTMLInputElement>): void => onChange(fromStr(e.currentTarget.value))} /> + {help} + {children} + { required && <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> } + </p> + {error && <p class="help is-danger">{error}</p>} + </div> + {side} + </div> + </div>; +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx b/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx new file mode 100644 index 000000000..984c6dc49 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputArray.tsx @@ -0,0 +1,97 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Translate, useTranslator } from "../../i18n"; +import { InputProps, useField } from "./useField"; + +export interface Props<T> extends InputProps<T> { + isValid?: (e: any) => boolean; + addonBefore?: string; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; +} + +const defaultToString = (f?: any): string => f || '' +const defaultFromString = (v: string): any => v as any + +export function InputArray<T>({ name, readonly, placeholder, tooltip, label, help, addonBefore, isValid = () => true, fromStr = defaultFromString, toStr = defaultToString }: Props<keyof T>): VNode { + const { error: formError, value, onChange, required } = useField<T>(name); + const [localError, setLocalError] = useState<string | null>(null) + + const error = localError || formError + + const array: any[] = (value ? value! : []) as any; + const [currentValue, setCurrentValue] = useState(''); + const i18n = useTranslator(); + + return <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + {addonBefore && <div class="control"> + <a class="button is-static">{addonBefore}</a> + </div>} + <p class="control is-expanded has-icons-right"> + <input class={error ? "input is-danger" : "input"} type="text" + placeholder={placeholder} readonly={readonly} disabled={readonly} + name={String(name)} value={currentValue} + onChange={(e): void => setCurrentValue(e.currentTarget.value)} /> + {required && <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span>} + </p> + <p class="control"> + <button class="button is-info has-tooltip-left" disabled={!currentValue} onClick={(): void => { + const v = fromStr(currentValue) + if (!isValid(v)) { + setLocalError(i18n`The value ${v} is invalid for a payment url`) + return; + } + setLocalError(null) + onChange([v, ...array] as any); + setCurrentValue(''); + }} data-tooltip={i18n`add element to the list`}><Translate>add</Translate></button> + </p> + </div> + {help} + {error && <p class="help is-danger"> {error} </p>} + {array.map((v, i) => <div key={i} class="tags has-addons mt-3 mb-0"> + <span class="tag is-medium is-info mb-0" style={{ maxWidth: '90%' }}>{v}</span> + <a class="tag is-medium is-danger is-delete mb-0" onClick={() => { + onChange(array.filter(f => f !== v) as any); + setCurrentValue(toStr(v)); + }} /> + </div> + )} + </div> + + </div> + </div>; +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx b/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx new file mode 100644 index 000000000..2771fe483 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputBoolean.tsx @@ -0,0 +1,72 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { InputProps, useField } from "./useField"; + +interface Props<T> extends InputProps<T> { + name: T; + readonly?: boolean; + expand?: boolean; + threeState?: boolean; + toBoolean?: (v?: any) => boolean | undefined; + fromBoolean?: (s: boolean | undefined) => any; +} + +const defaultToBoolean = (f?: any): boolean | undefined => f || '' +const defaultFromBoolean = (v: boolean | undefined): any => v as any + + +export function InputBoolean<T>({ name, readonly, placeholder, tooltip, label, help, threeState, expand, fromBoolean = defaultFromBoolean, toBoolean = defaultToBoolean }: Props<keyof T>): VNode { + const { error, value, onChange } = useField<T>(name); + + const onCheckboxClick = (): void => { + const c = toBoolean(value) + if (c === false && threeState) return onChange(undefined as any) + return onChange(fromBoolean(!c)) + } + + return <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class={expand ? "control is-expanded" : "control"}> + <label class="b-checkbox checkbox"> + <input type="checkbox" class={toBoolean(value) === undefined ? "is-indeterminate" : ""} + checked={toBoolean(value)} + placeholder={placeholder} readonly={readonly} + name={String(name)} disabled={readonly} + onChange={onCheckboxClick} /> + <span class="check" /> + </label> + {help} + </p> + {error && <p class="help is-danger">{error}</p>} + </div> + </div> + </div>; +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx new file mode 100644 index 000000000..d3a46f483 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputCurrency.tsx @@ -0,0 +1,47 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { ComponentChildren, h } from "preact"; +import { useConfigContext } from "../../context/config"; +import { Amount } from "../../declaration"; +import { InputWithAddon } from "./InputWithAddon"; +import { InputProps } from "./useField"; + +export interface Props<T> extends InputProps<T> { + expand?: boolean; + addonAfter?: ComponentChildren; + children?: ComponentChildren; + side?: ComponentChildren; +} + +export function InputCurrency<T>({ name, readonly, label, placeholder, help, tooltip, expand, addonAfter, children, side }: Props<keyof T>) { + const config = useConfigContext() + return <InputWithAddon<T> name={name} readonly={readonly} addonBefore={config.currency} + side={side} + label={label} placeholder={placeholder} help={help} tooltip={tooltip} + addonAfter={addonAfter} + inputType='number' expand={expand} + toStr={(v?: Amount) => v?.split(':')[1] || ''} + fromStr={(v: string) => !v ? '' : `${config.currency}:${v}`} + inputExtra={{ min: 0 }} + children={children} + /> +} + diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx new file mode 100644 index 000000000..77199527f --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx @@ -0,0 +1,159 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { format } from "date-fns"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Translate, useTranslator } from "../../i18n"; +import { DatePicker } from "../picker/DatePicker"; +import { InputProps, useField } from "./useField"; + +export interface Props<T> extends InputProps<T> { + readonly?: boolean; + expand?: boolean; + //FIXME: create separated components InputDate and InputTimestamp + withTimestampSupport?: boolean; +} + +export function InputDate<T>({ + name, + readonly, + label, + placeholder, + help, + tooltip, + expand, + withTimestampSupport, +}: Props<keyof T>): VNode { + const [opened, setOpened] = useState(false); + const i18n = useTranslator(); + + const { error, required, value, onChange } = useField<T>(name); + + let strValue = ""; + if (!value) { + strValue = withTimestampSupport ? "unknown" : ""; + } else if (value instanceof Date) { + strValue = format(value, "yyyy/MM/dd"); + } else if (value.t_s) { + strValue = + value.t_s === "never" + ? withTimestampSupport + ? "never" + : "" + : format(new Date(value.t_s * 1000), "yyyy/MM/dd"); + } + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + <p + class={ + expand + ? "control is-expanded has-icons-right" + : "control has-icons-right" + } + > + <input + class="input" + type="text" + readonly + value={strValue} + placeholder={placeholder} + onClick={() => { + if (!readonly) setOpened(true); + }} + /> + {required && ( + <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> + )} + {help} + </p> + <div + class="control" + onClick={() => { + if (!readonly) setOpened(true); + }} + > + <a class="button is-static"> + <span class="icon"> + <i class="mdi mdi-calendar" /> + </span> + </a> + </div> + </div> + {error && <p class="help is-danger">{error}</p>} + </div> + + {!readonly && ( + <span + data-tooltip={ + withTimestampSupport + ? i18n`change value to unknown date` + : i18n`change value to empty` + } + > + <button + class="button is-info mr-3" + onClick={() => onChange(undefined as any)} + > + <Translate>clear</Translate> + </button> + </span> + )} + {withTimestampSupport && ( + <span data-tooltip={i18n`change value to never`}> + <button + class="button is-info" + onClick={() => onChange({ t_s: "never" } as any)} + > + <Translate>never</Translate> + </button> + </span> + )} + </div> + <DatePicker + opened={opened} + closeFunction={() => setOpened(false)} + dateReceiver={(d) => { + if (withTimestampSupport) { + onChange({ t_s: d.getTime() / 1000 } as any); + } else { + onChange(d as any); + } + }} + /> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx new file mode 100644 index 000000000..d5c208e25 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputDuration.tsx @@ -0,0 +1,172 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { intervalToDuration, formatDuration } from "date-fns"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Translate, useTranslator } from "../../i18n"; +import { SimpleModal } from "../modal"; +import { DurationPicker } from "../picker/DurationPicker"; +import { InputProps, useField } from "./useField"; + +export interface Props<T> extends InputProps<T> { + expand?: boolean; + readonly?: boolean; + withForever?: boolean; +} + +export function InputDuration<T>({ + name, + expand, + placeholder, + tooltip, + label, + help, + readonly, + withForever, +}: Props<keyof T>): VNode { + const [opened, setOpened] = useState(false); + const i18n = useTranslator(); + + const { error, required, value, onChange } = useField<T>(name); + let strValue = ""; + if (!value) { + strValue = ""; + } else if (value.d_us === "forever") { + strValue = i18n`forever`; + } else { + strValue = formatDuration( + intervalToDuration({ start: 0, end: value.d_us / 1000 }), + { + locale: { + formatDistance: (name, value) => { + switch (name) { + case "xMonths": + return i18n`${value}M`; + case "xYears": + return i18n`${value}Y`; + case "xDays": + return i18n`${value}d`; + case "xHours": + return i18n`${value}h`; + case "xMinutes": + return i18n`${value}min`; + case "xSeconds": + return i18n`${value}sec`; + } + }, + localize: { + day: () => "s", + month: () => "m", + ordinalNumber: () => "th", + dayPeriod: () => "p", + quarter: () => "w", + era: () => "e", + }, + }, + } + ); + } + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + <p class={expand ? "control is-expanded " : "control "}> + <input + class="input" + type="text" + readonly + value={strValue} + placeholder={placeholder} + onClick={() => { + if (!readonly) setOpened(true); + }} + /> + {required && ( + <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span> + )} + {help} + </p> + <div + class="control" + onClick={() => { + if (!readonly) setOpened(true); + }} + > + <a class="button is-static"> + <span class="icon"> + <i class="mdi mdi-clock" /> + </span> + </a> + </div> + </div> + {error && <p class="help is-danger">{error}</p>} + </div> + {withForever && ( + <span data-tooltip={i18n`change value to never`}> + <button + class="button is-info mr-3" + onClick={() => onChange({ d_us: "forever" } as any)} + > + <Translate>forever</Translate> + </button> + </span> + )} + {!readonly && ( + <span data-tooltip={i18n`change value to empty`}> + <button + class="button is-info " + onClick={() => onChange(undefined as any)} + > + <Translate>clear</Translate> + </button> + </span> + )} + </div> + {opened && ( + <SimpleModal onCancel={() => setOpened(false)}> + <DurationPicker + days + hours + minutes + value={!value || value.d_us === "forever" ? 0 : value.d_us} + onChange={(v) => { + onChange({ d_us: v } as any); + }} + /> + </SimpleModal> + )} + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx b/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx new file mode 100644 index 000000000..8af9c7d96 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputGroup.tsx @@ -0,0 +1,66 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { ComponentChildren, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useGroupField } from "./useGroupField"; + +export interface Props<T> { + name: T; + children: ComponentChildren; + label: ComponentChildren; + tooltip?: ComponentChildren; + alternative?: ComponentChildren; + fixed?: boolean; + initialActive?: boolean; +} + +export function InputGroup<T>({ name, label, children, tooltip, alternative, fixed, initialActive }: Props<keyof T>): VNode { + const [active, setActive] = useState(initialActive || fixed); + const group = useGroupField<T>(name); + + return <div class="card"> + <header class="card-header"> + <p class="card-header-title"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + {group?.hasError && <span class="icon has-text-danger" data-tooltip={tooltip}> + <i class="mdi mdi-alert" /> + </span>} + </p> + { !fixed && <button class="card-header-icon" aria-label="more options" onClick={(): void => setActive(!active)}> + <span class="icon"> + {active ? + <i class="mdi mdi-arrow-up" /> : + <i class="mdi mdi-arrow-down" />} + </span> + </button> } + </header> + {active ? <div class="card-content"> + {children} + </div> : ( + alternative ? <div class="card-content"> + {alternative} + </div> : undefined + )} + </div>; +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx b/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx new file mode 100644 index 000000000..6cc9b9dcc --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputImage.tsx @@ -0,0 +1,95 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { ComponentChildren, h, VNode } from "preact"; +import { useRef, useState } from "preact/hooks"; +import emptyImage from "../../assets/empty.png"; +import { Translate } from "../../i18n"; +import { MAX_IMAGE_SIZE as MAX_IMAGE_UPLOAD_SIZE } from "../../utils/constants"; +import { InputProps, useField } from "./useField"; + +export interface Props<T> extends InputProps<T> { + expand?: boolean; + addonAfter?: ComponentChildren; + children?: ComponentChildren; +} + +export function InputImage<T>({ name, readonly, placeholder, tooltip, label, help, children, expand }: Props<keyof T>): VNode { + const { error, value, onChange } = useField<T>(name); + + const image = useRef<HTMLInputElement>(null) + + const [sizeError, setSizeError] = useState(false) + + return <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class={expand ? "control is-expanded" : "control"}> + {value && + <img src={value} style={{ width: 200, height: 200 }} onClick={() => image.current?.click()} /> + } + <input + ref={image} style={{ display: 'none' }} + type="file" name={String(name)} + placeholder={placeholder} readonly={readonly} + onChange={e => { + const f: FileList | null = e.currentTarget.files + if (!f || f.length != 1) { + return onChange(undefined!) + } + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true) + return onChange(undefined!) + } + setSizeError(false) + return f[0].arrayBuffer().then(b => { + const b64 = btoa( + new Uint8Array(b) + .reduce((data, byte) => data + String.fromCharCode(byte), '') + ) + return onChange(`data:${f[0].type};base64,${b64}` as any) + }) + }} /> + {help} + {children} + </p> + {error && <p class="help is-danger">{error}</p>} + {sizeError && <p class="help is-danger"> + <Translate>Image should be smaller than 1 MB</Translate> + </p>} + {!value && + <button class="button" onClick={() => image.current?.click()} ><Translate>Add</Translate></button> + } + {value && + <button class="button" onClick={() => onChange(undefined!)} ><Translate>Remove</Translate></button> + } + </div> + </div> + </div> +} + diff --git a/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx b/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx new file mode 100644 index 000000000..12755f47a --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputLocation.tsx @@ -0,0 +1,43 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { Fragment, h } from "preact"; +import { useTranslator } from "../../i18n"; +import { Input } from "./Input"; + +export function InputLocation({name}:{name:string}) { + const i18n = useTranslator() + return <> + <Input name={`${name}.country`} label={i18n`Country`} /> + <Input name={`${name}.address_lines`} inputType="multiline" + label={i18n`Address`} + toStr={(v: string[] | undefined) => !v ? '' : v.join('\n')} + fromStr={(v: string) => v.split('\n')} + /> + <Input name={`${name}.building_number`} label={i18n`Building number`} /> + <Input name={`${name}.building_name`} label={i18n`Building name`} /> + <Input name={`${name}.street`} label={i18n`Street`} /> + <Input name={`${name}.post_code`} label={i18n`Post code`} /> + <Input name={`${name}.town_location`} label={i18n`Town location`} /> + <Input name={`${name}.town`} label={i18n`Town`} /> + <Input name={`${name}.district`} label={i18n`District`} /> + <Input name={`${name}.country_subdivision`} label={i18n`Country subdivision`} /> + </> +}
\ No newline at end of file diff --git a/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx b/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx new file mode 100644 index 000000000..046cda59e --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputNumber.tsx @@ -0,0 +1,42 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { ComponentChildren, h } from "preact"; +import { InputWithAddon } from "./InputWithAddon"; +import { InputProps } from "./useField"; + +export interface Props<T> extends InputProps<T> { + readonly?: boolean; + expand?: boolean; + side?: ComponentChildren; + children?: ComponentChildren; +} + +export function InputNumber<T>({ name, readonly, placeholder, tooltip, label, help, expand, children, side }: Props<keyof T>) { + return <InputWithAddon<T> name={name} readonly={readonly} + fromStr={(v) => !v ? undefined : parseInt(v, 10) } toStr={(v) => `${v}`} + inputType='number' expand={expand} + label={label} placeholder={placeholder} help={help} tooltip={tooltip} + inputExtra={{ min: 0 }} + children={children} + side={side} + /> +} + diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx new file mode 100644 index 000000000..44252317e --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputPayto.tsx @@ -0,0 +1,39 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { InputArray } from "./InputArray"; +import { PAYTO_REGEX } from "../../utils/constants"; +import { InputProps } from "./useField"; + +export type Props<T> = InputProps<T>; + +const PAYTO_START_REGEX = /^payto:\/\// + +export function InputPayto<T>({ name, readonly, placeholder, tooltip, label, help }: Props<keyof T>): VNode { + return <InputArray<T> name={name} readonly={readonly} + addonBefore="payto://" + label={label} placeholder={placeholder} help={help} tooltip={tooltip} + isValid={(v) => v && PAYTO_REGEX.test(v) } + toStr={(v?: string) => !v ? '': v.replace(PAYTO_START_REGEX, '')} + fromStr={(v: string) => `payto://${v}` } + /> +} + diff --git a/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx new file mode 100644 index 000000000..9cfef07cf --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputPaytoForm.tsx @@ -0,0 +1,392 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode, Fragment } from "preact"; +import { useCallback, useState } from "preact/hooks"; +import { Translate, Translator, useTranslator } from "../../i18n"; +import { COUNTRY_TABLE } from "../../utils/constants"; +import { FormErrors, FormProvider } from "./FormProvider"; +import { Input } from "./Input"; +import { InputGroup } from "./InputGroup"; +import { InputSelector } from "./InputSelector"; +import { InputProps, useField } from "./useField"; + +export interface Props<T> extends InputProps<T> { + isValid?: (e: any) => boolean; +} + +// https://datatracker.ietf.org/doc/html/rfc8905 +type Entity = { + // iban, bitcoin, x-taler-bank. it defined the format + target: string; + // path1 if the first field to be used + path1: string; + // path2 if the second field to be used, optional + path2?: string; + // options of the payto uri + options: { + "receiver-name"?: string; + sender?: string; + message?: string; + amount?: string; + instruction?: string; + [name: string]: string | undefined; + }; +}; + +function isEthereumAddress(address: string) { + if (!/^(0x)?[0-9a-f]{40}$/i.test(address)) { + return false; + } else if ( + /^(0x|0X)?[0-9a-f]{40}$/.test(address) || + /^(0x|0X)?[0-9A-F]{40}$/.test(address) + ) { + return true; + } + return checkAddressChecksum(address); +} + +function checkAddressChecksum(address: string) { + //TODO implement ethereum checksum + return true; +} + +function validateBitcoin(addr: string, i18n: Translator): string | undefined { + try { + const valid = /^(bc1|[13])[a-zA-HJ-NP-Z0-9]{25,39}$/.test(addr); + if (valid) return undefined; + } catch (e) { + console.log(e); + } + return i18n`This is not a valid bitcoin address.`; +} + +function validateEthereum(addr: string, i18n: Translator): string | undefined { + try { + const valid = isEthereumAddress(addr); + if (valid) return undefined; + } catch (e) { + console.log(e); + } + return i18n`This is not a valid Ethereum address.`; +} + +/** + * An IBAN is validated by converting it into an integer and performing a + * basic mod-97 operation (as described in ISO 7064) on it. + * If the IBAN is valid, the remainder equals 1. + * + * The algorithm of IBAN validation is as follows: + * 1.- Check that the total IBAN length is correct as per the country. If not, the IBAN is invalid + * 2.- Move the four initial characters to the end of the string + * 3.- Replace each letter in the string with two digits, thereby expanding the string, where A = 10, B = 11, ..., Z = 35 + * 4.- Interpret the string as a decimal integer and compute the remainder of that number on division by 97 + * + * If the remainder is 1, the check digit test is passed and the IBAN might be valid. + * + */ +function validateIBAN(iban: string, i18n: Translator): string | undefined { + // Check total length + if (iban.length < 4) + return i18n`IBAN numbers usually have more that 4 digits`; + if (iban.length > 34) + return i18n`IBAN numbers usually have less that 34 digits`; + + const A_code = "A".charCodeAt(0); + const Z_code = "Z".charCodeAt(0); + const IBAN = iban.toUpperCase(); + // check supported country + const code = IBAN.substr(0, 2); + const found = code in COUNTRY_TABLE; + if (!found) return i18n`IBAN country code not found`; + + // 2.- Move the four initial characters to the end of the string + const step2 = IBAN.substr(4) + iban.substr(0, 4); + const step3 = Array.from(step2) + .map((letter) => { + const code = letter.charCodeAt(0); + if (code < A_code || code > Z_code) return letter; + return `${letter.charCodeAt(0) - "A".charCodeAt(0) + 10}`; + }) + .join(""); + + function calculate_iban_checksum(str: string): number { + const numberStr = str.substr(0, 5); + const rest = str.substr(5); + const number = parseInt(numberStr, 10); + const result = number % 97; + if (rest.length > 0) { + return calculate_iban_checksum(`${result}${rest}`); + } + return result; + } + + const checksum = calculate_iban_checksum(step3); + if (checksum !== 1) return i18n`IBAN number is not valid, checksum is wrong`; + return undefined; +} + +// const targets = ['ach', 'bic', 'iban', 'upi', 'bitcoin', 'ilp', 'void', 'x-taler-bank'] +const targets = [ + "Choose one...", + "iban", + "x-taler-bank", + "bitcoin", + "ethereum", +]; +const noTargetValue = targets[0]; +const defaultTarget = { target: noTargetValue, options: {} }; + +function undefinedIfEmpty<T>(obj: T): T | undefined { + return Object.keys(obj).some((k) => (obj as any)[k] !== undefined) + ? obj + : undefined; +} + +export function InputPaytoForm<T>({ + name, + readonly, + label, + tooltip, +}: Props<keyof T>): VNode { + const { value: paytos, onChange } = useField<T>(name); + + const [value, valueHandler] = useState<Partial<Entity>>(defaultTarget); + + let payToPath; + if (value.target === "iban" && value.path1) { + payToPath = `/${value.path1.toUpperCase()}`; + } else if (value.path1) { + if (value.path2) { + payToPath = `/${value.path1}/${value.path2}`; + } else { + payToPath = `/${value.path1}`; + } + } + const i18n = useTranslator(); + + const ops = value.options!; + const url = tryUrl(`payto://${value.target}${payToPath}`); + if (url) { + Object.keys(ops).forEach((opt_key) => { + const opt_value = ops[opt_key]; + if (opt_value) url.searchParams.set(opt_key, opt_value); + }); + } + const paytoURL = !url ? "" : url.toString(); + + const errors: FormErrors<Entity> = { + target: value.target === noTargetValue ? i18n`required` : undefined, + path1: !value.path1 + ? i18n`required` + : value.target === "iban" + ? validateIBAN(value.path1, i18n) + : value.target === "bitcoin" + ? validateBitcoin(value.path1, i18n) + : value.target === "ethereum" + ? validateEthereum(value.path1, i18n) + : undefined, + path2: + value.target === "x-taler-bank" + ? !value.path2 + ? i18n`required` + : undefined + : undefined, + options: undefinedIfEmpty({ + "receiver-name": !value.options?.["receiver-name"] + ? i18n`required` + : undefined, + }), + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined + ); + + const submit = useCallback((): void => { + const alreadyExists = + paytos.findIndex((x: string) => x === paytoURL) !== -1; + if (!alreadyExists) { + onChange([paytoURL, ...paytos] as any); + } + valueHandler(defaultTarget); + }, [value]); + + //FIXME: translating plural singular + return ( + <InputGroup name="payto" label={label} fixed tooltip={tooltip}> + <FormProvider<Entity> + name="tax" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <InputSelector<Entity> + name="target" + label={i18n`Target type`} + tooltip={i18n`Method to use for wire transfer`} + values={targets} + toStr={(v) => (v === noTargetValue ? i18n`Choose one...` : v)} + /> + + {value.target === "ach" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Routing`} + tooltip={i18n`Routing number.`} + /> + <Input<Entity> + name="path2" + label={i18n`Account`} + tooltip={i18n`Account number.`} + /> + </Fragment> + )} + {value.target === "bic" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Code`} + tooltip={i18n`Business Identifier Code.`} + /> + </Fragment> + )} + {value.target === "iban" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Account`} + tooltip={i18n`Bank Account Number.`} + inputExtra={{ style: { textTransform: "uppercase" } }} + /> + </Fragment> + )} + {value.target === "upi" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Account`} + tooltip={i18n`Unified Payment Interface.`} + /> + </Fragment> + )} + {value.target === "bitcoin" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Address`} + tooltip={i18n`Bitcoin protocol.`} + /> + </Fragment> + )} + {value.target === "ethereum" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Address`} + tooltip={i18n`Ethereum protocol.`} + /> + </Fragment> + )} + {value.target === "ilp" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Address`} + tooltip={i18n`Interledger protocol.`} + /> + </Fragment> + )} + {value.target === "void" && <Fragment />} + {value.target === "x-taler-bank" && ( + <Fragment> + <Input<Entity> + name="path1" + label={i18n`Host`} + tooltip={i18n`Bank host.`} + /> + <Input<Entity> + name="path2" + label={i18n`Account`} + tooltip={i18n`Bank account.`} + /> + </Fragment> + )} + + {value.target !== noTargetValue && ( + <Input + name="options.receiver-name" + label={i18n`Name`} + tooltip={i18n`Bank account owner's name.`} + /> + )} + + <div class="field is-horizontal"> + <div class="field-label is-normal" /> + <div class="field-body" style={{ display: "block" }}> + {paytos.map((v: any, i: number) => ( + <div + key={i} + class="tags has-addons mt-3 mb-0 mr-3" + style={{ flexWrap: "nowrap" }} + > + <span + class="tag is-medium is-info mb-0" + style={{ maxWidth: "90%" }} + > + {v} + </span> + <a + class="tag is-medium is-danger is-delete mb-0" + onClick={() => { + onChange(paytos.filter((f: any) => f !== v) as any); + }} + /> + </div> + ))} + {!paytos.length && i18n`No accounts yet.`} + </div> + </div> + + {value.target !== noTargetValue && ( + <div class="buttons is-right mt-5"> + <button + class="button is-info" + data-tooltip={i18n`add tax to the tax list`} + disabled={hasErrors} + onClick={submit} + > + <Translate>Add</Translate> + </button> + </div> + )} + </FormProvider> + </InputGroup> + ); +} + +function tryUrl(s: string): URL | undefined { + try { + return new URL(s); + } catch (e) { + return undefined; + } +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSearchProduct.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSearchProduct.tsx new file mode 100644 index 000000000..51f84fd12 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputSearchProduct.tsx @@ -0,0 +1,139 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import emptyImage from "../../assets/empty.png"; +import { MerchantBackend, WithId } from "../../declaration"; +import { useInstanceProducts } from "../../hooks/product"; +import { Translate, useTranslator } from "../../i18n"; +import { FormErrors, FormProvider } from "./FormProvider"; +import { InputWithAddon } from "./InputWithAddon"; + +type Entity = MerchantBackend.Products.ProductDetail & WithId + +export interface Props { + selected?: Entity; + onChange: (p?: Entity) => void; + products: (MerchantBackend.Products.ProductDetail & WithId)[], +} + +interface ProductSearch { + name: string; +} + +export function InputSearchProduct({ selected, onChange, products }: Props): VNode { + const [prodForm, setProdName] = useState<Partial<ProductSearch>>({ name: '' }) + + const errors: FormErrors<ProductSearch> = { + name: undefined + } + const i18n = useTranslator() + + + if (selected) { + return <article class="media"> + <figure class="media-left"> + <p class="image is-128x128"> + <img src={selected.image ? selected.image : emptyImage} /> + </p> + </figure> + <div class="media-content"> + <div class="content"> + <p class="media-meta"><Translate>Product id</Translate>: <b>{selected.id}</b></p> + <p><Translate>Description</Translate>: {selected.description}</p> + <div class="buttons is-right mt-5"> + <button class="button is-info" onClick={() => onChange(undefined)}>clear</button> + </div> + </div> + </div> + </article> + } + + return <FormProvider<ProductSearch> errors={errors} object={prodForm} valueHandler={setProdName} > + + <InputWithAddon<ProductSearch> + name="name" + label={i18n`Product`} + tooltip={i18n`search products by it's description or id`} + addonAfter={<span class="icon" ><i class="mdi mdi-magnify" /></span>} + > + <div> + <ProductList + name={prodForm.name} + list={products} + onSelect={(p) => { + setProdName({ name: '' }) + onChange(p) + }} + /> + </div> + </InputWithAddon> + + </FormProvider> + +} + +interface ProductListProps { + name?: string; + onSelect: (p: MerchantBackend.Products.ProductDetail & WithId) => void; + list: (MerchantBackend.Products.ProductDetail & WithId)[] +} + +function ProductList({ name, onSelect, list }: ProductListProps) { + if (!name) { + /* FIXME + this BR is added to occupy the space that will be added when the + dropdown appears + */ + return <div ><br /></div> + } + const filtered = list.filter(p => p.id.includes(name) || p.description.includes(name)) + + return <div class="dropdown is-active"> + <div class="dropdown-menu" id="dropdown-menu" role="menu" style={{ minWidth: '20rem' }}> + <div class="dropdown-content"> + {!filtered.length ? + <div class="dropdown-item" > + <Translate>no products found with that description</Translate> + </div> : + filtered.map(p => ( + <div key={p.id} class="dropdown-item" onClick={() => onSelect(p)} style={{ cursor: 'pointer' }}> + <article class="media"> + <div class="media-left"> + <div class="image" style={{ minWidth: 64 }}><img src={p.image ? p.image : emptyImage} style={{ width: 64, height: 64 }} /></div> + </div> + <div class="media-content"> + <div class="content"> + <p> + <strong>{p.id}</strong> <small>{p.price}</small> + <br /> + {p.description} + </p> + </div> + </div> + </article> + </div> + )) + } + </div> + </div> + </div> +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx new file mode 100644 index 000000000..1990eeeae --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputSecured.stories.tsx @@ -0,0 +1,55 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { h, VNode } from 'preact'; +import { useState } from 'preact/hooks'; +import { FormProvider } from "./FormProvider"; +import { InputSecured } from './InputSecured'; + +export default { + title: 'Components/Form/InputSecured', + component: InputSecured, +}; + +type T = { auth_token: string | null } + +export const InitialValueEmpty = (): VNode => { + const [state, setState] = useState<Partial<T>>({ auth_token: '' }) + return <FormProvider<T> object={state} errors={{}} valueHandler={setState}> + Initial value: '' + <InputSecured<T> name="auth_token" label="Access token" /> + </FormProvider> +} + +export const InitialValueToken = (): VNode => { + const [state, setState] = useState<Partial<T>>({ auth_token: 'token' }) + return <FormProvider<T> object={state} errors={{}} valueHandler={setState}> + <InputSecured<T> name="auth_token" label="Access token" /> + </FormProvider> +} + +export const InitialValueNull = (): VNode => { + const [state, setState] = useState<Partial<T>>({ auth_token: null }) + return <FormProvider<T> object={state} errors={{}} valueHandler={setState}> + Initial value: '' + <InputSecured<T> name="auth_token" label="Access token" /> + </FormProvider> +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx new file mode 100644 index 000000000..c9b0f43b9 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputSecured.tsx @@ -0,0 +1,119 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Translate, useTranslator } from "../../i18n"; +import { InputProps, useField } from "./useField"; + +export type Props<T> = InputProps<T>; + +const TokenStatus = ({ prev, post }: any) => { + if ((prev === undefined || prev === null) && (post === undefined || post === null)) + return null + return (prev === post) ? null : ( + post === null ? + <span class="tag is-danger is-align-self-center ml-2"><Translate>Deleting</Translate></span> : + <span class="tag is-warning is-align-self-center ml-2"><Translate>Changing</Translate></span> + ) +} + +export function InputSecured<T>({ name, readonly, placeholder, tooltip, label, help }: Props<keyof T>): VNode { + const { error, value, initial, onChange, toStr, fromStr } = useField<T>(name); + + const [active, setActive] = useState(false); + const [newValue, setNuewValue] = useState("") + + const i18n = useTranslator() + + return <Fragment> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + {!active ? + <Fragment> + <div class="field has-addons"> + <button class="button" + onClick={(): void => { setActive(!active); }} > + <div class="icon is-left"><i class="mdi mdi-lock-reset" /></div> + <span><Translate>Manage access token</Translate></span> + </button> + <TokenStatus prev={initial} post={value} /> + </div> + </Fragment> : + <Fragment> + <div class="field has-addons"> + <div class="control"> + <a class="button is-static">secret-token:</a> + </div> + <div class="control is-expanded"> + <input class="input" type="text" + placeholder={placeholder} readonly={readonly || !active} + disabled={readonly || !active} + name={String(name)} value={newValue} + onInput={(e): void => { + setNuewValue(e.currentTarget.value) + }} /> + {help} + </div> + <div class="control"> + <button class="button is-info" disabled={fromStr(newValue) === value} onClick={(): void => { onChange(fromStr(newValue)); setActive(!active); setNuewValue(""); }} > + <div class="icon is-left"><i class="mdi mdi-lock-outline" /></div> + <span><Translate>Update</Translate></span> + </button> + </div> + </div> + </Fragment> + } + {error ? <p class="help is-danger">{error}</p> : null} + </div> + </div> + {active && + <div class="field is-horizontal"> + <div class="field-body is-flex-grow-3"> + <div class="level" style={{ width: '100%' }}> + <div class="level-right is-flex-grow-1"> + <div class="level-item"> + <button class="button is-danger" disabled={null === value || undefined === value} onClick={(): void => { onChange(null!); setActive(!active); setNuewValue(""); }} > + <div class="icon is-left"><i class="mdi mdi-lock-open-variant" /></div> + <span><Translate>Remove</Translate></span> + </button> + </div> + <div class="level-item"> + <button class="button " onClick={(): void => { onChange(initial!); setActive(!active); setNuewValue(""); }} > + <div class="icon is-left"><i class="mdi mdi-lock-open-variant" /></div> + <span><Translate>Cancel</Translate></span> + </button> + </div> + </div> + + </div> + </div> + </div> + } + </Fragment >; +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx new file mode 100644 index 000000000..86f4de756 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx @@ -0,0 +1,86 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, VNode } from "preact"; +import { InputProps, useField } from "./useField"; + +interface Props<T> extends InputProps<T> { + readonly?: boolean; + expand?: boolean; + values: string[]; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; +} + +const defaultToString = (f?: any): string => f || ""; +const defaultFromString = (v: string): any => v as any; + +export function InputSelector<T>({ + name, + readonly, + expand, + placeholder, + tooltip, + label, + help, + values, + toStr = defaultToString, +}: Props<keyof T>): VNode { + const { error, value, onChange } = useField<T>(name); + + return ( + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class={expand ? "control is-expanded select" : "control select"}> + <select + class={error ? "select is-danger" : "select"} + name={String(name)} + disabled={readonly} + readonly={readonly} + onChange={(e) => { + onChange(e.currentTarget.value as any); + }} + > + {placeholder && <option>{placeholder}</option>} + {values.map((v, i) => ( + <option key={i} value={v} selected={value === v}> + {toStr(v)} + </option> + ))} + </select> + {help} + </p> + {error && <p class="help is-danger">{error}</p>} + </div> + </div> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx b/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx new file mode 100644 index 000000000..63c7e4131 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputStock.stories.tsx @@ -0,0 +1,162 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { addDays } from "date-fns"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { FormProvider } from "./FormProvider"; +import { InputStock, Stock } from "./InputStock"; + +export default { + title: "Components/Form/InputStock", + component: InputStock, +}; + +type T = { stock?: Stock }; + +export const CreateStockEmpty = () => { + const [state, setState] = useState<Partial<T>>({}); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const CreateStockUnknownRestock = () => { + const [state, setState] = useState<Partial<T>>({ + stock: { + current: 10, + lost: 0, + sold: 0, + }, + }); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const CreateStockNoRestock = () => { + const [state, setState] = useState<Partial<T>>({ + stock: { + current: 10, + lost: 0, + sold: 0, + nextRestock: { t_s: "never" }, + }, + }); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const CreateStockWithRestock = () => { + const [state, setState] = useState<Partial<T>>({ + stock: { + current: 15, + lost: 0, + sold: 0, + nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 }, + }, + }); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const UpdatingProductWithManagedStock = () => { + const [state, setState] = useState<Partial<T>>({ + stock: { + current: 100, + lost: 0, + sold: 0, + nextRestock: { t_s: addDays(new Date(), 1).getTime() / 1000 }, + }, + }); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" alreadyExist /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; + +export const UpdatingProductWithInfiniteStock = () => { + const [state, setState] = useState<Partial<T>>({}); + return ( + <FormProvider<T> + name="product" + object={state} + errors={{}} + valueHandler={setState} + > + <InputStock<T> name="stock" label="Stock" alreadyExist /> + <div> + <pre>{JSON.stringify(state, undefined, 2)}</pre> + </div> + </FormProvider> + ); +}; diff --git a/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx b/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx new file mode 100644 index 000000000..158f44192 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputStock.tsx @@ -0,0 +1,171 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { Fragment, h } from "preact"; +import { MerchantBackend, Timestamp } from "../../declaration"; +import { InputProps, useField } from "./useField"; +import { FormProvider, FormErrors } from "./FormProvider"; +import { useLayoutEffect, useState } from "preact/hooks"; +import { Input } from "./Input"; +import { InputGroup } from "./InputGroup"; +import { InputNumber } from "./InputNumber"; +import { InputDate } from "./InputDate"; +import { Translate, useTranslator } from "../../i18n"; +import { InputLocation } from "./InputLocation"; + +export interface Props<T> extends InputProps<T> { + alreadyExist?: boolean; +} + + +type Entity = Stock + +export interface Stock { + current: number; + lost: number; + sold: number; + address?: MerchantBackend.Location; + nextRestock?: Timestamp; +} + +interface StockDelta { + incoming: number; + lost: number; +} + + +export function InputStock<T>({ name, tooltip, label, alreadyExist }: Props<keyof T>) { + const { error, value, onChange } = useField<T>(name); + + const [errors, setErrors] = useState<FormErrors<Entity>>({}) + + const [formValue, valueHandler] = useState<Partial<Entity>>(value) + const [addedStock, setAddedStock] = useState<StockDelta>({ incoming: 0, lost: 0 }) + const i18n = useTranslator() + + + useLayoutEffect(() => { + if (!formValue) { + onChange(undefined as any) + } else { + onChange({ + ...formValue, + current: (formValue?.current || 0) + addedStock.incoming, + lost: (formValue?.lost || 0) + addedStock.lost + } as any) + } + }, [formValue, addedStock]) + + if (!formValue) { + return <Fragment> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field has-addons"> + {!alreadyExist ? + <button class="button" + data-tooltip={i18n`click here to configure the stock of the product, leave it as is and the backend will not control stock`} + onClick={(): void => { valueHandler({ current: 0, lost: 0, sold: 0 } as Stock as any); }} > + <span><Translate>Manage stock</Translate></span> + </button> : <button class="button" + data-tooltip={i18n`this product has been configured without stock control`} + disabled > + <span><Translate>Infinite</Translate></span> + </button> + } + </div> + </div> + </div> + </Fragment > + } + + const currentStock = (formValue.current || 0) - (formValue.lost || 0) - (formValue.sold || 0) + + const stockAddedErrors: FormErrors<typeof addedStock> = { + lost: currentStock + addedStock.incoming < addedStock.lost ? + i18n`lost cannot be greater than current and incoming (max ${currentStock + addedStock.incoming})` + : undefined + } + + // const stockUpdateDescription = stockAddedErrors.lost ? '' : ( + // !!addedStock.incoming || !!addedStock.lost ? + // i18n`current stock will change from ${currentStock} to ${currentStock + addedStock.incoming - addedStock.lost}` : + // i18n`current stock will stay at ${currentStock}` + // ) + + return <Fragment> + <div class="card"> + <header class="card-header"> + <p class="card-header-title"> + {label} + {tooltip && <span class="icon" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </p> + </header> + <div class="card-content"> + <FormProvider<Entity> name="stock" errors={errors} object={formValue} valueHandler={valueHandler}> + {alreadyExist ? <Fragment> + + <FormProvider name="added" errors={stockAddedErrors} object={addedStock} valueHandler={setAddedStock as any}> + <InputNumber name="incoming" label={i18n`Incoming`} /> + <InputNumber name="lost" label={i18n`Lost`} /> + </FormProvider> + + {/* <div class="field is-horizontal"> + <div class="field-label is-normal" /> + <div class="field-body is-flex-grow-3"> + <div class="field"> + {stockUpdateDescription} + </div> + </div> + </div> */} + + </Fragment> : <InputNumber<Entity> name="current" + label={i18n`Current`} + side={ + <button class="button is-danger" + data-tooltip={i18n`remove stock control for this product`} + onClick={(): void => { valueHandler(undefined as any) }} > + <span><Translate>without stock</Translate></span> + </button> + } + />} + + <InputDate<Entity> name="nextRestock" label={i18n`Next restock`} withTimestampSupport /> + + <InputGroup<Entity> name="address" label={i18n`Delivery address`}> + <InputLocation name="address" /> + </InputGroup> + </FormProvider> + </div> + </div> + </Fragment> +} + // ( + + diff --git a/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx b/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx new file mode 100644 index 000000000..507a61242 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputTaxes.tsx @@ -0,0 +1,97 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { useCallback, useState } from "preact/hooks"; +import * as yup from 'yup'; +import { MerchantBackend } from "../../declaration"; +import { Translate, useTranslator } from "../../i18n"; +import { TaxSchema as schema } from '../../schemas'; +import { FormErrors, FormProvider } from "./FormProvider"; +import { Input } from "./Input"; +import { InputGroup } from "./InputGroup"; +import { InputProps, useField } from "./useField"; + +export interface Props<T> extends InputProps<T> { + isValid?: (e: any) => boolean; +} + +type Entity = MerchantBackend.Tax +export function InputTaxes<T>({ name, readonly, label }: Props<keyof T>): VNode { + const { value: taxes, onChange, } = useField<T>(name); + + const [value, valueHandler] = useState<Partial<Entity>>({}) + // const [errors, setErrors] = useState<FormErrors<Entity>>({}) + + let errors: FormErrors<Entity> = {} + + try { + schema.validateSync(value, { abortEarly: false }) + } catch (err) { + if (err instanceof yup.ValidationError) { + const yupErrors = err.inner as yup.ValidationError[] + errors = yupErrors.reduce((prev, cur) => !cur.path ? prev : ({ ...prev, [cur.path]: cur.message }), {}) + } + } + const hasErrors = Object.keys(errors).some(k => (errors as any)[k] !== undefined) + + const submit = useCallback((): void => { + onChange([value as any, ...taxes] as any) + valueHandler({}) + }, [value]) + + const i18n = useTranslator() + + //FIXME: translating plural singular + return ( + <InputGroup name="tax" label={label} alternative={taxes.length > 0 && <p>This product has {taxes.length} applicable taxes configured.</p>}> + <FormProvider<Entity> name="tax" errors={errors} object={value} valueHandler={valueHandler} > + + <div class="field is-horizontal"> + <div class="field-label is-normal" /> + <div class="field-body" style={{ display: 'block' }}> + {taxes.map((v: any, i: number) => <div key={i} class="tags has-addons mt-3 mb-0 mr-3" style={{ flexWrap: 'nowrap' }}> + <span class="tag is-medium is-info mb-0" style={{ maxWidth: '90%' }}><b>{v.tax}</b>: {v.name}</span> + <a class="tag is-medium is-danger is-delete mb-0" onClick={() => { + onChange(taxes.filter((f: any) => f !== v) as any); + valueHandler(v); + }} /> + </div> + )} + {!taxes.length && i18n`No taxes configured for this product.`} + </div> + </div> + + <Input<Entity> name="tax" label={i18n`Amount`} tooltip={i18n`Taxes can be in currencies that differ from the main currency used by the merchant.`}> + <Translate>Enter currency and value separated with a colon, e.g. "USD:2.3".</Translate> + </Input> + + <Input<Entity> name="name" label={i18n`Description`} tooltip={i18n`Legal name of the tax, e.g. VAT or import duties.`} /> + + <div class="buttons is-right mt-5"> + <button class="button is-info" + data-tooltip={i18n`add tax to the tax list`} + disabled={hasErrors} + onClick={submit}><Translate>Add</Translate></button> + </div> + </FormProvider> + </InputGroup> + ) +} diff --git a/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx b/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx new file mode 100644 index 000000000..a16ebc2e9 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/InputWithAddon.tsx @@ -0,0 +1,77 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { ComponentChildren, h, VNode } from "preact"; +import { InputProps, useField } from "./useField"; + +export interface Props<T> extends InputProps<T> { + expand?: boolean; + inputType?: 'text' | 'number'; + addonBefore?: ComponentChildren; + addonAfter?: ComponentChildren; + toStr?: (v?: any) => string; + fromStr?: (s: string) => any; + inputExtra?: any, + children?: ComponentChildren, + side?: ComponentChildren; +} + +const defaultToString = (f?: any): string => f || '' +const defaultFromString = (v: string): any => v as any + +export function InputWithAddon<T>({ name, readonly, addonBefore, children, expand, label, placeholder, help, tooltip, inputType, inputExtra, side, addonAfter, toStr = defaultToString, fromStr = defaultFromString }: Props<keyof T>): VNode { + const { error, value, onChange, required } = useField<T>(name); + + return <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <div class="field has-addons"> + {addonBefore && <div class="control"> + <a class="button is-static">{addonBefore}</a> + </div>} + <p class={`control${expand ? " is-expanded" :""}${required ? " has-icons-right" : ''}`}> + <input {...(inputExtra || {})} class={error ? "input is-danger" : "input"} type={inputType} + placeholder={placeholder} readonly={readonly} + name={String(name)} value={toStr(value)} + onChange={(e): void => onChange(fromStr(e.currentTarget.value))} /> + {required && <span class="icon has-text-danger is-right"> + <i class="mdi mdi-alert" /> + </span>} + {help} + {children} + </p> + {addonAfter && <div class="control"> + <a class="button is-static">{addonAfter}</a> + </div>} + </div> + {error && <p class="help is-danger">{error}</p>} + </div> + {side} + </div> + </div>; +} diff --git a/packages/merchant-backoffice-ui/src/components/form/TextField.tsx b/packages/merchant-backoffice-ui/src/components/form/TextField.tsx new file mode 100644 index 000000000..2579a27b2 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/TextField.tsx @@ -0,0 +1,53 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { ComponentChildren, h, VNode } from "preact"; +import { useField, InputProps } from "./useField"; + +interface Props<T> extends InputProps<T> { + inputType?: 'text' | 'number' | 'multiline' | 'password'; + expand?: boolean; + side?: ComponentChildren; + children: ComponentChildren; +} + +export function TextField<T>({ name, tooltip, label, expand, help, children, side}: Props<keyof T>): VNode { + const { error } = useField<T>(name); + return <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + {label} + {tooltip && <span class="icon has-tooltip-right" data-tooltip={tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class={expand ? "control is-expanded has-icons-right" : "control has-icons-right"}> + {children} + {help} + </p> + {error && <p class="help is-danger">{error}</p>} + </div> + {side} + </div> + </div>; +} diff --git a/packages/merchant-backoffice-ui/src/components/form/useField.tsx b/packages/merchant-backoffice-ui/src/components/form/useField.tsx new file mode 100644 index 000000000..8479d7ad8 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/useField.tsx @@ -0,0 +1,86 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { ComponentChildren, VNode } from "preact"; +import { useFormContext } from "./FormProvider"; + +interface Use<V> { + error?: string; + required: boolean; + value: any; + initial: any; + onChange: (v: V) => void; + toStr: (f: V | undefined) => string; + fromStr: (v: string) => V +} + +export function useField<T>(name: keyof T): Use<T[typeof name]> { + const { errors, object, initialObject, toStr, fromStr, valueHandler } = useFormContext<T>() + type P = typeof name + type V = T[P] + + const updateField = (field: P) => (value: V): void => { + return valueHandler((prev) => { + return setValueDeeper(prev, String(field).split('.'), value) + }) + } + + const defaultToString = ((f?: V): string => String(!f ? '' : f)) + const defaultFromString = ((v: string): V => v as any) + const value = readField(object, String(name)) + const initial = readField(initialObject, String(name)) + const isDirty = value !== initial + const hasError = readField(errors, String(name)) + return { + error: isDirty ? hasError : undefined, + required: !isDirty && hasError, + value, + initial, + onChange: updateField(name) as any, + toStr: toStr[name] ? toStr[name]! : defaultToString, + fromStr: fromStr[name] ? fromStr[name]! : defaultFromString, + } +} +/** + * read the field of an object an support accessing it using '.' + * + * @param object + * @param name + * @returns + */ +const readField = (object: any, name: string) => { + return name.split('.').reduce((prev, current) => prev && prev[current], object) +} + +const setValueDeeper = (object: any, names: string[], value: any): any => { + if (names.length === 0) return value + const [head, ...rest] = names + return { ...object, [head]: setValueDeeper(object[head] || {}, rest, value) } +} + +export interface InputProps<T> { + name: T; + label: ComponentChildren; + placeholder?: string; + tooltip?: ComponentChildren; + readonly?: boolean; + help?: ComponentChildren; +}
\ No newline at end of file diff --git a/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx b/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx new file mode 100644 index 000000000..a73f464a1 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/components/form/useGroupField.tsx @@ -0,0 +1,40 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { useFormContext } from "./FormProvider"; + +interface Use { + hasError?: boolean; +} + +export function useGroupField<T>(name: keyof T): Use { + const f = useFormContext<T>(); + if (!f) + return {}; + + return { + hasError: readField(f.errors, String(name)) + }; +} + +const readField = (object: any, name: string) => { + return name.split('.').reduce((prev, current) => prev && prev[current], object) +} |