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:
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>
+ );
+}