taler-typescript-core

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

commit 35fee72ef3d75b7a9681353ab7a1ca5bacff150e
parent 5db79542f34477911f14f2e454925368c0d2c33f
Author: Sebastian <sebasjm@gmail.com>
Date:   Fri,  3 May 2024 18:23:01 -0300

form implemented, moving functions to web-utils some final testing still pedning

Diffstat:
Mpackages/aml-backoffice-ui/dev.mjs | 2+-
Mpackages/aml-backoffice-ui/src/App.tsx | 7+++++--
Mpackages/aml-backoffice-ui/src/context/ui-forms.ts | 439+------------------------------------------------------------------------------
Apackages/aml-backoffice-ui/src/forms.json | 539+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/aml-backoffice-ui/src/forms/index.ts | 3+--
Mpackages/aml-backoffice-ui/src/forms/simplest.ts | 8+++++---
Mpackages/aml-backoffice-ui/src/hooks/form.ts | 249++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/aml-backoffice-ui/src/index.html | 1-
Mpackages/aml-backoffice-ui/src/pages/CaseDetails.tsx | 19+++++++++++++------
Mpackages/aml-backoffice-ui/src/pages/CaseUpdate.tsx | 175+++++--------------------------------------------------------------------------
Mpackages/aml-backoffice-ui/src/pages/Cases.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/CreateAccount.tsx | 2+-
Mpackages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx | 31+++++++++++--------------------
Mpackages/aml-backoffice-ui/src/pages/UnlockAccount.tsx | 2+-
Apackages/aml-backoffice-ui/src/settings.json | 5+++++
Mpackages/aml-backoffice-ui/src/utils/converter.ts | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpackages/taler-util/src/codec.ts | 13+++++++++++++
Mpackages/web-util/src/forms/DefaultForm.tsx | 13+++++++------
Mpackages/web-util/src/forms/InputAbsoluteTime.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputAmount.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputArray.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputArray.tsx | 3++-
Mpackages/web-util/src/forms/InputChoiceHorizontal.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputChoiceStacked.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputFile.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputFile.tsx | 54++++++++++++++++++++++++++++++++++++++++--------------
Mpackages/web-util/src/forms/InputInteger.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputLine.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputSelectMultiple.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputSelectMultiple.tsx | 2+-
Mpackages/web-util/src/forms/InputSelectOne.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputText.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputTextArea.stories.tsx | 4++--
Mpackages/web-util/src/forms/InputToggle.stories.tsx | 4++--
Mpackages/web-util/src/forms/index.ts | 1+
Apackages/web-util/src/forms/ui-form.ts | 451+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
36 files changed, 1414 insertions(+), 732 deletions(-)

