taler-typescript-core

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

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:
Mpackages/taler-merchant-webui/src/screens/CreateAccessScreen.tsx | 25++++++++++++-------------
Mpackages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx | 24+++++++++++++-----------
Mpackages/taler-merchant-webui/src/screens/CreateOrderScreen.tsx | 31++++++++++++++++++++++---------
Mpackages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/CreateProductScreen.tsx | 16++++++++++++----
Mpackages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx | 24+++++++++++-------------
Mpackages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx | 24+++++++++++-------------
Mpackages/taler-merchant-webui/src/screens/CreateWebhookScreen.tsx | 24+++++++++++-------------
Mpackages/taler-merchant-webui/src/screens/ScheduleReportScreen.tsx | 24++++++++++++------------
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> );