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:
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;