commit 5922d98eb48cfd950164cff449728652a41e3251 parent a9346a767f278d2f21294a12121b3e5c8b1ebb0d Author: Florian Dold <dold@taler.net> Date: Tue, 3 Nov 2026 22:14:01 +0100 merchant web UI: dismiss action feedback completely Hide save errors and validation summaries on dismissal. Restore focus to an action and show feedback again on each failed submission. Diffstat:
18 files changed, 104 insertions(+), 111 deletions(-)
diff --git a/packages/taler-merchant-webui/contrib/qa/save-feedback-check.mjs b/packages/taler-merchant-webui/contrib/qa/save-feedback-check.mjs @@ -238,7 +238,10 @@ try { if (screen !== "signup") { await page.$eval(`${form} [data-dismiss-error]`, (el) => el.click()); await page.waitForFunction( - () => document.activeElement?.textContent === "Show details", + () => + !document.querySelector("[data-form-action-footer] [role=alert]") && + document.activeElement?.closest("[data-form-action-footer]") !== + null, ); } await performAction(); diff --git a/packages/taler-merchant-webui/src/i18n/de-CH.po b/packages/taler-merchant-webui/src/i18n/de-CH.po @@ -14608,8 +14608,6 @@ msgstr "QR-Code zur OTP-Einrichtung" msgid "Shared Secret Key" msgstr "Gemeinsamer geheimer Schlüssel" -msgid "Save not confirmed" -msgstr "Speichern nicht bestätigt" msgid "Could not save product" msgstr "Produkt konnte nicht gespeichert werden" @@ -14631,6 +14629,3 @@ msgstr "Gerät konnte nicht gespeichert werden" msgid "Could not schedule report" msgstr "Bericht konnte nicht geplant werden" - -msgid "Action not confirmed" -msgstr "Aktion nicht bestätigt" diff --git a/packages/taler-merchant-webui/src/i18n/de.po b/packages/taler-merchant-webui/src/i18n/de.po @@ -14605,8 +14605,6 @@ msgstr "QR-Code zur OTP-Einrichtung" msgid "Shared Secret Key" msgstr "Gemeinsamer geheimer Schlüssel" -msgid "Save not confirmed" -msgstr "Speichern nicht bestätigt" msgid "Could not save product" msgstr "Produkt konnte nicht gespeichert werden" @@ -14628,6 +14626,3 @@ msgstr "Gerät konnte nicht gespeichert werden" msgid "Could not schedule report" msgstr "Bericht konnte nicht geplant werden" - -msgid "Action not confirmed" -msgstr "Aktion nicht bestätigt" diff --git a/packages/taler-merchant-webui/src/i18n/fr.po b/packages/taler-merchant-webui/src/i18n/fr.po @@ -14562,8 +14562,6 @@ msgstr "Code QR de configuration OTP" msgid "Shared Secret Key" msgstr "Clé secrète partagée" -msgid "Save not confirmed" -msgstr "Enregistrement non confirmé" msgid "Could not save product" msgstr "Impossible d’enregistrer le produit" @@ -14585,6 +14583,3 @@ msgstr "Impossible d’enregistrer l’appareil" msgid "Could not schedule report" msgstr "Impossible de planifier le rapport" - -msgid "Action not confirmed" -msgstr "Action non confirmée" diff --git a/packages/taler-merchant-webui/src/i18n/it.po b/packages/taler-merchant-webui/src/i18n/it.po @@ -14500,8 +14500,6 @@ msgstr "Codice QR di configurazione OTP" msgid "Shared Secret Key" msgstr "Chiave segreta condivisa" -msgid "Save not confirmed" -msgstr "Salvataggio non confermato" msgid "Could not save product" msgstr "Impossibile salvare il prodotto" @@ -14523,6 +14521,3 @@ msgstr "Impossibile salvare il dispositivo" msgid "Could not schedule report" msgstr "Impossibile pianificare il rapporto" - -msgid "Action not confirmed" -msgstr "Operazione non confermata" diff --git a/packages/taler-merchant-webui/src/i18n/strings.ts b/packages/taler-merchant-webui/src/i18n/strings.ts @@ -5841,7 +5841,6 @@ strings["it"] = { ], "OTP setup QR code": ["Codice QR di configurazione OTP"], "Shared Secret Key": ["Chiave segreta condivisa"], - "Save not confirmed": ["Salvataggio non confermato"], "Could not save product": ["Impossibile salvare il prodotto"], "Could not save template": ["Impossibile salvare il modello"], "Could not create access": ["Impossibile creare l’accesso"], @@ -5849,7 +5848,6 @@ strings["it"] = { "Could not save rule": ["Impossibile salvare la regola"], "Could not save device": ["Impossibile salvare il dispositivo"], "Could not schedule report": ["Impossibile pianificare il rapporto"], - "Action not confirmed": ["Operazione non confermata"], }, }, domain: "messages", @@ -5857,8 +5855,8 @@ strings["it"] = { lang: "it", completeness: 100, _pogen: { - total: 2868, - translated: 2868, + total: 2866, + translated: 2866, untranslated: [], optional: [], }, @@ -11720,7 +11718,6 @@ strings["fr"] = { ], "OTP setup QR code": ["Code QR de configuration OTP"], "Shared Secret Key": ["Clé secrète partagée"], - "Save not confirmed": ["Enregistrement non confirmé"], "Could not save product": ["Impossible d’enregistrer le produit"], "Could not save template": ["Impossible d’enregistrer le modèle"], "Could not create access": ["Impossible de créer l’accès"], @@ -11728,7 +11725,6 @@ strings["fr"] = { "Could not save rule": ["Impossible d’enregistrer la règle"], "Could not save device": ["Impossible d’enregistrer l’appareil"], "Could not schedule report": ["Impossible de planifier le rapport"], - "Action not confirmed": ["Action non confirmée"], }, }, domain: "messages", @@ -11736,8 +11732,8 @@ strings["fr"] = { lang: "fr", completeness: 100, _pogen: { - total: 2868, - translated: 2868, + total: 2866, + translated: 2866, untranslated: [], optional: [], }, @@ -17569,7 +17565,6 @@ strings["de"] = { ], "OTP setup QR code": ["QR-Code zur OTP-Einrichtung"], "Shared Secret Key": ["Gemeinsamer geheimer Schlüssel"], - "Save not confirmed": ["Speichern nicht bestätigt"], "Could not save product": ["Produkt konnte nicht gespeichert werden"], "Could not save template": ["Vorlage konnte nicht gespeichert werden"], "Could not create access": ["Zugang konnte nicht erstellt werden"], @@ -17577,7 +17572,6 @@ strings["de"] = { "Could not save rule": ["Regel konnte nicht gespeichert werden"], "Could not save device": ["Gerät konnte nicht gespeichert werden"], "Could not schedule report": ["Bericht konnte nicht geplant werden"], - "Action not confirmed": ["Aktion nicht bestätigt"], }, }, domain: "messages", @@ -17585,8 +17579,8 @@ strings["de"] = { lang: "de", completeness: 100, _pogen: { - total: 2868, - translated: 2868, + total: 2866, + translated: 2866, untranslated: [], optional: [], }, @@ -23418,7 +23412,6 @@ strings["de-CH"] = { ], "OTP setup QR code": ["QR-Code zur OTP-Einrichtung"], "Shared Secret Key": ["Gemeinsamer geheimer Schlüssel"], - "Save not confirmed": ["Speichern nicht bestätigt"], "Could not save product": ["Produkt konnte nicht gespeichert werden"], "Could not save template": ["Vorlage konnte nicht gespeichert werden"], "Could not create access": ["Zugang konnte nicht erstellt werden"], @@ -23426,7 +23419,6 @@ strings["de-CH"] = { "Could not save rule": ["Regel konnte nicht gespeichert werden"], "Could not save device": ["Gerät konnte nicht gespeichert werden"], "Could not schedule report": ["Bericht konnte nicht geplant werden"], - "Action not confirmed": ["Aktion nicht bestätigt"], }, }, domain: "messages", @@ -23434,8 +23426,8 @@ strings["de-CH"] = { lang: "de-CH", completeness: 100, _pogen: { - total: 2868, - translated: 2868, + total: 2866, + translated: 2866, untranslated: [], optional: [], }, diff --git a/packages/taler-merchant-webui/src/i18n/taler-merchant-webui.pot b/packages/taler-merchant-webui/src/i18n/taler-merchant-webui.pot @@ -13055,8 +13055,6 @@ msgstr "" msgid "Shared Secret Key" msgstr "" -msgid "Save not confirmed" -msgstr "" msgid "Could not save product" msgstr "" @@ -13078,6 +13076,3 @@ msgstr "" msgid "Could not schedule report" msgstr "" - -msgid "Action not confirmed" -msgstr "" diff --git a/packages/taler-merchant-webui/src/screens/AccessScreen.tsx b/packages/taler-merchant-webui/src/screens/AccessScreen.tsx @@ -586,7 +586,6 @@ export function AccessScreen({ feedbackId={feedbackId} pending={isPairing} title={t`Could not create till access`} - dismissedLabel={t`Action not confirmed`} > <button type="button" diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx @@ -110,9 +110,7 @@ test("settings serialize section saves, retain other drafts and use the latest c await click( failureFooter.querySelector<HTMLButtonElement>("[data-dismiss-error]")!, ); - assert.match(failureFooter.textContent!, /Save not confirmed/); - await click(button("Show details", failureFooter)); - assert.match(failureFooter.textContent!, /Save failed/); + assert.equal(failureFooter.querySelector("[role=alert]"), null); assert.equal(field("business-email").value, "new@example.com"); await act(() => submit(field("business-email"))); diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx @@ -182,7 +182,7 @@ test("product save failures preserve edits through dismissal, validation, and re await act(() => footer.querySelector<HTMLButtonElement>("[data-dismiss-error]")!.click(), ); - assert.match(footer.textContent!, /Save not confirmed/); + assert.equal(footer.querySelector("[role=alert]"), null); assert.equal( container.querySelector<HTMLInputElement>("#prod_name_input")!.value, "Edited product", @@ -196,7 +196,7 @@ test("product save failures preserve edits through dismissal, validation, and re 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); + assert.ok(footer.querySelector("[data-dismiss-error]")); setInput( container.querySelector<HTMLInputElement>("#prod_price_input")!, "4.25", diff --git a/packages/taler-merchant-webui/src/screens/DevicesScreen.tsx b/packages/taler-merchant-webui/src/screens/DevicesScreen.tsx @@ -362,7 +362,6 @@ export function DevicesScreen({ feedbackId={feedbackId} pending={isRotating} title={t`Could not rotate the device key`} - dismissedLabel={t`Action not confirmed`} > <Button variant="secondary" diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.state.test.tsx @@ -89,7 +89,7 @@ for (const layout of ["mobile", "desktop"]) { await click( failureFooter.querySelector<HTMLButtonElement>("[data-dismiss-error]")!, ); - assert.match(failureFooter.textContent!, /Save not confirmed/); + assert.equal(failureFooter.querySelector("[role=alert]"), null); assert.equal(field("cat_name_input").value, "New"); await act(async () => submit(field("cat_name_input"))); assert.match(failureFooter.textContent!, /Previous operation failed/); diff --git a/packages/taler-merchant-webui/src/screens/OrderRefundScreen.tsx b/packages/taler-merchant-webui/src/screens/OrderRefundScreen.tsx @@ -408,7 +408,6 @@ export function OrderRefundScreen({ feedbackId={feedbackId} pending={isSubmitting} title={t`Failed to grant refund`} - dismissedLabel={t`Action not confirmed`} > <button type="button" diff --git a/packages/taler-merchant-webui/src/screens/PosScreen.tsx b/packages/taler-merchant-webui/src/screens/PosScreen.tsx @@ -1159,7 +1159,6 @@ export function PosScreen({ feedbackId={cancelFeedbackId} pending={isCanceling} title={t`The sale could not be canceled`} - dismissedLabel={t`Action not confirmed`} > <button type="button" @@ -1498,7 +1497,6 @@ export function PosScreen({ feedbackId={refundFeedbackId} pending={isRefunding} title={t`Failed to issue refund.`} - dismissedLabel={t`Action not confirmed`} > <button type="button" diff --git a/packages/taler-merchant-webui/src/screens/TemplateDetailsScreen.tsx b/packages/taler-merchant-webui/src/screens/TemplateDetailsScreen.tsx @@ -444,7 +444,6 @@ export function TemplateDetailsScreen({ feedbackId={feedbackId} pending={creatingOrder} title={t`Could not create an order from this template.`} - dismissedLabel={t`Action not confirmed`} > {orderability && ( <Button diff --git a/packages/taler-merchant-webui/src/screens/actionFeedback.test.tsx b/packages/taler-merchant-webui/src/screens/actionFeedback.test.tsx @@ -109,7 +109,7 @@ for (const kind of ["refund", "rotation", "template", "pairing"] as const) { await click( footer.querySelector<HTMLButtonElement>("[data-dismiss-error]")!, ); - assert.match(footer.textContent!, /Action not confirmed/); + assert.equal(footer.querySelector("[role=alert]"), null); assert.doesNotMatch(footer.textContent!, /Save not confirmed/); assert.equal(attempts, 1, "dismissal never repeats the request"); await attempt(); diff --git a/packages/taler-merchant-webui/src/ui/FormActionFooter.test.tsx b/packages/taler-merchant-webui/src/ui/FormActionFooter.test.tsx @@ -52,12 +52,9 @@ test("dismissal keeps drafts, transfers focus, and identical failures reopen", a ); 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.doesNotMatch(root.textContent!, /Save not confirmed|Show details/); + assert.equal(document.activeElement, button(root, "Save")); 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!, @@ -69,7 +66,14 @@ test("dismissal keeps drafts, transfers focus, and identical failures reopen", a /Enter a valid price/, ); assert.doesNotMatch(root.textContent!, /server rejected/); - assert.equal(root.querySelector("[data-dismiss-error]"), null); + await act(() => button(root, "Dismiss error").click()); + assert.equal(root.querySelector('[role="alert"]'), null); + assert.equal(document.activeElement, button(root, "Save")); + await act(() => button(root, "Validate").click()); + assert.match( + root.querySelector('[role="alert"]')!.textContent!, + /Enter a valid price/, + ); await act(() => button(root, "Succeed").click()); assert.equal(root.querySelector('[role="alert"]'), null); assert.doesNotMatch(root.textContent!, /Save not confirmed/); diff --git a/packages/taler-merchant-webui/src/ui/FormActionFooter.tsx b/packages/taler-merchant-webui/src/ui/FormActionFooter.tsx @@ -17,14 +17,19 @@ export function useFormFeedback() { errorMsg: "", rawError: undefined as unknown, feedbackId: 0, + field: undefined as string | undefined, }); - const setErrorMsg = useCallback((message: string | undefined) => { - setFeedback((old) => ({ - errorMsg: message ?? "", - rawError: undefined, - feedbackId: old.feedbackId + 1, - })); - }, []); + const setErrorMsg = useCallback( + (message: string | undefined, field?: string) => { + setFeedback((old) => ({ + errorMsg: message ?? "", + field, + rawError: undefined, + feedbackId: old.feedbackId + 1, + })); + }, + [], + ); const setRawError = useCallback((error: unknown) => { setFeedback((old) => ({ ...old, @@ -32,7 +37,18 @@ export function useFormFeedback() { feedbackId: old.feedbackId + 1, })); }, []); - return { ...feedback, setErrorMsg, setRawError }; + // Field guidance survives summary dismissal and is revalidated on submit. + const fieldProps = (id: string) => ({ + "aria-invalid": feedback.field === id || undefined, + "aria-describedby": feedback.field === id ? `${id}-error` : undefined, + }); + const fieldError = (id: string) => + feedback.field === id && ( + <p id={`${id}-error`} class="mt-1 text-sm text-red-800"> + {feedback.errorMsg} + </p> + ); + return { ...feedback, setErrorMsg, setRawError, fieldProps, fieldError }; } export interface FormActionFooterProps { @@ -42,7 +58,6 @@ export interface FormActionFooterProps { title?: string; pending?: boolean; feedbackId?: number; - dismissedLabel?: string; } export function FormActionFooter({ @@ -52,70 +67,82 @@ export function FormActionFooter({ 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; + const [dismissed, setDismissed] = useState<{ + error: unknown; + validationError?: string; + id: number; + }>(); + const actions = useRef<HTMLDivElement>(null); + const focusAfterDismiss = useRef(false); + const hidden = + dismissed?.error === error && + dismissed?.validationError === validationError && + dismissed?.id === feedbackId; + const dismiss = () => { + focusAfterDismiss.current = true; + setDismissed({ error, validationError, 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; + if (!focusAfterDismiss.current) return; + const controls = actions.current; + const primary = controls?.querySelector<HTMLButtonElement>( + 'button[type="submit"]:not(:disabled)', + ); + const fallback = controls?.querySelector<HTMLButtonElement>( + "button:not(:disabled)", + ); + (primary ?? fallback ?? controls)?.focus(); + focusAfterDismiss.current = false; }); 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> + !hidden && + (validationError ? ( + <div + role="alert" + data-error-banner + class="flex items-start justify-between gap-3 rounded-md bg-red-50 p-3 text-sm text-red-800" + > + <div class="max-h-[20dvh] overflow-auto [overflow-wrap:anywhere]"> + {validationError} + </div> <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> + data-dismiss-error + aria-label={t`Dismiss error`} + onClick={dismiss} + class="shrink-0 rounded px-1 font-bold focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-600" + > + ✕ + </button> </div> ) : ( - <div ref={bannerContainer} key={feedbackId}> + Boolean(error) && ( <ApiErrorBanner + key={feedbackId} compact error={error} title={title ?? t`Could not save changes`} - onDismiss={() => { - focusAfterToggle.current = "details"; - setDismissed({ error, id: feedbackId }); - }} + onDismiss={dismiss} /> - </div> + ) ))} </> } > - {children} + <div + ref={actions} + tabIndex={-1} + class="flex flex-wrap items-center justify-end gap-3" + > + {children} + </div> </ActionFooter> ); }