commit 4239599a7dc4984b9c352f82255f0e891c9ec0cf
parent 8e4bb79c96f4077b60dadd7f5dcb92b48f57d4a9
Author: Florian Dold <dold@taler.net>
Date: Tue, 3 Nov 2026 23:18:06 +0100
merchant web UI: guard inventory mutations with signals
Diffstat:
2 files changed, 133 insertions(+), 30 deletions(-)
diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.state.test.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.state.test.tsx
@@ -115,3 +115,105 @@ for (const layout of ["mobile", "desktop"]) {
}
});
}
+
+for (const operation of [
+ "category-save",
+ "category-delete",
+ "price",
+ "product-delete",
+] as const) {
+ test(`${operation} blocks same-tick duplicates and allows retry after failure`, async () => {
+ const root = mount();
+ let calls = 0;
+ let reject!: (error: Error) => void;
+ const first = new Promise<void>((_resolve, fail) => {
+ reject = fail;
+ });
+ const save = () => (++calls === 1 ? first : Promise.resolve());
+ try {
+ await act(() =>
+ render(
+ <InventoryScreen
+ initialTab={
+ operation.startsWith("category") ? "categories" : "products"
+ }
+ categories={[{ id: 1, name: "Coffee", productCount: 0 }]}
+ products={[
+ {
+ id: "coffee",
+ name: "Coffee",
+ price: "EUR:2",
+ stock: "",
+ stockTracked: false,
+ category: "",
+ },
+ ]}
+ onAddCategory={save}
+ onDeleteCategory={save}
+ onUpdateProduct={save}
+ onDeleteProduct={save}
+ />,
+ root,
+ ),
+ );
+ if (operation === "category-save") {
+ await click(button("Add a category"));
+ input(field("cat_name_input"), "New category");
+ } else {
+ await click(
+ root.querySelector<HTMLButtonElement>(
+ 'button[aria-label="Actions for Coffee"]',
+ )!,
+ );
+ await click(
+ button(
+ operation === "price"
+ ? "Edit price"
+ : operation === "category-delete"
+ ? "Delete category"
+ : "Delete product",
+ document.querySelector('[role="menu"]')!,
+ ),
+ );
+ }
+ const dialog = document.querySelector('[role="dialog"]')!;
+ const trigger = () => {
+ if (operation === "category-save") submit(field("cat_name_input"));
+ else if (operation === "price") submit(field("quick_price_input"));
+ else
+ button(
+ operation === "category-delete"
+ ? "Delete Category"
+ : "Delete Product",
+ dialog,
+ ).click();
+ };
+ await act(() => {
+ trigger();
+ trigger();
+ });
+ assert.equal(calls, 1);
+ assert.equal(button("Cancel", dialog).disabled, true);
+ await click(button("Cancel", dialog));
+ assert.ok(dialog.isConnected);
+ await act(async () => {
+ reject(new Error("Save rejected"));
+ await settle();
+ });
+ assert.match(dialog.textContent!, /Save rejected/);
+ assert.equal(button("Cancel", dialog).disabled, false);
+ if (operation === "category-save")
+ assert.equal(field("cat_name_input").value, "New category");
+ if (operation === "price")
+ assert.equal(field("quick_price_input").value, "2");
+ await act(async () => {
+ trigger();
+ await settle();
+ });
+ assert.equal(calls, 2);
+ assert.equal(document.querySelector('[role="dialog"]'), null);
+ } finally {
+ await cleanup(root);
+ }
+ });
+}
diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx
@@ -16,6 +16,7 @@
import { FormActionFooter, useFormFeedback } from "../ui/FormActionFooter.js";
import type { VNode } from "preact";
+import { useSignal } from "@preact/signals";
import { useState } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
@@ -100,7 +101,7 @@ export function InventoryScreen({
);
const [forceDeleteProduct, setForceDeleteProduct] = useState<boolean>(false);
const [deleteProductError, setDeleteProductError] = useState<string>("");
- const [isDeletingProduct, setIsDeletingProduct] = useState<boolean>(false);
+ const isDeletingProduct = useSignal(false);
// Category Modal State
const [isCategoryModalOpen, setIsCategoryModalOpen] =
@@ -112,8 +113,8 @@ export function InventoryScreen({
const [deletingCategory, setDeletingCategory] = useState<CategoryItem | null>(
null,
);
- const [isSavingCategory, setIsSavingCategory] = useState<boolean>(false);
- const [isDeletingCategory, setIsDeletingCategory] = useState<boolean>(false);
+ const isSavingCategory = useSignal(false);
+ const isDeletingCategory = useSignal(false);
const {
rawError: categoryMutationError,
setRawError: setCategoryMutationError,
@@ -124,7 +125,7 @@ export function InventoryScreen({
const [priceEditCandidate, setPriceEditCandidate] =
useState<ProductItem | null>(null);
const [quickPrice, setQuickPrice] = useState<string>("");
- const [isSavingPrice, setIsSavingPrice] = useState<boolean>(false);
+ const isSavingPrice = useSignal(false);
const {
rawError: quickPriceError,
setRawError: setQuickPriceError,
@@ -173,8 +174,8 @@ export function InventoryScreen({
const handleSaveCategory = async (e: Event) => {
e.preventDefault();
- if (!categoryName.trim() || isSavingCategory) return;
- setIsSavingCategory(true);
+ if (!categoryName.trim() || isSavingCategory.value) return;
+ isSavingCategory.value = true;
setCategoryMutationError(undefined);
try {
if (editingCategory) {
@@ -204,13 +205,13 @@ export function InventoryScreen({
} catch (cause) {
setCategoryMutationError(cause);
} finally {
- setIsSavingCategory(false);
+ isSavingCategory.value = false;
}
};
const handleDeleteCategoryConfirm = async () => {
- if (!deletingCategory || isDeletingCategory) return;
- setIsDeletingCategory(true);
+ if (!deletingCategory || isDeletingCategory.value) return;
+ isDeletingCategory.value = true;
setCategoryMutationError(undefined);
try {
if (propDeleteCategory) {
@@ -224,14 +225,14 @@ export function InventoryScreen({
} catch (cause) {
setCategoryMutationError(cause);
} finally {
- setIsDeletingCategory(false);
+ isDeletingCategory.value = false;
}
};
const handleDeleteProductConfirm = async () => {
- if (!deleteCandidate || isDeletingProduct) return;
+ if (!deleteCandidate || isDeletingProduct.value) return;
setDeleteProductError("");
- setIsDeletingProduct(true);
+ isDeletingProduct.value = true;
try {
if (onDeleteProduct) {
await onDeleteProduct(deleteCandidate.id, forceDeleteProduct);
@@ -243,7 +244,7 @@ export function InventoryScreen({
formatErrorMessage(err, t`Failed to delete product.`, t),
);
} finally {
- setIsDeletingProduct(false);
+ isDeletingProduct.value = false;
}
};
@@ -727,7 +728,7 @@ export function InventoryScreen({
<Modal
isOpen={isCategoryModalOpen}
onClose={() => {
- if (!isSavingCategory) setIsCategoryModalOpen(false);
+ if (!isSavingCategory.value) setIsCategoryModalOpen(false);
}}
title={editingCategory ? t`Rename Category` : t`Add a Category`}
>
@@ -755,20 +756,20 @@ export function InventoryScreen({
<FormActionFooter
error={categoryMutationError}
feedbackId={categoryMutationErrorId}
- pending={isSavingCategory}
+ pending={isSavingCategory.value}
title={t`The category could not be saved`}
>
<Button
variant="secondary"
onClick={() => setIsCategoryModalOpen(false)}
- disabled={isSavingCategory}
+ disabled={isSavingCategory.value}
>
{t`Cancel`}
</Button>
<Button
type="submit"
variant="primary"
- isLoading={isSavingCategory}
+ isLoading={isSavingCategory.value}
>
{editingCategory ? t`Save Name` : t`Create Category`}
</Button>
@@ -780,7 +781,7 @@ export function InventoryScreen({
<Modal
isOpen={Boolean(deletingCategory)}
onClose={() => {
- if (!isDeletingCategory) setDeletingCategory(null);
+ if (!isDeletingCategory.value) setDeletingCategory(null);
}}
title={t`Delete Category?`}
>
@@ -795,14 +796,14 @@ export function InventoryScreen({
<Button
variant="secondary"
onClick={() => setDeletingCategory(null)}
- disabled={isDeletingCategory}
+ disabled={isDeletingCategory.value}
>
{t`Cancel`}
</Button>
<Button
variant="danger"
onClick={() => void handleDeleteCategoryConfirm()}
- isLoading={isDeletingCategory}
+ isLoading={isDeletingCategory.value}
>
{t`Delete Category`}
</Button>
@@ -813,21 +814,21 @@ export function InventoryScreen({
<Modal
isOpen={Boolean(priceEditCandidate)}
onClose={() => {
- if (!isSavingPrice) setPriceEditCandidate(null);
+ if (!isSavingPrice.value) setPriceEditCandidate(null);
}}
title={t`Quick Edit Price`}
>
<form
onSubmit={async (e) => {
e.preventDefault();
- if (!priceEditCandidate || !onUpdateProduct || isSavingPrice)
+ if (!priceEditCandidate || !onUpdateProduct || isSavingPrice.value)
return;
setQuickPriceError(undefined);
try {
const parsed = Amounts.parseOrThrow(quickPrice);
if (!Amounts.isNonZero(parsed))
throw new Error(t`Enter a price greater than zero.`);
- setIsSavingPrice(true);
+ isSavingPrice.value = true;
await onUpdateProduct(priceEditCandidate.id, {
price: Amounts.stringify(parsed),
});
@@ -835,7 +836,7 @@ export function InventoryScreen({
} catch (cause) {
setQuickPriceError(cause);
} finally {
- setIsSavingPrice(false);
+ isSavingPrice.value = false;
}
}}
class="space-y-4"
@@ -858,21 +859,21 @@ export function InventoryScreen({
<FormActionFooter
error={quickPriceError}
- pending={isSavingPrice}
+ pending={isSavingPrice.value}
feedbackId={quickPriceErrorId}
title={t`The price could not be updated`}
>
<Button
variant="secondary"
onClick={() => setPriceEditCandidate(null)}
- disabled={isSavingPrice}
+ disabled={isSavingPrice.value}
>
{t`Cancel`}
</Button>
<Button
type="submit"
variant="primary"
- isLoading={isSavingPrice}
+ isLoading={isSavingPrice.value}
disabled={!onUpdateProduct}
>
{t`Save Price`}
@@ -885,7 +886,7 @@ export function InventoryScreen({
<Modal
isOpen={Boolean(deleteCandidate)}
onClose={() => {
- if (isDeletingProduct) return;
+ if (isDeletingProduct.value) return;
setDeleteCandidate(null);
setForceDeleteProduct(false);
setDeleteProductError("");
@@ -935,7 +936,7 @@ export function InventoryScreen({
setForceDeleteProduct(false);
setDeleteProductError("");
}}
- disabled={isDeletingProduct}
+ disabled={isDeletingProduct.value}
>
{t`Cancel`}
</Button>
@@ -943,7 +944,7 @@ export function InventoryScreen({
id="pos_confirm_delete_btn"
variant="danger"
onClick={() => void handleDeleteProductConfirm()}
- isLoading={isDeletingProduct}
+ isLoading={isDeletingProduct.value}
>
{t`Delete Product`}
</Button>