taler-typescript-core

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

commit 5d0e09b46049be4fab92a993acb4011462859046
parent cb6924c018c0b1a6d2ca8ebbb6f75ddf4969dc45
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 20:42:35 +0100

merchant web UI: guard pending report dialog mutations

Diffstat:
Apackages/taler-merchant-webui/src/screens/ReportsScreen.state.test.tsx | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/ReportsScreen.tsx | 521+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
2 files changed, 553 insertions(+), 222 deletions(-)

diff --git a/packages/taler-merchant-webui/src/screens/ReportsScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/ReportsScreen.state.test.tsx @@ -0,0 +1,254 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. +*/ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { ReportsScreen } from "./ReportsScreen.js"; + +function deferred<T>() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise<T>((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function button(text: string, root: ParentNode = document): HTMLButtonElement { + const found = [...root.querySelectorAll("button")].find((b) => + b.textContent?.includes(text), + ); + assert.ok(found, `Missing button: ${text}`); + return found; +} +function field(id: string): HTMLInputElement { + const found = document.querySelector<HTMLInputElement>(`#${id}`); + assert.ok(found, `Missing field: ${id}`); + return found; +} +const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0)); +const click = (element: HTMLElement) => + act(async () => { + element.click(); + await settle(); + }); +const input = (element: HTMLInputElement, value: string) => + act(() => { + element.value = value; + element.dispatchEvent(new Event("input", { bubbles: true })); + }); +function submit(element: HTMLInputElement) { + element + .closest("form")! + .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); +} +function mount() { + const root = document.createElement("div"); + document.body.append(root); + return root; +} +async function cleanup(root: HTMLElement) { + await act(() => render(null, root)); + root.remove(); +} + +for (const { kind, editing } of [ + { kind: "group", editing: false }, + { kind: "group", editing: true }, + { kind: "pot", editing: false }, + { kind: "pot", editing: true }, +] as const) { + test(`${kind} ${editing ? "edit" : "create"} save blocks duplicates and dismissal, preserves failed drafts and allows retry`, async () => { + const root = mount(); + const requests: ReturnType<typeof deferred<void>>[] = []; + const save = () => { + const request = deferred<void>(); + requests.push(request); + return request.promise; + }; + const label = kind === "group" ? "Add product group" : "Add Money Pot"; + const id = kind === "group" ? "grp_name_input" : "pot_name_input"; + try { + await act(() => + render( + <ReportsScreen + initialTab="groupings" + onAddGroup={save} + onAddPot={save} + onUpdateGroup={save} + onUpdatePot={save} + sampleGroups={[ + { + group_serial: 1, + group_name: "existing_group", + description: "Original", + }, + ]} + samplePots={[ + { pot_serial: 2, pot_name: "existing_pot", pot_totals: [] }, + ]} + />, + root, + ), + ); + if (editing) { + await click( + root.querySelector<HTMLButtonElement>( + `button[aria-label="Actions for existing_${kind}"]`, + )!, + ); + await click(button("Edit", document.querySelector('[role="menu"]')!)); + } else await click(button(label)); + await input(field(id), "first"); + await act(() => { + submit(field(id)); + submit(field(id)); + }); + assert.equal(requests.length, 1); + const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!; + assert.equal(dialog.querySelector("fieldset")!.disabled, true); + await click( + dialog.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!, + ); + await click(button("Cancel", dialog)); + await click(dialog.querySelector<HTMLElement>('[aria-hidden="true"]')!); + await act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); + }); + assert.equal(document.querySelector('[role="dialog"]'), dialog); + await act(async () => { + requests[0]!.reject(new Error("Request failed")); + await settle(); + }); + assert.equal(field(id).value, "first"); + assert.match(dialog.textContent!, /Request failed/); + await act(() => submit(field(id))); + await act(async () => { + requests[1]!.resolve(); + await settle(); + }); + assert.equal(document.querySelector('[role="dialog"]'), null); + await click(button(label)); + assert.equal(field(id).value, ""); + assert.doesNotMatch( + document.querySelector('[role="dialog"]')!.textContent!, + /Request failed/, + ); + } finally { + await cleanup(root); + } + }); +} + +for (const kind of ["group", "pot", "report"] as const) { + test(`${kind} deletion stays open on failure and submits once while pending`, async () => { + const root = mount(); + const requests: ReturnType<typeof deferred<void>>[] = []; + const serials: string[] = []; + const remove = (serial: string) => { + serials.push(serial); + const request = deferred<void>(); + requests.push(request); + return request.promise; + }; + try { + await act(() => + render( + <ReportsScreen + initialTab={kind === "report" ? "scheduled" : "groupings"} + sampleGroups={[ + { + group_serial: 1, + group_name: "existing_group", + description: "Original", + }, + ]} + samplePots={[ + { pot_serial: 2, pot_name: "existing_pot", pot_totals: [] }, + ]} + sampleReports={[ + { + report_serial: 3, + description: "existing_report", + report_frequency: { d_us: 86400000000 }, + }, + ]} + onDeleteGroup={remove} + onDeletePot={remove} + onDeleteReport={remove} + />, + root, + ), + ); + const open = async () => { + await click( + root.querySelector<HTMLButtonElement>( + `button[aria-label="Actions for existing_${kind}"]`, + )!, + ); + await click( + button( + kind === "report" ? "Cancel Schedule" : "Delete", + document.querySelector('[role="menu"]')!, + ), + ); + }; + await open(); + const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!; + const confirm = button( + kind === "group" + ? "Delete Group" + : kind === "pot" + ? "Delete Money Pot" + : "Cancel Report", + dialog, + ); + await act(() => { + confirm.click(); + confirm.click(); + }); + assert.deepEqual(serials, [ + kind === "group" ? "1" : kind === "pot" ? "2" : "3", + ]); + await click( + dialog.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!, + ); + assert.equal(document.querySelector('[role="dialog"]'), dialog); + await act(async () => { + requests[0]!.reject(new Error("Delete failed")); + await settle(); + }); + assert.match(dialog.textContent!, /Delete failed/); + await click(button("Cancel", dialog)); + await open(); + assert.doesNotMatch( + document.querySelector('[role="dialog"]')!.textContent!, + /Delete failed/, + ); + await click( + button( + kind === "group" + ? "Delete Group" + : kind === "pot" + ? "Delete Money Pot" + : "Cancel Report", + document.querySelector('[role="dialog"]')!, + ), + ); + await act(async () => { + requests[1]!.resolve(); + await settle(); + }); + assert.equal(document.querySelector('[role="dialog"]'), null); + } finally { + await cleanup(root); + } + }); +} diff --git a/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx b/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx @@ -15,8 +15,9 @@ */ import type { VNode } from "preact"; -import { useState } from "preact/hooks"; +import { useRef, useState } from "preact/hooks"; import { Header } from "../ui/Header.js"; +import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { Modal } from "../ui/Modal.js"; import { Button } from "../ui/Button.js"; import { useLocation } from "wouter-preact"; @@ -208,9 +209,39 @@ export function ReportsScreen({ text: string; } | null>(null); + const mutationLock = useRef(false); + const [mutationPending, setMutationPending] = useState(false); + const [mutationError, setMutationError] = useState<unknown>(); + + const runMutation = async (action: () => Promise<void>) => { + if (mutationLock.current) return; + mutationLock.current = true; + setMutationPending(true); + setMutationError(undefined); + try { + await action(); + } catch (error) { + setMutationError(error); + } finally { + mutationLock.current = false; + setMutationPending(false); + } + }; + + const openDialog = (open: () => void) => { + if (mutationLock.current) return; + setMutationError(undefined); + open(); + }; + const closeDialog = (close: () => void) => { + if (!mutationLock.current) close(); + }; + const toSlug = normalizeMerchantSlug; const handleOpenAddGroup = () => { + if (mutationLock.current) return; + setMutationError(undefined); setEditingGroup(null); setGroupName(""); setGroupDesc(""); @@ -218,6 +249,8 @@ export function ReportsScreen({ }; const handleOpenEditGroup = (grp: TalerMerchantApi.GroupEntry) => { + if (mutationLock.current) return; + setMutationError(undefined); setEditingGroup(grp); setGroupName(grp.group_name); setGroupDesc(grp.description || ""); @@ -226,17 +259,15 @@ export function ReportsScreen({ const handleSaveGroup = async (e: Event) => { e.preventDefault(); + if (mutationLock.current) return; if (!groupName.trim()) return; const cleanName = toSlug(groupName); if (!isValidMerchantSlug(cleanName)) { - setFeedbackMsg({ - type: "error", - text: t`Enter a valid product group identifier.`, - }); + setMutationError(t`Enter a valid product group identifier.`); return; } - try { + await runMutation(async () => { if (editingGroup) { await onUpdateGroup?.(String(editingGroup.group_serial), { group_name: cleanName, @@ -257,12 +288,12 @@ export function ReportsScreen({ }); } setShowGroupModal(false); - } catch { - setFeedbackMsg({ type: "error", text: t`Failed to save product group.` }); - } + }); }; const handleOpenAddPot = () => { + if (mutationLock.current) return; + setMutationError(undefined); setEditingPot(null); setPotName(""); setPotDesc(""); @@ -272,6 +303,8 @@ export function ReportsScreen({ const handleOpenEditPot = ( pot: TalerMerchantApi.PotEntry & { description?: string }, ) => { + if (mutationLock.current) return; + setMutationError(undefined); setEditingPot(pot); setPotName(pot.pot_name); setPotDesc(pot.description ?? ""); @@ -280,17 +313,15 @@ export function ReportsScreen({ const handleSavePot = async (e: Event) => { e.preventDefault(); + if (mutationLock.current) return; if (!potName.trim()) return; const cleanName = toSlug(potName); if (!isValidMerchantSlug(cleanName)) { - setFeedbackMsg({ - type: "error", - text: t`Enter a valid money pot identifier.`, - }); + setMutationError(t`Enter a valid money pot identifier.`); return; } - try { + await runMutation(async () => { if (editingPot) { await onUpdatePot?.(String(editingPot.pot_serial), { pot_name: cleanName, @@ -311,9 +342,7 @@ export function ReportsScreen({ }); } setShowPotModal(false); - } catch { - setFeedbackMsg({ type: "error", text: t`Failed to save money pot.` }); - } + }); }; /** @@ -505,7 +534,9 @@ export function ReportsScreen({ <button type="button" onClick={() => - setDeletingReportSerial(rep.report_serial) + openDialog(() => + setDeletingReportSerial(rep.report_serial), + ) } class={destructiveRowActionClass} >{t`Cancel Schedule`}</button> @@ -577,7 +608,9 @@ export function ReportsScreen({ <button type="button" onClick={() => - setDeletingReportSerial(rep.report_serial) + openDialog(() => + setDeletingReportSerial(rep.report_serial), + ) } class={destructiveRowActionClass} >{t`Cancel Schedule`}</button> @@ -646,7 +679,9 @@ export function ReportsScreen({ >{t`Edit`}</button> <button type="button" - onClick={() => setDeletingGroup(grp)} + onClick={() => + openDialog(() => setDeletingGroup(grp)) + } class={destructiveRowActionClass} >{t`Delete`}</button> </RowActions> @@ -690,7 +725,9 @@ export function ReportsScreen({ >{t`Edit`}</button> <button type="button" - onClick={() => setDeletingGroup(grp)} + onClick={() => + openDialog(() => setDeletingGroup(grp)) + } class={destructiveRowActionClass} >{t`Delete`}</button> </RowActions> @@ -752,7 +789,9 @@ export function ReportsScreen({ >{t`Edit`}</button> <button type="button" - onClick={() => setDeletingPot(pot)} + onClick={() => + openDialog(() => setDeletingPot(pot)) + } class={destructiveRowActionClass} >{t`Delete`}</button> </RowActions> @@ -792,7 +831,9 @@ export function ReportsScreen({ >{t`Edit`}</button> <button type="button" - onClick={() => setDeletingPot(pot)} + onClick={() => + openDialog(() => setDeletingPot(pot)) + } class={destructiveRowActionClass} >{t`Delete`}</button> </RowActions> @@ -810,259 +851,295 @@ export function ReportsScreen({ <Modal isOpen={showGroupModal} - onClose={() => setShowGroupModal(false)} + onClose={() => closeDialog(() => setShowGroupModal(false))} title={editingGroup ? t`Edit Product Group` : t`Add Product Group`} > - <form onSubmit={handleSaveGroup} class="space-y-4"> - <div> - <label - htmlFor="grp_name_input" - class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between" - > - <span> - {t`Group Identifier`} <span class="text-red-500">*</span> - </span> - <button - type="button" - onClick={() => - setGroupName(toSlug(groupName || "beverages_group")) - } - class="text-[10px] text-blue-600 hover:underline normal-case font-normal" + <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <ApiErrorBanner + error={mutationError} + title={t`Could not save changes`} + /> + <form onSubmit={handleSaveGroup} class="space-y-4"> + <div> + <label + htmlFor="grp_name_input" + class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between" > - {t`Reset to suggested`} - </button> - </label> - <input - id="grp_name_input" - type="text" - required - value={groupName} - onInput={(e) => - setGroupName((e.target as HTMLInputElement).value) - } - placeholder={groupIdentifierExample} - class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" - /> - </div> + <span> + {t`Group Identifier`} <span class="text-red-500">*</span> + </span> + <button + type="button" + onClick={() => + setGroupName(toSlug(groupName || "beverages_group")) + } + class="text-[10px] text-blue-600 hover:underline normal-case font-normal" + > + {t`Reset to suggested`} + </button> + </label> + <input + id="grp_name_input" + type="text" + required + value={groupName} + onInput={(e) => + setGroupName((e.target as HTMLInputElement).value) + } + placeholder={groupIdentifierExample} + class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> + </div> - <div> - <label - htmlFor="grp_desc_input" - class="block text-xs font-bold text-gray-700 mb-1" - > - {t`Description`} - </label> - <textarea - id="grp_desc_input" - rows={3} - value={groupDesc} - onInput={(e) => - setGroupDesc((e.target as HTMLTextAreaElement).value) - } - placeholder={t`Describe what products belong to this reporting group...`} - class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" - /> - </div> + <div> + <label + htmlFor="grp_desc_input" + class="block text-xs font-bold text-gray-700 mb-1" + > + {t`Description`} + </label> + <textarea + id="grp_desc_input" + rows={3} + value={groupDesc} + onInput={(e) => + setGroupDesc((e.target as HTMLTextAreaElement).value) + } + placeholder={t`Describe what products belong to this reporting group...`} + class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> + </div> - <div class="flex items-center justify-end space-x-3 pt-2"> - <Button - variant="secondary" - onClick={() => setShowGroupModal(false)} - > - {t`Cancel`} - </Button> - <Button type="submit" variant="primary"> - {editingGroup ? t`Save Group` : t`Create Product Group`} - </Button> - </div> - </form> + <div class="flex items-center justify-end space-x-3 pt-2"> + <Button + variant="secondary" + onClick={() => closeDialog(() => setShowGroupModal(false))} + > + {t`Cancel`} + </Button> + <Button + type="submit" + variant="primary" + isLoading={mutationPending} + > + {editingGroup ? t`Save Group` : t`Create Product Group`} + </Button> + </div> + </form> + </fieldset> </Modal> <Modal isOpen={showPotModal} - onClose={() => setShowPotModal(false)} + onClose={() => closeDialog(() => setShowPotModal(false))} title={editingPot ? t`Edit Money Pot` : t`Add Money Pot`} > - <form onSubmit={handleSavePot} class="space-y-4"> - <div> - <label - htmlFor="pot_name_input" - class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between" - > - <span> - {t`Money Pot Identifier`} <span class="text-red-500">*</span> - </span> - <button - type="button" - onClick={() => setPotName(toSlug(potName || "breakfast_pot"))} - class="text-[10px] text-blue-600 hover:underline normal-case font-normal" + <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <ApiErrorBanner + error={mutationError} + title={t`Could not save changes`} + /> + <form onSubmit={handleSavePot} class="space-y-4"> + <div> + <label + htmlFor="pot_name_input" + class="block text-xs font-bold text-gray-700 mb-1 flex items-center justify-between" > - {t`Reset to suggested`} - </button> - </label> - <input - id="pot_name_input" - type="text" - required - value={potName} - onInput={(e) => setPotName((e.target as HTMLInputElement).value)} - placeholder={potIdentifierExample} - class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" - /> - </div> + <span> + {t`Money Pot Identifier`} <span class="text-red-500">*</span> + </span> + <button + type="button" + onClick={() => setPotName(toSlug(potName || "breakfast_pot"))} + class="text-[10px] text-blue-600 hover:underline normal-case font-normal" + > + {t`Reset to suggested`} + </button> + </label> + <input + id="pot_name_input" + type="text" + required + value={potName} + onInput={(e) => + setPotName((e.target as HTMLInputElement).value) + } + placeholder={potIdentifierExample} + class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> + </div> - <div> - <label - htmlFor="pot_desc_input" - class="block text-xs font-bold text-gray-700 mb-1" - > - {t`Description / Target Info`} - </label> - <input - id="pot_desc_input" - type="text" - value={potDesc} - onInput={(e) => - setPotDesc((e.target as HTMLTextAreaElement).value) - } - placeholder={t`Describe revenue target or assigned products...`} - class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" - /> - </div> + <div> + <label + htmlFor="pot_desc_input" + class="block text-xs font-bold text-gray-700 mb-1" + > + {t`Description / Target Info`} + </label> + <input + id="pot_desc_input" + type="text" + value={potDesc} + onInput={(e) => + setPotDesc((e.target as HTMLTextAreaElement).value) + } + placeholder={t`Describe revenue target or assigned products...`} + class="w-full px-3.5 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500" + /> + </div> - <div class="flex items-center justify-end space-x-3 pt-2"> - <Button variant="secondary" onClick={() => setShowPotModal(false)}> - {t`Cancel`} - </Button> - <Button type="submit" variant="primary"> - {editingPot ? t`Save Money Pot` : t`Create Money Pot`} - </Button> - </div> - </form> + <div class="flex items-center justify-end space-x-3 pt-2"> + <Button + variant="secondary" + onClick={() => closeDialog(() => setShowPotModal(false))} + > + {t`Cancel`} + </Button> + <Button + type="submit" + variant="primary" + isLoading={mutationPending} + > + {editingPot ? t`Save Money Pot` : t`Create Money Pot`} + </Button> + </div> + </form> + </fieldset> </Modal> <Modal isOpen={Boolean(deletingGroup)} - onClose={() => setDeletingGroup(null)} + onClose={() => closeDialog(() => setDeletingGroup(null))} title={ deletingGroup ? t`Delete group "${deletingGroup.group_name}"?` : undefined } > - <p class="text-xs text-gray-700 leading-relaxed"> - {t`Are you sure you want to delete this reporting group? Products assigned to it will remain in inventory.`} - </p> - <div class="flex items-center justify-end space-x-3 pt-2"> - <Button variant="secondary" onClick={() => setDeletingGroup(null)}> - {t`Cancel`} - </Button> - <Button - variant="danger" - onClick={async () => { - try { - if (deletingGroup) + <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <ApiErrorBanner + error={mutationError} + title={t`Could not save changes`} + /> + <p class="text-xs text-gray-700 leading-relaxed"> + {t`Are you sure you want to delete this reporting group? Products assigned to it will remain in inventory.`} + </p> + <div class="flex items-center justify-end space-x-3 pt-2"> + <Button + variant="secondary" + onClick={() => closeDialog(() => setDeletingGroup(null))} + > + {t`Cancel`} + </Button> + <Button + variant="danger" + isLoading={mutationPending} + onClick={() => + void runMutation(async () => { + if (deletingGroup === null) return; await onDeleteGroup?.(String(deletingGroup.group_serial)); - setFeedbackMsg({ - type: "success", - text: t`Product group "${deletingGroup?.group_name}" deleted.`, - }); - } catch { - setFeedbackMsg({ - type: "error", - text: t`Failed to delete group.`, - }); + setFeedbackMsg({ + type: "success", + text: t`Product group "${deletingGroup.group_name}" deleted.`, + }); + setDeletingGroup(null); + }) } - setDeletingGroup(null); - }} - > - {t`Delete Group`} - </Button> - </div> + > + {t`Delete Group`} + </Button> + </div> + </fieldset> </Modal> <Modal isOpen={Boolean(deletingPot)} - onClose={() => setDeletingPot(null)} + onClose={() => closeDialog(() => setDeletingPot(null))} title={ deletingPot ? t`Delete money pot "${deletingPot.pot_name}"?` : undefined } > - <p class="text-xs text-gray-700 leading-relaxed"> - {t`Are you sure you want to delete this money pot?`} - </p> - <div class="flex items-center justify-end space-x-3 pt-2"> - <Button variant="secondary" onClick={() => setDeletingPot(null)}> - {t`Cancel`} - </Button> - <Button - variant="danger" - onClick={async () => { - try { - if (deletingPot) + <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <ApiErrorBanner + error={mutationError} + title={t`Could not save changes`} + /> + <p class="text-xs text-gray-700 leading-relaxed"> + {t`Are you sure you want to delete this money pot?`} + </p> + <div class="flex items-center justify-end space-x-3 pt-2"> + <Button + variant="secondary" + onClick={() => closeDialog(() => setDeletingPot(null))} + > + {t`Cancel`} + </Button> + <Button + variant="danger" + isLoading={mutationPending} + onClick={() => + void runMutation(async () => { + if (deletingPot === null) return; await onDeletePot?.(String(deletingPot.pot_serial)); - setFeedbackMsg({ - type: "success", - text: t`Money pot "${deletingPot?.pot_name}" deleted.`, - }); - } catch { - setFeedbackMsg({ - type: "error", - text: t`Failed to delete money pot.`, - }); + setFeedbackMsg({ + type: "success", + text: t`Money pot "${deletingPot.pot_name}" deleted.`, + }); + setDeletingPot(null); + }) } - setDeletingPot(null); - }} - > - {t`Delete Money Pot`} - </Button> - </div> + > + {t`Delete Money Pot`} + </Button> + </div> + </fieldset> </Modal> <Modal isOpen={deletingReportSerial !== null} - onClose={() => setDeletingReportSerial(null)} + onClose={() => closeDialog(() => setDeletingReportSerial(null))} title={ deletingReportSerial !== null ? t`Cancel scheduled report ${deletingReportSerial}?` : undefined } > - <p class="text-xs text-gray-700 leading-relaxed"> - {t`Are you sure you want to cancel this scheduled report transmission?`} - </p> - <div class="flex items-center justify-end space-x-3 pt-2"> - <Button - variant="secondary" - onClick={() => setDeletingReportSerial(null)} - > - {t`Cancel`} - </Button> - <Button - variant="danger" - onClick={async () => { - if (deletingReportSerial !== null) { - try { + <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <ApiErrorBanner + error={mutationError} + title={t`Could not save changes`} + /> + <p class="text-xs text-gray-700 leading-relaxed"> + {t`Are you sure you want to cancel this scheduled report transmission?`} + </p> + <div class="flex items-center justify-end space-x-3 pt-2"> + <Button + variant="secondary" + onClick={() => closeDialog(() => setDeletingReportSerial(null))} + > + {t`Cancel`} + </Button> + <Button + variant="danger" + isLoading={mutationPending} + onClick={() => + void runMutation(async () => { + if (deletingReportSerial === null) return; await onDeleteReport?.(String(deletingReportSerial)); setFeedbackMsg({ type: "success", text: t`Scheduled report cancelled.`, }); - } catch { - setFeedbackMsg({ - type: "error", - text: t`Failed to cancel scheduled report.`, - }); - } + setDeletingReportSerial(null); + }) } - setDeletingReportSerial(null); - }} - > - {t`Cancel Report`} - </Button> - </div> + > + {t`Cancel Report`} + </Button> + </div> + </fieldset> </Modal> </div> );