taler-typescript-core

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

commit 3833e01b4c22a7830cd17e3693adcb7a950f3e2c
parent 60d0fe42e69f5e5f31a9897122f285ea7e32fe2f
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 21:10:40 +0100

merchant web UI: simplify mutation state with signals

Diffstat:
Mpackages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx | 134++++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/taler-merchant-webui/src/screens/ReportsScreen.tsx | 51+++++++++++++++++++++++++--------------------------
2 files changed, 92 insertions(+), 93 deletions(-)

diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx @@ -9,6 +9,7 @@ import type { ComponentChildren, VNode } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; +import { useSignal } from "@preact/signals"; import type { TalerMerchantApi } from "@gnu-taler/taler-util"; import { Header } from "../ui/Header.js"; import { DurationInput, type DurationValue } from "../ui/DurationInput.js"; @@ -255,11 +256,9 @@ export function BusinessSettingsScreen({ }: BusinessSettingsScreenProps): VNode { const { t } = useTranslation(); const incomingSettings = settings ?? settingsResource?.data; - const [committed, setCommitted] = useState<BusinessSettings>( + const committed = useSignal<BusinessSettings>( incomingSettings || DEFAULT_SETTINGS, ); - const committedRef = useRef(committed); - const saveLock = useRef(false); const [editors, setEditors] = useState<Record<EditorSection, boolean>>(() => editorState(initialSection), ); @@ -267,37 +266,38 @@ export function BusinessSettingsScreen({ const hasReceivedSettingsRef = useRef(Boolean(incomingSettings)); editorsRef.current = editors; - const [name, setName] = useState(committed.name); - const [email, setEmail] = useState(committed.email || ""); - const [phone, setPhone] = useState(committed.phone_number || ""); + const [name, setName] = useState(committed.value.name); + const [email, setEmail] = useState(committed.value.email || ""); + const [phone, setPhone] = useState(committed.value.phone_number || ""); const isPhoneInvalid = phone !== "" && !isValidMfaPhone(phone, phoneRegex); - const [website, setWebsite] = useState(committed.website || ""); - const [logoUrl, setLogoUrl] = useState(committed.logoUrl || ""); + const [website, setWebsite] = useState(committed.value.website || ""); + const [logoUrl, setLogoUrl] = useState(committed.value.logoUrl || ""); const [address, setAddress] = useState<TalerMerchantApi.Location>( - committed.addressLocation || {}, + committed.value.addressLocation || {}, ); const [jurisdiction, setJurisdiction] = useState<TalerMerchantApi.Location>( - committed.jurisdictionLocation || {}, + committed.value.jurisdictionLocation || {}, + ); + const [useStefan, setUseStefan] = useState( + Boolean(committed.value.use_stefan), ); - const [useStefan, setUseStefan] = useState(Boolean(committed.use_stefan)); const [payDelayUs, setPayDelayUs] = useState( - committed.defaultPayDelayUs ?? DEFAULT_SETTINGS.defaultPayDelayUs!, + committed.value.defaultPayDelayUs ?? DEFAULT_SETTINGS.defaultPayDelayUs!, ); const [refundDelayUs, setRefundDelayUs] = useState( - committed.defaultRefundDelayUs ?? 0, + committed.value.defaultRefundDelayUs ?? 0, ); const [wireDelayUs, setWireDelayUs] = useState( - committed.defaultWireTransferDelayUs ?? + committed.value.defaultWireTransferDelayUs ?? DEFAULT_SETTINGS.defaultWireTransferDelayUs!, ); const [delayValidity, setDelayValidity] = useState([true, true, true]); const [roundingInterval, setRoundingInterval] = useState( - committed.defaultWireTransferRoundingInterval || "NONE", + committed.value.defaultWireTransferRoundingInterval || "NONE", ); - const [pendingSection, setPendingSection] = useState<EditorSection | null>( - null, - ); + // A non-null section is both the synchronous save guard and the pending UI state. + const pendingSection = useSignal<EditorSection | null>(null); const [sectionErrors, setSectionErrors] = useState< Partial<Record<EditorSection, unknown>> >({}); @@ -313,10 +313,12 @@ export function BusinessSettingsScreen({ const [deletionPending, setDeletionPending] = useState(false); const [deletionError, setDeletionError] = useState<unknown>(); const deletionLock = useRef(false); - const committedLogoStatus = useImageDataUrlStatus(committed.logoUrl || ""); + const committedLogoStatus = useImageDataUrlStatus( + committed.value.logoUrl || "", + ); const draftLogoStatus = useImageDataUrlStatus(logoUrl); - const seedSection = (section: EditorSection, source = committed) => { + const seedSection = (section: EditorSection, source = committed.value) => { if (section === "profile") { setName(source.name); setLogoUrl(source.logoUrl || ""); @@ -350,8 +352,7 @@ export function BusinessSettingsScreen({ useEffect(() => { if (!incomingSettings) return; const preserveOpenDrafts = hasReceivedSettingsRef.current; - committedRef.current = incomingSettings; - setCommitted(incomingSettings); + committed.value = incomingSettings; for (const section of Object.keys(CLOSED_EDITORS) as EditorSection[]) { if (!preserveOpenDrafts || !editorsRef.current[section]) seedSection(section, incomingSettings); @@ -383,29 +384,26 @@ export function BusinessSettingsScreen({ section: EditorSection, patch: Partial<BusinessSettings>, ) => { - if (saveLock.current) return; - saveLock.current = true; - const next: BusinessSettings = { ...committedRef.current, ...patch }; - setPendingSection(section); + if (pendingSection.value !== null) return; + const next: BusinessSettings = { ...committed.value, ...patch }; + pendingSection.value = section; setSectionErrors((current) => ({ ...current, [section]: undefined })); try { const result = onSave ? await onSave(next) : "saved"; if (result === "challenge") return; - committedRef.current = next; - setCommitted(next); + committed.value = next; setEditors((current) => ({ ...current, [section]: false })); setSavedSection(section); } catch (error) { setSectionErrors((current) => ({ ...current, [section]: error })); } finally { - saveLock.current = false; - setPendingSection(null); + pendingSection.value = null; } }; const savePassword = async (event: Event) => { event.preventDefault(); - if (saveLock.current) return; + if (pendingSection.value !== null) return; setSectionErrors((current) => ({ ...current, security: undefined })); if (!newPassword) { setSectionErrors((current) => ({ @@ -437,8 +435,7 @@ export function BusinessSettingsScreen({ })); return; } - saveLock.current = true; - setPendingSection("security"); + pendingSection.value = "security"; try { const result = await onChangePassword?.({ currentPassword, @@ -453,8 +450,7 @@ export function BusinessSettingsScreen({ } catch (error) { setSectionErrors((current) => ({ ...current, security: error })); } finally { - saveLock.current = false; - setPendingSection(null); + pendingSection.value = null; } }; @@ -519,11 +515,11 @@ export function BusinessSettingsScreen({ const profileSummary = ( <span class="inline-flex items-center gap-2"> <strong class="font-semibold text-gray-900"> - {committed.name || t`Business name required`} + {committed.value.name || t`Business name required`} </strong> {committedLogoStatus === "ready" ? ( <img - src={committed.logoUrl} + src={committed.value.logoUrl} alt={t`Business logo`} class="h-6 w-6 rounded border border-gray-200 bg-white object-contain p-0.5" /> @@ -541,12 +537,16 @@ export function BusinessSettingsScreen({ </span> ); const contactSummary = - committed.email || committed.website - ? [committed.email, committed.website].filter(Boolean).join(" · ") + committed.value.email || committed.value.website + ? [committed.value.email, committed.value.website] + .filter(Boolean) + .join(" · ") : t`No public contact details configured`; - const addressLines = formatStructuredAddressLines(committed.addressLocation); + const addressLines = formatStructuredAddressLines( + committed.value.addressLocation, + ); const jurisdictionLines = formatStructuredAddressLines( - committed.jurisdictionLocation, + committed.value.jurisdictionLocation, ); const addressSummary = addressLines.length || jurisdictionLines.length ? ( @@ -563,13 +563,13 @@ export function BusinessSettingsScreen({ const delaySummary = ( <> <strong>{t`Payment window`}:</strong>{" "} - {formatUsToDuration(committed.defaultPayDelayUs ?? 0, t)} + {formatUsToDuration(committed.value.defaultPayDelayUs ?? 0, t)} {" · "} <strong>{t`Refund window`}:</strong>{" "} - {formatUsToDuration(committed.defaultRefundDelayUs ?? 0, t)} + {formatUsToDuration(committed.value.defaultRefundDelayUs ?? 0, t)} {" · "} <strong>{t`Payout delay`}:</strong>{" "} - {formatUsToDuration(committed.defaultWireTransferDelayUs ?? 0, t)} + {formatUsToDuration(committed.value.defaultWireTransferDelayUs ?? 0, t)} </> ); @@ -605,7 +605,7 @@ export function BusinessSettingsScreen({ class="max-w-2xl space-y-5" > <fieldset - disabled={pendingSection === "profile"} + disabled={pendingSection.value === "profile"} class="min-w-0 space-y-5" > <div class="space-y-1"> @@ -638,8 +638,8 @@ export function BusinessSettingsScreen({ >{t`Remove or replace the logo before saving this section.`}</p> )} <EditorActions - saveBlocked={pendingSection !== null} - pending={pendingSection === "profile"} + saveBlocked={pendingSection.value !== null} + pending={pendingSection.value === "profile"} error={sectionErrors.profile} disabled={ !name.trim() || @@ -668,7 +668,7 @@ export function BusinessSettingsScreen({ class="max-w-xl space-y-5" > <fieldset - disabled={pendingSection === "contact"} + disabled={pendingSection.value === "contact"} class="min-w-0 space-y-5" > <div class="space-y-1"> @@ -701,8 +701,8 @@ export function BusinessSettingsScreen({ /> </div> <EditorActions - saveBlocked={pendingSection !== null} - pending={pendingSection === "contact"} + saveBlocked={pendingSection.value !== null} + pending={pendingSection.value === "contact"} error={sectionErrors.contact} onCancel={() => cancelSection("contact")} /> @@ -729,7 +729,7 @@ export function BusinessSettingsScreen({ class="space-y-6" > <fieldset - disabled={pendingSection === "address"} + disabled={pendingSection.value === "address"} class="min-w-0 space-y-6" > <LocationInput @@ -759,8 +759,8 @@ export function BusinessSettingsScreen({ /> </div> <EditorActions - saveBlocked={pendingSection !== null} - pending={pendingSection === "address"} + saveBlocked={pendingSection.value !== null} + pending={pendingSection.value === "address"} error={sectionErrors.address} onCancel={() => cancelSection("address")} /> @@ -779,7 +779,7 @@ export function BusinessSettingsScreen({ title={t`Transaction fees`} subtitle={t`Choose whether the business or customer covers transaction costs.`} summary={ - committed.use_stefan + committed.value.use_stefan ? t`Business covers transaction fees` : t`Transaction fees are added to the customer’s payment` } @@ -795,7 +795,7 @@ export function BusinessSettingsScreen({ class="space-y-5" > <fieldset - disabled={pendingSection === "fees"} + disabled={pendingSection.value === "fees"} class="min-w-0 space-y-5" > <div class="flex max-w-2xl items-start gap-3"> @@ -817,8 +817,8 @@ export function BusinessSettingsScreen({ </div> </div> <EditorActions - saveBlocked={pendingSection !== null} - pending={pendingSection === "fees"} + saveBlocked={pendingSection.value !== null} + pending={pendingSection.value === "fees"} error={sectionErrors.fees} onCancel={() => cancelSection("fees")} /> @@ -852,7 +852,7 @@ export function BusinessSettingsScreen({ class="space-y-5" > <fieldset - disabled={pendingSection === "delays"} + disabled={pendingSection.value === "delays"} class="min-w-0 space-y-5" > <div class="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2"> @@ -924,9 +924,9 @@ export function BusinessSettingsScreen({ </div> </div> <EditorActions - saveBlocked={pendingSection !== null} + saveBlocked={pendingSection.value !== null} disabled={delayValidity.some((valid) => !valid)} - pending={pendingSection === "delays"} + pending={pendingSection.value === "delays"} error={sectionErrors.delays} onCancel={() => cancelSection("delays")} /> @@ -945,7 +945,7 @@ export function BusinessSettingsScreen({ title={t`Verification phone`} subtitle={t`Private mobile number used for administrative verification codes.`} summary={ - committed.phone_number || t`No verification phone configured` + committed.value.phone_number || t`No verification phone configured` } isOpen={editors.phone} saved={savedSection === "phone"} @@ -960,7 +960,7 @@ export function BusinessSettingsScreen({ class="max-w-xl space-y-5" > <fieldset - disabled={pendingSection === "phone"} + disabled={pendingSection.value === "phone"} class="min-w-0 space-y-5" > <div class="space-y-1"> @@ -988,8 +988,8 @@ export function BusinessSettingsScreen({ </p> </div> <EditorActions - saveBlocked={pendingSection !== null} - pending={pendingSection === "phone"} + saveBlocked={pendingSection.value !== null} + pending={pendingSection.value === "phone"} error={sectionErrors.phone} disabled={isPhoneInvalid} onCancel={() => cancelSection("phone")} @@ -1011,7 +1011,7 @@ export function BusinessSettingsScreen({ class="max-w-xl space-y-5" > <fieldset - disabled={pendingSection === "security"} + disabled={pendingSection.value === "security"} class="min-w-0 space-y-5" > <PasswordInput @@ -1041,8 +1041,8 @@ export function BusinessSettingsScreen({ minLength={8} /> <EditorActions - saveBlocked={pendingSection !== null} - pending={pendingSection === "security"} + saveBlocked={pendingSection.value !== null} + pending={pendingSection.value === "security"} error={sectionErrors.security} onCancel={() => cancelSection("security")} saveLabel={t`Update password`} diff --git a/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx b/packages/taler-merchant-webui/src/screens/ReportsScreen.tsx @@ -15,7 +15,8 @@ */ import type { VNode } from "preact"; -import { useRef, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; +import { useSignal } from "@preact/signals"; import { Header } from "../ui/Header.js"; import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { Modal } from "../ui/Modal.js"; @@ -209,38 +210,36 @@ export function ReportsScreen({ text: string; } | null>(null); - const mutationLock = useRef(false); - const [mutationPending, setMutationPending] = useState(false); + // The signal updates synchronously for guards and also drives the pending UI. + const mutationPending = useSignal(false); const [mutationError, setMutationError] = useState<unknown>(); const runMutation = async (action: () => Promise<void>) => { - if (mutationLock.current) return; - mutationLock.current = true; - setMutationPending(true); + if (mutationPending.value) return; + mutationPending.value = true; setMutationError(undefined); try { await action(); } catch (error) { setMutationError(error); } finally { - mutationLock.current = false; - setMutationPending(false); + mutationPending.value = false; } }; const openDialog = (open: () => void) => { - if (mutationLock.current) return; + if (mutationPending.value) return; setMutationError(undefined); open(); }; const closeDialog = (close: () => void) => { - if (!mutationLock.current) close(); + if (!mutationPending.value) close(); }; const toSlug = normalizeMerchantSlug; const handleOpenAddGroup = () => { - if (mutationLock.current) return; + if (mutationPending.value) return; setMutationError(undefined); setEditingGroup(null); setGroupName(""); @@ -249,7 +248,7 @@ export function ReportsScreen({ }; const handleOpenEditGroup = (grp: TalerMerchantApi.GroupEntry) => { - if (mutationLock.current) return; + if (mutationPending.value) return; setMutationError(undefined); setEditingGroup(grp); setGroupName(grp.group_name); @@ -259,7 +258,7 @@ export function ReportsScreen({ const handleSaveGroup = async (e: Event) => { e.preventDefault(); - if (mutationLock.current) return; + if (mutationPending.value) return; if (!groupName.trim()) return; const cleanName = toSlug(groupName); if (!isValidMerchantSlug(cleanName)) { @@ -292,7 +291,7 @@ export function ReportsScreen({ }; const handleOpenAddPot = () => { - if (mutationLock.current) return; + if (mutationPending.value) return; setMutationError(undefined); setEditingPot(null); setPotName(""); @@ -303,7 +302,7 @@ export function ReportsScreen({ const handleOpenEditPot = ( pot: TalerMerchantApi.PotEntry & { description?: string }, ) => { - if (mutationLock.current) return; + if (mutationPending.value) return; setMutationError(undefined); setEditingPot(pot); setPotName(pot.pot_name); @@ -313,7 +312,7 @@ export function ReportsScreen({ const handleSavePot = async (e: Event) => { e.preventDefault(); - if (mutationLock.current) return; + if (mutationPending.value) return; if (!potName.trim()) return; const cleanName = toSlug(potName); if (!isValidMerchantSlug(cleanName)) { @@ -854,7 +853,7 @@ export function ReportsScreen({ onClose={() => closeDialog(() => setShowGroupModal(false))} title={editingGroup ? t`Edit Product Group` : t`Add Product Group`} > - <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> <ApiErrorBanner error={mutationError} title={t`Could not save changes`} @@ -920,7 +919,7 @@ export function ReportsScreen({ <Button type="submit" variant="primary" - isLoading={mutationPending} + isLoading={mutationPending.value} > {editingGroup ? t`Save Group` : t`Create Product Group`} </Button> @@ -934,7 +933,7 @@ export function ReportsScreen({ onClose={() => closeDialog(() => setShowPotModal(false))} title={editingPot ? t`Edit Money Pot` : t`Add Money Pot`} > - <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> <ApiErrorBanner error={mutationError} title={t`Could not save changes`} @@ -998,7 +997,7 @@ export function ReportsScreen({ <Button type="submit" variant="primary" - isLoading={mutationPending} + isLoading={mutationPending.value} > {editingPot ? t`Save Money Pot` : t`Create Money Pot`} </Button> @@ -1016,7 +1015,7 @@ export function ReportsScreen({ : undefined } > - <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> <ApiErrorBanner error={mutationError} title={t`Could not save changes`} @@ -1033,7 +1032,7 @@ export function ReportsScreen({ </Button> <Button variant="danger" - isLoading={mutationPending} + isLoading={mutationPending.value} onClick={() => void runMutation(async () => { if (deletingGroup === null) return; @@ -1061,7 +1060,7 @@ export function ReportsScreen({ : undefined } > - <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> <ApiErrorBanner error={mutationError} title={t`Could not save changes`} @@ -1078,7 +1077,7 @@ export function ReportsScreen({ </Button> <Button variant="danger" - isLoading={mutationPending} + isLoading={mutationPending.value} onClick={() => void runMutation(async () => { if (deletingPot === null) return; @@ -1106,7 +1105,7 @@ export function ReportsScreen({ : undefined } > - <fieldset disabled={mutationPending} class="min-w-0 space-y-4"> + <fieldset disabled={mutationPending.value} class="min-w-0 space-y-4"> <ApiErrorBanner error={mutationError} title={t`Could not save changes`} @@ -1123,7 +1122,7 @@ export function ReportsScreen({ </Button> <Button variant="danger" - isLoading={mutationPending} + isLoading={mutationPending.value} onClick={() => void runMutation(async () => { if (deletingReportSerial === null) return;