taler-typescript-core

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

commit a85af3425d935c8d454676a177f8fe72a150a336
parent ab605e4088c8f789bfbc62d72e3b22189b56b497
Author: Sebastian <sebasjm@gmail.com>
Date:   Sun, 19 Jan 2025 15:40:38 -0300

events impl

Diffstat:
Mpackages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx | 23++++++++++++++---------
Mpackages/aml-backoffice-ui/src/hooks/decision-request.ts | 1-
Mpackages/aml-backoffice-ui/src/hooks/preferences.ts | 23++++++++++++++++-------
Mpackages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx | 229++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mpackages/web-util/src/components/ToastBanner.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mpackages/web-util/src/forms/fields/InputArray.tsx | 1-
6 files changed, 286 insertions(+), 70 deletions(-)

diff --git a/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx b/packages/aml-backoffice-ui/src/ExchangeAmlFrame.tsx @@ -112,15 +112,8 @@ const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; * ui props and state */ -export function ExchangeAmlFrame({ - children, - officer, -}: { - officer?: OfficerState; - children?: ComponentChildren; -}): VNode { +function useErrorReport() { const { i18n } = useTranslationContext(); - const [error] = useErrorBoundary(); useEffect(() => { @@ -137,6 +130,18 @@ export function ExchangeAmlFrame({ // resetError() } }, [error]); +} + +export function ExchangeAmlFrame({ + children, + officer, +}: { + officer?: OfficerState; + children?: ComponentChildren; +}): VNode { + const { i18n } = useTranslationContext(); + + useErrorReport(); const [preferences, updatePreferences] = usePreferences(); const settings = useUiSettingsContext(); @@ -207,7 +212,7 @@ export function ExchangeAmlFrame({ <div class="fixed z-20 w-full"> <div class="mx-auto w-4/5"> - <ToastBanner /> + <ToastBanner debug={preferences.showDebugInfo} /> </div> </div> diff --git a/packages/aml-backoffice-ui/src/hooks/decision-request.ts b/packages/aml-backoffice-ui/src/hooks/decision-request.ts @@ -165,7 +165,6 @@ export function useCurrentDecisionRequest(): [ v: DecisionRequest[T], ) { const newValue = { ...value, [k]: v }; - console.log("===", v, k); update(newValue); } return [value, updateField, update]; diff --git a/packages/aml-backoffice-ui/src/hooks/preferences.ts b/packages/aml-backoffice-ui/src/hooks/preferences.ts @@ -18,7 +18,7 @@ import { Codec, TranslatedString, buildCodecForObject, - codecForBoolean + codecForBoolean, } from "@gnu-taler/taler-util"; import { buildStorageKey, @@ -30,18 +30,21 @@ interface Preferences { showDebugInfo: boolean; allowInsecurePassword: boolean; keepSessionAfterReload: boolean; + testingDialect: boolean; } export const codecForPreferences = (): Codec<Preferences> => buildCodecForObject<Preferences>() - .property("allowInsecurePassword", (codecForBoolean())) - .property("showDebugInfo", codecForBoolean()) - .property("keepSessionAfterReload", (codecForBoolean())) + .property("allowInsecurePassword", codecForBoolean()) + .property("showDebugInfo", codecForBoolean()) + .property("testingDialect", codecForBoolean()) + .property("keepSessionAfterReload", codecForBoolean()) .build("Preferences"); const defaultPreferences: Preferences = { allowInsecurePassword: false, showDebugInfo: false, + testingDialect: false, keepSessionAfterReload: false, }; @@ -75,6 +78,7 @@ export function getAllBooleanPreferences(): Array<keyof Preferences> { "showDebugInfo", "allowInsecurePassword", "keepSessionAfterReload", + "testingDialect", ]; } @@ -83,8 +87,13 @@ export function getLabelForPreferences( i18n: ReturnType<typeof useTranslationContext>["i18n"], ): TranslatedString { switch (k) { - case "showDebugInfo": return i18n.str`Show debug info` - case "allowInsecurePassword": return i18n.str`Allow Insecure password` - case "keepSessionAfterReload": return i18n.str`Keep session after reload` + case "showDebugInfo": + return i18n.str`Show debug info`; + case "testingDialect": + return i18n.str`Use testing dialect`; + case "allowInsecurePassword": + return i18n.str`Allow Insecure password`; + case "keepSessionAfterReload": + return i18n.str`Keep session after reload`; } } diff --git a/packages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx b/packages/aml-backoffice-ui/src/pages/AmlDecisionRequestWizard.tsx @@ -41,6 +41,7 @@ import { useAccountActiveDecision } from "../hooks/decisions.js"; import { ShowDecisionLimitInfo, ShowMeasuresToSelect } from "./CaseDetails.js"; import { useEffect, useRef } from "preact/hooks"; import { useServerMeasures } from "../hooks/account.js"; +import { usePreferences } from "../hooks/preferences.js"; export type WizardSteps = | "rules" // define the limits @@ -338,31 +339,31 @@ function Rules({ account }: { account?: string }): VNode { } type PropertiesForm = { - defined: { [name: string]: boolean }; - custom: { [name: string]: boolean }; + defined: { [name: string]: string }; + custom: { name: string; value: string }[]; }; const propertiesForm = ( i18n: InternationalizationAPI, - fields: UIFormElementConfig[], + props: UIFormElementConfig[], ): FormDesign<PropertiesForm> => ({ type: "double-column", sections: [ { title: i18n.str`Properties`, - description: i18n.str`props.`, - fields: fields.map((f) => + description: i18n.str`Default properties are defined by the server dialect`, + fields: props.map((f) => "id" in f ? { ...f, id: ("defined." + f.id) as UIHandlerId } : f, ), }, { title: i18n.str`Custom properties`, - description: i18n.str`add properties not listed above`, + description: i18n.str`Add more properties that not listed above.`, fields: [ { id: "custom" as UIHandlerId, type: "array", - label: i18n.str`Fields`, + label: i18n.str`New properties`, labelFieldId: "name" as UIHandlerId, fields: [ { @@ -382,7 +383,7 @@ const propertiesForm = ( ], }); -function fieldsByDialect( +function propertiesByDialect( i18n: InternationalizationAPI, dialect: string | undefined, ): UIFormElementConfig[] { @@ -467,21 +468,40 @@ function fieldsByDialect( */ function Properties({}: {}): VNode { const { i18n } = useTranslationContext(); - const [request, updateRequest] = useCurrentDecisionRequest(); + const [request, _, updateRequest] = useCurrentDecisionRequest(); const { config } = useExchangeApiContext(); + const [pref] = usePreferences(); const design = propertiesForm( i18n, - fieldsByDialect(i18n, config.config.aml_spa_dialect), + propertiesByDialect( + i18n, + pref.testingDialect ? "testing" : config.config.aml_spa_dialect, + ), ); const form = useForm<PropertiesForm>(design, { defined: request.properties, - custom: request.custom_properties, + custom: Object.entries(request.custom_properties ?? {}).map( + ([name, value]) => { + return { name, value }; + }, + ), }); onComponentUnload(() => { - updateRequest("properties", form.status.result.defined); - updateRequest("custom_properties", form.status.result.custom); + updateRequest({ + ...request, + properties: form.status.result.defined ?? {}, + custom_properties: (form.status.result.custom ?? []).reduce( + (prev, cur) => { + if (!cur || !cur.name || !cur.value) return prev; + console.log(cur); + prev[cur.name] = cur.value; + return prev; + }, + {} as Record<string, string>, + ), + }); }); return ( @@ -492,28 +512,136 @@ function Properties({}: {}): VNode { } type EventsForm = { - trigger: { [name: string]: boolean }; + trigger: { name: string }[]; inhibit: { [name: string]: boolean }; }; const eventsForm = ( i18n: InternationalizationAPI, - props: string[], + defaultEvents: UIFormElementConfig[], ): FormDesign<MeasureForm> => ({ type: "double-column", sections: [ { - title: i18n.str`Custom events`, - description: i18n.str`This events will be triggered by default unless you marked to skip it.`, - fields: [], + title: i18n.str`Inhibit default events`, + description: i18n.str`Use this form to prevent events to be triggered by the current status.`, + fields: !defaultEvents.length + ? [ + { + type: "caption", + label: i18n.str`No default events calculated.`, + }, + ] + : defaultEvents.map((f) => + "id" in f ? { ...f, id: ("inhibit." + f.id) as UIHandlerId } : f, + ), }, { - title: i18n.str`Inhibit`, - description: i18n.str`This events will be triggered by default unless you marked to skip it.`, - fields: [], + title: i18n.str`Custom event`, + description: i18n.str`Add more events to be triggered by this request.`, + fields: [ + { + id: "trigger" as UIHandlerId, + type: "array", + label: i18n.str`Event list`, + labelFieldId: "name" as UIHandlerId, + fields: [ + { + type: "text", + label: i18n.str`Name`, + id: "name" as UIHandlerId, + }, + ], + }, + ], }, ], + // fields: [ + // { + // id: "trigger" as UIHandlerId, + // type: "array", + // labelFieldId: "name" as UIHandlerId, + // label: i18n.str`Trigger`, + // fields: [], + // }, + // { + // id: "inhibit" as UIHandlerId, + // type: "array", + // labelFieldId: "name" as UIHandlerId, + // label: i18n.str`Inhibit`, + // fields: [], + // }, + // ], }); + +function eventsByDialect( + i18n: InternationalizationAPI, + dialect: string | undefined, + properties: object, +): UIFormElementConfig[] { + if (!dialect) return []; + const result: UIFormElementConfig[] = []; + switch (dialect) { + case "testing": { + const props = properties as TalerFormAttributes.AccountProperties_TOPS; + if (props.ACCOUNT_FROZEN) { + result.push({ + id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is froozen?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + if (props.ACCOUNT_SANCTIONED) { + result.push({ + id: "ACCOUNT_SANCTIONED" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is sacntioned?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + if (props.ACCOUNT_HIGH_RISK) { + result.push({ + id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is high risk?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + break; + } + case "gls": { + const props = properties as TalerFormAttributes.AccountProperties_TOPS; + if (props.ACCOUNT_FROZEN) { + result.push({ + id: "ACCOUNT_FROZEN" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is frozen?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + break; + } + case "tops": { + const props = properties as TalerFormAttributes.AccountProperties_TOPS; + if (props.ACCOUNT_HIGH_RISK) { + result.push({ + id: "ACCOUNT_HIGH_RISK" satisfies keyof TalerFormAttributes.AccountProperties_TOPS as UIHandlerId, + label: i18n.str`Is high risk?`, + // gana_type: "Boolean", + type: "toggle", + required: true, + }); + } + break; + } + } + return result; +} /** * Trigger additional events * @param param0 @@ -521,8 +649,57 @@ const eventsForm = ( */ function Events({}: {}): VNode { const { i18n } = useTranslationContext(); - const [request] = useCurrentDecisionRequest(); - return <div> not yet impltemented: events</div>; + const [request, _, updateRequest] = useCurrentDecisionRequest(); + const [pref] = usePreferences(); + const { config } = useExchangeApiContext(); + + const calculatedProps = { + ...(request.properties ?? {}), + ...(request.custom_properties ?? {}), + }; + + const calculatedEvents = eventsByDialect( + i18n, + pref.testingDialect ? "testing" : config.config.aml_spa_dialect, + calculatedProps, + ); + + const design = eventsForm(i18n, calculatedEvents); + + const form = useForm<EventsForm>(design, { + inhibit: calculatedEvents.reduce( + (prev, cur) => { + if (cur.type !== "toggle") return prev; + const isInhibit = + request.inhibit_events !== undefined && + request.inhibit_events.indexOf(cur.id) !== -1; + prev[cur.id] = isInhibit; + return prev; + }, + {} as EventsForm["inhibit"], + ), + trigger: !request.custom_events + ? [] + : request.custom_events.map((name) => ({ name })), + }); + + onComponentUnload(() => { + updateRequest({ + ...request, + custom_events: !form.status.result.trigger + ? [] + : form.status.result.trigger.map((t) => t?.name!), + inhibit_events: Object.entries(form.status.result.inhibit ?? {}) + .filter(([key, inhibit]) => !!inhibit) + .map(([key]) => key), + }); + }); + + return ( + <div> + <FormUI design={design} handler={form.handler} /> + </div> + ); } type MeasureForm = { @@ -566,7 +743,7 @@ const measureForm = ( */ function Measures({}: {}): VNode { const { i18n } = useTranslationContext(); - const [request, updateRequest] = useCurrentDecisionRequest(); + const [request, _, updateRequest] = useCurrentDecisionRequest(); const measures = useServerMeasures(); const measureList = !measures || measures instanceof TalerError || measures.type === "fail" @@ -586,8 +763,8 @@ function Measures({}: {}): VNode { : (form.status.result.paths.map( (path) => path?.steps ?? [], ) as string[][]); - updateRequest("next_measure", r); - updateRequest("custom_measures", {}); + + updateRequest({ ...request, next_measure: r, custom_measures: {} }); }); return ( diff --git a/packages/web-util/src/components/ToastBanner.tsx b/packages/web-util/src/components/ToastBanner.tsx @@ -13,49 +13,76 @@ 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 { Fragment, VNode, h } from "preact" -import { Attention, GLOBAL_NOTIFICATION_TIMEOUT as GLOBAL_TOAST_TIMEOUT, Notification, useNotifications } from "../index.browser.js" +import { Fragment, VNode, h } from "preact"; +import { + Attention, + GLOBAL_NOTIFICATION_TIMEOUT as GLOBAL_TOAST_TIMEOUT, + Notification, + useNotifications, +} from "../index.browser.js"; +import { Duration } from "@gnu-taler/taler-util"; /** * Toasts should be considered when displaying these types of information to the user: - * + * * Low attention messages that do not require user action * Singular status updates * Confirmations * Information that does not need to be followed up - * + * * Do not use toasts if the information contains the following: - * + * * High attention and crtitical information * Time-sensitive information * Requires user action or input * Batch updates - * - * @returns + * + * @returns */ -export function ToastBanner(): VNode { - const notifs = useNotifications() - if (notifs.length === 0) return <Fragment /> - const show = notifs.filter(e => !e.message.ack && !e.message.timeout) - if (show.length === 0) return <Fragment /> - return <AttentionByType msg={show[0]} /> +export function ToastBanner({ debug }: { debug?: boolean }): VNode { + const notifs = useNotifications(); + if (notifs.length === 0) return <Fragment />; + const show = notifs.filter((e) => !e.message.ack && !e.message.timeout); + if (show.length === 0) return <Fragment />; + return <AttentionByType msg={show[0]} debug={debug} />; } -function AttentionByType({ msg }: { msg: Notification }) { +function AttentionByType({ + msg, + debug, +}: { + debug?: boolean; + msg: Notification; +}) { switch (msg.message.type) { case "error": - return <Attention type="danger" title={msg.message.title} onClose={() => { - msg.acknowledge() - }} timeout={GLOBAL_TOAST_TIMEOUT}> - {msg.message.description && - <div class="mt-2 text-sm text-red-700"> - {msg.message.description} - </div> - } - </Attention> + return ( + <Attention + type="danger" + title={msg.message.title} + onClose={() => { + msg.acknowledge(); + }} + timeout={debug ? Duration.getForever() : GLOBAL_TOAST_TIMEOUT} + > + {msg.message.description && ( + <div class="mt-2 text-sm text-red-700"> + {msg.message.description} + </div> + )} + {!debug ? undefined : <pre>{msg.message.debug}</pre>} + </Attention> + ); case "info": - return <Attention type="success" title={msg.message.title} onClose={() => { - msg.acknowledge(); - }} timeout={GLOBAL_TOAST_TIMEOUT} /> + return ( + <Attention + type="success" + title={msg.message.title} + onClose={() => { + msg.acknowledge(); + }} + timeout={GLOBAL_TOAST_TIMEOUT} + /> + ); } } diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx @@ -213,7 +213,6 @@ export function InputArray<T extends object, K extends keyof T>( <button type="button" - disabled={selected !== undefined} onClick={() => { const newValue = [...list]; newValue.splice(selectedIndex, 1);