taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 38144b1845cfa3f941b3c01c2e38d16456739db0
parent 47e1fd5ee5ee48d38390f004a7d4c057aa0c981e
Author: Florian Dold <florian@dold.me>
Date:   Mon, 24 Mar 2025 21:31:18 +0100

forms: use structure for file uploads, various tweaks

Diffstat:
Mpackages/aml-backoffice-ui/src/pages/Cases.tsx | 2+-
Mpackages/web-util/src/forms/FormProvider.tsx | 8+++++---
Mpackages/web-util/src/forms/fields/InputArray.tsx | 6++++--
Mpackages/web-util/src/forms/fields/InputDuration.tsx | 3+++
Mpackages/web-util/src/forms/fields/InputFile.tsx | 76+++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/web-util/src/forms/fields/InputIsoDate.tsx | 2+-
Mpackages/web-util/src/forms/fields/InputLine.tsx | 4++--
Mpackages/web-util/src/forms/forms-types.ts | 28+++++++++++++++++++++++++++-
Mpackages/web-util/src/forms/forms-ui.tsx | 44+++++++++++++++++++++++++++++++++++++++++++-
Mpackages/web-util/src/forms/forms-utils.ts | 2+-
Mpackages/web-util/src/forms/gana/VQF_902_1_customer.ts | 42+++++++++++++++++++++++++++++++++++++-----
Mpackages/web-util/src/forms/gana/VQF_902_1_officer.ts | 28++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/gana/taler_form_attributes.ts | 24+++++++++++++++++++++---
Mpackages/web-util/src/hooks/useForm.ts | 22++++++++--------------
14 files changed, 226 insertions(+), 65 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -389,7 +389,7 @@ function JumpByIdForm({ label={i18n.str`Only investigated`} handler={{ name: "inv", - onChange: onTog, + onChange: (x) => onTog(x ?? false), value: fitered, computedProperties: {}, }} diff --git a/packages/web-util/src/forms/FormProvider.tsx b/packages/web-util/src/forms/FormProvider.tsx @@ -1,5 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { ComponentChildren, VNode } from "preact"; +import { UIFormElementConfig } from "./forms-types.js"; /** * Properties for form elements set at design type. @@ -58,19 +59,20 @@ export interface UIFormProps<ValType> { */ converter?: StringConverter<ValType>; - handler?: UIFieldHandler; + handler?: UIFieldHandler<ValType>; } export type UIFieldHandler<T = any> = { name: string; value: T | undefined; - onChange: (s: T) => void; + onChange: (s: T | undefined) => void; error?: TranslatedString; /** * Root result of the form. */ - parentRef?: any; + formRootResult?: any; + computedProperties: { hidden?: boolean; }; diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx @@ -113,7 +113,8 @@ export function InputArray( props.handler ?? noHandlerPropsAndNoContextForField(props.name); const [dirty, setDirty] = useState<boolean>(); // FIXME: dirty state should come from handler - const list = (value ?? []) as Array<Record<string, string | undefined>>; + //@ts-ignore + const list = (value ?? []) as Array<Record<string, string>>; const [selectedIndex, setSelectedIndex] = useState<number | undefined>( undefined, ); @@ -147,7 +148,8 @@ export function InputArray( <div class="-space-y-px rounded-md bg-white "> {list.map((v, idx) => { const labelValue = - getValueFromPath(v, labelField.split(".")) ?? "<<incomplete>>"; + getValueFromPath(v, labelField.split(".")) ?? + `<<Item ${idx + 1}>>`; const label = Array.isArray(labelValue) ? labelValue.join(", ") : labelValue; diff --git a/packages/web-util/src/forms/fields/InputDuration.tsx b/packages/web-util/src/forms/fields/InputDuration.tsx @@ -127,13 +127,16 @@ export function InputDuration(props: UIFormProps<Duration>): VNode { clazz += " text-gray-900 ring-gray-300 placeholder:text-gray-400 focus:ring-indigo-600"; } + // FIXME: Fix the types! return ( <InputWrapper {...props} converter={{ + //@ts-ignore fromStringUI(v) { return v ?? ""; }, + //@ts-ignore toStringUI(v) { return v ?? ""; }, diff --git a/packages/web-util/src/forms/fields/InputFile.tsx b/packages/web-util/src/forms/fields/InputFile.tsx @@ -1,11 +1,12 @@ import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; import { UIFormProps } from "../FormProvider.js"; +import { FileFieldData } from "../forms-types.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; export function InputFile( - // FIXME: Specify type properly, not "any" - props: { maxBites: number; accept?: string } & UIFormProps<any>, + props: { maxBites: number; accept?: string } & UIFormProps<FileFieldData>, ): VNode { const { label, tooltip, required, help: propsHelp, maxBites, accept } = props; const { value, onChange } = @@ -16,18 +17,37 @@ export function InputFile( return <Fragment />; } - const valueStr = !value ? "" : value.toString(); - const firstColon = valueStr.indexOf(";"); + const [dataUri, setDataUri] = useState<string | undefined>(() => { + if (!value) { + return undefined; + } + if (value.ENCODING != "base64") { + throw Error("unsupported file storage type"); + } + return `data:${value.MIME_TYPE ?? "application/octet-stream"};base64,${ + value.CONTENTS + }`; + }); - const { fileName, dataUri } = valueStr.startsWith("file:") - ? { - fileName: valueStr.substring(5, firstColon), - dataUri: valueStr.substring(firstColon + 1), - } - : { - fileName: "", - dataUri: valueStr, - }; + const handleFile = ( + contentsBase64?: string, + mimeType?: string, + filename?: string, + ) => { + console.log(`handleFile`, contentsBase64, mimeType, filename); + if (contentsBase64 == null) { + setDataUri(undefined); + onChange(undefined); + return; + } + setDataUri(`data:${mimeType}};base64,${contentsBase64}`); + onChange({ + CONTENTS: contentsBase64, + ENCODING: "base64", + FILENAME: filename, + MIME_TYPE: mimeType, + }); + }; return ( <div class="col-span-full"> @@ -37,7 +57,7 @@ export function InputFile( required={required} name={props.name as string} /> - {!dataUri ? ( + {!value ? ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> <div class="text-center"> <svg @@ -67,10 +87,12 @@ export function InputFile( onChange={(e) => { const f: FileList | null = e.currentTarget.files; if (!f || f.length != 1) { - return onChange(undefined!); + handleFile(undefined); + return; } if (f[0].size > maxBites) { - return onChange(undefined!); + handleFile(undefined); + return; } const fileName = f[0].name; return f[0].arrayBuffer().then((b) => { @@ -80,15 +102,7 @@ export function InputFile( "", ), ); - if (fileName) { - return onChange( - `file:${fileName};data:${f[0].type};base64,${b64}` as any, - ); - } else { - return onChange( - `data:${f[0].type};base64,${b64}` as any, - ); - } + handleFile(b64, f[0].type, fileName); }); }} /> @@ -100,12 +114,12 @@ export function InputFile( </div> ) : ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> - {(dataUri as string).startsWith("data:image/") ? ( + {value.MIME_TYPE?.startsWith("image/") ? ( <Fragment> <img src={dataUri} class=" h-24 w-full object-cover relative" /> - {fileName ? ( + {value.FILENAME ? ( <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> - {fileName} + {value.FILENAME} </div> ) : ( <Fragment /> @@ -129,9 +143,9 @@ export function InputFile( /> </svg> - {fileName ? ( + {value.FILENAME ? ( <div class=" flex justify-center text-xl items-center "> - {fileName} + {value.FILENAME} </div> ) : ( <div /> @@ -144,7 +158,7 @@ export function InputFile( <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!); + handleFile(undefined); }} > Clear diff --git a/packages/web-util/src/forms/fields/InputIsoDate.tsx b/packages/web-util/src/forms/fields/InputIsoDate.tsx @@ -59,7 +59,7 @@ export function InputIsoDate( } }); - const time = parse(value, pattern, Date.now()).getTime(); + const time = parse(value!, pattern, Date.now()).getTime(); return ( <Fragment> <InputLine diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx @@ -115,7 +115,7 @@ export function RenderAddon({ /** * FIXME: Document what this is! */ -export function InputWrapper({ +export function InputWrapper<T>({ children, label, tooltip, @@ -130,7 +130,7 @@ export function InputWrapper({ error?: string; disabled: boolean; children: ComponentChildren; -} & UIFormProps<string>): VNode { +} & UIFormProps<T>): VNode { return ( <div class="sm:col-span-6 "> <LabelWithTooltipMaybeRequired diff --git a/packages/web-util/src/forms/forms-types.ts b/packages/web-util/src/forms/forms-types.ts @@ -124,10 +124,11 @@ type UIFormFieldAmount = { type UIFormFieldArray = { type: "array"; + /** * id of the field shown when the array is collapsed. */ - labelFieldId: UIHandlerId; + labelFieldId: string; /** * Validator for the field. @@ -292,6 +293,31 @@ export type UIFormFieldBaseConfig = UIFieldElementDescription & { id: UIHandlerId; }; +export interface FileFieldData { + /** + * File contents storage type. + * + * We only support base64, but might support more in the future. + */ + ENCODING: "base64"; + + /** + * Contents of the file, as a "data:base64," + * URI without the MIME type in the filename. + */ + CONTENTS: string; + + /** + * Filename of the uploaded file. + */ + FILENAME?: string; + + /** + * Mime-type of the uploaded file. + */ + MIME_TYPE?: string; +} + declare const __handlerId: unique symbol; export type UIHandlerId = string & { [__handlerId]: true }; diff --git a/packages/web-util/src/forms/forms-ui.tsx b/packages/web-util/src/forms/forms-ui.tsx @@ -29,14 +29,31 @@ export function DefaultForm<T>({ }): VNode { const { handler, status } = useForm(design, initial); + const [shorten, setShorten] = useState(true); + return ( <div> <hr class="mt-3 mb-3" /> <FormUI design={design} handler={handler} /> <hr class="mt-3 mb-3" /> + <label> + <input + type="checkbox" + checked={shorten} + onChange={(e) => setShorten(!shorten)} + />{" "} + Shorten file contents. + </label> + <p>Result JSON:</p> <pre class="break-all whitespace-pre-wrap"> - {JSON.stringify(status.result ?? {}, undefined, 2)} + {JSON.stringify( + shorten + ? redactFileContents(status.result ?? {}) + : status.result ?? {}, + undefined, + 2, + )} </pre> <hr class="mt-3 mb-3" /> {status.status !== "ok" ? ( @@ -46,6 +63,31 @@ export function DefaultForm<T>({ ); } +export function redactFileContents(result: any): any { + if (Array.isArray(result)) { + return result.map((x) => redactFileContents(x)); + } + if ( + typeof result === "object" && + "ENCODING" in result && + result.ENCODING === "base64" && + "CONTENTS" in result + ) { + return { + ...result, + CONTENTS: "[... skipped ...]", + }; + } + if (typeof result === "object") { + return Object.fromEntries( + Object.entries(result).map(([k, v]) => { + return [k, redactFileContents(v)]; + }), + ); + } + return result; +} + export const DEFAULT_FORM_UI_NAME = "form-ui"; /** diff --git a/packages/web-util/src/forms/forms-utils.ts b/packages/web-util/src/forms/forms-utils.ts @@ -99,7 +99,7 @@ export function convertFormConfigToUiField( config.hidden === true ? true : config.hide - ? config.hide(handler.value, handler.parentRef) + ? config.hide(handler.value, handler.formRootResult) : undefined; // Input Fields diff --git a/packages/web-util/src/forms/gana/VQF_902_1_customer.ts b/packages/web-util/src/forms/gana/VQF_902_1_customer.ts @@ -290,15 +290,40 @@ export function design_VQF_902_1_customer( required: true, }, { - id: TalerFormAttributes.SIGNING_AUTHORITY, - label: i18n.str`Type of authorization (signing authority)`, - type: "text", + id: TalerFormAttributes.SIGNING_AUTHORITY_TYPE, + label: i18n.str`Signing authority of the person`, + type: "choiceStacked", + required: true, + choices: [ + { + label: "Sole signature authority", + value: "SINGLE", + }, + { + label: "Collective authority with two signatures", + value: "COLLECTIVE_TWO", + }, + { + label: "Other (please specify)", + value: "OTHER", + }, + ], + }, + { + id: TalerFormAttributes.SIGNING_AUTHORITY_TYPE_OTHER, required: true, + label: i18n.str`Other type of signing authority`, + type: "text", + hide(value, root) { + return ( + root[TalerFormAttributes.SIGNING_AUTHORITY_TYPE] !== "OTHER" + ); + }, }, { id: TalerFormAttributes.SIGNING_AUTHORITY_EVIDENCE, required: true, - label: i18n.str`Evidence of signing authority.`, + label: i18n.str`Evidence of signing authority:`, type: "choiceStacked", choices: [ { @@ -318,7 +343,7 @@ export function design_VQF_902_1_customer( { id: TalerFormAttributes.SIGNING_AUTHORITY_EVIDENCE_OTHER, required: true, - label: i18n.str`Other power of attorney arrangement`, + label: i18n.str`Specify other way of establishing signing authority:`, type: "text", hide(value, root) { return ( @@ -327,6 +352,13 @@ export function design_VQF_902_1_customer( ); }, }, + { + id: TalerFormAttributes.SIGNING_AUTHORITY_EVIDENCE_DOCUMENT_COPY, + label: i18n.str`Copy of document that serves as evidence of signing authority:`, + type: "file", + accept: "application/pdf", + required: true, + }, ], }, ], diff --git a/packages/web-util/src/forms/gana/VQF_902_1_officer.ts b/packages/web-util/src/forms/gana/VQF_902_1_officer.ts @@ -84,6 +84,34 @@ export function VQF_902_1_officer( }, ], }, + { + title: i18n.str`Supplemental File Upload`, + description: i18n.str`Optional supplemental information for the establishment of the business relationship with the customer.`, + fields: [ + { + id: TalerFormAttributes.ESTABLISHER_LIST, + label: i18n.str`Supplemental Files`, + type: "array", + labelFieldId: "FILE.FILENAME", + required: false, + fields: [ + { + id: TalerFormAttributes.DESCRIPTION, + label: i18n.str`Description`, + type: "textArea", + required: true, + }, + { + id: TalerFormAttributes.FILE, + label: i18n.str`File (PDF)`, + type: "file", + accept: "application/pdf", + required: true, + }, + ], + }, + ], + }, ], }; } diff --git a/packages/web-util/src/forms/gana/taler_form_attributes.ts b/packages/web-util/src/forms/gana/taler_form_attributes.ts @@ -140,7 +140,13 @@ export const TalerFormAttributes = { * * GANA Type: String */ - BUSINESS_IDENTIFICATION_: "BUSINESS_IDENTIFICATION_" as UIHandlerId, + DESCRIPTION: "DESCRIPTION" as UIHandlerId, + /** + * Description: + * + * GANA Type: DataUri + */ + FILE: "FILE" as UIHandlerId, /** * Description: List of founder with the fields below. * @@ -166,11 +172,17 @@ export const TalerFormAttributes = { */ NATIONALITY: "NATIONALITY" as UIHandlerId, /** - * Description: Signatory of representation + * Description: Signatory of representation (single, collective two, ...) + * + * GANA Type: String + */ + SIGNING_AUTHORITY_TYPE: "SIGNING_AUTHORITY_TYPE" as UIHandlerId, + /** + * Description: Signatory of representation (other type) * * GANA Type: String */ - SIGNING_AUTHORITY: "SIGNING_AUTHORITY" as UIHandlerId, + SIGNING_AUTHORITY_TYPE_OTHER: "SIGNING_AUTHORITY_TYPE_OTHER" as UIHandlerId, /** * Description: * @@ -196,6 +208,12 @@ export const TalerFormAttributes = { */ SIGNING_AUTHORITY_EVIDENCE_OTHER: "SIGNING_AUTHORITY_EVIDENCE_OTHER" as UIHandlerId, /** + * Description: + * + * GANA Type: File + */ + SIGNING_AUTHORITY_EVIDENCE_DOCUMENT_COPY: "SIGNING_AUTHORITY_EVIDENCE_DOCUMENT_COPY" as UIHandlerId, + /** * Description: Conclusion of the conract * * GANA Type: AbsoluteDate diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -117,18 +117,16 @@ export function useForm<T>( }; } -interface Tree<T> extends Record<string, Tree<T> | T> {} - /** * Use {@link path} to get the value of {@link object}. * Return {@link fallbackValue} if the target property is undefined */ -export function getValueFromPath<T>( - object: Tree<T> | undefined, +export function getValueFromPath( + object: any, path: string[], - fallbackValue?: T, -): T | undefined { - if (path.length === 0) return object as T; + fallbackValue?: any, +): any { + if (path.length === 0) return object; const [head, ...rest] = path; if (!head) { return getValueFromPath(object, rest, fallbackValue); @@ -136,7 +134,7 @@ export function getValueFromPath<T>( if (object === undefined) { return fallbackValue; } - return getValueFromPath(object[head] as Tree<T>, rest, fallbackValue); + return getValueFromPath(object[head], rest, fallbackValue); } /** @@ -235,11 +233,7 @@ function constructFormHandler<T>( } const path = formElement.id.split("."); - const currentValue = getValueFromPath<string>( - formValue as any, - path, - undefined, - ); + const currentValue = getValueFromPath(formValue as any, path, undefined); // compute prop based on state const hidden = @@ -265,7 +259,7 @@ function constructFormHandler<T>( error: currentError?.message, value: currentValue, onChange: updater, - parentRef: result, + formRootResult: result, computedProperties: { hidden, },