taler-typescript-core

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

commit c6c98d166062df285251e503d9fc6366e3bb1044
parent 700ff51d545d0f88c8c75df002c9345196ccd566
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 21:51:30 +0100

merchant web UI: add sticky action feedback

Keep failures beside action controls, with dismissible details that
reopen after repeated failures.

Diffstat:
Mpackages/taler-merchant-webui/src/screens/screens.test.tsx | 1+
Mpackages/taler-merchant-webui/src/ui/ApiErrorBanner.tsx | 17++++++++++++-----
Apackages/taler-merchant-webui/src/ui/FormActionFooter.test.tsx | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/ui/FormActionFooter.tsx | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 292 insertions(+), 5 deletions(-)

diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx @@ -8409,6 +8409,7 @@ test("every error a screen shows can be copied with its detail", async () => { if (!source.includes("setErrorMsg(")) continue; if ( !source.includes("ApiErrorBanner") && + !source.includes("FormActionFooter") && !source.includes("CopyErrorButton") ) { offenders.push(name); diff --git a/packages/taler-merchant-webui/src/ui/ApiErrorBanner.tsx b/packages/taler-merchant-webui/src/ui/ApiErrorBanner.tsx @@ -27,6 +27,8 @@ export interface ApiErrorBannerProps { actionLabel?: string; onAction?: () => void; isActionPending?: boolean; + /** Keep long details scrollable while the title and controls stay visible. */ + compact?: boolean; } export function ApiErrorBanner({ @@ -37,6 +39,7 @@ export function ApiErrorBanner({ actionLabel, onAction, isActionPending = false, + compact = false, }: ApiErrorBannerProps): VNode | null { const { t } = useTranslation(); if (!error) return null; @@ -57,13 +60,15 @@ export function ApiErrorBanner({ <div data-error-banner role="alert" - class={`p-3.5 bg-[#f9dedc] border border-[#b3261e]/40 text-[#410e0b] text-xs font-semibold rounded-md flex items-center justify-between shadow-2xs ${className}`} + class={`p-3.5 bg-[#f9dedc] border border-[#b3261e]/40 text-[#410e0b] text-xs font-semibold rounded-md flex flex-wrap gap-3 items-center justify-between shadow-2xs ${className}`} > - <div class="flex items-start space-x-2"> + <div class="flex min-w-0 flex-1 basis-48 items-start space-x-2 [overflow-wrap:anywhere]"> <span class="text-base shrink-0 text-[#b3261e]">⚠️</span> - <div> + <div class="min-w-0"> <div class="font-bold text-[#410e0b]">{displayTitle}</div> - <div class="mt-0.5 font-medium text-[#410e0b] leading-snug"> + <div + class={`mt-0.5 font-medium text-[#410e0b] leading-snug ${compact ? "max-h-[20dvh] overflow-auto" : ""}`} + > {message} </div> <div class="mt-1 text-2xs font-semibold uppercase tracking-wide text-[#6f3632]"> @@ -86,8 +91,10 @@ export function ApiErrorBanner({ {onDismiss && ( <button type="button" + data-dismiss-error + aria-label={t`Dismiss error`} onClick={onDismiss} - class="text-[#b3261e] hover:text-[#410e0b] font-bold px-1 focus:outline-none transition-colors" + class="text-[#b3261e] hover:text-[#410e0b] font-bold px-1 rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-600 transition-colors" title={t`Dismiss error`} > ✕ diff --git a/packages/taler-merchant-webui/src/ui/FormActionFooter.test.tsx b/packages/taler-merchant-webui/src/ui/FormActionFooter.test.tsx @@ -0,0 +1,137 @@ +/* + 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 { FormActionFooter, useFormFeedback } from "./FormActionFooter.js"; + +const failure = new Error("The server rejected this save"); +function Harness() { + const { rawError, errorMsg, feedbackId, setErrorMsg, setRawError } = + useFormFeedback(); + return ( + <> + <input aria-label="Draft" defaultValue="Keep this draft" /> + <button onClick={() => setRawError(failure)}>Fail</button> + <button onClick={() => setErrorMsg("Enter a valid price.")}> + Validate + </button> + <button onClick={() => setErrorMsg("")}>Succeed</button> + <FormActionFooter + error={rawError} + validationError={rawError ? undefined : errorMsg} + feedbackId={feedbackId} + > + <button type="submit">Save</button> + </FormActionFooter> + </> + ); +} +function button(root: ParentNode, label: string) { + const found = [...root.querySelectorAll<HTMLButtonElement>("button")].find( + (b) => b.textContent === label || b.getAttribute("aria-label") === label, + ); + assert.ok(found, label); + return found; +} +test("dismissal keeps drafts, transfers focus, and identical failures reopen", async () => { + const root = document.body.appendChild(document.createElement("div")); + try { + await act(() => render(<Harness />, root)); + const fail = button(root, "Fail"); + fail.focus(); + await act(() => fail.click()); + assert.equal(document.activeElement, fail, "an error does not steal focus"); + assert.match( + root.querySelector('[role="alert"]')!.textContent!, + /server rejected/, + ); + await act(() => button(root, "Dismiss error").click()); + assert.equal(root.querySelector('[role="alert"]'), null); + assert.match(root.textContent!, /Save not confirmed/); + assert.equal(document.activeElement, button(root, "Show details")); + assert.equal(root.querySelector("input")!.value, "Keep this draft"); + await act(() => button(root, "Show details").click()); + assert.equal(document.activeElement, button(root, "Dismiss error")); + await act(() => button(root, "Dismiss error").click()); + await act(() => fail.click()); + assert.match( + root.querySelector('[role="alert"]')!.textContent!, + /server rejected/, + ); + await act(() => button(root, "Validate").click()); + assert.match( + root.querySelector('[role="alert"]')!.textContent!, + /Enter a valid price/, + ); + assert.doesNotMatch(root.textContent!, /server rejected/); + assert.equal(root.querySelector("[data-dismiss-error]"), null); + await act(() => button(root, "Succeed").click()); + assert.equal(root.querySelector('[role="alert"]'), null); + assert.doesNotMatch(root.textContent!, /Save not confirmed/); + } finally { + act(() => render(null, root)); + root.remove(); + } +}); + +test("pending saves hide stale feedback and keep the caller's disabled controls", async () => { + const root = document.body.appendChild(document.createElement("div")); + try { + await act(() => + render( + <FormActionFooter pending error={failure}> + <button disabled>Saving…</button> + </FormActionFooter>, + root, + ), + ); + assert.equal(root.querySelector('[role="alert"]'), null); + assert.equal(button(root, "Saving…").disabled, true); + } finally { + act(() => render(null, root)); + root.remove(); + } +}); + +test("the footer copies original backend error details", async () => { + const root = document.body.appendChild(document.createElement("div")); + const writeText = navigator.clipboard.writeText; + let copied = ""; + navigator.clipboard.writeText = async (text) => { + copied = text; + }; + try { + const error = { + errorDetail: { + httpStatusCode: 409, + errorResponse: { + code: 12345, + hint: "Product changed on the server", + requestId: "save-42", + }, + }, + }; + await act(() => + render( + <FormActionFooter error={error}> + <button>Save</button> + </FormActionFooter>, + root, + ), + ); + await act(() => button(root, "Copy Error").click()); + const detail = JSON.parse(copied); + assert.equal(detail.code, 12345); + assert.equal(detail.hint, "Product changed on the server"); + assert.equal(detail.requestId, "save-42"); + } finally { + navigator.clipboard.writeText = writeText; + act(() => render(null, root)); + root.remove(); + } +}); diff --git a/packages/taler-merchant-webui/src/ui/FormActionFooter.tsx b/packages/taler-merchant-webui/src/ui/FormActionFooter.tsx @@ -0,0 +1,142 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + SPDX-License-Identifier: GPL-3.0-or-later + */ + +import type { ComponentChildren } from "preact"; +import { useCallback, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { ApiErrorBanner } from "./ApiErrorBanner.js"; +import { useTranslation } from "../context/translation.js"; + +/** Keep local validation separate from the original exception and its details. + * Every update identifies a new feedback event, including identical failures. + */ +export function useFormFeedback() { + const [feedback, setFeedback] = useState({ + errorMsg: "", + rawError: undefined as unknown, + feedbackId: 0, + }); + const setErrorMsg = useCallback((message: string | undefined) => { + setFeedback((old) => ({ + errorMsg: message ?? "", + rawError: undefined, + feedbackId: old.feedbackId + 1, + })); + }, []); + const setRawError = useCallback((error: unknown) => { + setFeedback((old) => ({ + ...old, + rawError: error, + feedbackId: old.feedbackId + 1, + })); + }, []); + return { ...feedback, setErrorMsg, setRawError }; +} + +export interface FormActionFooterProps { + children: ComponentChildren; + error?: unknown; + validationError?: string; + title?: string; + pending?: boolean; + feedbackId?: number; + dismissedLabel?: string; +} + +export function FormActionFooter({ + children, + error, + validationError, + title, + pending = false, + feedbackId = 0, + dismissedLabel, +}: FormActionFooterProps) { + const { t } = useTranslation(); + const [dismissed, setDismissed] = useState<{ error: unknown; id: number }>(); + const detailsButton = useRef<HTMLButtonElement>(null); + const bannerContainer = useRef<HTMLDivElement>(null); + const focusAfterToggle = useRef<"details" | "dismiss" | null>(null); + const collapsed = + !pending && dismissed?.error === error && dismissed?.id === feedbackId; + useLayoutEffect(() => { + if (focusAfterToggle.current === "details") detailsButton.current?.focus(); + if (focusAfterToggle.current === "dismiss") + bannerContainer.current + ?.querySelector<HTMLButtonElement>("[data-dismiss-error]") + ?.focus(); + focusAfterToggle.current = null; + }); + + return ( + <ActionFooter + feedback={ + <> + {!pending && validationError && ( + <div + role="alert" + data-error-banner + class="max-h-[30dvh] overflow-auto rounded-md bg-red-50 p-3 text-sm text-red-800 [overflow-wrap:anywhere]" + > + {validationError} + </div> + )} + {!pending && + !validationError && + Boolean(error) && + (collapsed ? ( + <div class="flex flex-wrap items-center justify-between gap-2 text-sm text-red-800"> + <span>{dismissedLabel ?? t`Save not confirmed`}</span> + <button + ref={detailsButton} + type="button" + class="rounded px-2 py-1 font-semibold underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-600" + onClick={() => { + focusAfterToggle.current = "dismiss"; + setDismissed(undefined); + }} + >{t`Show details`}</button> + </div> + ) : ( + <div ref={bannerContainer} key={feedbackId}> + <ApiErrorBanner + compact + error={error} + title={title ?? t`Could not save changes`} + onDismiss={() => { + focusAfterToggle.current = "details"; + setDismissed({ error, id: feedbackId }); + }} + /> + </div> + ))} + </> + } + > + {children} + </ActionFooter> + ); +} + +/** A footer constrained to the current form, section, or dialog scroll area. */ +export function ActionFooter({ + children, + feedback, +}: { + children: ComponentChildren; + feedback?: ComponentChildren; +}) { + return ( + <div + data-form-action-footer + class="sticky bottom-0 z-20 space-y-3 border-t border-gray-200 bg-white px-3 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] shadow-[0_-2px_6px_rgb(0_0_0/0.06)] rounded-b-lg" + > + {feedback} + <div class="flex flex-wrap items-center justify-end gap-3"> + {children} + </div> + </div> + ); +}