commit 8e4bb79c96f4077b60dadd7f5dcb92b48f57d4a9
parent ecd2aee1818eb76240608ed3d92f64f8bb5598ec
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 23:18:03 +0100
merchant web UI: use signals for settings editor and deletion state
Diffstat:
2 files changed, 54 insertions(+), 27 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.state.test.tsx
@@ -171,3 +171,35 @@ test("password changes share the settings save lock and release it after a chall
await cleanup(root);
}
});
+
+test("settings refresh preserves open drafts while closed sections use the latest values", async () => {
+ const root = mount();
+ try {
+ await act(() =>
+ render(
+ <BusinessSettingsScreen
+ settings={{ name: "Old", email: "old@example.com" }}
+ />,
+ root,
+ ),
+ );
+ await click(button("Identity and logo"));
+ input(field("business-name"), "Unsaved draft");
+ await act(() =>
+ render(
+ <BusinessSettingsScreen
+ settings={{ name: "Server name", email: "server@example.com" }}
+ />,
+ root,
+ ),
+ );
+ assert.equal(field("business-name").value, "Unsaved draft");
+ await click(button("Customer contact"));
+ assert.equal(field("business-email").value, "server@example.com");
+ await click(button("Cancel", field("business-name").closest("form")!));
+ await click(button("Identity and logo"));
+ assert.equal(field("business-name").value, "Server name");
+ } finally {
+ await cleanup(root);
+ }
+});
diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx
@@ -268,12 +268,10 @@ export function BusinessSettingsScreen({
const committed = useSignal<BusinessSettings>(
incomingSettings || DEFAULT_SETTINGS,
);
- const [editors, setEditors] = useState<Record<EditorSection, boolean>>(() =>
+ const editors = useSignal<Record<EditorSection, boolean>>(
editorState(initialSection),
);
- const editorsRef = useRef(editors);
const hasReceivedSettingsRef = useRef(Boolean(incomingSettings));
- editorsRef.current = editors;
const [name, setName] = useState(committed.value.name);
const [email, setEmail] = useState(committed.value.email || "");
@@ -328,9 +326,8 @@ export function BusinessSettingsScreen({
null,
);
const [deletionAccountId, setDeletionAccountId] = useState("");
- const [deletionPending, setDeletionPending] = useState(false);
+ const deletionPending = useSignal(false);
const [deletionError, setDeletionError] = useState<unknown>();
- const deletionLock = useRef(false);
const committedLogoStatus = useImageDataUrlStatus(
committed.value.logoUrl || "",
);
@@ -372,7 +369,7 @@ export function BusinessSettingsScreen({
const preserveOpenDrafts = hasReceivedSettingsRef.current;
committed.value = incomingSettings;
for (const section of Object.keys(CLOSED_EDITORS) as EditorSection[]) {
- if (!preserveOpenDrafts || !editorsRef.current[section])
+ if (!preserveOpenDrafts || !editors.value[section])
seedSection(section, incomingSettings);
}
hasReceivedSettingsRef.current = true;
@@ -389,14 +386,14 @@ export function BusinessSettingsScreen({
const openSection = (section: EditorSection) => {
seedSection(section);
setSectionErrors((current) => ({ ...current, [section]: undefined }));
- setEditors((current) => ({ ...current, [section]: true }));
+ editors.value = { ...editors.value, [section]: true };
};
const cancelSection = (section: EditorSection) => {
if (section === "security") setSecurityValidation("");
seedSection(section);
setSectionErrors((current) => ({ ...current, [section]: undefined }));
- setEditors((current) => ({ ...current, [section]: false }));
+ editors.value = { ...editors.value, [section]: false };
};
const saveSection = async (
@@ -415,7 +412,7 @@ export function BusinessSettingsScreen({
const result = onSave ? await onSave(next) : "saved";
if (result === "challenge") return;
committed.value = next;
- setEditors((current) => ({ ...current, [section]: false }));
+ editors.value = { ...editors.value, [section]: false };
setSavedSection(section);
} catch (error) {
setSectionErrors((current) => ({ ...current, [section]: error }));
@@ -464,7 +461,7 @@ export function BusinessSettingsScreen({
if (!result) return;
if (result.status === "error") throw result.error;
if (result.status === "challenge") return;
- setEditors((current) => ({ ...current, security: false }));
+ editors.value = { ...editors.value, security: false };
setSavedSection("security");
seedSection("security");
} catch (error) {
@@ -481,17 +478,16 @@ export function BusinessSettingsScreen({
};
const closeDeletion = () => {
- if (deletionPending) return;
+ if (deletionPending.value) return;
setDeletionMode(null);
setDeletionAccountId("");
setDeletionError(undefined);
};
const deleteAccount = async () => {
- if (!deletionMode || !onDeleteAccount || deletionLock.current) return;
+ if (!deletionMode || !onDeleteAccount || deletionPending.value) return;
if (deletionMode === "purge" && deletionAccountId !== accountId) return;
- deletionLock.current = true;
- setDeletionPending(true);
+ deletionPending.value = true;
setDeletionError(undefined);
try {
await onDeleteAccount(deletionMode);
@@ -500,8 +496,7 @@ export function BusinessSettingsScreen({
} catch (error) {
setDeletionError(error);
} finally {
- deletionLock.current = false;
- setDeletionPending(false);
+ deletionPending.value = false;
}
};
@@ -613,7 +608,7 @@ export function BusinessSettingsScreen({
title={t`Identity and logo`}
subtitle={t`Your public business name and uploaded logo.`}
summary={profileSummary}
- isOpen={editors.profile}
+ isOpen={editors.value.profile}
saved={savedSection === "profile"}
onOpen={() => openSection("profile")}
>
@@ -677,7 +672,7 @@ export function BusinessSettingsScreen({
title={t`Customer contact`}
subtitle={t`Public email address and business website.`}
summary={contactSummary}
- isOpen={editors.contact}
+ isOpen={editors.value.contact}
saved={savedSection === "contact"}
onOpen={() => openSection("contact")}
>
@@ -736,7 +731,7 @@ export function BusinessSettingsScreen({
title={t`Business locations`}
subtitle={t`Physical business address and legal jurisdiction.`}
summary={addressSummary}
- isOpen={editors.address}
+ isOpen={editors.value.address}
saved={savedSection === "address"}
onOpen={() => openSection("address")}
>
@@ -806,7 +801,7 @@ export function BusinessSettingsScreen({
? t`Business covers transaction fees`
: t`Transaction fees are added to the customer’s payment`
}
- isOpen={editors.fees}
+ isOpen={editors.value.fees}
saved={savedSection === "fees"}
onOpen={() => openSection("fees")}
>
@@ -854,7 +849,7 @@ export function BusinessSettingsScreen({
title={t`Payment, refund, and payout timing`}
subtitle={t`Default time limits for new orders and payouts.`}
summary={delaySummary}
- isOpen={editors.delays}
+ isOpen={editors.value.delays}
saved={savedSection === "delays"}
onOpen={() => openSection("delays")}
>
@@ -972,7 +967,7 @@ export function BusinessSettingsScreen({
summary={
committed.value.phone_number || t`No verification phone configured`
}
- isOpen={editors.phone}
+ isOpen={editors.value.phone}
saved={savedSection === "phone"}
onOpen={() => openSection("phone")}
>
@@ -1028,7 +1023,7 @@ export function BusinessSettingsScreen({
title={t`Account password`}
subtitle={t`Change the password used to sign into this merchant account.`}
summary={t`Password is hidden`}
- isOpen={editors.security}
+ isOpen={editors.value.security}
saved={savedSection === "security"}
onOpen={() => openSection("security")}
>
@@ -1162,7 +1157,7 @@ export function BusinessSettingsScreen({
onInput={(event) =>
setDeletionAccountId(event.currentTarget.value)
}
- disabled={deletionPending}
+ disabled={deletionPending.value}
autoComplete="off"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
/>
@@ -1175,14 +1170,14 @@ export function BusinessSettingsScreen({
<Button
variant="secondary"
onClick={closeDeletion}
- disabled={deletionPending}
+ disabled={deletionPending.value}
>{t`Cancel`}</Button>
<Button
variant="danger"
onClick={() => void deleteAccount()}
- isLoading={deletionPending}
+ isLoading={deletionPending.value}
disabled={
- deletionPending ||
+ deletionPending.value ||
(deletionMode === "purge" && deletionAccountId !== accountId)
}
>