taler-typescript-core

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

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:
Mpackages/taler-merchant-webui/src/screens/InventoryScreen.state.test.tsx | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/InventoryScreen.tsx | 61+++++++++++++++++++++++++++++++------------------------------
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>