commit 667d6e8d126968042cdf10700b123b2e86d9f3bf parent 5dd9792d4882d8e0260c1db44f30402ad95f7df7 Author: Florian Dold <dold@taler.net> Date: Tue, 3 Nov 2026 21:51:31 +0100 merchant web UI: show errors beside actions Apply footers to refunds, key rotation, pairing, template orders, and sale cancellation. Diffstat:
10 files changed, 315 insertions(+), 109 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/AccessScreen.tsx b/packages/taler-merchant-webui/src/screens/AccessScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { useLocation } from "wouter-preact"; @@ -26,7 +27,6 @@ import type { RemoteResource } from "../api/contracts.js"; import { ReadErrorBanner } from "../ui/ReadErrorBanner.js"; import { InitialLoadingState } from "../ui/InitialLoadingState.js"; import { ReloadControl } from "../ui/ReloadControl.js"; -import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { DisclosureChevron } from "../ui/DisclosureChevron.js"; import { PasswordInput } from "../ui/PasswordInput.js"; import type { @@ -119,7 +119,11 @@ export function AccessScreen({ const [showEarlyCloseWarning, setShowEarlyCloseWarning] = useState<boolean>(false); const [isPairing, setIsPairing] = useState<boolean>(false); - const [pairingError, setPairingError] = useState<unknown>(undefined); + const { + rawError: pairingError, + setRawError: setPairingError, + feedbackId, + } = useFormFeedback(); useEffect(() => { if (!issuedPairingCredential) return; @@ -128,7 +132,7 @@ export function AccessScreen({ setPairStep("qr"); setShowPairModal(true); setPairingError(undefined); - }, [issuedPairingCredential]); + }, [issuedPairingCredential, setPairingError]); const displayTokens = tokensResource?.data ?? propTokens ?? []; const isInitiallyLoading = @@ -458,7 +462,7 @@ export function AccessScreen({ }} class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" > - <div class="bg-white rounded-2xl max-w-md w-full p-6 shadow-xl space-y-4 text-left"> + <div class="bg-white rounded-2xl max-w-md w-full p-6 shadow-xl space-y-4 text-left max-h-[90dvh] overflow-y-auto"> {pairStep === "form" ? ( <form onSubmit={handleStartPairing} class="space-y-4"> <div class="flex items-center justify-between border-b border-gray-100 pb-3"> @@ -472,13 +476,6 @@ export function AccessScreen({ </button> </div> - {pairingError !== undefined && ( - <ApiErrorBanner - error={pairingError} - title={t`Could not create till access`} - /> - )} - <div> <label htmlFor="pair_dev_name" @@ -584,7 +581,13 @@ export function AccessScreen({ )} </div> - <div class="flex items-center justify-end space-x-3 pt-2"> + <FormActionFooter + error={pairingError} + feedbackId={feedbackId} + pending={isPairing} + title={t`Could not create till access`} + dismissedLabel={t`Action not confirmed`} + > <button type="button" onClick={handleCancelPairing} @@ -599,7 +602,7 @@ export function AccessScreen({ > {isPairing ? t`Generating…` : t`Generate Pairing Code →`} </button> - </div> + </FormActionFooter> </form> ) : ( <div class="space-y-4 text-center"> diff --git a/packages/taler-merchant-webui/src/screens/AdminAccountsScreen.tsx b/packages/taler-merchant-webui/src/screens/AdminAccountsScreen.tsx @@ -269,6 +269,7 @@ export function AdminAccountsScreen({ {target && ( <div class="space-y-4"> <ApiErrorBanner + compact error={mutationError} title={ operation === "purge" ? t`Purge failed` : t`Disable failed` diff --git a/packages/taler-merchant-webui/src/screens/DevicesScreen.tsx b/packages/taler-merchant-webui/src/screens/DevicesScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { useLocation } from "wouter-preact"; @@ -31,7 +32,6 @@ import { } from "../ui/RowActions.js"; import type { RemoteResource } from "../api/contracts.js"; import { ReadErrorBanner } from "../ui/ReadErrorBanner.js"; -import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { InitialLoadingState } from "../ui/InitialLoadingState.js"; import { randomRfc3548Base32Key } from "@gnu-taler/taler-util"; import { resourcePath } from "../routing/resourcePath.js"; @@ -92,7 +92,11 @@ export function DevicesScreen({ name: string; key: string; } | null>(null); - const [rotateError, setRotateError] = useState<unknown>(); + const { + rawError: rotateError, + setRawError: setRotateError, + feedbackId, + } = useFormFeedback(); const [isRotating, setIsRotating] = useState(false); const { @@ -132,6 +136,7 @@ export function DevicesScreen({ setRotateError(new Error(t`Could not load offline payment devices`)); return; } + setRotateError(undefined); const newKey = randomRfc3548Base32Key(); const devName = rotatingKeyDevice.name; const devId = rotatingKeyDevice.id; @@ -166,11 +171,6 @@ export function DevicesScreen({ title={t`Could not load offline payment devices`} /> )} - <ApiErrorBanner - error={rotateError} - title={t`Could not rotate the device key`} - onDismiss={() => setRotateError(undefined)} - /> {isInitiallyLoading ? ( <InitialLoadingState>{t`Loading…`}</InitialLoadingState> @@ -237,7 +237,10 @@ export function DevicesScreen({ >{t`Edit`}</a> <button type="button" - onClick={() => setRotatingKeyDevice(dev)} + onClick={() => { + setRotateError(undefined); + setRotatingKeyDevice(dev); + }} class={rowActionClass} >{t`Replace secret key`}</button> <button @@ -316,7 +319,10 @@ export function DevicesScreen({ >{t`Edit`}</a> <button type="button" - onClick={() => setRotatingKeyDevice(dev)} + onClick={() => { + setRotateError(undefined); + setRotatingKeyDevice(dev); + }} class={rowActionClass} >{t`Replace secret key`}</button> <button @@ -351,7 +357,13 @@ export function DevicesScreen({ ⚠️ <strong>{t`Warning:`}</strong>{" "} {t`The physical machine must be updated with the newly generated secret key immediately, or it will stop accepting payment codes.`} </p> - <div class="flex items-center justify-end space-x-3 pt-2"> + <FormActionFooter + error={rotateError} + feedbackId={feedbackId} + pending={isRotating} + title={t`Could not rotate the device key`} + dismissedLabel={t`Action not confirmed`} + > <Button variant="secondary" disabled={isRotating} @@ -366,7 +378,7 @@ export function DevicesScreen({ > {isRotating ? t`Rotating…` : t`Generate New Key & Rotate`} </Button> - </div> + </FormActionFooter> </Modal> {/* Rotated Key Display Modal */} diff --git a/packages/taler-merchant-webui/src/screens/MoneyInScreen.tsx b/packages/taler-merchant-webui/src/screens/MoneyInScreen.tsx @@ -1323,6 +1323,7 @@ export function MoneyInScreen({ ? {t`Future payouts will no longer land in this account.`} </p> <ApiErrorBanner + compact error={deleteAccountError} title={t`The bank account could not be removed`} /> diff --git a/packages/taler-merchant-webui/src/screens/OrderRefundScreen.tsx b/packages/taler-merchant-webui/src/screens/OrderRefundScreen.tsx @@ -16,6 +16,7 @@ import { Amounts, AbsoluteTime } from "@gnu-taler/taler-util"; import { useLocation } from "wouter-preact"; +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState, useEffect } from "preact/hooks"; import { Header } from "../ui/Header.js"; @@ -139,7 +140,13 @@ export function OrderRefundScreen({ ); const [selectedPreset, setSelectedPreset] = useState<string>("100%"); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); - const [submitError, setSubmitError] = useState<unknown>(); + const { + rawError: submitError, + setRawError: setSubmitError, + errorMsg: validationError, + setErrorMsg: setValidationError, + feedbackId, + } = useFormFeedback(); const [submitSuccess, setSubmitSuccess] = useState<boolean>(false); useEffect(() => { @@ -162,28 +169,25 @@ export function OrderRefundScreen({ const handleSubmit = async (e: Event) => { e.preventDefault(); - setSubmitError(undefined); + if (isSubmitting || submitSuccess) return; + setValidationError(""); if (isFullyRefunded) { - setSubmitError( + setValidationError( t`This order has already been 100% refunded. No further refunds can be granted.`, ); return; } + const finalAmountStr = validateRefundAmount(refundAmount, remainingAmount); + if (!finalAmountStr) { + setValidationError( + t`Enter a positive refund in the order currency that does not exceed the remaining refundable amount.`, + ); + return; + } setIsSubmitting(true); - try { - const finalAmountStr = validateRefundAmount( - refundAmount, - remainingAmount, - ); - if (!finalAmountStr) { - throw new Error( - t`Enter a positive refund in the order currency that does not exceed the remaining refundable amount.`, - ); - } - if (onConfirmRefund) { await onConfirmRefund(finalAmountStr, refundReason); } @@ -286,10 +290,6 @@ export function OrderRefundScreen({ </div> )} - {submitError && ( - <ApiErrorBanner error={submitError} title={t`Failed to grant refund`} /> - )} - {/* Order Context Summary */} <div class="bg-white border border-gray-200 rounded-lg p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-sm"> <div> @@ -402,7 +402,14 @@ export function OrderRefundScreen({ </div> {/* Actions */} - <div class="flex items-center justify-end space-x-3 pt-3 border-t border-gray-100"> + <FormActionFooter + error={submitError} + validationError={validationError} + feedbackId={feedbackId} + pending={isSubmitting} + title={t`Failed to grant refund`} + dismissedLabel={t`Action not confirmed`} + > <button type="button" onClick={() => { @@ -431,7 +438,7 @@ export function OrderRefundScreen({ ? t`Already 100% Refunded` : t`Confirm Refund (${Amounts.formatAmount(refundAmount)})`} </button> - </div> + </FormActionFooter> </form> </div> ); diff --git a/packages/taler-merchant-webui/src/screens/PosScreen.tsx b/packages/taler-merchant-webui/src/screens/PosScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useState, useEffect, useRef } from "preact/hooks"; import { useLocation } from "wouter-preact"; @@ -226,7 +227,11 @@ export function PosScreen({ const [isCreating, setIsCreating] = useState<boolean>(false); const [createError, setCreateError] = useState<unknown>(); const [isCanceling, setIsCanceling] = useState<boolean>(false); - const [cancelError, setCancelError] = useState<unknown>(); + const { + rawError: cancelError, + setRawError: setCancelError, + feedbackId: cancelFeedbackId, + } = useFormFeedback(); // Numpad State for Quick Amount Mode const [numpadDigits, setNumpadDigits] = useState<string>("0"); @@ -258,6 +263,12 @@ export function PosScreen({ ? propSampleOrder : (orderResource?.data ?? fetchedOrder); + const { + rawError: refundError, + setRawError: setRefundError, + feedbackId: refundFeedbackId, + } = useFormFeedback(); + // Refund Drawer State for History Mode const [refundOrderId, setRefundOrderId] = useState<string | null>(null); const [refundAmountVal, setRefundAmountVal] = useState<string>(""); @@ -761,6 +772,7 @@ export function PosScreen({ setRefundAmountVal(""); setRefundDetails(undefined); setRefundMsg(null); + setRefundError(undefined); setIsRefundDetailsLoading(true); try { const details = await onLoadRefundOrder?.(orderId); @@ -778,6 +790,7 @@ export function PosScreen({ setRefundDetails(details); setRefundAmountVal(details.remaining.split(":")[1] || ""); } catch (err: unknown) { + setRefundError(normalizeUiFailure(err, { outcome: "indeterminate" })); setRefundMsg({ type: "error", text: formatErrorMessage(err, t`Failed to issue refund.`, t), @@ -799,6 +812,7 @@ export function PosScreen({ ) : undefined; if (!refundAmount) { + setRefundError(undefined); setRefundMsg({ type: "error", text: t`Enter a positive refund amount no greater than ${Amounts.formatAmount(refundDetails.remaining)}.`, @@ -807,6 +821,7 @@ export function PosScreen({ } setIsRefunding(true); setRefundMsg(null); + setRefundError(undefined); try { if (!onIssueRefund) throw new Error(t`Failed to issue refund.`); @@ -818,6 +833,7 @@ export function PosScreen({ setRefundOrderId(null); setRefundDetails(undefined); } catch (err: unknown) { + setRefundError(normalizeUiFailure(err, { outcome: "indeterminate" })); setRefundMsg({ type: "error", text: formatErrorMessage(err, t`Failed to issue refund.`, t), @@ -980,6 +996,7 @@ export function PosScreen({ )} {createError && ( <ApiErrorBanner + compact error={createError} title={t`The sale could not be created`} actionLabel={t`Retry`} @@ -1084,14 +1101,8 @@ export function PosScreen({ {/* MODE 3: LIVE PAYMENT & QR CODE SCREEN */} {activeTab === "pay" && ( - <div class="flex-1 flex flex-col items-center justify-center p-6 bg-gray-50 overflow-y-auto"> - <div class="bg-white border border-gray-200 rounded-3xl p-8 shadow-xl max-w-lg w-full text-center space-y-5"> - <ApiErrorBanner - error={cancelError} - title={t`The sale could not be canceled`} - onDismiss={() => setCancelError(undefined)} - /> - + <div class="flex-1 flex flex-col items-center p-6 bg-gray-50 overflow-y-auto"> + <div class="bg-white border border-gray-200 rounded-3xl p-8 shadow-xl max-w-lg w-full text-center space-y-5 my-auto shrink-0"> <div class="inline-flex items-center space-x-2 px-3.5 py-1.5 rounded-full bg-blue-50 text-blue-900 border border-blue-200 text-2xs font-extrabold"> <span class="w-2 h-2 rounded-full bg-blue-600 animate-ping" /> <span>{t`Awaiting Customer Wallet Payment...`}</span> @@ -1143,7 +1154,13 @@ export function PosScreen({ </div> {/* Actions */} - <div class="flex flex-wrap items-center justify-center gap-2 pt-1"> + <FormActionFooter + error={cancelError} + feedbackId={cancelFeedbackId} + pending={isCanceling} + title={t`The sale could not be canceled`} + dismissedLabel={t`Action not confirmed`} + > <button type="button" onClick={() => setShowNewSaleDecision(true)} @@ -1174,7 +1191,7 @@ export function PosScreen({ > {isCanceling ? t`Canceling…` : t`✕ Cancel Sale`} </button> - </div> + </FormActionFooter> </div> </div> )} @@ -1189,7 +1206,7 @@ export function PosScreen({ <p class="text-sm text-gray-700"> {t`Keep it in this tab so you can return with Previous and Next, or cancel it at the backend before starting another sale.`} </p> - {cancelError && <ApiErrorBanner error={cancelError} />} + {cancelError && <ApiErrorBanner compact error={cancelError} />} <div class="flex flex-wrap justify-end gap-3 pt-3"> <Button variant="secondary" @@ -1361,8 +1378,13 @@ export function PosScreen({ {/* Ad-hoc Custom Item Modal */} {showCustomModal && ( - <div class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"> - <div class="bg-white rounded-2xl max-w-sm w-full p-6 shadow-xl space-y-4"> + <div + role="dialog" + aria-modal="true" + aria-label={t`Issue Refund for Order #${refundOrderId}`} + class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" + > + <div class="bg-white rounded-2xl max-w-sm w-full p-6 shadow-xl space-y-4 max-h-[90dvh] overflow-y-auto"> <h3 class="text-sm font-extrabold text-gray-900">{t`Add Ad-hoc Custom Item`}</h3> <form onSubmit={handleAddCustomItem} class="space-y-4"> <div> @@ -1421,22 +1443,19 @@ export function PosScreen({ {/* Quick Refund Execution Modal */} {refundOrderId && ( - <div class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"> - <div class="bg-white rounded-2xl max-w-sm w-full p-6 shadow-xl space-y-4"> + <div + role="dialog" + aria-modal="true" + aria-label={t`Issue Refund for Order #${refundOrderId}`} + class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" + > + <div class="bg-white rounded-2xl max-w-sm w-full p-6 shadow-xl space-y-4 max-h-[90dvh] overflow-y-auto"> <h3 class="text-sm font-extrabold text-gray-900">{t`Issue Refund for Order #${refundOrderId}`}</h3> <form onSubmit={handleExecuteRefund} class="space-y-4"> {isRefundDetailsLoading && ( <p class="text-xs text-gray-600">{t`Loading order details...`}</p> )} - {refundMsg?.type === "error" && ( - <div - data-error-banner - role="alert" - class="rounded-xl border border-red-200 bg-red-50 p-3 text-xs font-bold text-red-900" - > - {refundMsg.text} - </div> - )} + <div> <label class="block text-2xs font-bold text-gray-700 mb-1"> {t`Refund Amount (${Amounts.amountCurrency(refundDetails?.remaining) || "—"}) *`} @@ -1469,7 +1488,18 @@ export function PosScreen({ /> </div> - <div class="flex items-center justify-end space-x-2 pt-2"> + <FormActionFooter + error={refundError} + validationError={ + !refundError && refundMsg?.type === "error" + ? refundMsg.text + : undefined + } + feedbackId={refundFeedbackId} + pending={isRefunding} + title={t`Failed to issue refund.`} + dismissedLabel={t`Action not confirmed`} + > <button type="button" onClick={() => { @@ -1489,7 +1519,7 @@ export function PosScreen({ > {t`Execute Refund`} </button> - </div> + </FormActionFooter> </form> </div> </div> diff --git a/packages/taler-merchant-webui/src/screens/TemplateDetailsScreen.tsx b/packages/taler-merchant-webui/src/screens/TemplateDetailsScreen.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js"; import type { VNode } from "preact"; import { useRef, useState, useEffect } from "preact/hooks"; import { useLocation } from "wouter-preact"; @@ -27,7 +28,6 @@ import { Input } from "../ui/Input.js"; import { TalerQrCode } from "../ui/TalerQrCode.js"; import { useTranslation } from "../context/translation.js"; import { printElement } from "../utils/print.js"; -import { formatErrorMessage } from "../utils/errors.js"; import { getTemplateSellsOptions } from "../utils/templates.js"; import type { TemplateItem } from "./TemplatesScreen.js"; import { @@ -147,7 +147,11 @@ export function TemplateDetailsScreen({ const deleteLock = useRef(false); const [previewQr, setPreviewQr] = useState<boolean>(false); const [creatingOrder, setCreatingOrder] = useState<boolean>(false); - const [orderError, setOrderError] = useState<string>(""); + const { + rawError: orderError, + setRawError: setOrderError, + feedbackId, + } = useFormFeedback(); useEffect(() => { if (!previewQr && !deleting) return; @@ -166,8 +170,8 @@ export function TemplateDetailsScreen({ * code to show the customer is. */ const handleCreateOrder = async () => { - if (!detail || !onCreateOrder) return; - setOrderError(""); + if (!detail || !onCreateOrder || creatingOrder) return; + setOrderError(undefined); setCreatingOrder(true); try { const body: TalerMerchantApi.UsingTemplateDetailsRequest = { @@ -185,13 +189,7 @@ export function TemplateDetailsScreen({ if (!orderId) throw new Error(t`The backend did not return an order ID.`); setLocation(resourcePath("/orders", orderId)); } catch (err: unknown) { - setOrderError( - formatErrorMessage( - err, - t`Could not create an order from this template.`, - t, - ), - ); + setOrderError(err); } finally { setCreatingOrder(false); } @@ -305,22 +303,6 @@ export function TemplateDetailsScreen({ {/* No `shrink-0`: four buttons that refuse to shrink ran off the card and the last of them was clipped. They wrap instead. */} <div class="flex flex-wrap items-center justify-end gap-2 min-w-0"> - {orderability && ( - <Button - variant="primary" - onClick={handleCreateOrder} - disabled={!orderability.canCreate || creatingOrder} - isLoading={creatingOrder} - title={ - orderability.canCreate - ? undefined - : blockerText(orderability.blocker) - } - > - {t`Create order from this template`} - </Button> - )} - <Button variant="secondary" onClick={() => setPreviewQr(true)}> {t`Print QR code`} </Button> @@ -345,16 +327,6 @@ export function TemplateDetailsScreen({ </p> )} - {orderError && ( - <div - data-error-banner - role="alert" - class="p-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800 font-medium" - > - ⚠️ {orderError} - </div> - )} - {/* Section 1: What it Sells */} <div class="bg-gray-50 p-5 rounded-xl border border-gray-200 space-y-3"> <h4 class="text-xs font-bold text-gray-700">{t`1. What it Sells`}</h4> @@ -467,6 +439,29 @@ export function TemplateDetailsScreen({ )} </div> </div> + <FormActionFooter + error={orderError} + feedbackId={feedbackId} + pending={creatingOrder} + title={t`Could not create an order from this template.`} + dismissedLabel={t`Action not confirmed`} + > + {orderability && ( + <Button + variant="primary" + onClick={handleCreateOrder} + disabled={!orderability.canCreate || creatingOrder} + isLoading={creatingOrder} + title={ + orderability.canCreate + ? undefined + : blockerText(orderability.blocker) + } + > + {t`Create order from this template`} + </Button> + )} + </FormActionFooter> </div> {/* Delete Modal */} @@ -481,6 +476,7 @@ export function TemplateDetailsScreen({ {t`Any printed QR code for "${template.name}" will stop working. This cannot be undone.`} </p> <ApiErrorBanner + compact error={deleteError} title={t`Could not delete this template`} /> diff --git a/packages/taler-merchant-webui/src/screens/TemplatesScreen.tsx b/packages/taler-merchant-webui/src/screens/TemplatesScreen.tsx @@ -320,6 +320,7 @@ export function TemplatesScreen({ </button> </div> <ApiErrorBanner + compact error={deleteError} title={t`The template could not be deleted`} /> diff --git a/packages/taler-merchant-webui/src/screens/actionFeedback.test.tsx b/packages/taler-merchant-webui/src/screens/actionFeedback.test.tsx @@ -0,0 +1,151 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + SPDX-License-Identifier: GPL-3.0-or-later + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { OrderRefundScreen } from "./OrderRefundScreen.js"; +import { DevicesScreen } from "./DevicesScreen.js"; +import { TemplateDetailsScreen } from "./TemplateDetailsScreen.js"; +import { AccessScreen } from "./AccessScreen.js"; + +function button(text: string, root: ParentNode = document) { + const b = [...root.querySelectorAll<HTMLButtonElement>("button")].find( + (b) => + b.textContent?.includes(text) || b.getAttribute("aria-label") === text, + ); + assert.ok(b, `Missing button: ${text}`); + return b; +} +const click = (b: HTMLElement) => + act(async () => { + b.click(); + }); +const input = (id: string, value: string) => + act(() => { + const field = document.getElementById(id) as HTMLInputElement; + assert.ok(field, id); + field.value = value; + field.dispatchEvent(new Event("input", { bubbles: true })); + }); +const submit = (form: HTMLFormElement) => + act(async () => { + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + }); + +for (const kind of ["refund", "rotation", "template", "pairing"] as const) { + test(`${kind} failures stay at the action, retain details, and reappear after dismissal`, async () => { + const root = document.body.appendChild(document.createElement("div")); + const failure = new Error("Request rejected with diagnostic details"); + let attempts = 0; + const fail = async () => { + attempts++; + throw failure; + }; + try { + await act(() => + render( + kind === "refund" ? ( + <OrderRefundScreen onConfirmRefund={fail} preventNavigation /> + ) : kind === "rotation" ? ( + <DevicesScreen + authenticators={[ + { id: "device", name: "Test device", algorithm: 1 }, + ]} + onRotateKey={fail} + /> + ) : kind === "template" ? ( + <TemplateDetailsScreen + templateId="test" + initialTemplate={{ + id: "test", + name: "Test", + amount: "EUR:5", + summary: "Coffee", + type: "fixed", + }} + onCreateOrder={fail} + /> + ) : ( + <AccessScreen tokens={[]} onCreatePairing={fail} /> + ), + root, + ), + ); + if (kind === "rotation") { + await click(button("Actions for Test device")); + await click(button("Replace secret key")); + } + if (kind === "pairing") { + await click(button("Pair a till")); + await input("pair_dev_name", "Checkout"); + await input("pair_password", "password"); + } + if (kind === "refund") + await input("refund-reason-input", "Keep my reason"); + const scope = + kind === "rotation" || kind === "pairing" + ? document.querySelector('[role="dialog"]')! + : root; + const attempt = () => + kind === "rotation" + ? click(button("Generate New Key & Rotate", scope)) + : kind === "template" + ? click(button("Create order from this template", scope)) + : submit(scope.querySelector("form")!); + await attempt(); + const footer = scope.querySelector("[data-form-action-footer]")!; + assert.ok(footer); + assert.match( + footer.textContent!, + /Request rejected with diagnostic details/, + ); + assert.ok(button("Copy Error", footer)); + await click( + footer.querySelector<HTMLButtonElement>("[data-dismiss-error]")!, + ); + assert.match(footer.textContent!, /Action not confirmed/); + assert.doesNotMatch(footer.textContent!, /Save not confirmed/); + assert.equal(attempts, 1, "dismissal never repeats the request"); + await attempt(); + assert.equal(attempts, 2); + assert.match( + footer.textContent!, + /Request rejected with diagnostic details/, + ); + if (kind === "refund") + assert.equal( + (document.getElementById("refund-reason-input") as HTMLInputElement) + .value, + "Keep my reason", + ); + if (kind === "pairing") + assert.equal( + (document.getElementById("pair_dev_name") as HTMLInputElement).value, + "Checkout", + ); + if (kind === "rotation") { + assert.equal( + root.querySelector('[role="alert"]'), + null, + "error is inside the portalled dialog, not behind it", + ); + await click(button("Cancel", scope)); + await click(button("Actions for Test device")); + await click(button("Replace secret key")); + assert.doesNotMatch( + document.querySelector('[role="dialog"]')!.textContent!, + /Request rejected/, + ); + } + } finally { + act(() => render(null, root)); + root.remove(); + } + }); +} diff --git a/packages/taler-merchant-webui/src/ui/ConfirmDeleteModal.tsx b/packages/taler-merchant-webui/src/ui/ConfirmDeleteModal.tsx @@ -54,7 +54,11 @@ export function ConfirmDeleteModal({ title={title} > <p class="text-xs text-gray-700 leading-relaxed">{description}</p> - <ApiErrorBanner error={error} title={t`Could not delete this item`} /> + <ApiErrorBanner + compact + error={error} + title={t`Could not delete this item`} + /> <div class="flex items-center justify-end space-x-3 pt-2"> <Button variant="secondary" onClick={onClose} disabled={isPending}> {cancelLabel || t`Cancel`}