commit 6e20f6bf65fb5190d04e701293c56692bb8fdbb1 parent b6fbc0166e877001b8e48663fd7b3e437f1805fb Author: Florian Dold <dold@taler.net> Date: Mon, 24 Aug 2026 02:31:21 +0200 bank web UI: add retry actions to data loading failures Diffstat:
24 files changed, 283 insertions(+), 156 deletions(-)
diff --git a/packages/libeufin-bank-webui/src/components/Cashouts/index.ts b/packages/libeufin-bank-webui/src/components/Cashouts/index.ts @@ -23,16 +23,12 @@ import { TranslatedString, } from "@gnu-taler/taler-util"; import { CashoutDetailFailure } from "../../hooks/regional.js"; -import { - ErrorLoading, - Loading, - RouteDefinition, - utils, -} from "@gnu-taler/web-util/browser"; +import { Loading, RouteDefinition, utils } from "@gnu-taler/web-util/browser"; import { VNode } from "preact"; import { useComponentState } from "./state.js"; import { FailedView, ReadyView } from "./views.js"; +import { RetryableError } from "../RetryableError.js"; export interface Props { account: string; @@ -55,6 +51,7 @@ export namespace State { status: "loading-error"; error: TalerError; title: TranslatedString; + onRetry(): void; } export interface Failed { @@ -71,6 +68,7 @@ export namespace State { cashouts: (TalerCorebankApi.CashoutStatusResponse & { id: number })[]; failures: CashoutDetailFailure[]; routeCashoutDetails: RouteDefinition<{ cid: string }>; + onRetryDetails(): void; } } @@ -84,7 +82,7 @@ export interface Transaction { const viewMapping: utils.StateViewMap<State> = { loading: Loading, - "loading-error": ErrorLoading, + "loading-error": RetryableError, failed: FailedView, ready: ReadyView, }; diff --git a/packages/libeufin-bank-webui/src/components/Cashouts/state.ts b/packages/libeufin-bank-webui/src/components/Cashouts/state.ts @@ -15,7 +15,7 @@ */ import { TalerError } from "@gnu-taler/taler-util"; -import { useCashouts } from "../../hooks/regional.js"; +import { revalidateCashouts, useCashouts } from "../../hooks/regional.js"; import { Props, State } from "./index.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -36,6 +36,7 @@ export function useComponentState({ status: "loading-error", error: result, title: i18n.str`Failed to load cashouts.`, + onRetry: () => void revalidateCashouts(), }; } if (result.type === "fail") { @@ -51,5 +52,6 @@ export function useComponentState({ cashouts: result.body.cashouts, failures: result.body.failures, routeCashoutDetails, + onRetryDetails: () => void revalidateCashouts(), }; } diff --git a/packages/libeufin-bank-webui/src/components/Cashouts/views.tsx b/packages/libeufin-bank-webui/src/components/Cashouts/views.tsx @@ -23,7 +23,6 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ErrorLoading, Loading, RenderAmount, Time, @@ -32,9 +31,11 @@ import { import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { State } from "./index.js"; -import { useConversionInfo } from "../../hooks/regional.js"; - -const TALER_SCREEN_ID = 3; +import { + revalidateConversionInfo, + useConversionInfo, +} from "../../hooks/regional.js"; +import { RetryableError } from "../RetryableError.js"; export function FailedView({ error }: State.Failed) { const { i18n } = useTranslationContext(); @@ -58,6 +59,7 @@ export function ReadyView({ cashouts, failures, routeCashoutDetails, + onRetryDetails, }: State.Ready): VNode { const { i18n, dateLocale } = useTranslationContext(); const conversionResp = useConversionInfo(); @@ -83,9 +85,10 @@ export function ReadyView({ return <Loading />; } else if (conversionResp instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={conversionResp} title={i18n.str`Failed to load conversion rate information.`} + onRetry={() => revalidateConversionInfo()} /> ); } else if (conversionResp.type === "fail") { @@ -120,6 +123,13 @@ export function ReadyView({ {failures.map(({ id }) => id).join(", ")}. </i18n.Translate> </p> + <button + type="button" + class="mt-2 font-semibold underline" + onClick={onRetryDetails} + > + <i18n.Translate>Try again</i18n.Translate> + </button> </Attention> )} <div class="sm:flex sm:items-center"> diff --git a/packages/libeufin-bank-webui/src/components/RetryableError.stories.tsx b/packages/libeufin-bank-webui/src/components/RetryableError.stories.tsx @@ -0,0 +1,26 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { TalerError } from "@gnu-taler/taler-util"; +import * as tests from "@gnu-taler/web-util/testing"; +import { RetryableError } from "./RetryableError.js"; + +export default { + title: "retryable error", +}; + +export const NetworkFailure = tests.createExample(RetryableError, { + error: TalerError.fromException(new Error("Network request failed")), + title: "Could not load the requested data." as any, + onRetry: () => undefined, +}); diff --git a/packages/libeufin-bank-webui/src/components/RetryableError.tsx b/packages/libeufin-bank-webui/src/components/RetryableError.tsx @@ -0,0 +1,39 @@ +/* + This file is part of GNU Taler + (C) 2026 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. +*/ + +import { TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { + ErrorLoading, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; + +export function RetryableError({ + error, + title, + onRetry, +}: { + error: TalerError; + title: TranslatedString; + onRetry: () => void | Promise<unknown>; +}): VNode { + const { i18n } = useTranslationContext(); + return ( + <div> + <ErrorLoading error={error} title={title} /> + <button + type="button" + class="mt-3 font-semibold underline" + onClick={() => void onRetry()} + > + <i18n.Translate>Try again</i18n.Translate> + </button> + </div> + ); +} diff --git a/packages/libeufin-bank-webui/src/components/Transactions/index.ts b/packages/libeufin-bank-webui/src/components/Transactions/index.ts @@ -20,15 +20,11 @@ import { TalerError, TranslatedString, } from "@gnu-taler/taler-util"; -import { - ErrorLoading, - Loading, - RouteDefinition, - utils, -} from "@gnu-taler/web-util/browser"; +import { Loading, RouteDefinition, utils } from "@gnu-taler/web-util/browser"; import { VNode } from "preact"; import { useComponentState } from "./state.js"; +import { RetryableError } from "../RetryableError.js"; import { FailedView, ReadyView } from "./views.js"; export interface Props { @@ -59,6 +55,7 @@ export namespace State { status: "loading-error"; error: TalerError; title: TranslatedString; + onRetry(): void; } export interface Failed { @@ -98,7 +95,7 @@ export interface Transaction { const viewMapping: utils.StateViewMap<State> = { loading: Loading, - "loading-error": ErrorLoading, + "loading-error": RetryableError, failed: FailedView, ready: ReadyView, }; diff --git a/packages/libeufin-bank-webui/src/components/Transactions/state.ts b/packages/libeufin-bank-webui/src/components/Transactions/state.ts @@ -46,6 +46,7 @@ export function useComponentState({ status: "loading-error", error: result, title: i18n.str`Failed to load transactions.`, + onRetry: () => void revalidateTransactions(), }; } if (result.type === "fail") { diff --git a/packages/libeufin-bank-webui/src/components/Transactions/views.tsx b/packages/libeufin-bank-webui/src/components/Transactions/views.tsx @@ -36,8 +36,6 @@ export function FailedView({ title, onRetry }: State.Failed): VNode { ); } -const TALER_SCREEN_ID = 4; - export function ReadyView({ transactions, routeCreateWireTransfer, @@ -240,7 +238,7 @@ export function ReadyView({ <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" - aria-label="Pagination" + aria-label={i18n.str`Pagination`} > <div class="flex flex-1 justify-between sm:justify-end"> <button diff --git a/packages/libeufin-bank-webui/src/hooks/regional.ts b/packages/libeufin-bank-webui/src/hooks/regional.ts @@ -138,6 +138,15 @@ export function useConversionRateForUser( return undefined; } +export function revalidateConversionRateForUser() { + return mutate( + (key) => + Array.isArray(key) && key[key.length - 1] === "useConversionInfoForUser", + undefined, + { revalidate: true }, + ); +} + function buildEstimatorWithTheBackend( conversion: TalerBankConversionHttpClient, token: AccessToken | undefined, diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/index.ts b/packages/libeufin-bank-webui/src/pages/AccountPage/index.ts @@ -21,18 +21,14 @@ import { TalerError, TranslatedString, } from "@gnu-taler/taler-util"; -import { - ErrorLoading, - Loading, - RouteDefinition, - utils, -} from "@gnu-taler/web-util/browser"; -import { Fragment, VNode } from "preact"; +import { Loading, RouteDefinition, utils } from "@gnu-taler/web-util/browser"; +import { VNode } from "preact"; import { LoginForm } from "../LoginForm.js"; import { useComponentState } from "./state.js"; +import { RetryableError } from "../../components/RetryableError.js"; import { InvalidIbanView, ReadyView } from "./views.js"; -import { IntAmountJson, IntAmounts } from "../regional/CreateCashout.js"; +import { IntAmountJson } from "../regional/CreateCashout.js"; export interface Props { account: string; @@ -74,6 +70,7 @@ export namespace State { status: "loading-error"; error: TalerError; title: TranslatedString; + onRetry(): void; } export interface BaseInfo { @@ -131,7 +128,7 @@ const viewMapping: utils.StateViewMap<State> = { loading: Loading, login: LoginForm, "invalid-iban": InvalidIbanView, - "loading-error": ErrorLoading, + "loading-error": RetryableError, ready: ReadyView, }; diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/state.ts b/packages/libeufin-bank-webui/src/pages/AccountPage/state.ts @@ -22,7 +22,10 @@ import { TalerError, assertUnreachable, } from "@gnu-taler/taler-util"; -import { useAccountDetails } from "../../hooks/account.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../../hooks/account.js"; import { IntAmounts } from "../regional/CreateCashout.js"; import { Props, State } from "./index.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; @@ -54,6 +57,7 @@ export function useComponentState({ status: "loading-error", error: result, title: i18n.str`Failed to load account details.`, + onRetry: () => void revalidateAccountDetails(), }; } diff --git a/packages/libeufin-bank-webui/src/pages/AccountPage/views.tsx b/packages/libeufin-bank-webui/src/pages/AccountPage/views.tsx @@ -14,19 +14,15 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { TranslatedString } from "@gnu-taler/taler-util"; import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { Transactions } from "../../components/Transactions/index.js"; -import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { PaymentOptions } from "../PaymentOptions.js"; import { State } from "./index.js"; import { RouteDefinition } from "@gnu-taler/web-util/browser"; import { useSettingsContext } from "../../context/settings.js"; -const TALER_SCREEN_ID = 5; - export function InvalidIbanView({ error }: State.InvalidIban) { return ( <div> diff --git a/packages/libeufin-bank-webui/src/pages/BankFrame.tsx b/packages/libeufin-bank-webui/src/pages/BankFrame.tsx @@ -36,7 +36,10 @@ import { import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { useSettingsContext } from "../context/settings.js"; -import { useAccountDetails } from "../hooks/account.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../hooks/account.js"; import { useBankState } from "../hooks/bank-state.js"; import { getAllBooleanPreferences, @@ -51,6 +54,19 @@ const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; const REVOCATION_WARNING_KEY = "bank-logout-revocation-unconfirmed"; +function AccountDetailsRetry(): VNode { + const { i18n } = useTranslationContext(); + return ( + <button + type="button" + class="font-semibold underline" + onClick={() => void revalidateAccountDetails()} + > + <i18n.Translate>Try again</i18n.Translate> + </button> + ); +} + BankFrame.SCREEN_ID = TALER_SCREEN_ID; export function BankFrame({ children, @@ -136,7 +152,7 @@ export function BankFrame({ <div class="text-xs font-semibold leading-6 text-gray-400"> <i18n.Translate>Preferences</i18n.Translate> </div> - <ul role="list" class="space-y-4"> + <ul class="space-y-4"> {getAllBooleanPreferences(settings).map((set) => { const isOn: boolean = !!preferences[set]; return ( @@ -401,7 +417,7 @@ function WelcomeAccount({ return <Loading />; } if (result instanceof TalerError) { - return <div />; + return <AccountDetailsRetry />; } if (result.type === "fail") { return ( @@ -434,7 +450,7 @@ function AccountBalance({ account }: { account: string }): VNode { return <Loading />; } if (result instanceof TalerError) { - return <div />; + return <AccountDetailsRetry />; } if (result.type === "fail") return <div />; diff --git a/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx b/packages/libeufin-bank-webui/src/pages/ConversionRateClassDetails.tsx @@ -12,7 +12,6 @@ import { import { Attention, AsyncButton, - ErrorLoading, InputText, InputToggle, Loading, @@ -41,6 +40,7 @@ import { useFormState, } from "../hooks/form.js"; import { + revalidateConversionInfo, revalidateConversionRateClassDetails, revalidateConversionRateClassUsers, TransferCalculation, @@ -55,8 +55,8 @@ import { RecursivePartial, undefinedIfEmpty } from "../utils.js"; import { DescribeConversion } from "./admin/ConversionClassList.js"; import { doAutoFocus, InputAmount } from "./PaytoWireTransferForm.js"; import { ConversionForm } from "./regional/ConversionConfig.js"; +import { RetryableError } from "../components/RetryableError.js"; -const TALER_SCREEN_ID = 11; interface Props { classId: number; routeCancel: RouteDefinition; @@ -87,17 +87,19 @@ export function ConversionRateClassDetails({ } if (detailsResult instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={detailsResult} title={i18n.str`Failed to load conversion details.`} + onRetry={() => revalidateConversionRateClassDetails()} /> ); } if (conversionInfoResult instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={conversionInfoResult} title={i18n.str`Failed to load conversion rate information.`} + onRetry={() => revalidateConversionInfo()} /> ); } @@ -156,7 +158,7 @@ function Form({ const { i18n } = useTranslationContext(); const { state: credentials } = useSessionState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const { lib, config } = useBankCoreApiContext(); + const { lib } = useBankCoreApiContext(); const { showError } = useNotificationContext(); const [section, setSection] = useState< "detail" | "cashout" | "cashin" | "users" | "test" | "delete" @@ -215,24 +217,6 @@ function Form({ }, ); - const input: TalerCorebankApi.ConversionRateClassInput | undefined = - status.status === "fail" - ? undefined - : { - name: status.result.name, - description: status.result.description, - - cashin_fee: status.result.conv.cashin_fee, - cashin_min_amount: status.result.conv.cashin_min_amount, - cashin_ratio: status.result.conv.cashin_ratio, - cashin_rounding_mode: status.result.conv.cashin_rounding_mode, - - cashout_fee: status.result.conv.cashout_fee, - cashout_min_amount: status.result.conv.cashout_min_amount, - cashout_ratio: status.result.conv.cashout_ratio, - cashout_rounding_mode: status.result.conv.cashout_rounding_mode, - }; - // i18n.str`update conversion rate class`, const updateClass = useNotifiedOperation< Awaited<ReturnType<typeof lib.bank.updateConversionRateClass>>, @@ -360,6 +344,7 @@ function Form({ </h2> <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> <label + aria-label={i18n.str`Details`} data-enabled={section === "detail"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -382,6 +367,7 @@ function Form({ </span> </label> <label + aria-label={i18n.str`Config cashout`} data-enabled={section === "cashout"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -404,6 +390,7 @@ function Form({ </span> </label> <label + aria-label={i18n.str`Config cashin`} data-enabled={section === "cashin"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -426,6 +413,7 @@ function Form({ </span> </label> <label + aria-label={i18n.str`Accounts`} data-enabled={section === "users"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -448,6 +436,7 @@ function Form({ </span> </label> <label + aria-label={i18n.str`Test`} data-enabled={section === "test"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -470,6 +459,7 @@ function Form({ </span> </label>{" "} <label + aria-label={i18n.str`Delete`} data-enabled={section === "delete"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -935,25 +925,28 @@ function TestConversionClass({ }, ), }); + const calculateRunning = calculate.running; + const calculateRun = calculate.run; + const calculateCancel = calculate.cancel; const calculationStartedFor = useRef<string>(); useEffect(() => { calculationStartedFor.current = undefined; setCalc(undefined); - if (calculate.running) calculate.cancel(); - }, [calculationKey]); + calculateCancel(); + }, [calculateCancel, calculationKey]); useEffect(() => { if ( - !calculate.running && + !calculateRunning && calculationStartedFor.current !== calculationKey && in_amount && !error ) { calculationStartedFor.current = calculationKey; - void calculate.run(calculationKey, in_amount); + void calculateRun(calculationKey, in_amount); } - }, [calculationKey, calculate.running, error]); + }, [calculateRun, calculateRunning, calculationKey, error, in_amount]); const currentCalculation = calculationResult?.key === calculationKey ? calculationResult : undefined; @@ -1174,17 +1167,19 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { } if (userListResult instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={userListResult} title={i18n.str`Failed to load users in conversion class.`} + onRetry={() => revalidateConversionRateClassUsers()} /> ); } if (resultInfo instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={resultInfo} title={i18n.str`Failed to load conversion rate information.`} + onRetry={() => revalidateConversionInfo()} /> ); } @@ -1399,7 +1394,7 @@ function AccountsOnConversionClass({ classId }: { classId: number }): VNode { {!userListResult.loadFirst && !userListResult.loadNext ? undefined : ( <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" - aria-label="Pagination" + aria-label={i18n.str`Pagination`} > <div class="flex flex-1 justify-between sm:justify-end"> <button diff --git a/packages/libeufin-bank-webui/src/pages/PublicHistoriesPage.tsx b/packages/libeufin-bank-webui/src/pages/PublicHistoriesPage.tsx @@ -28,8 +28,6 @@ import { usePublicAccounts, } from "../hooks/account.js"; -const TALER_SCREEN_ID = 108; - /** * Show histories of public accounts. */ @@ -81,17 +79,16 @@ export function PublicHistoriesPage(): VNode { : "pure-menu-item pure-menu" } > - <a - href="#" + <button + type="button" name={`show account ${account.username}`} class="pure-menu-link" onClick={(event) => { - event.preventDefault(); setShowAccount(account.username); }} > {account.username} - </a> + </button> </li>, ); } diff --git a/packages/libeufin-bank-webui/src/pages/WireTransfer.tsx b/packages/libeufin-bank-webui/src/pages/WireTransfer.tsx @@ -19,21 +19,19 @@ import { TalerError, assertUnreachable, } from "@gnu-taler/taler-util"; -import { - ErrorLoading, - Loading, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; +import { Loading, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { RouteDefinition } from "@gnu-taler/web-util/browser"; -import { useAccountDetails } from "../hooks/account.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../hooks/account.js"; import { useSessionState } from "../hooks/session.js"; import { LoginForm } from "./LoginForm.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { IntAmounts } from "./regional/CreateCashout.js"; - -const TALER_SCREEN_ID = 113; +import { RetryableError } from "../components/RetryableError.js"; export function WireTransfer({ toAccount, @@ -60,9 +58,10 @@ export function WireTransfer({ if (result instanceof TalerError) { return ( <Fragment> - <ErrorLoading + <RetryableError error={result} title={i18n.str`Failed to load account details.`} + onRetry={() => void revalidateAccountDetails()} /> <LoginForm currentUser={account} /> </Fragment> diff --git a/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx b/packages/libeufin-bank-webui/src/pages/account/ShowAccountDetails.tsx @@ -25,7 +25,6 @@ import { Attention, AsyncButton, CopyButton, - ErrorLoading, Loading, RouteDefinition, useBankCoreApiContext, @@ -46,8 +45,7 @@ import { useSessionState } from "../../hooks/session.js"; import { AccountForm } from "../admin/AccountForm.js"; import { LoginForm } from "../LoginForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; - -const TALER_SCREEN_ID = 118; +import { RetryableError } from "../../components/RetryableError.js"; export function ShowAccountDetails({ account, @@ -168,9 +166,10 @@ export function ShowAccountDetails({ if (result instanceof TalerError) { return ( <Fragment> - <ErrorLoading + <RetryableError error={result} title={i18n.str`Failed to load account details.`} + onRetry={() => void revalidateAccountDetails()} /> <LoginForm currentUser={account} /> </Fragment> diff --git a/packages/libeufin-bank-webui/src/pages/admin/AccountList.tsx b/packages/libeufin-bank-webui/src/pages/admin/AccountList.tsx @@ -20,7 +20,6 @@ import { assertUnreachable, } from "@gnu-taler/taler-util"; import { - ErrorLoading, Loading, RenderAmount, RouteDefinition, @@ -29,9 +28,11 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useBusinessAccounts } from "../../hooks/regional.js"; - -const TALER_SCREEN_ID = 121; +import { + revalidateBusinessAccounts, + useBusinessAccounts, +} from "../../hooks/regional.js"; +import { RetryableError } from "../../components/RetryableError.js"; interface Props { routeCreate: RouteDefinition; @@ -56,9 +57,10 @@ export function AccountList({ } if (result instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={result} title={i18n.str`Failed to load the account list.`} + onRetry={() => void revalidateBusinessAccounts()} /> ); } @@ -213,7 +215,7 @@ export function AccountList({ </div> <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" - aria-label="Pagination" + aria-label={i18n.str`Pagination`} > <div class="flex flex-1 justify-between sm:justify-end"> <button diff --git a/packages/libeufin-bank-webui/src/pages/admin/AdminHome.tsx b/packages/libeufin-bank-webui/src/pages/admin/AdminHome.tsx @@ -26,7 +26,6 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ErrorLoading, RenderAmount, RouteDefinition, useBankCoreApiContext, @@ -37,14 +36,18 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Transactions } from "../../components/Transactions/index.js"; -import { useConversionInfo, useLastMonitorInfo } from "../../hooks/regional.js"; +import { + revalidateConversionInfo, + revalidateLastMonitorInfo, + useConversionInfo, + useLastMonitorInfo, +} from "../../hooks/regional.js"; +import { RetryableError } from "../../components/RetryableError.js"; import { WireTransfer } from "../WireTransfer.js"; import { AccountList } from "./AccountList.js"; import { ConversionClassList } from "./ConversionClassList.js"; -const TALER_SCREEN_ID = 122; - /** * Query account information and show QR code if there is pending withdrawal */ @@ -250,17 +253,19 @@ function Metrics({ if (!resp) return <Fragment />; if (resp instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={resp} title={i18n.str`Failed to load the monitor info.`} + onRetry={() => void revalidateLastMonitorInfo()} /> ); } if (respInfo && respInfo instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={respInfo} title={i18n.str`Failed to load cashout info.`} + onRetry={() => void revalidateConversionInfo()} /> ); } @@ -343,7 +348,10 @@ function Metrics({ </div> <div class="sm:hidden"> - <label for="tabs" class="sr-only"> + <label + htmlFor="tabs" + class="sr-only" + > <i18n.Translate>Select a section</i18n.Translate> </label> <select @@ -391,7 +399,7 @@ function Metrics({ {/* FIXME: This should be LINKS */} <nav class="isolate flex divide-x divide-gray-200 rounded-lg shadow" - aria-label="Tabs" + aria-label={i18n.str`Tabs`} > <button type="button" diff --git a/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx b/packages/libeufin-bank-webui/src/pages/admin/ConversionClassList.tsx @@ -24,7 +24,6 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ErrorLoading, Loading, RenderAmount, RouteDefinition, @@ -34,11 +33,11 @@ import { Fragment, VNode, h } from "preact"; import { CurrencySpecification } from "@gnu-taler/taler-util"; import { + revalidateConversionRateClasses, useConversionInfo, useConversionRateClasses, } from "../../hooks/regional.js"; - -const TALER_SCREEN_ID = 130; +import { RetryableError } from "../../components/RetryableError.js"; interface Props { routeCreate: RouteDefinition; @@ -66,9 +65,10 @@ export function ConversionClassList({ } if (result instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={result} title={i18n.str`Failed to load conversion rate.`} + onRetry={() => void revalidateConversionRateClasses()} /> ); } @@ -249,7 +249,7 @@ export function ConversionClassList({ </div> <nav class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" - aria-label="Pagination" + aria-label={i18n.str`Pagination`} > <div class="flex flex-1 justify-between sm:justify-end"> <button diff --git a/packages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx b/packages/libeufin-bank-webui/src/pages/admin/RemoveAccount.tsx @@ -23,7 +23,6 @@ import { import { Attention, AsyncButton, - ErrorLoading, Loading, RouteDefinition, ShowInputErrorLabel, @@ -37,13 +36,15 @@ import { useState } from "preact/hooks"; import { UserAndToken } from "@gnu-taler/taler-util"; import { useBankChallengeHandlerContext } from "../../context/challenge.js"; -import { useAccountDetails } from "../../hooks/account.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../../hooks/account.js"; import { useSessionState } from "../../hooks/session.js"; import { undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; - -const TALER_SCREEN_ID = 125; +import { RetryableError } from "../../components/RetryableError.js"; export function RemoveAccount({ account, @@ -129,9 +130,10 @@ export function RemoveAccount({ if (result instanceof TalerError) { return ( <Fragment> - <ErrorLoading + <RetryableError error={result} title={i18n.str`Failed to load account details.`} + onRetry={() => void revalidateAccountDetails()} /> <LoginForm currentUser={account} /> </Fragment> diff --git a/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx b/packages/libeufin-bank-webui/src/pages/regional/ConversionConfig.tsx @@ -26,7 +26,6 @@ import { import { Attention, AsyncButton, - ErrorLoading, InternationalizationAPI, Loading, RenderAmount, @@ -63,8 +62,7 @@ import { ProfileNavigation } from "../ProfileNavigation.js"; import { TalerErrorCode, opFixedSuccess } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { DescribeConversion } from "../admin/ConversionClassList.js"; - -const TALER_SCREEN_ID = 126; +import { RetryableError } from "../../components/RetryableError.js"; interface Props { routeMyAccountDetails: RouteDefinition; @@ -108,9 +106,10 @@ function useComponentState({ } if (resp instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={resp} title={i18n.str`Failed to load conversion rate information.`} + onRetry={() => void revalidateConversionInfo()} /> ); } @@ -238,17 +237,20 @@ function useComponentState({ } }), }); + const calculateRunning = calculate.running; + const calculateRun = calculate.run; + const calculateCancel = calculate.cancel; const calculationStartedFor = useRef<string>(); useEffect(() => { calculationStartedFor.current = undefined; setCalc(undefined); - if (calculate.running) calculate.cancel(); - }, [calculationKey, status.status]); + calculateCancel(); + }, [calculateCancel, calculationKey, status.status]); useEffect(() => { if ( - !calculate.running && + !calculateRunning && calculationStartedFor.current !== calculationKey && inAmount && inFee && @@ -256,9 +258,17 @@ function useComponentState({ status.status !== "fail" ) { calculationStartedFor.current = calculationKey; - void calculate.run(calculationKey, inAmount, inFee, outFee); + void calculateRun(calculationKey, inAmount, inFee, outFee); } - }, [calculationKey, calculate.running, status.status]); + }, [ + calculateRun, + calculateRunning, + calculationKey, + inAmount, + inFee, + outFee, + status.status, + ]); const [section, setSection] = useState<"detail" | "cashout" | "cashin">( "detail", @@ -317,6 +327,7 @@ function useComponentState({ </h2> <div class="px-2 mt-2 grid grid-cols-1 gap-y-4 sm:gap-x-4"> <label + aria-label={i18n.str`Details`} data-enabled={section === "detail"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -340,6 +351,7 @@ function useComponentState({ </label> <label + aria-label={i18n.str`Config cashout`} data-enabled={section === "cashout"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -362,6 +374,7 @@ function useComponentState({ </span> </label> <label + aria-label={i18n.str`Config cashin`} data-enabled={section === "cashin"} class="relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none border-gray-300 -- data-[enabled=true]:border-indigo-600 data-[enabled=true]:ring-2 data-[enabled=true]:ring-indigo-600" > @@ -936,17 +949,16 @@ export function ConversionForm({ <div class="mt-2 max-w-xl text-sm text-gray-500"> <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> <label - onClick={(e) => { - e.preventDefault(); - rounding?.onUpdate("zero"); - }} + aria-label={i18n.str`Zero`} data-selected={rounding?.value === "zero"} class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" > <input type="radio" - name="channel" - value="Newsletter" + name={`${id}_rounding`} + value="zero" + checked={rounding?.value === "zero"} + onChange={() => rounding?.onUpdate("zero")} class="sr-only" /> <span class="flex flex-1"> @@ -976,17 +988,16 @@ export function ConversionForm({ </label> <label - onClick={(e) => { - e.preventDefault(); - rounding?.onUpdate("up"); - }} + aria-label={i18n.str`Up`} data-selected={rounding?.value === "up"} class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" > <input type="radio" - name="channel" - value="Existing Customers" + name={`${id}_rounding`} + value="up" + checked={rounding?.value === "up"} + onChange={() => rounding?.onUpdate("up")} class="sr-only" /> <span class="flex flex-1"> @@ -1015,17 +1026,16 @@ export function ConversionForm({ </svg> </label> <label - onClick={(e) => { - e.preventDefault(); - rounding?.onUpdate("nearest"); - }} + aria-label={i18n.str`Nearest`} data-selected={rounding?.value === "nearest"} class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600" > <input type="radio" - name="channel" - value="Existing Customers" + name={`${id}_rounding`} + value="nearest" + checked={rounding?.value === "nearest"} + onChange={() => rounding?.onUpdate("nearest")} class="sr-only" /> <span class="flex flex-1"> diff --git a/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx b/packages/libeufin-bank-webui/src/pages/regional/CreateCashout.tsx @@ -29,7 +29,6 @@ import { import { Attention, AsyncButton, - ErrorLoading, Loading, RenderAmount, RouteDefinition, @@ -49,9 +48,14 @@ import { } from "@gnu-taler/taler-util"; import { dummyHttpResponse } from "@gnu-taler/taler-util/http"; import { useBankChallengeHandlerContext } from "../../context/challenge.js"; -import { useAccountDetails } from "../../hooks/account.js"; +import { + revalidateAccountDetails, + useAccountDetails, +} from "../../hooks/account.js"; import { TransCalc, + revalidateConversionInfo, + revalidateConversionRateForUser, useCashoutEstimatorByUser, useConversionInfo, useConversionRateForUser, @@ -60,8 +64,7 @@ import { LoggedIn, useSessionState } from "../../hooks/session.js"; import { TanChannel, undefinedIfEmpty } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; - -const TALER_SCREEN_ID = 127; +import { RetryableError } from "../../components/RetryableError.js"; interface Props { account: string; @@ -119,9 +122,10 @@ export function CreateCashout({ return <Loading />; } else if (resultAccount instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={resultAccount} title={i18n.str`Failed to load account details.`} + onRetry={() => void revalidateAccountDetails()} /> ); } else if (resultAccount.type === "fail") { @@ -139,9 +143,10 @@ export function CreateCashout({ return <Loading />; } else if (conversionResp instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={conversionResp} title={i18n.str`Failed to load conversion rate information.`} + onRetry={() => void revalidateConversionInfo()} /> ); } else if (conversionResp.type === "fail") { @@ -165,9 +170,10 @@ export function CreateCashout({ return <Loading />; } else if (rateResp instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={rateResp} title={i18n.str`Failed to load conversion rate information.`} + onRetry={() => void revalidateConversionRateForUser()} /> ); } else if (rateResp.type === "fail") { @@ -338,24 +344,33 @@ function CreateCashoutInternal({ }, ), }); + const calculationRunning = conversionCalculator.running; + const runCalculation = conversionCalculator.run; const lastCalculationAttempt = useRef<string>(); useEffect(() => { if ( - conversionCalculator.running || + calculationRunning || lastCalculationAttempt.current === calculationKey ) { return; } lastCalculationAttempt.current = calculationKey; setCalculationQuote(undefined); - void conversionCalculator.run( + void runCalculation( calculationKey, form.isDebit ?? false, inputAmount, sellFee, ); - }, [calculationKey, conversionCalculator.running]); + }, [ + calculationKey, + calculationRunning, + form.isDebit, + inputAmount, + runCalculation, + sellFee, + ]); const calculationResult = calculationQuote?.key === calculationKey diff --git a/packages/libeufin-bank-webui/src/pages/regional/ShowCashoutDetails.tsx b/packages/libeufin-bank-webui/src/pages/regional/ShowCashoutDetails.tsx @@ -22,7 +22,6 @@ import { } from "@gnu-taler/taler-util"; import { Attention, - ErrorLoading, Loading, RenderAmount, RouteDefinition, @@ -31,7 +30,13 @@ import { } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { useCashoutDetails, useConversionInfo } from "../../hooks/regional.js"; +import { + revalidateCashoutDetails, + revalidateConversionInfo, + useCashoutDetails, + useConversionInfo, +} from "../../hooks/regional.js"; +import { RetryableError } from "../../components/RetryableError.js"; const TALER_SCREEN_ID = 128; @@ -61,9 +66,10 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { } if (result instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={result} title={i18n.str`Failed to load cashout details.`} + onRetry={() => void revalidateCashoutDetails()} /> ); } @@ -95,9 +101,10 @@ export function ShowCashoutDetails({ id, routeClose }: Props): VNode { if (info instanceof TalerError) { return ( - <ErrorLoading + <RetryableError error={info} title={i18n.str`Failed to load conversion rate information.`} + onRetry={() => void revalidateConversionInfo()} /> ); }