commit 4dc54f25b6da2a30ea30b862cd7c28b87a92d0f4
parent c6c98d166062df285251e503d9fc6366e3bb1044
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 21:51:30 +0100
merchant web UI: add error footers to forms
Use action footers in create and edit forms, preserving drafts and error
details.
Diffstat:
9 files changed, 163 insertions(+), 88 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/CreateAccessScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateAccessScreen.tsx
@@ -20,6 +20,7 @@ import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import { useTranslation, type TranslateFn } from "../context/translation.js";
import { formatErrorMessage } from "../utils/errors.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { DisclosureChevron } from "../ui/DisclosureChevron.js";
import { PasswordInput } from "../ui/PasswordInput.js";
@@ -129,10 +130,8 @@ export function CreateAccessScreen({
const setGeneratedToken = onGeneratedTokenChange ?? setLocalToken;
const { copied: tokenCopied, error: copyError, copy } = useClipboard();
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
- const [errorMsg, setErrorMsg] = useState<string>("");
- // Kept alongside the message so the banner can offer the underlying
- // TalerErrorDetail to copy (messages-and-vocabulary.md 13.5).
- const [rawError, setRawError] = useState<unknown>(undefined);
+ const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } =
+ useFormFeedback();
const handleSubmit = async (e: Event) => {
e.preventDefault();
@@ -196,13 +195,6 @@ export function CreateAccessScreen({
/>
<form onSubmit={handleSubmit} class="space-y-6 max-w-3xl">
- {(errorMsg || copyError) && (
- <ApiErrorBanner
- error={rawError ?? copyError ?? errorMsg}
- title={t`Could not create the access`}
- />
- )}
-
{/* Section 1: Used For & Purpose */}
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4">
<h3 class="text-base font-bold text-gray-900">{t`1. Purpose & Expiry`}</h3>
@@ -362,7 +354,13 @@ export function CreateAccessScreen({
</div>
{/* Form Footer */}
- <div class="flex items-center justify-end space-x-4 pt-4 border-t border-gray-200">
+ <FormActionFooter
+ error={rawError}
+ validationError={rawError ? undefined : errorMsg}
+ feedbackId={feedbackId}
+ pending={isSubmitting}
+ title={t`Could not create access`}
+ >
<button
type="button"
onClick={() => setLocation("/access")}
@@ -377,7 +375,7 @@ export function CreateAccessScreen({
>
{isSubmitting ? t`Generating...` : t`Create Machine Access`}
</button>
- </div>
+ </FormActionFooter>
</form>
{/* One-Time Token Reveal Modal (Spec 02 § 2.6.1 & Kept section) */}
@@ -394,6 +392,7 @@ export function CreateAccessScreen({
<div class="p-3 bg-gray-50 border border-gray-300 rounded-xl space-y-2 text-left">
<div class="text-xs font-bold text-gray-500">{t`Access`}</div>
+ <ApiErrorBanner compact error={copyError} />
<div class="flex items-center space-x-2">
<input
type="text"
diff --git a/packages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx
@@ -23,6 +23,7 @@ import {
type CreatedDeviceSetup,
} from "./DeviceSetupScreen.js";
import { Header } from "../ui/Header.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import type { OtpDeviceItemHook } from "../api/hooks.js";
import type { DeviceItem } from "./DevicesScreen.js";
@@ -169,7 +170,8 @@ function CreateDeviceForm({
const [created, setCreated] = useState<CreatedDeviceSetup | null>(null);
const submitting = useRef(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
- const [errorMsg, setErrorMsg] = useState<string>("");
+ const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } =
+ useFormFeedback();
const editingId = editId ?? null;
const existingDevice = editingId
@@ -271,6 +273,7 @@ function CreateDeviceForm({
t,
),
);
+ setRawError(err);
} finally {
submitting.current = false;
setIsSubmitting(false);
@@ -302,14 +305,6 @@ function CreateDeviceForm({
subtitle={t`Configure an offline vending machine or hardware terminal. The device shares a secret key to verify payment codes without internet access.`}
/>
- {(errorMsg || copyError) && (
- <ApiErrorBanner
- error={copyError ?? errorMsg}
- title={t`Could not add offline payment device`}
- className="mb-4"
- />
- )}
-
<form onSubmit={handleSubmit} class="space-y-6">
{/* Group 1: Device Identity & Location */}
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4">
@@ -462,6 +457,7 @@ function CreateDeviceForm({
>
{t`Generated Secret Key`}
</label>
+ <ApiErrorBanner compact error={copyError} />
<div class="flex items-center space-x-2">
<input
id="auth_secret_gen"
@@ -495,7 +491,13 @@ function CreateDeviceForm({
)}
{/* Form Footer */}
- <div class="flex items-center justify-end space-x-4 pt-4 border-t border-gray-200">
+ <FormActionFooter
+ error={rawError}
+ validationError={rawError ? undefined : errorMsg}
+ feedbackId={feedbackId}
+ pending={isSubmitting}
+ title={t`Could not save device`}
+ >
<button
type="button"
onClick={() => setLocation("/authenticators")}
@@ -514,7 +516,7 @@ function CreateDeviceForm({
? t`Save Changes`
: t`Add device`}
</button>
- </div>
+ </FormActionFooter>
</form>
</div>
);
diff --git a/packages/taler-merchant-webui/src/screens/CreateOrderScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateOrderScreen.tsx
@@ -18,7 +18,7 @@ import type { VNode } from "preact";
import { useState } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
-import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { AmountInput } from "../ui/AmountInput.js";
import {
Amounts,
@@ -33,7 +33,7 @@ import type {
} from "../types/domain.js";
import type { TokenFamilyItem } from "../api/hooks/useTokenFamilies.js";
import type { RemoteResource } from "../api/contracts.js";
-import { formatErrorMessage } from "../utils/errors.js";
+import { formatErrorMessage, normalizeUiFailure } from "../utils/errors.js";
import { preferences } from "../stores/preferences.js";
import { resourcePath } from "../routing/resourcePath.js";
import { useTranslation } from "../context/translation.js";
@@ -309,7 +309,8 @@ export function CreateOrderScreen({
ruleState === "ready" || ruleState === "stale";
const [submitting, setSubmitting] = useState<boolean>(false);
- const [errorMsg, setErrorMsg] = useState<string | undefined>(undefined);
+ const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } =
+ useFormFeedback();
const normalizedQuickAmount = amountInput.includes(":")
? amountInput.trim()
@@ -1080,6 +1081,15 @@ export function CreateOrderScreen({
? `${hint}${errCode ? ` (Error ${errCode})` : ""}`
: t`Order creation failed (${errCode || "HTTP 400 Bad Request"})`,
);
+ setRawError(
+ normalizeUiFailure(
+ {
+ httpStatus: res.status,
+ detail: res.detail ?? hint ?? t`Order Creation Error`,
+ },
+ { outcome: "rejected" },
+ ),
+ );
return;
}
@@ -1092,6 +1102,7 @@ export function CreateOrderScreen({
t,
),
);
+ setRawError(err);
} finally {
setSubmitting(false);
}
@@ -1210,10 +1221,6 @@ export function CreateOrderScreen({
</Header>
</div>
- {errorMsg && (
- <ApiErrorBanner error={errorMsg} title={t`Order Creation Error`} />
- )}
-
<form onSubmit={handleSubmitOrder} class="space-y-6">
<>
<div
@@ -1624,7 +1631,13 @@ export function CreateOrderScreen({
)}
{/* Submit Actions */}
- <div class="sticky bottom-0 z-10 -mx-2 flex items-center justify-end space-x-3 border-t border-gray-200 bg-white/95 px-2 py-3 backdrop-blur-sm">
+ <FormActionFooter
+ error={rawError}
+ validationError={rawError ? undefined : errorMsg}
+ feedbackId={feedbackId}
+ pending={submitting}
+ title={t`Order Creation Error`}
+ >
{isPreviewMode && (
<span
role="note"
@@ -1657,7 +1670,7 @@ export function CreateOrderScreen({
>
{submitting ? t`Creating Order...` : t`Create Order`}
</button>
- </div>
+ </FormActionFooter>
</form>
</div>
);
diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx
@@ -151,3 +151,62 @@ test("CreateProductScreen creates a product in a selected payout currency", asyn
container.remove();
}
});
+
+test("product save failures preserve edits through dismissal, validation, and retry", async () => {
+ const container = document.body.appendChild(document.createElement("div"));
+ const patches: TalerMerchantApi.ProductPatchDetailRequest[] = [];
+ const error = new Error("Product save rejected");
+ try {
+ await act(() =>
+ render(
+ <CreateProductScreen
+ editId={product.id}
+ initialProduct={product}
+ currency="EUR"
+ categories={[]}
+ onUpdateProduct={async (_id, patch) => {
+ patches.push(patch);
+ throw error;
+ }}
+ />,
+ container,
+ ),
+ );
+ setInput(
+ container.querySelector<HTMLInputElement>("#prod_name_input")!,
+ "Edited product",
+ );
+ await submit(container);
+ const footer = container.querySelector("[data-form-action-footer]")!;
+ assert.match(footer.textContent!, /Product save rejected/);
+ await act(() =>
+ footer.querySelector<HTMLButtonElement>("[data-dismiss-error]")!.click(),
+ );
+ assert.match(footer.textContent!, /Save not confirmed/);
+ assert.equal(
+ container.querySelector<HTMLInputElement>("#prod_name_input")!.value,
+ "Edited product",
+ );
+ await submit(container);
+ assert.match(footer.textContent!, /Product save rejected/);
+ setInput(
+ container.querySelector<HTMLInputElement>("#prod_price_input")!,
+ "",
+ );
+ await submit(container);
+ assert.match(footer.textContent!, /Enter a valid price/);
+ assert.doesNotMatch(footer.textContent!, /Product save rejected/);
+ assert.equal(footer.querySelector("[data-dismiss-error]"), null);
+ setInput(
+ container.querySelector<HTMLInputElement>("#prod_price_input")!,
+ "4.25",
+ );
+ await submit(container);
+ assert.equal(patches.length, 3);
+ assert.equal(patches[2]!.price, "EUR:4.25");
+ assert.equal(patches[2]!.product_name, "Edited product");
+ } finally {
+ act(() => render(null, container));
+ container.remove();
+ }
+});
diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx
@@ -20,6 +20,7 @@ import { Amounts } from "@gnu-taler/taler-util";
import { useEffect, useState } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { AmountInput } from "../ui/AmountInput.js";
import { ImageUploadInput } from "../ui/ImageUploadInput.js";
@@ -320,7 +321,8 @@ function CreateProductForm({
const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
- const [errorMsg, setErrorMsg] = useState<string>("");
+ const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } =
+ useFormFeedback();
const [newCategoryName, setNewCategoryName] = useState<string>("");
const [isCreatingCategory, setIsCreatingCategory] = useState<boolean>(false);
const [categoryError, setCategoryError] = useState<string>("");
@@ -445,6 +447,7 @@ function CreateProductForm({
t,
),
);
+ setRawError(err);
} finally {
setIsSubmitting(false);
}
@@ -472,7 +475,6 @@ function CreateProductForm({
{!currency && configResource?.isLoading && (
<InitialLoadingState>{t`Loading currency configuration…`}</InitialLoadingState>
)}
- <ApiErrorBanner error={errorMsg} onDismiss={() => setErrorMsg("")} />
<form onSubmit={handleSubmit} class="space-y-6">
{/* Section 1: Basic Information */}
@@ -880,7 +882,13 @@ function CreateProductForm({
</div>
{/* Form Footer */}
- <div class="flex items-center justify-end space-x-4 pt-4 border-t border-gray-200">
+ <FormActionFooter
+ error={rawError}
+ validationError={rawError ? undefined : errorMsg}
+ feedbackId={feedbackId}
+ pending={isSubmitting}
+ title={t`Could not save product`}
+ >
<button
type="button"
onClick={() => setLocation("/inventory")}
@@ -904,7 +912,7 @@ function CreateProductForm({
? t`Save Product Changes`
: t`Add Product`}
</button>
- </div>
+ </FormActionFooter>
</form>
</div>
);
diff --git a/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx
@@ -23,6 +23,7 @@ import { Header } from "../ui/Header.js";
import type { SubscriptionItem } from "./SubscriptionsScreen.js";
import type { TokenFamilyItem } from "../api/hooks.js";
import { useTranslation } from "../context/translation.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { formatErrorMessage } from "../utils/errors.js";
import { DisclosureChevron } from "../ui/DisclosureChevron.js";
@@ -463,10 +464,8 @@ function CreateSubscriptionForm({
);
const [manualId, setManualId] = useState<boolean>(initialDraft.manualId);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
- const [errorMsg, setErrorMsg] = useState<string>("");
- // Kept alongside the message so the banner can offer the underlying
- // TalerErrorDetail to copy (messages-and-vocabulary.md 13.5).
- const [rawError, setRawError] = useState<unknown>(undefined);
+ const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } =
+ useFormFeedback();
useEffect(() => {
if (roundingPrecisionIsDefault.current) {
@@ -1067,13 +1066,6 @@ function CreateSubscriptionForm({
)}
<form onSubmit={handleSubmit} class="space-y-6 max-w-3xl">
- {errorMsg && (
- <ApiErrorBanner
- error={rawError ?? errorMsg}
- title={t`Could not save this`}
- />
- )}
-
{/* Section 1: Basic Information & Kind */}
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4">
<h2 class="text-base font-bold text-gray-900">{t`1. Basic Information`}</h2>
@@ -1932,7 +1924,13 @@ function CreateSubscriptionForm({
</div>
{/* Form Footer */}
- <div class="flex items-center justify-end space-x-4 pt-4 border-t border-gray-200">
+ <FormActionFooter
+ error={rawError}
+ validationError={rawError ? undefined : errorMsg}
+ feedbackId={feedbackId}
+ pending={isSubmitting}
+ title={t`Could not save rule`}
+ >
<button
type="button"
onClick={() => setLocation("/subscriptions")}
@@ -1951,7 +1949,7 @@ function CreateSubscriptionForm({
? t`Save Changes`
: t`Create Discount / Pass`}
</button>
- </div>
+ </FormActionFooter>
</form>
</div>
);
diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx
@@ -29,6 +29,7 @@ import type { TemplateWriteExtras } from "../api/hooks.js";
import type { OtpDeviceItemHook } from "../api/hooks/useOtpDevices.js";
import type { TemplateItem } from "./TemplatesScreen.js";
import { useTranslation } from "../context/translation.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { formatErrorMessage } from "../utils/errors.js";
import {
@@ -214,10 +215,8 @@ function CreateTemplateForm({
const submitting = useRef(false);
const idInput = useRef<HTMLInputElement>(null);
const [idConflict, setIdConflict] = useState(false);
- const [errorMsg, setErrorMsg] = useState<string>("");
- // Kept alongside the message so the banner can offer the underlying
- // TalerErrorDetail to copy (messages-and-vocabulary.md 13.5).
- const [rawError, setRawError] = useState<unknown>(undefined);
+ const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } =
+ useFormFeedback();
useLayoutEffect(() => {
if (idConflict && showAdvanced && !isSubmitting) idInput.current?.focus();
@@ -423,13 +422,6 @@ function CreateTemplateForm({
<InitialLoadingState>{t`Loading currency configuration…`}</InitialLoadingState>
)}
<form onSubmit={handleSubmit} class="space-y-6 max-w-3xl">
- {errorMsg && (
- <ApiErrorBanner
- error={rawError ?? errorMsg}
- title={t`Could not save the template`}
- />
- )}
-
{/* Section 1: What it Sells */}
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4">
<div>
@@ -876,7 +868,13 @@ function CreateTemplateForm({
</div>
{/* Form Footer */}
- <div class="flex items-center justify-end space-x-4 pt-4 border-t border-gray-200">
+ <FormActionFooter
+ error={rawError}
+ validationError={rawError ? undefined : errorMsg}
+ feedbackId={feedbackId}
+ pending={isSubmitting}
+ title={t`Could not save template`}
+ >
<button
type="button"
onClick={() => setLocation("/templates")}
@@ -895,7 +893,7 @@ function CreateTemplateForm({
? t`Save Changes`
: t`Create Template`}
</button>
- </div>
+ </FormActionFooter>
</form>
</div>
);
diff --git a/packages/taler-merchant-webui/src/screens/CreateWebhookScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateWebhookScreen.tsx
@@ -20,6 +20,7 @@ import { useState, useRef } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import { useTranslation } from "../context/translation.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import type { WebhookEndpoint } from "./WebhooksScreen.js";
import { formatErrorMessage } from "../utils/errors.js";
@@ -329,10 +330,8 @@ function CreateWebhookForm({
);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
- const [errorMsg, setErrorMsg] = useState<string>("");
- // Kept alongside the message so the banner can offer the underlying
- // TalerErrorDetail to copy (messages-and-vocabulary.md 13.5).
- const [rawError, setRawError] = useState<unknown>(undefined);
+ const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } =
+ useFormFeedback();
const insertVariable = (varName: string) => {
const placeholder = `{{${varName}}}`;
@@ -410,13 +409,6 @@ function CreateWebhookForm({
/>
<form onSubmit={handleSubmit} class="space-y-6 max-w-3xl">
- {errorMsg && (
- <ApiErrorBanner
- error={rawError ?? errorMsg}
- title={t`Could not save the webhook`}
- />
- )}
-
{/* Section 1: Event & Target URL */}
<div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4">
<h3 class="text-base font-bold text-gray-900">{t`1. Trigger Event & Address`}</h3>
@@ -605,7 +597,13 @@ function CreateWebhookForm({
</div>
{/* Form Footer */}
- <div class="flex items-center justify-end space-x-4 pt-4 border-t border-gray-200">
+ <FormActionFooter
+ error={rawError}
+ validationError={rawError ? undefined : errorMsg}
+ feedbackId={feedbackId}
+ pending={isSubmitting}
+ title={t`Could not save webhook`}
+ >
<button
type="button"
onClick={() => setLocation("/settings/webhooks")}
@@ -624,7 +622,7 @@ function CreateWebhookForm({
? t`Save Webhook Changes`
: t`Add Webhook`}
</button>
- </div>
+ </FormActionFooter>
</form>
</div>
);
diff --git a/packages/taler-merchant-webui/src/screens/ScheduleReportScreen.tsx b/packages/taler-merchant-webui/src/screens/ScheduleReportScreen.tsx
@@ -28,6 +28,7 @@ import { useEffect, useMemo, useState } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import { useTranslation } from "../context/translation.js";
+import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { formatErrorMessage } from "../utils/errors.js";
@@ -74,10 +75,8 @@ export function ScheduleReportScreen({
generatorEntries[0]?.[0] ?? "",
);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
- const [errorMsg, setErrorMsg] = useState<string>("");
- // Kept alongside the message so the banner can offer the underlying
- // TalerErrorDetail to copy (messages-and-vocabulary.md 13.5).
- const [rawError, setRawError] = useState<unknown>(undefined);
+ const { errorMsg, setErrorMsg, rawError, setRawError, feedbackId } =
+ useFormFeedback();
useEffect(() => {
if (!reportSenderProgram && generatorEntries[0]) {
@@ -139,12 +138,6 @@ export function ScheduleReportScreen({
/>
<form onSubmit={handleSubmit} class="space-y-6 max-w-3xl">
- {errorMsg && (
- <ApiErrorBanner
- error={rawError ?? errorMsg}
- title={t`Could not schedule the report`}
- />
- )}
{configError && (
<ApiErrorBanner
error={configError}
@@ -333,7 +326,14 @@ export function ScheduleReportScreen({
</div>
</div>
- <div class="flex items-center justify-end space-x-4 pt-4 border-t border-gray-200">
+ {/* Form Footer */}
+ <FormActionFooter
+ error={rawError}
+ validationError={rawError ? undefined : errorMsg}
+ feedbackId={feedbackId}
+ pending={isSubmitting}
+ title={t`Could not schedule report`}
+ >
<button
type="button"
onClick={done}
@@ -353,7 +353,7 @@ export function ScheduleReportScreen({
>
{isSubmitting ? t`Scheduling...` : t`Schedule Report`}
</button>
- </div>
+ </FormActionFooter>
</form>
</div>
);