diff options
Diffstat (limited to 'packages')
-rw-r--r-- | packages/web-util/src/components/Button.tsx | 176 | ||||
-rw-r--r-- | packages/web-util/src/context/activity.ts | 6 | ||||
-rw-r--r-- | packages/web-util/src/context/challenger-api.ts | 209 | ||||
-rw-r--r-- | packages/web-util/src/context/index.ts | 1 | ||||
-rw-r--r-- | packages/web-util/src/context/navigation.ts | 46 | ||||
-rw-r--r-- | packages/web-util/src/hooks/useNotifications.ts | 23 | ||||
-rw-r--r-- | packages/web-util/src/utils/route.ts | 9 |
7 files changed, 359 insertions, 111 deletions
diff --git a/packages/web-util/src/components/Button.tsx b/packages/web-util/src/components/Button.tsx index ea0ea2f38..18cecbdab 100644 --- a/packages/web-util/src/components/Button.tsx +++ b/packages/web-util/src/components/Button.tsx @@ -14,36 +14,56 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AbsoluteTime, OperationFail, OperationOk, OperationResult, TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + OperationAlternative, + OperationFail, + OperationOk, + OperationResult, + TalerError, + TranslatedString, +} from "@gnu-taler/taler-util"; // import { NotificationMessage, notifyInfo } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { HTMLAttributes, useEffect, useState, useTransition } from "preact/compat"; -import { NotificationMessage, buildUnifiedRequestErrorMessage, notifyInfo, useTranslationContext } from "../index.browser.js"; +import { HTMLAttributes, useState } from "preact/compat"; +import { + NotificationMessage, + buildUnifiedRequestErrorMessage, + notifyInfo, + useTranslationContext, +} from "../index.browser.js"; // import { useBankCoreApiContext } from "../context/config.js"; // function errorMap<T extends OperationFail<unknown>>(resp: T, map: (d: T["case"]) => TranslatedString): void { +export type OnOperationSuccesReturnType<T> = ( + result: T extends OperationOk<any> ? T : never, +) => TranslatedString | void; +export type OnOperationFailReturnType<T> = ( + (d: (T extends OperationFail<any> ? T : never) | (T extends OperationAlternative<any,any> ? T : never)) => TranslatedString) + export interface ButtonHandler<T extends OperationResult<A, B>, A, B> { - onClick: () => Promise<T | undefined>, + onClick: () => Promise<T | undefined>; onNotification: (n: NotificationMessage) => void; - onOperationSuccess: ((result: T extends OperationOk<any> ? T : never) => void) | ((result: T extends OperationOk<any> ? T : never) => TranslatedString | undefined), - onOperationFail: (d: T extends OperationFail<any> ? T : never) => TranslatedString; + onOperationSuccess: OnOperationSuccesReturnType<T>; + onOperationFail: OnOperationFailReturnType<T>; onOperationComplete?: () => void; } -interface Props<T extends OperationResult<A, B>, A, B> extends HTMLAttributes<HTMLButtonElement> { - handler: ButtonHandler<T, A, B> | undefined, +interface Props<T extends OperationResult<A, B>, A, B> + extends HTMLAttributes<HTMLButtonElement> { + handler: ButtonHandler<T, A, B> | undefined; } /** * This button accept an async function and report a notification * on error or success. - * + * * When the async function is running the inner text will change into * a "loading" animation. - * - * @param param0 - * @returns + * + * @param param0 + * @returns */ export function Button<T extends OperationResult<A, B>, A, B>({ handler, @@ -53,69 +73,84 @@ export function Button<T extends OperationResult<A, B>, A, B>({ ...rest }: Props<T, A, B>): VNode { const { i18n } = useTranslationContext(); - const [running, setRunning] = useState(false) - return <button {...rest} disabled={disabled || running} onClick={(e) => { - e.preventDefault(); - if (!handler) { return; } - setRunning(true) - handler.onClick().then((resp) => { - if (resp) { - if (resp.type === "ok") { - const result: OperationOk<any> = resp - // @ts-expect-error this is an operationOk - const msg = handler.onOperationSuccess(result) - if (msg) { - notifyInfo(msg) - } + const [running, setRunning] = useState(false); + return ( + <button + {...rest} + disabled={disabled || running} + onClick={(e) => { + e.preventDefault(); + if (!handler) { + return; } - if (resp.type === "fail") { - // @ts-expect-error this is an operationFail - const error: OperationFail<any> = resp; - // @ts-expect-error this is an operationFail - const title = handler.onOperationFail(error) - handler.onNotification({ - title, - type: "error", - description: error.detail.hint as TranslatedString, - debug: error.detail, - when: AbsoluteTime.now(), + setRunning(true); + handler + .onClick() + .then((resp) => { + if (resp) { + if (resp.type === "ok") { + const result: OperationOk<any> = resp; + // @ts-expect-error this is an operationOk + const msg = handler.onOperationSuccess(result); + if (msg) { + notifyInfo(msg); + } + } + if (resp.type === "fail") { + const d = 'detail' in resp ? resp.detail : undefined + + const title = handler.onOperationFail(resp as any); + handler.onNotification({ + title, + type: "error", + description: d && d.hint ? d.hint as TranslatedString : undefined, + debug: d, + when: AbsoluteTime.now(), + }); + } + } + if (handler.onOperationComplete) { + handler.onOperationComplete(); + } + setRunning(false); }) - } - } - if (handler.onOperationComplete) { - handler.onOperationComplete() - } - setRunning(false) - }).catch(error => { - console.error(error) + .catch((error) => { + console.error(error); - if (error instanceof TalerError) { - handler.onNotification(buildUnifiedRequestErrorMessage(i18n, error)) - } else { - const description = (error instanceof Error ? - error.message : String(error)) as TranslatedString + if (error instanceof TalerError) { + handler.onNotification( + buildUnifiedRequestErrorMessage(i18n, error), + ); + } else { + const description = ( + error instanceof Error ? error.message : String(error) + ) as TranslatedString; - handler.onNotification({ - title: i18n.str`Operation failed`, - type: "error", - description, - when: AbsoluteTime.now(), - }) - } + handler.onNotification({ + title: i18n.str`Operation failed`, + type: "error", + description, + when: AbsoluteTime.now(), + }); + } - if (handler.onOperationComplete) { - handler.onOperationComplete() - } - setRunning(false) - }) - }} > - {running ? <Wait /> : children} - </button> + if (handler.onOperationComplete) { + handler.onOperationComplete(); + } + setRunning(false); + }); + }} + > + {running ? <Wait /> : children} + </button> + ); } function Wait(): VNode { - return <Fragment> - <style>{` + return ( + <Fragment> + <style> + {` #l1 { width: 120px; height: 20px; -webkit-mask: radial-gradient(circle closest-side, currentColor 90%, #0000) left/20% 100%; @@ -125,7 +160,8 @@ function Wait(): VNode { @keyframes l17 { 100% {background-size:120% 100%} `} - </style> - <div id="l1" /> - </Fragment> + </style> + <div id="l1" /> + </Fragment> + ); } diff --git a/packages/web-util/src/context/activity.ts b/packages/web-util/src/context/activity.ts index 422b25909..3cfd83bda 100644 --- a/packages/web-util/src/context/activity.ts +++ b/packages/web-util/src/context/activity.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { ObservabilityEvent, TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util"; +import { ChallengerHttpClient, ObservabilityEvent, TalerAuthenticationHttpClient, TalerBankConversionHttpClient, TalerCoreBankHttpClient, TalerMerchantInstanceHttpClient, TalerMerchantManagementHttpClient } from "@gnu-taler/taler-util"; type Listener<Event> = (e: Event) => void; type Unsuscriber = () => void; @@ -66,3 +66,7 @@ export interface BankLib { auth: (user: string) => TalerAuthenticationHttpClient; } +export interface ChallengerLib { + bank: ChallengerHttpClient; +} + diff --git a/packages/web-util/src/context/challenger-api.ts b/packages/web-util/src/context/challenger-api.ts new file mode 100644 index 000000000..960f1db0d --- /dev/null +++ b/packages/web-util/src/context/challenger-api.ts @@ -0,0 +1,209 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + ChallengerApi, + ChallengerHttpClient, + LibtoolVersion, + ObservabilityEvent, + ObservableHttpClientLibrary, + TalerError +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + FunctionComponent, + VNode, + createContext, + h, +} from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; +import { BrowserFetchHttpLib, ErrorLoading } from "../index.browser.js"; +import { + APIClient, + ActiviyTracker, + ChallengerLib, + Subscriber +} from "./activity.js"; +import { useTranslationContext } from "./translation.js"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type ChallengerContextType = { + url: URL; + config: ChallengerApi.ChallengerTermsOfServiceResponse; + lib: ChallengerLib; + hints: VersionHint[]; + onActivity: Subscriber<ObservabilityEvent>; + cancelRequest: (eventId: string) => void; +}; + +// @ts-expect-error default value to undefined, should it be another thing? +const ChallengerContext = createContext<ChallengerContextType>(undefined); + +export const useChallengerApiContext = (): ChallengerContextType => + useContext(ChallengerContext); + +enum VersionHint { + NONE, +} + +type Evictors = Record<string, never>; + +type ConfigResult<T> = + | undefined + | { type: "ok"; config: T; hints: VersionHint[] } + | { type: "incompatible"; result: T; supported: string } + | { type: "error"; error: TalerError }; + +const CONFIG_FAIL_TRY_AGAIN_MS = 5000; + +export const ChallengerApiProvider = ({ + baseUrl, + children, + frameOnError, + evictors = {}, +}: { + baseUrl: URL; + children: ComponentChildren; + evictors?: Evictors; + frameOnError: FunctionComponent<{ children: ComponentChildren }>; +}): VNode => { + const [checked, setChecked] = + useState<ConfigResult<ChallengerApi.ChallengerTermsOfServiceResponse>>(); + const { i18n } = useTranslationContext(); + + const { getRemoteConfig, VERSION, lib, cancelRequest, onActivity } = + buildChallengerApiClient(baseUrl, evictors); + + useEffect(() => { + let keepRetrying = true; + async function testConfig(): Promise<void> { + try { + const config = await getRemoteConfig(); + if (LibtoolVersion.compare(VERSION, config.version)) { + setChecked({ type: "ok", config, hints: [] }); + } else { + setChecked({ + type: "incompatible", + result: config, + supported: VERSION, + }); + } + } catch (error) { + if (error instanceof TalerError) { + if (keepRetrying) { + setTimeout(() => { + testConfig(); + }, CONFIG_FAIL_TRY_AGAIN_MS); + } + setChecked({ type: "error", error }); + } else { + setChecked({ type: "error", error: TalerError.fromException(error) }); + } + } + } + testConfig(); + return () => { + // on unload, stop retry + keepRetrying = false; + }; + }, []); + + if (checked === undefined) { + return h(frameOnError, { + children: h("div", {}, "checking compatibility with server..."), + }); + } + if (checked.type === "error") { + return h(frameOnError, { + children: h(ErrorLoading, { error: checked.error, showDetail: true }), + }); + } + if (checked.type === "incompatible") { + return h(frameOnError, { + children: h( + "div", + {}, + i18n.str`The server version is not supported. Supported version "${checked.supported}", server version "${checked.result.version}"`, + ), + }); + } + + const value: ChallengerContextType = { + url: baseUrl, + config: checked.config, + onActivity: onActivity, + lib, + cancelRequest, + hints: checked.hints, + }; + return h(ChallengerContext.Provider, { + value, + children, + }); +}; + +function buildChallengerApiClient( + url: URL, + evictors: Evictors, +): APIClient<ChallengerLib, ChallengerApi.ChallengerTermsOfServiceResponse> { + const httpFetch = new BrowserFetchHttpLib({ + enableThrottling: true, + requireTls: false, + }); + const tracker = new ActiviyTracker<ObservabilityEvent>(); + const httpLib = new ObservableHttpClientLibrary(httpFetch, { + observe(ev) { + tracker.notify(ev); + }, + }); + + const bank = new ChallengerHttpClient(url.href, httpLib); + + async function getRemoteConfig(): Promise<ChallengerApi.ChallengerTermsOfServiceResponse> { + const resp = await bank.getConfig(); + if (resp.type === "fail") { + throw TalerError.fromUncheckedDetail(resp.detail); + } + return resp.body; + } + + return { + getRemoteConfig, + VERSION: bank.PROTOCOL_VERSION, + lib: { + bank, + }, + onActivity: tracker.subscribe, + cancelRequest: httpLib.cancelRequest, + }; +} + +export const ChallengerApiProviderTesting = ({ + children, + value, +}: { + value: ChallengerContextType; + children: ComponentChildren; +}): VNode => { + return h(ChallengerContext.Provider, { + value, + children, + }); +}; diff --git a/packages/web-util/src/context/index.ts b/packages/web-util/src/context/index.ts index 0e28b844a..8e7f096da 100644 --- a/packages/web-util/src/context/index.ts +++ b/packages/web-util/src/context/index.ts @@ -5,6 +5,7 @@ export { useTranslationContext } from "./translation.js"; export * from "./bank-api.js"; +export * from "./challenger-api.js"; export * from "./merchant-api.js"; export * from "./navigation.js"; export * from "./wallet-integration.js"; diff --git a/packages/web-util/src/context/navigation.ts b/packages/web-util/src/context/navigation.ts index a2fe3ff12..c2f2bbbc1 100644 --- a/packages/web-util/src/context/navigation.ts +++ b/packages/web-util/src/context/navigation.ts @@ -16,17 +16,13 @@ import { ComponentChildren, createContext, h, VNode } from "preact"; import { useContext, useEffect, useState } from "preact/hooks"; -import { AppLocation, ObjectOf, Location, findMatch, RouteDefinition } from "../utils/route.js"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>( - pagesMap: T, -): Location<T> | undefined { - const pageList = Object.keys(pagesMap as object) as Array<keyof T>; - const { path, params } = useNavigationContext(); - - return findMatch(pagesMap, pageList, path, params); -} +import { + AppLocation, + ObjectOf, + Location, + findMatch, + RouteDefinition, +} from "../utils/route.js"; /** * @@ -35,7 +31,7 @@ export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>( export type Type = { path: string; - params: Record<string, string>; + params: Record<string, string[]>; navigateTo: (path: AppLocation) => void; // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void); }; @@ -45,13 +41,29 @@ const Context = createContext<Type>(undefined); export const useNavigationContext = (): Type => useContext(Context); -function getPathAndParamsFromWindow() { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function useCurrentLocation<T extends ObjectOf<RouteDefinition<any>>>( + pagesMap: T, +): Location<T> | undefined { + const pageList = Object.keys(pagesMap as object) as Array<keyof T>; + const { path, params } = useNavigationContext(); + + return findMatch(pagesMap, pageList, path, params); +} + +function getPathAndParamsFromWindow(): { + path: string; + params: Record<string, string[]>; +} { const path = typeof window !== "undefined" ? window.location.hash.substring(1) : "/"; - const params: Record<string, string> = {}; + const params: Record<string, string[]> = {}; if (typeof window !== "undefined") { for (const [key, value] of new URLSearchParams(window.location.search)) { - params[key] = value; + if (!params[key]) { + params[key] = []; + } + params[key].push(value); } } return { path, params }; @@ -80,14 +92,14 @@ export const BrowserHashNavigationProvider = ({ "Can't use BrowserHashNavigationProvider if there is no window object", ); } - function navigateTo(path: string) { + function navigateTo(path: string): void { const { params } = getPathAndParamsFromWindow(); setState({ path, params }); window.location.href = path; } useEffect(() => { - function eventListener() { + function eventListener(): void { setState(getPathAndParamsFromWindow()); } window.addEventListener(PopStateEventType, eventListener); diff --git a/packages/web-util/src/hooks/useNotifications.ts b/packages/web-util/src/hooks/useNotifications.ts index 99f4f2699..81a1ae91e 100644 --- a/packages/web-util/src/hooks/useNotifications.ts +++ b/packages/web-util/src/hooks/useNotifications.ts @@ -1,6 +1,7 @@ import { AbsoluteTime, Duration, + OperationAlternative, OperationFail, OperationOk, OperationResult, @@ -9,7 +10,7 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { useEffect, useState } from "preact/hooks"; -import { ButtonHandler } from "../components/Button.js"; +import { ButtonHandler, OnOperationFailReturnType, OnOperationSuccesReturnType } from "../components/Button.js"; import { InternationalizationAPI, memoryMap, @@ -207,14 +208,8 @@ export function useLocalNotification(): [ type HandlerMaker = <T extends OperationResult<A, B>, A, B>( onClick: () => Promise<T | undefined>, - onOperationSuccess: - | ((result: T extends OperationOk<any> ? T : never) => void) - | (( - result: T extends OperationOk<any> ? T : never, - ) => TranslatedString | undefined), - onOperationFail: ( - d: T extends OperationFail<any> ? T : never, - ) => TranslatedString, + onOperationSuccess: OnOperationSuccesReturnType<T>, + onOperationFail: OnOperationFailReturnType<T>, onOperationComplete?: () => void, ) => ButtonHandler<T, A, B>; @@ -235,14 +230,8 @@ export function useLocalNotificationHandler(): [ function makeHandler<T extends OperationResult<A, B>, A, B>( onClick: () => Promise<T | undefined>, - onOperationSuccess: - | ((result: T extends OperationOk<any> ? T : never) => void) - | (( - result: T extends OperationOk<any> ? T : never, - ) => TranslatedString | undefined), - onOperationFail: ( - d: T extends OperationFail<any> ? T : never, - ) => TranslatedString, + onOperationSuccess:OnOperationSuccesReturnType<T>, + onOperationFail: OnOperationFailReturnType<T>, onOperationComplete?: () => void, ): ButtonHandler<T, A, B> { return { diff --git a/packages/web-util/src/utils/route.ts b/packages/web-util/src/utils/route.ts index 4f8a020f6..494a61efa 100644 --- a/packages/web-util/src/utils/route.ts +++ b/packages/web-util/src/utils/route.ts @@ -74,7 +74,7 @@ export function findMatch<T extends ObjectOf<RouteDefinition>>( pagesMap: T, pageList: Array<keyof T>, path: string, - params: Record<string, string>, + params: Record<string, string[]>, ): Location<T> | undefined { for (let idx = 0; idx < pageList.length; idx++) { const name = pageList[idx]; @@ -82,10 +82,6 @@ export function findMatch<T extends ObjectOf<RouteDefinition>>( if (found !== null) { const values = {} as Record<string, unknown>; - Object.entries(params).forEach(([key, value]) => { - values[key] = value; - }); - if (found.groups !== undefined) { Object.entries(found.groups).forEach(([key, value]) => { values[key] = value; @@ -93,7 +89,7 @@ export function findMatch<T extends ObjectOf<RouteDefinition>>( } // @ts-expect-error values is a map string which is equivalent to the RouteParamsType - return { name, parent: pagesMap, values }; + return { name, parent: pagesMap, values, params }; } } return undefined; @@ -117,6 +113,7 @@ type MapKeyValue<Type> = { parent: Type; name: Key; values: RouteParamsType<Type, Key>; + params: Record<string, string[]>; } : never; }; |