taler-typescript-core

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

commit 7db9786fff1f0981f69869287b1d8ae01f444686
parent 3b11b38eaf6ae1f00a44681fea8739ad0b4f56b2
Author: Florian Dold <dold@taler.net>
Date:   Sat,  5 Sep 2026 17:39:48 +0200

merchant web UI: save product prices in the selected currency

Allow creating and editing products with a price currency different from
the merchant default. Preserve the selected currency when the numeric
price is cleared, including when selecting a currency for an empty price.

Issue: https://bugs.taler.net/n/9857

Diffstat:
Apackages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/CreateProductScreen.tsx | 5+++--
2 files changed, 156 insertions(+), 2 deletions(-)

diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx @@ -0,0 +1,153 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import type { TalerMerchantApi } from "@gnu-taler/taler-util"; +import { CreateProductScreen } from "./CreateProductScreen.js"; + +const product = { + id: "eur-product", + name: "EUR product", + price: "EUR:2.50", + stock: "", + stockTracked: false, + soldCount: undefined, + category: "", + categories: [], + unit: "piece", + totalStock: -1, +}; + +function setInput(input: HTMLInputElement, value: string): void { + act(() => { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function submit(container: HTMLElement): Promise<void> { + await act(async () => { + container + .querySelector("form")! + .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); +} + +for (const scenario of [ + { name: "unchanged price", inputs: [], expected: "EUR:2.50" }, + { name: "updated price", inputs: ["3.75"], expected: "EUR:3.75" }, + { + name: "cleared and retyped price", + inputs: ["", "4.25"], + expected: "EUR:4.25", + }, + { name: "zero price", inputs: ["0"], expected: "EUR:0" }, + { name: "empty price", inputs: [""], expected: undefined }, + { name: "negative price", inputs: ["-1"], expected: undefined }, + { name: "excess precision", inputs: ["1.123456789"], expected: undefined }, +]) { + test(`CreateProductScreen edits a non-default currency: ${scenario.name}`, async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const patches: TalerMerchantApi.ProductPatchDetailRequest[] = []; + try { + await act(async () => { + render( + <CreateProductScreen + editId={product.id} + initialProduct={product} + currency="KUDOS" + categories={[]} + onUpdateProduct={async (id, patch) => { + assert.equal(id, product.id); + patches.push(patch); + }} + />, + container, + ); + }); + const currency = container.querySelector<HTMLSelectElement>( + 'select[aria-label="Currency"]', + )!; + const input = + container.querySelector<HTMLInputElement>("#prod_price_input")!; + assert.equal(currency.value, "EUR"); + for (const value of scenario.inputs) { + setInput(input, value); + assert.equal(currency.value, "EUR"); + } + await submit(container); + if (scenario.expected !== undefined) { + assert.equal(patches.length, 1); + assert.equal(patches[0]!.price, scenario.expected); + } else { + assert.equal(patches.length, 0); + assert.match(container.textContent ?? "", /Enter a valid price\./); + } + } finally { + act(() => render(null, container)); + container.remove(); + } + }); +} + +test("CreateProductScreen creates a product in a selected payout currency", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const products: Omit< + TalerMerchantApi.ProductAddDetailRequest, + "product_id" + >[] = []; + try { + await act(async () => { + render( + <CreateProductScreen + currency="KUDOS" + payoutCurrencies={["EUR"]} + onCreateProduct={async (_id, detail) => { + products.push(detail); + }} + />, + container, + ); + }); + setInput( + container.querySelector<HTMLInputElement>("#prod_name_input")!, + "EUR product", + ); + const currency = container.querySelector<HTMLSelectElement>( + 'select[aria-label="Currency"]', + )!; + act(() => { + currency.value = "EUR"; + currency.dispatchEvent(new Event("change", { bubbles: true })); + }); + const input = + container.querySelector<HTMLInputElement>("#prod_price_input")!; + setInput(input, ""); + assert.equal(currency.value, "EUR"); + setInput(input, "3.75"); + await submit(container); + assert.equal(products.length, 1); + assert.equal(products[0]!.price, "EUR:3.75"); + } finally { + act(() => render(null, container)); + container.remove(); + } +}); diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx @@ -371,8 +371,8 @@ export function CreateProductScreen({ setErrorMsg(t`Remove or replace the product image before saving.`); return; } - if (!Amounts.checkString(price) || Amounts.currencyOf(price) !== currency) { - setErrorMsg(t`Enter a valid price in the merchant currency.`); + if (!Amounts.checkString(price)) { + setErrorMsg(t`Enter a valid price.`); return; } if ( @@ -550,6 +550,7 @@ export function CreateProductScreen({ label={t`Price per unit`} required value={price} + preserveCurrencyWhenEmpty onChange={(val) => setPrice(val)} primaryCurrency={currency} payoutCurrencies={payoutCurrencies}