diff --git a/packages/aml-backoffice-ui/dev.mjs b/packages/aml-backoffice-ui/dev.mjs @@ -24,7 +24,7 @@ const build = initializeDev({ type: "development", source: { js: devEntryPoints, - assets: [{ base: "src", files: ["src/index.html"] }], + assets: [{ base: "src", files: ["src/index.html","src/forms.json","src/settings.json"] }], }, destination: "./dist/dev", css: "postcss", diff --git a/packages/aml-backoffice-ui/src/App.tsx b/packages/aml-backoffice-ui/src/App.tsx @@ -19,6 +19,7 @@ import { ExchangeApiProvider, Loading, TranslationProvider, + UiForms, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; @@ -29,7 +30,7 @@ import { UiSettingsProvider } from "./context/ui-settings.js"; import { strings } from "./i18n/strings.js"; import "./scss/main.css"; import { UiSettings, fetchUiSettings } from "./context/ui-settings.js"; -import { UiForms, fetchUiForms } from "./context/ui-forms.js"; +import { UiFormsProvider, fetchUiForms } from "./context/ui-forms.js"; const WITH_LOCAL_STORAGE_CACHE = false; @@ -84,7 +85,9 @@ export function App(): VNode { }} > <BrowserHashNavigationProvider> - <Routing /> + <UiFormsProvider value={forms}> + <Routing /> + </UiFormsProvider> </BrowserHashNavigationProvider> </SWRConfig> </ExchangeApiProvider> diff --git a/packages/aml-backoffice-ui/src/context/ui-forms.ts b/packages/aml-backoffice-ui/src/context/ui-forms.ts @@ -14,20 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { - buildCodecForObject, - buildCodecForUnion, - Codec, - codecForBoolean, - codecForConstString, - codecForList, - codecForNumber, - codecForString, - codecForTimestamp, - codecOptional, - Integer, - TalerProtocolTimestamp, -} from "@gnu-taler/taler-util"; +import { codecForUIForms, UiForms } from "@gnu-taler/web-util/browser"; import { ComponentChildren, createContext, h, VNode } from "preact"; import { useContext } from "preact/hooks"; @@ -60,431 +47,7 @@ export const UiFormsProvider = ({ }); }; -export type FormMetadata = { - label: string; - id: string; - version: number; - config: FlexibleForm; -}; - -type FlexibleForm = DoubleColumnForm; - -export interface DoubleColumnForm { - type: "double-column"; - design: Array<DoubleColumnFormSection>; - // behavior?: (form: Partial<T>) => FormState<T>; -} - -export type DoubleColumnFormSection = { - title: string; - description?: string; - fields: UIFormFieldConfig[]; -}; - -// export interface BaseForm { -// state: TalerExchangeApi.AmlState; -// threshold: AmountJson; -// } - -export interface UiForms { - // Where libeufin backend is localted - // default: window.origin without "webui/" - forms: Array<FormMetadata>; -} - -export type UIFormFieldConfig = - | UIFormFieldConfigAbsoluteTime - | UIFormFieldConfigAmount - | UIFormFieldConfigArray - | UIFormFieldConfigCaption - | UIFormFieldConfigChoiseHorizontal - | UIFormFieldConfigChoiseStacked - | UIFormFieldConfigFile - | UIFormFieldConfigGroup - | UIFormFieldConfigInteger - | UIFormFieldConfigSelectMultiple - | UIFormFieldConfigSelectOne - | UIFormFieldConfigText - | UIFormFieldConfigTextArea - | UIFormFieldConfigToggle; - -type UIFormFieldConfigAbsoluteTime = { - type: "absoluteTime"; - properties: UIFormFieldBaseConfig & { - max?: TalerProtocolTimestamp; - min?: TalerProtocolTimestamp; - pattern: string; - }; -}; - -type UIFormFieldConfigAmount = { - type: "amount"; - properties: UIFormFieldBaseConfig & { - max?: Integer; - min?: Integer; - currency: string; - }; -}; - -type UIFormFieldConfigArray = { - type: "array"; - properties: UIFormFieldBaseConfig & { - // id of the field shown when the array is collapsed - labelFieldId: UIHandlerId; - fields: UIFormFieldConfig[]; - }; -}; - -type UIFormFieldConfigCaption = { - type: "caption"; - properties: UIFieldBaseDescription; -}; - -type UIFormFieldConfigGroup = { - type: "group"; - properties: UIFieldBaseDescription & { - fields: UIFormFieldConfig[]; - }; -}; - -type UIFormFieldConfigChoiseHorizontal = { - type: "choiceHorizontal"; - properties: UIFormFieldBaseConfig & { - choices: Array<SelectUiChoice>; - }; -}; - -type UIFormFieldConfigChoiseStacked = { - type: "choiceStacked"; - properties: UIFormFieldBaseConfig & { - choices: Array<SelectUiChoice>; - }; -}; - -type UIFormFieldConfigFile = { - type: "file"; - properties: UIFormFieldBaseConfig & { - maxBytes?: Integer; - minBytes?: Integer; - // comma-separated list of one or more file types - // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers - accept?: string; - }; -}; -type UIFormFieldConfigInteger = { - type: "integer"; - properties: UIFormFieldBaseConfig & { - max?: Integer; - min?: Integer; - }; -}; - -interface SelectUiChoice { - label: string; - description?: string; - value: string; -} - -type UIFormFieldConfigSelectMultiple = { - type: "selectMultiple"; - properties: UIFormFieldBaseConfig & { - max?: Integer; - min?: Integer; - unique?: boolean; - choices: Array<SelectUiChoice>; - }; -}; -type UIFormFieldConfigSelectOne = { - type: "selectOne"; - properties: UIFormFieldBaseConfig & { - choices: Array<SelectUiChoice>; - }; -}; -type UIFormFieldConfigText = { - type: "text"; - properties: UIFormFieldBaseConfig; -}; -type UIFormFieldConfigTextArea = { - type: "textArea"; - properties: UIFormFieldBaseConfig; -}; -type UIFormFieldConfigToggle = { - type: "toggle"; - properties: UIFormFieldBaseConfig; -}; - -export type UIFieldBaseDescription = { - /* label if the field, visible for the user */ - label: string; - /* long text to be shown on user demand */ - tooltip?: string; - - /* short text to be shown close to the field */ - help?: string; - - /* name of the field, useful for a11y */ - name: string; - - /* if the field should be initialy hidden */ - hidden?: boolean; - /* ui element to show before */ - addonBeforeId?: string; - /* ui element to show after */ - addonAfterId?: string; -}; - -export type UIFormFieldBaseConfig = UIFieldBaseDescription & { - /* example to be shown inside the field */ - placeholder?: string; - - /* show a mark as required */ - required?: boolean; - - /* readonly and dim */ - disabled?: boolean; - - /* conversion id to conver the string into the value type - the id should be known to the ui impl - */ - converterId?: string; - - /* property id of the form */ - id: UIHandlerId; -}; - -declare const __handlerId: unique symbol; -export type UIHandlerId = string & { [__handlerId]: true }; - -// FIXME: validate well formed ui field id -const codecForUiFieldId = codecForString as () => Codec<UIHandlerId>; - -const codecForUIFormFieldBaseDescriptionTemplate = < - T extends UIFieldBaseDescription, ->() => - buildCodecForObject<T>() - .property("addonAfterId", codecOptional(codecForString())) - .property("addonBeforeId", codecOptional(codecForString())) - .property("hidden", codecOptional(codecForBoolean())) - .property("help", codecOptional(codecForString())) - .property("label", codecForString()) - .property("name", codecForString()) - .property("tooltip", codecOptional(codecForString())); - -const codecForUIFormFieldBaseConfigTemplate = < - T extends UIFormFieldBaseConfig, ->() => - codecForUIFormFieldBaseDescriptionTemplate<T>() - .property("id", codecForUiFieldId()) - .property("converterId", codecOptional(codecForString())) - .property("disabled", codecOptional(codecForBoolean())) - .property("required", codecOptional(codecForBoolean())) - .property("placeholder", codecOptional(codecForString())); -const codecForUIFormFieldBaseConfig = (): Codec<UIFormFieldBaseConfig> => - codecForUIFormFieldBaseConfigTemplate().build("UIFieldToggleProperties"); - -const codecForUIFormFieldAbsoluteTimeConfig = (): Codec< - UIFormFieldConfigAbsoluteTime["properties"] -> => - codecForUIFormFieldBaseConfigTemplate< - UIFormFieldConfigAbsoluteTime["properties"] - >() - .property("pattern", codecForString()) - .property("max", codecOptional(codecForTimestamp)) - .property("min", codecOptional(codecForTimestamp)) - .build("UIFormFieldConfigAbsoluteTime.properties"); - -const codecForUiFormFieldAbsoluteTime = - (): Codec<UIFormFieldConfigAbsoluteTime> => - buildCodecForObject<UIFormFieldConfigAbsoluteTime>() - .property("type", codecForConstString("absoluteTime")) - .property("properties", codecForUIFormFieldAbsoluteTimeConfig()) - .build("UIFormFieldConfigAbsoluteTime"); - -const codecForUIFormFieldAmountConfig = (): Codec< - UIFormFieldConfigAmount["properties"] -> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigAmount["properties"]>() - .property("currency", codecForString()) - .property("max", codecOptional(codecForNumber())) - .property("min", codecOptional(codecForNumber())) - .build("UIFormFieldConfigAmount.properties"); - -const codecForUiFormFieldAmount = (): Codec<UIFormFieldConfigAmount> => - buildCodecForObject<UIFormFieldConfigAmount>() - .property("type", codecForConstString("amount")) - .property("properties", codecForUIFormFieldAmountConfig()) - .build("UIFormFieldConfigAmount"); - -const codecForUIFormFieldArrayConfig = (): Codec< - UIFormFieldConfigArray["properties"] -> => - codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigArray["properties"]>() - .property("labelFieldId", codecForUiFieldId()) - .property("fields", codecForList(codecForUiFormField())) - .build("UIFormFieldConfigArray.properties"); - -const codecForUiFormFieldArray = (): Codec<UIFormFieldConfigArray> => - buildCodecForObject<UIFormFieldConfigArray>() - .property("type", codecForConstString("array")) - .property("properties", codecForUIFormFieldArrayConfig()) - .build("UIFormFieldConfigArray"); - -const codecForUiFormFieldCaption = (): Codec<UIFormFieldConfigCaption> => - buildCodecForObject<UIFormFieldConfigCaption>() - .property("type", codecForConstString("caption")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigCaption"); - -const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => - buildCodecForObject<SelectUiChoice>() - .property("description", codecForString()) - .property("label", codecForString()) - .property("value", codecForString()) - .build("SelectUiChoice"); - -const codecForUIFormFieldWithChoiseConfig = (): Codec< - UIFormFieldConfigChoiseHorizontal["properties"] -> => - codecForUIFormFieldBaseConfigTemplate< - UIFormFieldConfigChoiseHorizontal["properties"] - >() - .property("choices", codecForList(codecForUiFormSelectUiChoice())) - .build("UIFormFieldConfigChoiseHorizontal.properties"); - -const codecForUiFormFieldChoiceHorizontal = - (): Codec<UIFormFieldConfigChoiseHorizontal> => - buildCodecForObject<UIFormFieldConfigChoiseHorizontal>() - .property("type", codecForConstString("choiceHorizontal")) - .property("properties", codecForUIFormFieldWithChoiseConfig()) - .build("UIFormFieldConfigChoiseHorizontal"); - -const codecForUiFormFieldChoiceStacked = - (): Codec<UIFormFieldConfigChoiseStacked> => - buildCodecForObject<UIFormFieldConfigChoiseStacked>() - .property("type", codecForConstString("choiceStacked")) - .property("properties", codecForUIFormFieldWithChoiseConfig()) - .build("UIFormFieldConfigChoiseStacked"); - -const codecForUiFormFieldFile = (): Codec<UIFormFieldConfigFile> => - buildCodecForObject<UIFormFieldConfigFile>() - .property("type", codecForConstString("file")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigFile"); - -const codecForUIFormFieldWithFieldsConfig = (): Codec< - UIFormFieldConfigGroup["properties"] -> => - codecForUIFormFieldBaseDescriptionTemplate< - UIFormFieldConfigGroup["properties"] - >() - .property("fields", codecForList(codecForUiFormField())) - .build("UIFormFieldConfigGroup.properties"); - -const codecForUiFormFieldGroup = (): Codec<UIFormFieldConfigGroup> => - buildCodecForObject<UIFormFieldConfigGroup>() - .property("type", codecForConstString("group")) - .property("properties", codecForUIFormFieldWithFieldsConfig()) - .build("UiFormFieldGroup"); - -const codecForUiFormFieldInteger = (): Codec<UIFormFieldConfigInteger> => - buildCodecForObject<UIFormFieldConfigInteger>() - .property("type", codecForConstString("integer")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigInteger"); - -const codecForUIFormFieldSelectMultipleConfig = (): Codec< - UIFormFieldConfigSelectMultiple["properties"] -> => - codecForUIFormFieldBaseConfigTemplate< - UIFormFieldConfigSelectMultiple["properties"] - >() - .property("max", codecOptional(codecForNumber())) - .property("min", codecOptional(codecForNumber())) - .property("unique", codecOptional(codecForBoolean())) - .property("choices", codecForList(codecForUiFormSelectUiChoice())) - .build("UIFormFieldConfigSelectMultiple.properties"); - -const codecForUiFormFieldSelectMultiple = - (): Codec<UIFormFieldConfigSelectMultiple> => - buildCodecForObject<UIFormFieldConfigSelectMultiple>() - .property("type", codecForConstString("selectMultiple")) - .property("properties", codecForUIFormFieldSelectMultipleConfig()) - .build("UiFormFieldSelectMultiple"); - -const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldConfigSelectOne> => - buildCodecForObject<UIFormFieldConfigSelectOne>() - .property("type", codecForConstString("selectOne")) - .property("properties", codecForUIFormFieldWithChoiseConfig()) - .build("UIFormFieldConfigSelectOne"); - -const codecForUiFormFieldText = (): Codec<UIFormFieldConfigText> => - buildCodecForObject<UIFormFieldConfigText>() - .property("type", codecForConstString("text")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigText"); - -const codecForUiFormFieldTextArea = (): Codec<UIFormFieldConfigTextArea> => - buildCodecForObject<UIFormFieldConfigTextArea>() - .property("type", codecForConstString("textArea")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigTextArea"); - -const codecForUiFormFieldToggle = (): Codec<UIFormFieldConfigToggle> => - buildCodecForObject<UIFormFieldConfigToggle>() - .property("type", codecForConstString("toggle")) - .property("properties", codecForUIFormFieldBaseConfig()) - .build("UIFormFieldConfigToggle"); - -const codecForUiFormField = (): Codec<UIFormFieldConfig> => - buildCodecForUnion<UIFormFieldConfig>() - .discriminateOn("type") - .alternative("absoluteTime", codecForUiFormFieldAbsoluteTime()) - .alternative("amount", codecForUiFormFieldAmount()) - .alternative("array", codecForUiFormFieldArray()) - .alternative("caption", codecForUiFormFieldCaption()) - .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) - .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) - .alternative("file", codecForUiFormFieldFile()) - .alternative("group", codecForUiFormFieldGroup()) - .alternative("integer", codecForUiFormFieldInteger()) - .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) - .alternative("selectOne", codecForUiFormFieldSelectOne()) - .alternative("text", codecForUiFormFieldText()) - .alternative("textArea", codecForUiFormFieldTextArea()) - .alternative("toggle", codecForUiFormFieldToggle()) - .build("UIFormField"); - -const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => - buildCodecForObject<DoubleColumnFormSection>() - .property("title", codecForString()) - .property("description", codecForString()) - .property("fields", codecForList(codecForUiFormField())) - .build("DoubleColumnFormSection"); - -const codecForDoubleColumnForm = (): Codec<DoubleColumnForm> => - buildCodecForObject<DoubleColumnForm>() - .property("type", codecForConstString("double-column")) - .property("design", codecForList(codecForDoubleColumnFormSection())) - .build("DoubleColumnForm"); - -const codecForFlexibleForm = (): Codec<FlexibleForm> => - buildCodecForUnion<FlexibleForm>() - .discriminateOn("type") - .alternative("double-column", codecForDoubleColumnForm()) - .build<FlexibleForm>("FlexibleForm"); - -const codecForFormMetadata = (): Codec<FormMetadata> => - buildCodecForObject<FormMetadata>() - .property("label", codecForString()) - .property("id", codecForString()) - .property("version", codecForNumber()) - .property("config", codecForFlexibleForm()) - .build("FormMetadata"); -const codecForUIForms = (): Codec<UiForms> => - buildCodecForObject<UiForms>() - .property("forms", codecForList(codecForFormMetadata())) - .build("UiForms"); function removeUndefineField<T extends object>(obj: T): T { const keys = Object.keys(obj) as Array<keyof T>; diff --git a/packages/aml-backoffice-ui/src/forms.json b/packages/aml-backoffice-ui/src/forms.json @@ -0,0 +1,539 @@ +{ + "forms": [ + { + "label": "Information on customer", + "id": "902_1e", + "version": 1, + "config": { + "type": "double-column", + "design": [ + { + "title": "Information on customer", + "description": "The customer is the person with whom the member concludes the contract with regard to the financial service provided (civil law). Does the member act as director of a domiciliary company, this domiciliary company is the customer.", + "fields": [ + { + "type": "choiceStacked", + "properties": { + "name": "customerType", + "id": ".customerType", + "label": "Type of customer", + "required": true, + "choices": [ + { + "label": "Natural person", + "value": "natural" + }, + { + "label": "Legal entity", + "value": "legal" + } + ] + } + }, + { + "type": "group", + "properties": { + "label": "Natural customer form", + "name": "algo", + "id": "algo", + "before": "a) Country risk (nationality)", + "after": "a) Country risk (nationality)", + "fields": [ + { + "type": "text", + "properties": { + "name": "naturalCustomer.fullName", + "id": ".naturalCustomer.fullName", + "label": "Full name", + "required": true + } + } + ] + } + }, + + { + "type": "text", + "properties": { + "name": "naturalCustomer.address", + "id": ".naturalCustomer.address", + "label": "Residential address", + "required": true + } + }, + { + "type": "integer", + "properties": { + "name": "naturalCustomer.telephone", + "id": ".naturalCustomer.telephone", + "label": "Telephone" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.email", + "id": ".naturalCustomer.email", + "label": "E-mail" + } + }, + { + "type": "absoluteTime", + "properties": { + "pattern": "dd/MM/yyyy", + "name": "naturalCustomer.dateOfBirth", + "id": ".naturalCustomer.dateOfBirth", + "label": "Date of birth", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.nationality", + "id": ".naturalCustomer.nationality", + "label": "Nationality", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.document", + "id": ".naturalCustomer.document", + "label": "Identification document", + "required": true + } + }, + { + "type": "file", + "properties": { + "name": "naturalCustomer.documentAttachment", + "id": ".naturalCustomer.documentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".pdf", + "help": "PDF file with max size of 2 mega bytes" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.companyName", + "id": ".naturalCustomer.companyName", + "label": "Company name" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.office", + "id": ".naturalCustomer.office", + "label": "Registered office" + } + }, + { + "type": "text", + "properties": { + "name": "naturalCustomer.companyDocument", + "id": ".naturalCustomer.companyDocument", + "label": "Company identification document" + } + }, + { + "type": "file", + "properties": { + "name": "naturalCustomer.companyDocumentAttachment", + "id": ".naturalCustomer.companyDocumentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".png", + "help": "PNG file with max size of 2 mega bytes" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.companyName", + "id": ".legalCustomer.companyName", + "label": "Company name", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.domicile", + "id": ".legalCustomer.domicile", + "label": "Domicile", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.contactPerson", + "id": ".legalCustomer.contactPerson", + "label": "Contact person" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.telephone", + "id": ".legalCustomer.telephone", + "label": "Telephone" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.email", + "id": ".legalCustomer.email", + "label": "E-mail" + } + }, + { + "type": "text", + "properties": { + "name": "legalCustomer.document", + "id": ".legalCustomer.document", + "label": "Identification document", + "help": "Not older than 12 month" + } + }, + { + "type": "file", + "properties": { + "name": "legalCustomer.documentAttachment", + "id": ".legalCustomer.documentAttachment", + "label": "Document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".png", + "help": "PNG file with max size of 2 mega bytes" + } + } + ] + }, + { + "title": "Information on the natural persons who establish the business relationship for legal entities and partnerships", + "description": "For legal entities and partnerships the identity of the natural persons who establish the business relationship must be verified.", + "fields": [ + { + "type": "array", + "properties": { + "name": "businessEstablisher", + "id": ".businessEstablisher", + "label": "Persons", + "required": true, + "labelFieldId": "fullName", + "placeholder": "this is the placeholder", + "fields": [ + { + "type": "text", + "properties": { + "name": "fullName", + "id": ".fullName", + "label": "Full name", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "address", + "id": ".address", + "label": "Residential address", + "required": true + } + }, + { + "type": "absoluteTime", + "properties": { + "pattern": "dd/MM/yyyy", + "name": "dateOfBirth", + "id": ".dateOfBirth", + "label": "Date of birth", + "required": true + } + }, + + { + "type": "text", + "properties": { + "name": "nationality", + "id": ".nationality", + "label": "Nationality", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "typeOfAuthorization", + "id": ".typeOfAuthorization", + "label": "Type of authorization (signatory of representation)", + "required": true + } + }, + { + "type": "file", + "properties": { + "name": "documentAttachment", + "id": ".documentAttachment", + "label": "Identification document attachment", + "required": true, + "maxBites": 2097152, + "accept": ".pdf", + "help": "PDF file with max size of 2 mega bytes" + } + }, + { + "type": "choiceStacked", + "properties": { + "name": "powerOfAttorneyArrangements", + "id": ".powerOfAttorneyArrangements", + "label": "Power of attorney arrangements", + "required": true, + "choices": [ + { + "label": "CR extract", + "value": "cr" + }, + { + "label": "Mandate", + "value": "mandate" + }, + { + "label": "Other", + "value": "other" + } + ] + } + }, + { + "type": "text", + "properties": { + "name": "powerOfAttorneyArrangementsOther", + "id": ".powerOfAttorneyArrangementsOther", + "label": "Power of attorney arrangements", + "required": true + } + } + ], + "labelField": "fullName" + } + } + ] + }, + { + "title": "Acceptance of business relationship", + "fields": [ + { + "type": "absoluteTime", + "properties": { + "name": "acceptance.when", + "id": ".acceptance.when", + "pattern": "dd/MM/yyyy", + "converterId": "Taler.AbsoluteTime", + "label": "Date (conclusion of contract)" + } + }, + { + "type": "choiceStacked", + "properties": { + "name": "acceptance.acceptedBy", + "id": ".acceptance.acceptedBy", + "label": "Accepted by", + "required": true, + "choices": [ + { + "label": "Face-to-face meeting with customer", + "value": "face-to-face" + }, + { + "label": "Correspondence: authenticated copy of identification document obtained", + "value": "correspondence-document" + }, + { + "label": "Correspondence: residential address validated", + "value": "correspondence-address" + } + ] + } + }, + { + "type": "choiceStacked", + "properties": { + "name": "acceptance.typeOfCorrespondence", + "id": ".acceptance.typeOfCorrespondence", + "label": "Type of correspondence service", + "choices": [ + { + "label": "to the customer", + "value": "customer" + }, + { + "label": "hold at bank", + "value": "bank" + }, + { + "label": "to the member", + "value": "member" + }, + { + "label": "to a third party", + "value": "third-party" + } + ] + } + }, + { + "type": "text", + "properties": { + "name": "acceptance.thirdPartyFullName", + "id": ".acceptance.thirdPartyFullName", + "label": "Third party full name", + "required": true + } + }, + { + "type": "text", + "properties": { + "name": "acceptance.thirdPartyAddress", + "id": ".acceptance.thirdPartyAddress", + "label": "Third party address", + "required": true + } + }, + { + "type": "selectMultiple", + "properties": { + "name": "acceptance.language", + "id": ".acceptance.language", + "label": "Languages", + "choices": [ + { + "label": "Espanol", + "value": "es" + } + ], + "unique": true + } + }, + { + "type": "textArea", + "properties": { + "name": "acceptance.furtherInformation", + "id": ".acceptance.furtherInformation", + "label": "Further information" + } + } + ] + }, + { + "title": "Information on the beneficial owner of the assets and/or controlling person", + "description": "Establishment of the beneficial owner of the assets and/or controlling person", + "fields": [ + { + "type": "choiceStacked", + "properties": { + "name": "establishment", + "id": ".establishment", + "label": "The customer is", + "required": true, + "choices": [ + { + "label": "a natural person and there are no doubts that this person is the sole beneficial owner of the assets", + "value": "natural" + }, + { + "label": "a foundation (or a similar construct; incl. underlying companies)", + "value": "foundation" + }, + { + "label": "a trust (incl. underlying companies)", + "value": "trust" + }, + { + "label": "a life insurance policy with separately managed accounts/securities accounts", + "value": "insurance-wrapper" + }, + { + "label": "all other cases", + "value": "other" + } + ] + } + } + ] + }, + { + "title": "Evaluation with regard to embargo procedures/terrorism lists on establishing the business relationship", + "description": "Verification whether the customer, beneficial owners of the assets, controlling persons, authorized representatives or other involved persons are listed on an embargo/terrorism list (date of verification/result)", + "fields": [ + { + "type": "textArea", + "properties": { + "name": "embargoEvaluation", + "id": ".embargoEvaluation", + "help": "The evaluation must be made at the beginning of the business relationship and has to be repeated in the case of permanent business relationship every time the according lists are updated.", + "label": "Evaluation" + } + } + ] + }, + { + "title": "In the case of cash transactions/occasional customers: Information on type and purpose of business relationship", + "description": "These details are only necessary for occasional customers, i.e. money exchange, money and asset transfer or other cash transactions provided that no customer profile (VQF doc. No. 902.5) is created", + "fields": [ + { + "type": "choiceStacked", + "properties": { + "name": "cashTransactions.typeOfBusiness", + "id": ".cashTransactions.typeOfBusiness", + "label": "Type of business relationship", + "choices": [ + { + "label": "Money exchange", + "value": "money-exchange" + }, + { + "label": "Money and asset transfer", + "value": "money-and-asset-transfer" + }, + { + "label": "Other cash transactions. Specify below", + "value": "other" + } + ] + } + }, + { + "type": "text", + "properties": { + "name": "cashTransactions.otherTypeOfBusiness", + "id": ".cashTransactions.otherTypeOfBusiness", + "required": true, + "label": "Specify other cash transactions:" + } + }, + { + "type": "textArea", + "properties": { + "name": "cashTransactions.purpose", + "id": ".cashTransactions.purpose", + "label": "Purpose of the business relationship (purpose of service requested)" + } + } + ] + } + ] + } + } + ], + "not_yet_supported": [] +} diff --git a/packages/aml-backoffice-ui/src/forms/index.ts b/packages/aml-backoffice-ui/src/forms/index.ts @@ -13,8 +13,7 @@ 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/> */ -import type { InternationalizationAPI } from "@gnu-taler/web-util/browser"; -import { FormMetadata } from "../context/ui-forms.js"; +import type { FormMetadata, InternationalizationAPI } from "@gnu-taler/web-util/browser"; import { v1 as simplest } from "./simplest.js"; const languages = (i18n: InternationalizationAPI) => [ diff --git a/packages/aml-backoffice-ui/src/forms/simplest.ts b/packages/aml-backoffice-ui/src/forms/simplest.ts @@ -15,9 +15,11 @@ */ import type { - InternationalizationAPI + DoubleColumnForm, + DoubleColumnFormSection, + InternationalizationAPI, + UIHandlerId } from "@gnu-taler/web-util/browser"; -import { DoubleColumnForm, DoubleColumnFormSection, UIHandlerId } from "../context/ui-forms.js"; export const v1 = (i18n: InternationalizationAPI): DoubleColumnForm => ({ type: "double-column" as const, @@ -30,7 +32,7 @@ export const v1 = (i18n: InternationalizationAPI): DoubleColumnForm => ({ properties: { id: ".comment" as UIHandlerId, name: "comment", - label: i18n.str`Comments`, + label: i18n.str`Comment`, }, }, ], diff --git a/packages/aml-backoffice-ui/src/hooks/form.ts b/packages/aml-backoffice-ui/src/hooks/form.ts @@ -19,10 +19,11 @@ import { AmountJson, TalerExchangeApi, TranslatedString, + assertUnreachable, } from "@gnu-taler/taler-util"; -import { UIFieldHandler } from "@gnu-taler/web-util/browser"; +import { Addon, InternationalizationAPI, UIFieldBaseDescription, UIFieldHandler, UIFormField, UIFormFieldBaseConfig, UIFormFieldConfig, UIHandlerId } from "@gnu-taler/web-util/browser"; import { useState } from "preact/hooks"; -import { UIFormFieldConfig, UIHandlerId } from "../context/ui-forms.js"; +import { getConverterById } from "../utils/converter.js"; // export type UIField = { // value: string | undefined; @@ -93,41 +94,17 @@ function constructFormHandler<T>( updateForm(setValueDeeper(form, path, newValue)); } - const currentValue: unknown = getValueDeeper(form, path) - const currentError: unknown = errors === undefined ? undefined : getValueDeeper(errors, path) + const currentValue = getValueDeeper<string>(form as any, path, undefined) + const currentError = getValueDeeper<TranslatedString>(errors as any, path, undefined) const field: UIFieldHandler = { - // @ts-expect-error FIXME better typing error: currentError, - // @ts-expect-error FIXME better typing value: currentValue, onChange: updater, - state: {}, + state: {}, //FIXME: add the state of the field (hidden, ) }; return setValueDeeper(handleForm, path, field) - /** - * There is no clear way to know if this object is a custom field - * or a group of fields - */ - // if (typeof currentValue === "object") { - // // @ts-expect-error FIXME better typing - // const group = constructFormHandler(currentValue, updater, currentError); - // // @ts-expect-error FIXME better typing - // prev[fieldName] = group; - // return prev; - // } - - // const field: UIFieldHandler = { - // // @ts-expect-error FIXME better typing - // error: currentError, - // // @ts-expect-error FIXME better typing - // value: currentValue, - // onChange: updater, - // state: {}, - // }; - // handleForm[fieldName] = field; - // return handleForm; }, {} as FormHandler<T>); return handler; @@ -156,22 +133,40 @@ export function useFormState<T>( return [handler, status]; } +interface Tree<T> extends Record<string, Tree<T> | T> {} -function getValueDeeper( +function getValueDeeper<T>( + object: Tree<T> | undefined, + names: string[], + notFoundValue?: T, +): T | undefined { + if (names.length === 0) return object as T; + const [head, ...rest] = names; + if (!head) { + return getValueDeeper(object, rest, notFoundValue); + } + if (object === undefined) { + return notFoundValue + } + return getValueDeeper(object[head] as Tree<T>, rest, notFoundValue); +} + +function getValueDeeper2( object: Record<string, any>, names: string[], ): UIFieldHandler { if (names.length === 0) return object as UIFieldHandler; const [head, ...rest] = names; if (!head) { - return getValueDeeper(object, rest); + return getValueDeeper2(object, rest); } if (object === undefined) { throw Error("handler not found"); } - return getValueDeeper(object[head], rest); + return getValueDeeper2(object[head], rest); } + function setValueDeeper(object: any, names: string[], value: any): any { if (names.length === 0) return value; const [head, ...rest] = names; @@ -183,3 +178,193 @@ function setValueDeeper(object: any, names: string[], value: any): any { } return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) }; } + +function getAddonById(_id: string | undefined): Addon { + return undefined!; +} + + +function converInputFieldsProps( + form: FormHandler<unknown>, + p: UIFormFieldBaseConfig, +) { + return { + converter: getConverterById(p.converterId, p), + handler: getValueDeeper2(form, p.id.split(".")), + name: p.name, + required: p.required, + disabled: p.disabled, + help: p.help, + placeholder: p.placeholder, + tooltip: p.tooltip, + label: p.label as TranslatedString, + }; +} + +function converBaseFieldsProps( + i18n_: InternationalizationAPI, + p: UIFieldBaseDescription, +) { + return { + after: getAddonById(p.addonAfterId), + before: getAddonById(p.addonBeforeId), + hidden: p.hidden, + name: p.name, + help: i18n_.str`${p.help}`, + label: i18n_.str`${p.label}`, + tooltip: i18n_.str`${p.tooltip}`, + }; +} + +export function convertUiField( + i18n_: InternationalizationAPI, + fieldConfig: UIFormFieldConfig[], + form: FormHandler<unknown>, +): UIFormField[] { + return fieldConfig.map((config) => { + // NON input fields + switch (config.type) { + case "caption": { + const resp: UIFormField = { + type: config.type, + properties: converBaseFieldsProps(i18n_, config.properties), + }; + return resp; + } + case "group": { + const resp: UIFormField = { + type: config.type, + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + fields: convertUiField(i18n_, config.properties.fields, form), + }, + }; + return resp; + } + } + // Input Fields + switch (config.type) { + case "array": { + return { + type: "array", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + labelField: config.properties.labelFieldId, + fields: convertUiField(i18n_, config.properties.fields, form), + }, + } as UIFormField; + } + case "absoluteTime": { + return { + type: "absoluteTime", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + }, + } as UIFormField; + } + case "amount": { + return { + type: "amount", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + }, + } as UIFormField; + } + case "choiceHorizontal": { + return { + type: "choiceHorizontal", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "choiceStacked": { + return { + type: "choiceStacked", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + choices: config.properties.choices, + + }, + }as UIFormField; + } + case "file":{ + console.log("ASDASD", config.properties.accept) + return { + type: "file", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + accept: config.properties.accept, + maxBites: config.properties.maxBytes, + }, + } as UIFormField; + } + case "integer":{ + return { + type: "integer", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + }, + } as UIFormField; + } + case "selectMultiple":{ + return { + type: "selectMultiple", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "selectOne": { + return { + type: "selectOne", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + choices: config.properties.choices, + }, + } as UIFormField; + } + case "text": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + }, + } as UIFormField; + } + case "textArea": { + return { + type: "text", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + }, + } as UIFormField; + } + case "toggle": { + return { + type: "toggle", + properties: { + ...converBaseFieldsProps(i18n_, config.properties), + ...converInputFieldsProps(form, config.properties), + }, + } as UIFormField; + } + default: { + assertUnreachable(config); + } + } + }); +} diff --git a/packages/aml-backoffice-ui/src/index.html b/packages/aml-backoffice-ui/src/index.html @@ -30,7 +30,6 @@ <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> <title>Exchange Backoffice</title> <!-- Entry point for the SPA. --> - <script type="module" src="forms.js"></script> <script type="module" src="index.js"></script> <link rel="stylesheet" href="index.css" /> </head> diff --git a/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx b/packages/aml-backoffice-ui/src/pages/CaseDetails.tsx @@ -34,24 +34,26 @@ import { import { DefaultForm, ErrorLoading, + FormMetadata, InternationalizationAPI, Loading, - useTranslationContext + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { VNode, h } from "preact"; import { useState } from "preact/hooks"; import { privatePages } from "../Routing.js"; -import { FormMetadata, useUiFormsContext } from "../context/ui-forms.js"; +import { useUiFormsContext } from "../context/ui-forms.js"; +import { preloadedForms } from "../forms/index.js"; import { useCaseDetails } from "../hooks/useCaseDetails.js"; import { ShowConsolidated } from "./ShowConsolidated.js"; -import { preloadedForms } from "../forms/index.js"; export type AmlEvent = | AmlFormEvent | AmlFormEventError | KycCollectionEvent | KycExpirationEvent; + type AmlFormEvent = { type: "aml-form"; when: AbsoluteTime; @@ -163,9 +165,9 @@ export function CaseDetails({ account }: { account: string }) { const { i18n } = useTranslationContext(); const details = useCaseDetails(account); - const {forms} = useUiFormsContext() + const { forms } = useUiFormsContext(); - const allForms = [...forms, ...preloadedForms(i18n)] + const allForms = [...forms, ...preloadedForms(i18n)]; if (!details) { return <Loading />; } @@ -185,7 +187,12 @@ export function CaseDetails({ account }: { account: string }) { } const { aml_history, kyc_attributes } = details.body; - const events = getEventsFromAmlHistory(aml_history, kyc_attributes, i18n, allForms); + const events = getEventsFromAmlHistory( + aml_history, + kyc_attributes, + i18n, + allForms, + ); if (showForm !== undefined) { return ( diff --git a/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx b/packages/aml-backoffice-ui/src/pages/CaseUpdate.tsx @@ -23,35 +23,27 @@ import { assertUnreachable, } from "@gnu-taler/taler-util"; import { - Addon, Button, + FormMetadata, InternationalizationAPI, LocalNotificationBanner, RenderAllFieldsByUiConfig, - StringConverter, - UIFieldHandler, - UIFormField, + UIHandlerId, useExchangeApiContext, useLocalNotificationHandler, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { privatePages } from "../Routing.js"; import { - FormMetadata, - UIFieldBaseDescription, - UIFormFieldBaseConfig, - UIFormFieldConfig, - UIHandlerId, - useUiFormsContext, + useUiFormsContext } from "../context/ui-forms.js"; import { preloadedForms } from "../forms/index.js"; -import { FormErrors, FormHandler, useFormState } from "../hooks/form.js"; +import { FormErrors, convertUiField, useFormState } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { Justification } from "./CaseDetails.js"; -import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; import { undefinedIfEmpty } from "./CreateAccount.js"; -import { getConverterById } from "../utils/converter.js"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; function searchForm( i18n: InternationalizationAPI, @@ -112,16 +104,18 @@ export function CaseUpdate({ theForm.config.design.forEach((section) => { section.fields.forEach((field) => { if ("id" in field.properties) { + //FIXME: this should be a validation + if (shape.indexOf(field.properties.id) !== -1) { + throw Error(`already present: ${field.properties.id}`) + } shape.push(field.properties.id); - // const path = field.properties.id.split("."); - // defaultValue = setValueDeeper(defaultValue, path, undefined); } }); }); const [form, state] = useFormState<FormType>(shape, initial, (st) => { const errors = undefinedIfEmpty<FormErrors<FormType>>({ - state: !st.state ? i18n.str`required` : undefined, + state: st.state === undefined ? i18n.str`required` : undefined, threshold: !st.threshold ? i18n.str`required` : undefined, when: !st.when ? i18n.str`required` : undefined, comment: !st.comment ? i18n.str`required` : undefined, @@ -140,8 +134,6 @@ export function CaseUpdate({ }; }); - console.log("NOW FORM", form); - const validatedForm = state.status !== "ok" ? undefined : state.result; const submitHandler = @@ -249,7 +241,6 @@ export function SelectForm({ account }: { account: string }) { const { i18n } = useTranslationContext(); const { forms } = useUiFormsContext(); const pf = preloadedForms(i18n); - return ( <div> <pre>New form for account: {account.substring(0, 16)}...</pre> @@ -279,147 +270,3 @@ export function SelectForm({ account }: { account: string }) { ); } -function getValueDeeper( - object: Record<string, any>, - names: string[], -): UIFieldHandler { - if (names.length === 0) return object as UIFieldHandler; - const [head, ...rest] = names; - if (!head) { - return getValueDeeper(object, rest); - } - if (object === undefined) { - throw Error("handler not found"); - } - return getValueDeeper(object[head], rest); -} - -function setValueDeeper(object: any, names: string[], value: any): any { - if (names.length === 0) return value; - const [head, ...rest] = names; - if (!head) { - return setValueDeeper(object, rest, value); - } - if (object === undefined) { - return { [head]: setValueDeeper({}, rest, value) }; - } - return { ...object, [head]: setValueDeeper(object[head] ?? {}, rest, value) }; -} - -function getAddonById(_id: string | undefined): Addon { - return undefined!; -} - - -function converInputFieldsProps( - form: FormHandler<unknown>, - p: UIFormFieldBaseConfig, -) { - return { - converter: getConverterById(p.converterId), - handler: getValueDeeper(form, p.id.split(".")), - }; -} - -function converBaseFieldsProps( - i18n_: InternationalizationAPI, - p: UIFieldBaseDescription, -) { - return { - after: getAddonById(p.addonAfterId), - before: getAddonById(p.addonBeforeId), - hidden: p.hidden, - name: p.name, - help: i18n_.str`${p.help}`, - label: i18n_.str`${p.label}`, - tooltip: i18n_.str`${p.tooltip}`, - }; -} - -function convertUiField( - i18n_: InternationalizationAPI, - fieldConfig: UIFormFieldConfig[], - form: FormHandler<unknown>, -): UIFormField[] { - return fieldConfig.map((config) => { - // NON input fields - switch (config.type) { - case "caption": { - const resp: UIFormField = { - type: config.type, - properties: converBaseFieldsProps(i18n_, config.properties), - }; - return resp; - } - case "group": { - const resp: UIFormField = { - type: config.type, - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - fields: convertUiField(i18n_, config.properties.fields, form), - }, - }; - return resp; - } - } - // Input Fields - switch (config.type) { - case "absoluteTime": { - return undefined!; - } - case "amount": { - return { - type: "amount", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; - } - case "array": { - return undefined!; - } - case "choiceHorizontal": { - return { - type: "choiceHorizontal", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - choices: config.properties.choices, - }, - } as UIFormField; - } - case "choiceStacked": - case "file": - case "integer": - case "selectMultiple": - case "selectOne": { - return undefined!; - } - case "text": { - return { - type: "text", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; - } - case "textArea": { - return { - type: "text", - properties: { - ...converBaseFieldsProps(i18n_, config.properties), - ...converInputFieldsProps(form, config.properties), - }, - } as UIFormField; - } - case "toggle": { - return undefined!; - } - default: { - assertUnreachable(config); - } - } - }); -} diff --git a/packages/aml-backoffice-ui/src/pages/Cases.tsx b/packages/aml-backoffice-ui/src/pages/Cases.tsx @@ -24,6 +24,7 @@ import { ErrorLoading, InputChoiceHorizontal, Loading, + UIHandlerId, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -34,7 +35,6 @@ import { privatePages } from "../Routing.js"; import { FormErrors, RecursivePartial, useFormState } from "../hooks/form.js"; import { undefinedIfEmpty } from "./CreateAccount.js"; import { Officer } from "./Officer.js"; -import { UIHandlerId } from "../context/ui-forms.js"; import { amlStateConverter } from "../utils/converter.js"; type FormType = { diff --git a/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx b/packages/aml-backoffice-ui/src/pages/CreateAccount.tsx @@ -18,6 +18,7 @@ import { InputLine, InternationalizationAPI, LocalNotificationBanner, + UIHandlerId, useLocalNotificationHandler, useTranslationContext, } from "@gnu-taler/web-util/browser"; @@ -31,7 +32,6 @@ import { } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { usePreferences } from "../hooks/preferences.js"; -import { UIHandlerId } from "../context/ui-forms.js"; type FormType = { password: string; diff --git a/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx b/packages/aml-backoffice-ui/src/pages/ShowConsolidated.tsx @@ -23,6 +23,8 @@ import { DefaultForm, FlexibleForm, UIFormField, + UIFormFieldConfig, + UIHandlerId, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; @@ -40,22 +42,8 @@ export function ShowConsolidated({ const cons = getConsolidated(history, until); - const form: FlexibleForm<Consolidated> = { - behavior: (form) => { - return { - aml: { - threshold: { - hidden: !form.aml, - }, - since: { - hidden: !form.aml, - }, - state: { - hidden: !form.aml, - }, - }, - }; - }, + const form: FlexibleForm = { + type: "double-column", design: [ { title: i18n.str`AML`, @@ -63,6 +51,8 @@ export function ShowConsolidated({ { type: "amount", properties: { + id: ".aml.threshold" as UIHandlerId, + currency: "NETZBON", label: i18n.str`Threshold`, name: "aml.threshold", }, @@ -72,7 +62,7 @@ export function ShowConsolidated({ properties: { label: i18n.str`State`, name: "aml.state", - + id: ".aml.state" as UIHandlerId, choices: [ { label: i18n.str`Frozen`, @@ -95,10 +85,11 @@ export function ShowConsolidated({ ? { title: i18n.str`KYC`, fields: Object.entries(cons.kyc).map(([key, field]) => { - const result: UIFormField = { + const result: UIFormFieldConfig = { type: "text", properties: { label: key as TranslatedString, + id: `kyc.${key}.value` as UIHandlerId, name: `kyc.${key}.value`, help: `${field.provider} since ${ field.since.t_ms === "never" @@ -110,7 +101,7 @@ export function ShowConsolidated({ return result; }), } - : undefined, + : undefined!, ], }; return ( @@ -123,7 +114,7 @@ export function ShowConsolidated({ </h1> <DefaultForm key={`${String(Date.now())}`} - form={form} + form={form as any} initial={cons} readOnly onUpdate={() => {}} diff --git a/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/aml-backoffice-ui/src/pages/UnlockAccount.tsx @@ -17,6 +17,7 @@ import { Button, InputLine, LocalNotificationBanner, + UIHandlerId, useLocalNotificationHandler, useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -24,7 +25,6 @@ import { VNode, h } from "preact"; import { FormErrors, useFormState } from "../hooks/form.js"; import { useOfficer } from "../hooks/officer.js"; import { undefinedIfEmpty } from "./CreateAccount.js"; -import { UIHandlerId } from "../context/ui-forms.js"; type FormType = { password: string; diff --git a/packages/aml-backoffice-ui/src/settings.json b/packages/aml-backoffice-ui/src/settings.json @@ -0,0 +1,4 @@ +{ + "backendBaseURL": "http://exchange.taler.test:1180/", + "signupEmail": "do-not-contact-me@exchange.taler.test" +} +\ No newline at end of file diff --git a/packages/aml-backoffice-ui/src/utils/converter.ts b/packages/aml-backoffice-ui/src/utils/converter.ts @@ -14,8 +14,14 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson, Amounts, TalerExchangeApi } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + AmountJson, + Amounts, + TalerExchangeApi, +} from "@gnu-taler/taler-util"; import { StringConverter } from "@gnu-taler/web-util/browser"; +import { format, parse } from "date-fns"; export const amlStateConverter = { toStringUI: stringifyAmlState, @@ -47,20 +53,63 @@ function parseAmlState(s: string | undefined): TalerExchangeApi.AmlState { } } -const amountConverter: StringConverter<AmountJson> = { - fromStringUI(v: string | undefined): AmountJson { - // FIXME: requires currency - return Amounts.parse(`NETZBON:${v}`) ?? Amounts.zeroOfCurrency("NETZBON"); - }, - toStringUI(v: unknown): string { - return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); - }, -}; +function amountConverter(config: any): StringConverter<AmountJson> { + const currency = config["currency"]; + if (!currency || typeof currency !== "string") { + throw Error(`amount converter needs a currency`); + } + return { + fromStringUI(v: string | undefined): AmountJson { + // FIXME: requires currency + return Amounts.parse(`${currency}:${v}`) ?? Amounts.zeroOfCurrency(currency); + }, + toStringUI(v: unknown): string { + return v === undefined ? "" : Amounts.stringifyValue(v as AmountJson); + }, + }; +} + +function absTimeConverter(config: any): StringConverter<AbsoluteTime> { + const pattern = config["pattern"]; + if (!pattern || typeof pattern !== "string") { + throw Error(`absTime converter needs a pattern`); + } + return { + fromStringUI(v: string | undefined): AbsoluteTime { + if (v === undefined) { + return AbsoluteTime.never(); + } + try { + const time = parse(v, pattern, new Date()); + return AbsoluteTime.fromMilliseconds(time.getTime()); + } catch(e) { + return AbsoluteTime.never(); + } + }, + toStringUI(v: unknown): string { + if (v === undefined) return ""; + const d = v as AbsoluteTime; + if (d.t_ms === "never") return "never"; + try { + return format(d.t_ms, pattern) + } catch (e) { + return "" + } + }, + }; +} -export function getConverterById(id: string | undefined): StringConverter<unknown> { +export function getConverterById( + id: string | undefined, + config: unknown, +): StringConverter<unknown> { + if (id === "Taler.AbsoluteTime") { + // @ts-expect-error check this + return absTimeConverter(config); + } if (id === "Taler.Amount") { // @ts-expect-error check this - return amountConverter; + return amountConverter(config); } if (id === "TalerExchangeApi.AmlState") { // @ts-expect-error check this diff --git a/packages/taler-util/src/codec.ts b/packages/taler-util/src/codec.ts @@ -491,6 +491,19 @@ export function codecOptional<V>(innerCodec: Codec<V>): Codec<V | undefined> { }; } +export function codecForLazy<V>(innerCodec: () => Codec<V>): Codec<V> { + let instance: Codec<V> | undefined = undefined + return { + decode(x: any, c?: Context): V { + if (instance === undefined) { + instance = innerCodec() + } + return instance.decode(x, c); + }, + }; +} + + export type CodecType<T> = T extends Codec<infer X> ? X : any; export function codecForEither<T extends Array<Codec<unknown>>>( diff --git a/packages/web-util/src/forms/DefaultForm.tsx b/packages/web-util/src/forms/DefaultForm.tsx @@ -2,14 +2,15 @@ import { Fragment, VNode, h } from "preact"; import { FormProvider, FormProviderProps, FormState } from "./FormProvider.js"; import { RenderAllFieldsByUiConfig, UIFormField } from "./forms.js"; import { TranslatedString } from "@gnu-taler/taler-util"; +// import { FlexibleForm } from "./ui-form.js"; /** * Flexible form uses a DoubleColumForm for design * and may have a dynamic properties defined by * behavior function. */ -export interface FlexibleForm<T extends object> { - design: DoubleColumnForm; +export interface FlexibleForm_Deprecated<T extends object> { + design: DoubleColumnForm_Deprecated; behavior?: (form: Partial<T>) => FormState<T>; } @@ -20,9 +21,9 @@ export interface FlexibleForm<T extends object> { * have a description. * Every sections contain a set of fields. */ -export type DoubleColumnForm = Array<DoubleColumnFormSection | undefined>; +export type DoubleColumnForm_Deprecated = Array<DoubleColumnFormSection_Deprecated | undefined>; -export type DoubleColumnFormSection = { +export type DoubleColumnFormSection_Deprecated = { title: TranslatedString; description?: TranslatedString; fields: UIFormField[]; @@ -39,14 +40,14 @@ export function DefaultForm<T extends object>({ onSubmit, children, readOnly, -}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm<T> }): VNode { +}: Omit<FormProviderProps<T>, "computeFormState"> & { form: FlexibleForm_Deprecated<T> }): VNode { return ( <FormProvider initial={initial} onUpdate={onUpdate} onSubmit={onSubmit} readOnly={readOnly} - computeFormState={form.behavior} + // computeFormState={form.behavior} > <div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> {form.design.map((section, i) => { diff --git a/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx b/packages/web-util/src/forms/InputAbsoluteTime.stories.tsx @@ -22,7 +22,7 @@ import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { today: AbsoluteTime.now() } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputAmount.stories.tsx b/packages/web-util/src/forms/InputAmount.stories.tsx @@ -22,7 +22,7 @@ import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { amount: Amounts.parseOrThrow("USD:10") } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputArray.stories.tsx b/packages/web-util/src/forms/InputArray.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -49,7 +49,7 @@ const initial: TargetObject = { }] } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputArray.tsx b/packages/web-util/src/forms/InputArray.tsx @@ -157,7 +157,8 @@ export function InputArray<T extends object, K extends keyof T>( // elements should be present in the state object since this is expected to be an array //@ts-ignore - return state.elements[selectedIndex]; + // return state.elements[selectedIndex]; + return {} }} onSubmit={(v) => { const newValue = [...list]; diff --git a/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx b/packages/web-util/src/forms/InputChoiceHorizontal.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "0" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputChoiceStacked.stories.tsx b/packages/web-util/src/forms/InputChoiceStacked.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputFile.stories.tsx b/packages/web-util/src/forms/InputFile.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputFile.tsx b/packages/web-util/src/forms/InputFile.tsx @@ -1,16 +1,14 @@ import { Fragment, VNode, h } from "preact"; import { UIFormProps } from "./FormProvider.js"; +import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; import { useField } from "./useField.js"; -import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; export function InputFile<T extends object, K extends keyof T>( props: { maxBites: number; accept?: string } & UIFormProps<T, K>, ): VNode { const { - name, label, - placeholder, tooltip, required, help: propsHelp, @@ -26,6 +24,20 @@ export function InputFile<T extends object, K extends keyof T>( if (state.hidden) { return <div />; } + + const valueStr = !value ? "" : value.toString(); + const firstColon = valueStr.indexOf(";"); + + const { fileName, dataUri } = valueStr.startsWith("file:") + ? { + fileName: valueStr.substring(5, firstColon), + dataUri: valueStr.substring(firstColon + 1), + } + : { + fileName: "", + dataUri: valueStr, + }; + return ( <div class="col-span-full"> <LabelWithTooltipMaybeRequired @@ -33,7 +45,7 @@ export function InputFile<T extends object, K extends keyof T>( tooltip={tooltip} required={required} /> - {!value || !(value as string).startsWith("data:image/") ? ( + {!dataUri ? ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 py-1"> <div class="text-center"> <svg @@ -51,13 +63,12 @@ export function InputFile<T extends object, K extends keyof T>( {!state.disabled && ( <div class="my-2 flex text-sm leading-6 text-gray-600"> <label - for="file-upload" + for={String(props.name)} class="relative cursor-pointer rounded-md bg-white font-semibold text-indigo-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 hover:text-indigo-500" > <span>Upload a file</span> <input - id="file-upload" - name="file-upload" + id={String(props.name)} type="file" class="sr-only" accept={accept} @@ -69,6 +80,7 @@ export function InputFile<T extends object, K extends keyof T>( if (f[0].size > maxBites) { return onChange(undefined!); } + const fileName = f[0].name; return f[0].arrayBuffer().then((b) => { const b64 = window.btoa( new Uint8Array(b).reduce( @@ -76,9 +88,15 @@ export function InputFile<T extends object, K extends keyof T>( "", ), ); - return onChange( - `data:${f[0].type};base64,${b64}` as any, - ); + 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, + ); + } }); }} /> @@ -90,10 +108,18 @@ export function InputFile<T extends object, K extends keyof T>( </div> ) : ( <div class="mt-2 flex justify-center rounded-lg border border-dashed border-gray-900/25 relative"> - <img - src={value as string} - class=" h-24 w-full object-cover relative" - /> + {(dataUri as string).startsWith("data:image/") ? ( + <img src={dataUri} class=" h-24 w-full object-cover relative" /> + ) : ( + <div /> + )} + {fileName ? ( + <div class="absolute rounded-lg border flex justify-center text-xl items-center text-white "> + {fileName} + </div> + ) : ( + <Fragment /> + )} {!state.disabled && ( <div diff --git a/packages/web-util/src/forms/InputInteger.stories.tsx b/packages/web-util/src/forms/InputInteger.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -38,7 +38,7 @@ const initial: TargetObject = { age: 5, } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputLine.stories.tsx b/packages/web-util/src/forms/InputLine.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputSelectMultiple.stories.tsx b/packages/web-util/src/forms/InputSelectMultiple.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -45,7 +45,7 @@ const initial: TargetObject = { things: [], } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputSelectMultiple.tsx b/packages/web-util/src/forms/InputSelectMultiple.tsx @@ -13,7 +13,7 @@ export function InputSelectMultiple<T extends object, K extends keyof T>( max?: number; } & UIFormProps<T, K>, ): VNode { - const { label, choices, placeholder, tooltip, required, unique, max } = props; + const { converter, label, choices, placeholder, tooltip, required, unique, max } = props; //FIXME: remove deprecated const fieldCtx = useField<T, K>(props.name); const { value, onChange, state } = diff --git a/packages/web-util/src/forms/InputSelectOne.stories.tsx b/packages/web-util/src/forms/InputSelectOne.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { things: "one" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputText.stories.tsx b/packages/web-util/src/forms/InputText.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputTextArea.stories.tsx b/packages/web-util/src/forms/InputTextArea.stories.tsx @@ -23,7 +23,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { DefaultForm as TestedComponent, - FlexibleForm, + FlexibleForm_Deprecated, } from "./DefaultForm.js"; export default { @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/InputToggle.stories.tsx b/packages/web-util/src/forms/InputToggle.stories.tsx @@ -22,7 +22,7 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import * as tests from "@gnu-taler/web-util/testing"; import { - FlexibleForm, + FlexibleForm_Deprecated, DefaultForm as TestedComponent, } from "./DefaultForm.js"; @@ -43,7 +43,7 @@ const initial: TargetObject = { comment: "some initial comment" } -const form: FlexibleForm<TargetObject> = { +const form: FlexibleForm_Deprecated<TargetObject> = { design: [{ title: "this is a simple form" as TranslatedString, fields: [{ diff --git a/packages/web-util/src/forms/index.ts b/packages/web-util/src/forms/index.ts @@ -19,5 +19,6 @@ export * from "./InputTextArea.js" export * from "./InputToggle.js" export * from "./TimePicker.js" export * from "./forms.js" +export * from "./ui-form.js" export * from "./useField.js" diff --git a/packages/web-util/src/forms/ui-form.ts b/packages/web-util/src/forms/ui-form.ts @@ -0,0 +1,451 @@ +import { + buildCodecForObject, + buildCodecForUnion, + Codec, + codecForBoolean, + codecForConstString, + codecForLazy, + codecForList, + codecForNumber, + codecForString, + codecForTimestamp, + codecOptional, + Integer, + TalerProtocolTimestamp, +} from "@gnu-taler/taler-util"; + +export type FlexibleForm = DoubleColumnForm; + +export interface DoubleColumnForm { + type: "double-column"; + design: Array<DoubleColumnFormSection>; + // behavior?: (form: Partial<T>) => FormState<T>; +} + +export type DoubleColumnFormSection = { + title: string; + description?: string; + fields: UIFormFieldConfig[]; +}; + +// export interface BaseForm { +// state: TalerExchangeApi.AmlState; +// threshold: AmountJson; +// } + +export type UIFormFieldConfig = + | UIFormFieldConfigAbsoluteTime + | UIFormFieldConfigAmount + | UIFormFieldConfigArray + | UIFormFieldConfigCaption + | UIFormFieldConfigChoiseHorizontal + | UIFormFieldConfigChoiseStacked + | UIFormFieldConfigFile + | UIFormFieldConfigGroup + | UIFormFieldConfigInteger + | UIFormFieldConfigSelectMultiple + | UIFormFieldConfigSelectOne + | UIFormFieldConfigText + | UIFormFieldConfigTextArea + | UIFormFieldConfigToggle; + +type UIFormFieldConfigAbsoluteTime = { + type: "absoluteTime"; + properties: UIFormFieldBaseConfig & { + max?: TalerProtocolTimestamp; + min?: TalerProtocolTimestamp; + pattern: string; + }; +}; + +type UIFormFieldConfigAmount = { + type: "amount"; + properties: UIFormFieldBaseConfig & { + max?: Integer; + min?: Integer; + currency: string; + }; +}; + +type UIFormFieldConfigArray = { + type: "array"; + properties: UIFormFieldBaseConfig & { + // id of the field shown when the array is collapsed + labelFieldId: UIHandlerId; + fields: UIFormFieldConfig[]; + }; +}; + +type UIFormFieldConfigCaption = { + type: "caption"; + properties: UIFieldBaseDescription; +}; + +type UIFormFieldConfigGroup = { + type: "group"; + properties: UIFieldBaseDescription & { + fields: UIFormFieldConfig[]; + }; +}; + +type UIFormFieldConfigChoiseHorizontal = { + type: "choiceHorizontal"; + properties: UIFormFieldBaseConfig & { + choices: Array<SelectUiChoice>; + }; +}; + +type UIFormFieldConfigChoiseStacked = { + type: "choiceStacked"; + properties: UIFormFieldBaseConfig & { + choices: Array<SelectUiChoice>; + }; +}; + +type UIFormFieldConfigFile = { + type: "file"; + properties: UIFormFieldBaseConfig & { + maxBytes?: Integer; + minBytes?: Integer; + // comma-separated list of one or more file types + // https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/accept#unique_file_type_specifiers + accept?: string; + }; +}; +type UIFormFieldConfigInteger = { + type: "integer"; + properties: UIFormFieldBaseConfig & { + max?: Integer; + min?: Integer; + }; +}; + +interface SelectUiChoice { + label: string; + description?: string; + value: string; +} + +type UIFormFieldConfigSelectMultiple = { + type: "selectMultiple"; + properties: UIFormFieldBaseConfig & { + max?: Integer; + min?: Integer; + unique?: boolean; + choices: Array<SelectUiChoice>; + }; +}; +type UIFormFieldConfigSelectOne = { + type: "selectOne"; + properties: UIFormFieldBaseConfig & { + choices: Array<SelectUiChoice>; + }; +}; +type UIFormFieldConfigText = { + type: "text"; + properties: UIFormFieldBaseConfig; +}; +type UIFormFieldConfigTextArea = { + type: "textArea"; + properties: UIFormFieldBaseConfig; +}; +type UIFormFieldConfigToggle = { + type: "toggle"; + properties: UIFormFieldBaseConfig; +}; + +export type UIFieldBaseDescription = { + /* label if the field, visible for the user */ + label: string; + /* long text to be shown on user demand */ + tooltip?: string; + + /* short text to be shown close to the field */ + help?: string; + + /* name of the field, useful for a11y */ + name: string; + + /* if the field should be initialy hidden */ + hidden?: boolean; + /* ui element to show before */ + addonBeforeId?: string; + /* ui element to show after */ + addonAfterId?: string; +}; + +export type UIFormFieldBaseConfig = UIFieldBaseDescription & { + /* example to be shown inside the field */ + placeholder?: string; + + /* show a mark as required */ + required?: boolean; + + /* readonly and dim */ + disabled?: boolean; + + /* conversion id to conver the string into the value type + the id should be known to the ui impl + */ + converterId?: string; + + /* property id of the form */ + id: UIHandlerId; +}; + +declare const __handlerId: unique symbol; +export type UIHandlerId = string & { [__handlerId]: true }; + +// FIXME: validate well formed ui field id +const codecForUiFieldId = codecForString as () => Codec<UIHandlerId>; + +const codecForUIFormFieldBaseDescriptionTemplate = < + T extends UIFieldBaseDescription, +>() => + buildCodecForObject<T>() + .property("addonAfterId", codecOptional(codecForString())) + .property("addonBeforeId", codecOptional(codecForString())) + .property("hidden", codecOptional(codecForBoolean())) + .property("help", codecOptional(codecForString())) + .property("label", codecForString()) + .property("name", codecForString()) + .property("tooltip", codecOptional(codecForString())); + +const codecForUIFormFieldBaseConfigTemplate = < + T extends UIFormFieldBaseConfig, +>() => + codecForUIFormFieldBaseDescriptionTemplate<T>() + .property("id", codecForUiFieldId()) + .property("converterId", codecOptional(codecForString())) + .property("disabled", codecOptional(codecForBoolean())) + .property("required", codecOptional(codecForBoolean())) + .property("placeholder", codecOptional(codecForString())); + +const codecForUIFormFieldBaseConfig = (): Codec<UIFormFieldBaseConfig> => + codecForUIFormFieldBaseConfigTemplate().build("UIFieldToggleProperties"); + +const codecForUIFormFieldAbsoluteTimeConfig = (): Codec< + UIFormFieldConfigAbsoluteTime["properties"] +> => + codecForUIFormFieldBaseConfigTemplate< + UIFormFieldConfigAbsoluteTime["properties"] + >() + .property("pattern", codecForString()) + .property("max", codecOptional(codecForTimestamp)) + .property("min", codecOptional(codecForTimestamp)) + .build("UIFormFieldConfigAbsoluteTime.properties"); + +const codecForUiFormFieldAbsoluteTime = + (): Codec<UIFormFieldConfigAbsoluteTime> => + buildCodecForObject<UIFormFieldConfigAbsoluteTime>() + .property("type", codecForConstString("absoluteTime")) + .property("properties", codecForUIFormFieldAbsoluteTimeConfig()) + .build("UIFormFieldConfigAbsoluteTime"); + +const codecForUIFormFieldAmountConfig = (): Codec< + UIFormFieldConfigAmount["properties"] +> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigAmount["properties"]>() + .property("currency", codecForString()) + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .build("UIFormFieldConfigAmount.properties"); + +const codecForUiFormFieldAmount = (): Codec<UIFormFieldConfigAmount> => + buildCodecForObject<UIFormFieldConfigAmount>() + .property("type", codecForConstString("amount")) + .property("properties", codecForUIFormFieldAmountConfig()) + .build("UIFormFieldConfigAmount"); + +const codecForUIFormFieldArrayConfig = (): Codec< + UIFormFieldConfigArray["properties"] +> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigArray["properties"]>() + .property("labelFieldId", codecForUiFieldId()) + .property("fields", codecForList(codecForUiFormField())) + .build("UIFormFieldConfigArray.properties"); + +const codecForUiFormFieldArray = (): Codec<UIFormFieldConfigArray> => + buildCodecForObject<UIFormFieldConfigArray>() + .property("type", codecForConstString("array")) + .property("properties", codecForUIFormFieldArrayConfig()) + .build("UIFormFieldConfigArray"); + +const codecForUiFormFieldCaption = (): Codec<UIFormFieldConfigCaption> => + buildCodecForObject<UIFormFieldConfigCaption>() + .property("type", codecForConstString("caption")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigCaption"); + +const codecForUiFormSelectUiChoice = (): Codec<SelectUiChoice> => + buildCodecForObject<SelectUiChoice>() + .property("description", codecOptional(codecForString())) + .property("label", codecForString()) + .property("value", codecForString()) + .build("SelectUiChoice"); + +const codecForUIFormFieldWithChoiseConfig = (): Codec< + UIFormFieldConfigChoiseHorizontal["properties"] +> => + codecForUIFormFieldBaseConfigTemplate< + UIFormFieldConfigChoiseHorizontal["properties"] + >() + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldConfigChoiseHorizontal.properties"); + +const codecForUiFormFieldChoiceHorizontal = + (): Codec<UIFormFieldConfigChoiseHorizontal> => + buildCodecForObject<UIFormFieldConfigChoiseHorizontal>() + .property("type", codecForConstString("choiceHorizontal")) + .property("properties", codecForUIFormFieldWithChoiseConfig()) + .build("UIFormFieldConfigChoiseHorizontal"); + +const codecForUiFormFieldChoiceStacked = + (): Codec<UIFormFieldConfigChoiseStacked> => + buildCodecForObject<UIFormFieldConfigChoiseStacked>() + .property("type", codecForConstString("choiceStacked")) + .property("properties", codecForUIFormFieldWithChoiseConfig()) + .build("UIFormFieldConfigChoiseStacked"); + +const codecForUIFormFieldFileConfig = (): Codec< + UIFormFieldConfigFile["properties"] +> => + codecForUIFormFieldBaseConfigTemplate<UIFormFieldConfigFile["properties"]>() + .property("accept", codecOptional(codecForString())) + .property("maxBytes", codecOptional(codecForNumber())) + .property("minBytes", codecOptional(codecForNumber())) + .build("UIFormFieldConfigFile.properties"); + +const codecForUiFormFieldFile = (): Codec<UIFormFieldConfigFile> => + buildCodecForObject<UIFormFieldConfigFile>() + .property("type", codecForConstString("file")) + .property("properties", codecForUIFormFieldFileConfig()) + .build("UIFormFieldConfigFile"); + +const codecForUIFormFieldWithFieldsConfig = (): Codec< + UIFormFieldConfigGroup["properties"] +> => + codecForUIFormFieldBaseDescriptionTemplate< + UIFormFieldConfigGroup["properties"] + >() + .property("fields", codecForList(codecForUiFormField())) + .build("UIFormFieldConfigGroup.properties"); + +const codecForUiFormFieldGroup = (): Codec<UIFormFieldConfigGroup> => + buildCodecForObject<UIFormFieldConfigGroup>() + .property("type", codecForConstString("group")) + .property("properties", codecForUIFormFieldWithFieldsConfig()) + .build("UiFormFieldGroup"); + +const codecForUiFormFieldInteger = (): Codec<UIFormFieldConfigInteger> => + buildCodecForObject<UIFormFieldConfigInteger>() + .property("type", codecForConstString("integer")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigInteger"); + +const codecForUIFormFieldSelectMultipleConfig = (): Codec< + UIFormFieldConfigSelectMultiple["properties"] +> => + codecForUIFormFieldBaseConfigTemplate< + UIFormFieldConfigSelectMultiple["properties"] + >() + .property("max", codecOptional(codecForNumber())) + .property("min", codecOptional(codecForNumber())) + .property("unique", codecOptional(codecForBoolean())) + .property("choices", codecForList(codecForUiFormSelectUiChoice())) + .build("UIFormFieldConfigSelectMultiple.properties"); + +const codecForUiFormFieldSelectMultiple = + (): Codec<UIFormFieldConfigSelectMultiple> => + buildCodecForObject<UIFormFieldConfigSelectMultiple>() + .property("type", codecForConstString("selectMultiple")) + .property("properties", codecForUIFormFieldSelectMultipleConfig()) + .build("UiFormFieldSelectMultiple"); + +const codecForUiFormFieldSelectOne = (): Codec<UIFormFieldConfigSelectOne> => + buildCodecForObject<UIFormFieldConfigSelectOne>() + .property("type", codecForConstString("selectOne")) + .property("properties", codecForUIFormFieldWithChoiseConfig()) + .build("UIFormFieldConfigSelectOne"); + +const codecForUiFormFieldText = (): Codec<UIFormFieldConfigText> => + buildCodecForObject<UIFormFieldConfigText>() + .property("type", codecForConstString("text")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigText"); + +const codecForUiFormFieldTextArea = (): Codec<UIFormFieldConfigTextArea> => + buildCodecForObject<UIFormFieldConfigTextArea>() + .property("type", codecForConstString("textArea")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigTextArea"); + +const codecForUiFormFieldToggle = (): Codec<UIFormFieldConfigToggle> => + buildCodecForObject<UIFormFieldConfigToggle>() + .property("type", codecForConstString("toggle")) + .property("properties", codecForUIFormFieldBaseConfig()) + .build("UIFormFieldConfigToggle"); + +const codecForUiFormField = (): Codec<UIFormFieldConfig> => + buildCodecForUnion<UIFormFieldConfig>() + .discriminateOn("type") + .alternative("array", codecForLazy(codecForUiFormFieldArray)) + .alternative("group", codecForLazy(codecForUiFormFieldGroup)) + .alternative("absoluteTime", codecForUiFormFieldAbsoluteTime()) + .alternative("amount", codecForUiFormFieldAmount()) + .alternative("caption", codecForUiFormFieldCaption()) + .alternative("choiceHorizontal", codecForUiFormFieldChoiceHorizontal()) + .alternative("choiceStacked", codecForUiFormFieldChoiceStacked()) + .alternative("file", codecForUiFormFieldFile()) + .alternative("integer", codecForUiFormFieldInteger()) + .alternative("selectMultiple", codecForUiFormFieldSelectMultiple()) + .alternative("selectOne", codecForUiFormFieldSelectOne()) + .alternative("text", codecForUiFormFieldText()) + .alternative("textArea", codecForUiFormFieldTextArea()) + .alternative("toggle", codecForUiFormFieldToggle()) + .build("UIFormField"); + +const codecForDoubleColumnFormSection = (): Codec<DoubleColumnFormSection> => + buildCodecForObject<DoubleColumnFormSection>() + .property("title", codecForString()) + .property("description", codecOptional(codecForString())) + .property("fields", codecForList(codecForUiFormField())) + .build("DoubleColumnFormSection"); + +const codecForDoubleColumnForm = (): Codec<DoubleColumnForm> => + buildCodecForObject<DoubleColumnForm>() + .property("type", codecForConstString("double-column")) + .property("design", codecForList(codecForDoubleColumnFormSection())) + .build("DoubleColumnForm"); + +const codecForFlexibleForm = (): Codec<FlexibleForm> => + buildCodecForUnion<FlexibleForm>() + .discriminateOn("type") + .alternative("double-column", codecForDoubleColumnForm()) + .build<FlexibleForm>("FlexibleForm"); + +const codecForFormMetadata = (): Codec<FormMetadata> => + buildCodecForObject<FormMetadata>() + .property("label", codecForString()) + .property("id", codecForString()) + .property("version", codecForNumber()) + .property("config", codecForFlexibleForm()) + .build("FormMetadata"); + +export const codecForUIForms = (): Codec<UiForms> => + buildCodecForObject<UiForms>() + .property("forms", codecForList(codecForFormMetadata())) + .build("UiForms"); + +export type FormMetadata = { + label: string; + id: string; + version: number; + config: FlexibleForm; +}; + +export interface UiForms { + // Where libeufin backend is localted + // default: window.origin without "webui/" + forms: Array<FormMetadata>; +}