taler-typescript-core

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

commit 8fabd3c811f95179650e621994f82335c9b7733d
parent d34b3c415481cedcd4ea1f487d14463ed112f81b
Author: Sebastian <sebasjm@gmail.com>
Date:   Sun,  5 Jan 2025 15:52:33 -0300

change to enable new forms in KYC/AML:

Diffstat:
Mpackages/web-util/src/components/Footer.tsx | 56+++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/web-util/src/components/LangSelector.tsx | 2+-
Mpackages/web-util/src/context/api.ts | 2+-
Apackages/web-util/src/forms/DownloadLink.tsx | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/forms.ts | 16++++++++++++++++
Mpackages/web-util/src/forms/ui-form.ts | 28++++++++++++++++++++++++++--
Mpackages/web-util/src/hooks/useForm.ts | 34++++++++++++++++++++++++----------
Mpackages/web-util/src/utils/base64.ts | 14+++++++-------
Mpackages/web-util/src/utils/request.ts | 2+-
9 files changed, 188 insertions(+), 37 deletions(-)

diff --git a/packages/web-util/src/components/Footer.tsx b/packages/web-util/src/components/Footer.tsx @@ -1,34 +1,60 @@ import { useTranslationContext } from "../index.browser.js"; import { h } from "preact"; -export function Footer({ testingUrlKey, VERSION, GIT_HASH }: { VERSION?: string, GIT_HASH?: string, testingUrlKey?: string }) { - const { i18n } = useTranslationContext() +export function Footer({ + testingUrlKey, + VERSION, + GIT_HASH, +}: { + VERSION?: string; + GIT_HASH?: string; + testingUrlKey?: string; +}) { + const { i18n } = useTranslationContext(); - const testingUrl = (testingUrlKey && typeof localStorage !== "undefined") && localStorage.getItem(testingUrlKey) ? - localStorage.getItem(testingUrlKey) ?? undefined : - undefined - const versionText = VERSION - ? GIT_HASH - ? <a href={`https://git.taler.net/wallet-core.git/tree/?id=${GIT_HASH}`} target="_blank" rel="noreferrer noopener"> + const testingUrl = + testingUrlKey && + typeof localStorage !== "undefined" && + localStorage.getItem(testingUrlKey) + ? localStorage.getItem(testingUrlKey) ?? undefined + : undefined; + const versionText = VERSION ? ( + GIT_HASH ? ( + <a + href={`https://git.taler.net/wallet-core.git/tree/?id=${GIT_HASH}`} + target="_blank" + rel="noreferrer noopener" + > Version {VERSION} ({GIT_HASH.substring(0, 8)}) </a> - : VERSION - : ""; + ) : ( + VERSION + ) + ) : ( + "" + ); return ( <footer class="bottom-4 my-4 mx-8 bg-slate-200"> <div> <p class="text-xs leading-5 text-gray-400"> <i18n.Translate> - Learn more about <a target="_blank" rel="noreferrer noopener" class="font-semibold text-gray-500 hover:text-gray-400" href="https://taler.net">GNU Taler</a> + Learn more about{" "} + <a + target="_blank" + rel="noreferrer noopener" + class="font-semibold text-gray-500 hover:text-gray-400" + href="https://taler.net" + > + GNU Taler + </a> </i18n.Translate> </p> </div> <div style="flex-grow:1" /> <p class="text-xs leading-5 text-gray-400"> - Copyright &copy; 2014&mdash;2023 Taler Systems SA. {versionText}{" "} + Copyright &copy; 2014&mdash;2025 Taler Systems SA. {versionText}{" "} </p> - {testingUrlKey && testingUrl && - + {testingUrlKey && testingUrl && ( <p class="text-xs leading-5 text-gray-300"> Testing with {testingUrl}{" "} <a @@ -42,7 +68,7 @@ export function Footer({ testingUrlKey, VERSION, GIT_HASH }: { VERSION?: string, stop testing </a> </p> - } + )} </footer> ); } diff --git a/packages/web-util/src/components/LangSelector.tsx b/packages/web-util/src/components/LangSelector.tsx @@ -121,7 +121,7 @@ export function LangSelector({ e.stopPropagation(); }} > - <div class="flex"> + <div class="flex h-7 w-7"> <img alt="language" class="h-7 w-7 flex-shrink-0 rounded-full" diff --git a/packages/web-util/src/context/api.ts b/packages/web-util/src/context/api.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2025 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 diff --git a/packages/web-util/src/forms/DownloadLink.tsx b/packages/web-util/src/forms/DownloadLink.tsx @@ -0,0 +1,71 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { VNode, h } from "preact"; +import { LabelWithTooltipMaybeRequired, RenderAddon } from "./InputLine.js"; +import { Addon } from "./FormProvider.js"; + +interface Props { + label: TranslatedString; + url: string; + media?: string; + tooltip?: TranslatedString; + help?: TranslatedString; + before?: Addon; + after?: Addon; +} + +export function DownloadLink({ + before, + after, + label, + url, + media, + tooltip, + help, +}: Props): VNode { + return ( + <div class="sm:col-span-6"> + {before !== undefined && <RenderAddon addon={before} />} + <a + href="#" + onClick={(e) => { + return ( + fetch(url, { + headers: { + "Content-Type": media ?? "text/html", + }, + cache: "no-cache", + }) + // .then((r) => r.text()) + .then((r) => r.arrayBuffer()) + .then((r) => { + const b64 = window.btoa( + new Uint8Array(r).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + + const a = document.createElement("a"); + a.href = `data:${media ?? "text/html"};base64,${b64}`; + a.download = ""; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + return; + }) + ); + }} + media={media} + download + > + {label} + </a> + {after !== undefined && <RenderAddon addon={after} />} + {help && ( + <p class="mt-2 text-sm text-gray-500" id="email-description"> + {help} + </p> + )} + </div> + ); +} diff --git a/packages/web-util/src/forms/forms.ts b/packages/web-util/src/forms/forms.ts @@ -21,12 +21,14 @@ import { import { assertUnreachable, TranslatedString } from "@gnu-taler/taler-util"; import { UIFormFieldBaseConfig, UIFormElementConfig } from "./ui-form.js"; import { HtmlIframe } from "./HtmlIframe.js"; +import { DownloadLink } from "./DownloadLink.js"; /** * Constrain the type with the ui props */ type FieldType<T extends object = any, K extends keyof T = any> = { group: Parameters<typeof Group>[0]; caption: Parameters<typeof Caption>[0]; + "download-link": Parameters<typeof DownloadLink>[0]; htmlIframe: Parameters<typeof HtmlIframe>[0]; array: Parameters<typeof InputArray<T, K>>[0]; file: Parameters<typeof InputFile<T, K>>[0]; @@ -48,6 +50,7 @@ type FieldType<T extends object = any, K extends keyof T = any> = { export type UIFormField = | { type: "group"; properties: FieldType["group"] } | { type: "caption"; properties: FieldType["caption"] } + | { type: "download-link"; properties: FieldType["download-link"] } | { type: "htmlIframe"; properties: FieldType["htmlIframe"] } | { type: "array"; properties: FieldType["array"] } | { type: "file"; properties: FieldType["file"] } @@ -87,6 +90,7 @@ type UIFormFieldMap = { */ const UIFormConfiguration: UIFormFieldMap = { group: Group, + "download-link": DownloadLink, caption: Caption, htmlIframe: HtmlIframe, //@ts-ignore @@ -177,6 +181,18 @@ export function convertUiField( }; return resp; } + case "download-link": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config), + label: i18n_.str`${config.label}`, + url: config.url, + media: config.media, + }, + }; + return resp; + } case "htmlIframe": { const resp: UIFormField = { type: config.type, diff --git a/packages/web-util/src/forms/ui-form.ts b/packages/web-util/src/forms/ui-form.ts @@ -46,6 +46,7 @@ export type DoubleColumnFormSection = { export type UIFormElementConfig = | UIFormElementGroup | UIFormElementCaption + | UIFormElementDownloadLink | UIFormElementHtmlIframe | UIFormFieldAbsoluteTime | UIFormFieldAmount @@ -82,6 +83,11 @@ type UIFormFieldArray = { } & UIFormFieldBaseConfig; type UIFormElementCaption = { type: "caption" } & UIFieldElementDescription; +type UIFormElementDownloadLink = { + type: "download-link"; + url: string; + media?: string; +} & UIFieldElementDescription; type UIFormElementHtmlIframe = { type: "htmlIframe"; url: string; @@ -95,11 +101,13 @@ type UIFormElementGroup = { type UIFormFieldChoiseHorizontal = { type: "choiceHorizontal"; choices: Array<SelectUiChoice>; + allowFreeForm?: boolean; } & UIFormFieldBaseConfig; type UIFormFieldChoiseStacked = { type: "choiceStacked"; choices: Array<SelectUiChoice>; + allowFreeForm?: boolean; } & UIFormFieldBaseConfig; type UIFormFieldFile = { @@ -117,7 +125,7 @@ type UIFormFieldInteger = { min?: Integer; } & UIFormFieldBaseConfig; -interface SelectUiChoice { +export interface SelectUiChoice { label: string; description?: string; value: string; @@ -129,17 +137,19 @@ type UIFormFieldSelectMultiple = { min?: Integer; unique?: boolean; choices: Array<SelectUiChoice>; + allowFreeForm?: boolean; } & UIFormFieldBaseConfig; type UIFormFieldSelectOne = { type: "selectOne"; choices: Array<SelectUiChoice>; + allowFreeForm?: boolean; } & UIFormFieldBaseConfig; type UIFormFieldText = { type: "text" } & UIFormFieldBaseConfig; type UIFormFieldTextArea = { type: "textArea" } & UIFormFieldBaseConfig; type UIFormFieldToggle = { type: "toggle"; - threeState: boolean; + threeState?: boolean; } & UIFormFieldBaseConfig; export type UIFieldElementDescription = { @@ -243,6 +253,13 @@ const codecForUiFormFieldCaption = (): Codec<UIFormElementCaption> => .property("type", codecForConstString("caption")) .build("UIFormFieldCaption"); +const codecForUIFormElementLink = (): Codec<UIFormElementDownloadLink> => + codecForUIFormFieldBaseDescriptionTemplate<UIFormElementDownloadLink>() + .property("type", codecForConstString("download-link")) + .property("url", codecForString()) + .property("media", codecOptional(codecForString())) + .build("UIFormElementLink"); + const codecForUiFormFieldHtmlIFrame = (): Codec<UIFormElementHtmlIframe> => codecForUIFormFieldBaseDescriptionTemplate<UIFormElementHtmlIframe>() .property("type", codecForConstString("htmlIframe")) @@ -260,12 +277,14 @@ const codecForUiFormFieldChoiceHorizontal = (): Codec<UIFormFieldChoiseHorizontal> => codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseHorizontal>() .property("type", codecForConstString("choiceHorizontal")) + .property("allowFreeForm", codecOptional(codecForBoolean())) .property("choices", codecForList(codecForUiFormSelectUiChoice())) .build("UIFormFieldChoiseHorizontal"); const codecForUiFormFieldChoiceStacked = (): Codec<UIFormFieldChoiseStacked> => codecForUIFormFieldBaseConfigTemplate<UIFormFieldChoiseStacked>() .property("type", codecForConstString("choiceStacked")) + .property("allowFreeForm", codecOptional(codecForBoolean())) .property("choices", codecForList(codecForUiFormSelectUiChoice())) .build("UIFormFieldChoiseStacked"); @@ -299,12 +318,14 @@ const codecForUiFormFieldSelectMultiple = .property("max", codecOptional(codecForNumber())) .property("min", codecOptional(codecForNumber())) .property("unique", codecOptional(codecForBoolean())) + .property("allowFreeForm", codecOptional(codecForBoolean())) .property("choices", codecForList(codecForUiFormSelectUiChoice())) .build("UiFormFieldSelectMultiple"); const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldSelectOne> => codecForUIFormFieldBaseConfigTemplate<UIFormFieldSelectOne>() .property("type", codecForConstString("selectOne")) + .property("allowFreeForm", codecOptional(codecForBoolean())) .property("choices", codecForList(codecForUiFormSelectUiChoice())) .build("UIFormFieldSelectOne"); @@ -329,6 +350,7 @@ const codecForUiFormField = (): Codec<UIFormElementConfig> => .discriminateOn("type") .alternative("array", codecForLazy(codecForUiFormFieldArray)) .alternative("group", codecForLazy(codecForUiFormFieldGroup)) + .alternative("download-link", codecForUIFormElementLink()) .alternative("absoluteTimeText", codecForUiFormFieldAbsoluteTime()) .alternative("amount", codecForUiFormFieldAmount()) .alternative("caption", codecForUiFormFieldCaption()) @@ -373,6 +395,7 @@ const codecForFormConfiguration = (): Codec<FormConfiguration> => const codecForFormMetadata = (): Codec<FormMetadata> => buildCodecForObject<FormMetadata>() .property("label", codecForString()) + .property("description", codecOptional(codecForString())) .property("id", codecForString()) .property("version", codecForNumber()) .property("config", codecForFormConfiguration()) @@ -385,6 +408,7 @@ export const codecForUIForms = (): Codec<UiForms> => export type FormMetadata = { label: string; + description?: string; id: string; version: number; config: FormConfiguration; diff --git a/packages/web-util/src/hooks/useForm.ts b/packages/web-util/src/hooks/useForm.ts @@ -138,11 +138,21 @@ export function useFormStateFromConfig<T>( return undefined; } // check required fields - const requiredCheckResult = requiredFields.length > 0 ? defaultCheckAllRequired(form) : undefined; + const requiredCheckResult = + requiredFields.length > 0 ? defaultCheckAllRequired(form) : undefined; // verify if there is a custom check function and all required fields are ok // if there no custom check return "ok" - const status = requiredCheckResult ?? (check ? check(form) : {status: "ok" as const, result: form as any, errors: undefined}) - const handler = constructFormHandler(shape, form, updateForm, requiredCheckResult?.errors); + const status = + requiredCheckResult ?? + (check + ? check(form) + : { status: "ok" as const, result: form as any, errors: undefined }); + const handler = constructFormHandler( + shape, + form, + updateForm, + requiredCheckResult?.errors, + ); return [handler, status]; } @@ -220,9 +230,9 @@ export function getShapeFromFields( if ("id" in field) { // FIXME: this should be a validation when loading the form // consistency check - if (shape.indexOf(field.id) !== -1) { - throw Error(`already present: ${field.id}`); - } + // if (shape.indexOf(field.id) !== -1) { + // throw Error(`already present: ${field.id}`); + // } shape.push(field.id); } else if (field.type === "group") { Array.prototype.push.apply(shape, getShapeFromFields(field.fields)); @@ -239,9 +249,9 @@ export function getRequiredFields( if ("id" in field) { // FIXME: this should be a validation when loading the form // consistency check - if (shape.indexOf(field.id) !== -1) { - throw Error(`already present: ${field.id}`); - } + // if (shape.indexOf(field.id) !== -1) { + // throw Error(`already present: ${field.id}`); + // } if (!field.required) { return; } @@ -261,7 +271,11 @@ export function validateRequiredFields<FormType>( fields.forEach((f) => { const path = f.split("."); const v = getValueDeeper(form as any, path); - result = setValueDeeper(result, path, v === undefined ? "required" : undefined); + result = setValueDeeper( + result, + path, + v === undefined ? "required" : undefined, + ); }); return result; } diff --git a/packages/web-util/src/utils/base64.ts b/packages/web-util/src/utils/base64.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2025 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 @@ -128,10 +128,10 @@ function base64EncArr(aBytes: Uint8Array): string { /** * UTF-8 array to JS string and vice versa - * - * @param aBytes + * + * @param aBytes * @deprecated use textEncoder - * @returns + * @returns */ function UTF8ArrToStr(aBytes: Uint8Array): string { let sView = ""; @@ -177,10 +177,10 @@ function UTF8ArrToStr(aBytes: Uint8Array): string { } /** - * - * @param sDOMStr + * + * @param sDOMStr * @deprecated use textEncoder - * @returns + * @returns */ function strToUTF8Arr(sDOMStr: string): Uint8Array { let nChr; diff --git a/packages/web-util/src/utils/request.ts b/packages/web-util/src/utils/request.ts @@ -1,6 +1,6 @@ /* This file is part of GNU Taler - (C) 2021-2023 Taler Systems S.A. + (C) 2021-2025 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