taler-typescript-core

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

commit 282f611c3feb7eb7b4cde1d865f5c259fceaaf3c
parent aff5e1302efb6d7f74a525901728e9bb95a65f28
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 23:04:44 +0100

merchant web UI: refresh product details after saving

Diffstat:
Apackages/taler-merchant-webui/contrib/qa/product-restock-check.mjs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/package.json | 1+
Mpackages/taler-merchant-webui/src/routes/CreateProductRoute.tsx | 12++++++++++--
Mpackages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx | 50++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 213 insertions(+), 2 deletions(-)

diff --git a/packages/taler-merchant-webui/contrib/qa/product-restock-check.mjs b/packages/taler-merchant-webui/contrib/qa/product-restock-check.mjs @@ -0,0 +1,152 @@ +/* + 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/>. + */ +// Run from the package root. Exercise the real route and SWR cache in a browser. +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { build } from "esbuild"; +import puppeteer from "puppeteer-core"; +import { buildOptions } from "../../esbuild.config.mjs"; +const bundle = await build({ + ...buildOptions({ outdir: "unused", dev: true }), + entryPoints: undefined, + stdin: { + resolveDir: process.cwd(), + loader: "tsx", + contents: ` + import { render } from 'preact'; + import { TalerMerchantInstanceHttpClient } from '@gnu-taler/taler-util'; + import { CreateProductRoute } from './src/routes/CreateProductRoute.tsx'; + import { useHttpLibForTesting } from './src/api/client.ts'; + import { FakeHttpLib, ok, noContent } from './src/testing/fake-http.ts'; + import { signIn } from './src/stores/session.ts'; + window.saved = { + product_name: 'Tea', description: 'Tea', description_i18n: {}, + unit: 'Piece', price: 'EUR:2.5', image: '', categories: [], + total_stock: 10, total_sold: 0, total_lost: 0, + next_restock: { t_s: Date.parse('2026-11-10T00:00:00Z') / 1000 }, + }; + window.writes = []; + const http = new FakeHttpLib().handle((request) => { + const path = new URL(request.url).pathname; + if (path.endsWith('/private/products/tea')) { + if (request.method === 'PATCH') { + window.writes.push(request.body); + window.saved = { ...window.saved, ...request.body }; + return noContent(); + } + return ok(window.saved); + } + if (path.endsWith('/config')) return ok({ + version: TalerMerchantInstanceHttpClient.PROTOCOL_VERSION, name: 'taler-merchant', currency: 'EUR', currencies: {}, + exchanges: [], default_persona: 'expert', report_generators: [], + have_donau: false, mandatory_tan_channels: [], payment_target_types: '*', + }); + if (path.endsWith('/private/products')) return ok({ products: [{ product_id: 'tea' }] }); + if (path.endsWith('/private/categories')) return ok({ categories: [] }); + if (path.endsWith('/private/accounts')) return ok({ accounts: [] }); + return ok({}); + }); + useHttpLibForTesting(http); + signIn('shop', 'secret-token:test', 'https://merchant.example.test/'); + window.mount = () => { + render(null, document.getElementById('app')); + render(<CreateProductRoute editId="tea" />, document.getElementById('app')); + }; + window.mount(); + `, + }, + splitting: false, + sourcemap: false, + write: false, +}); +const script = bundle.outputFiles.find((file) => + file.path.endsWith(".js"), +).text; +const server = createServer((request, response) => { + response.setHeader( + "Content-Type", + request.url === "/app.js" ? "text/javascript" : "text/html", + ); + response.end( + request.url === "/app.js" + ? script + : '<div id="app"></div><script type="module" src="/app.js"></script>', + ); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +let browser; +try { + browser = await puppeteer.launch({ + executablePath: process.env.CHROME_BIN || "/usr/bin/google-chrome", + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const page = await browser.newPage(); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.waitForSelector("#prod_restock_date_input"); + assert.equal( + await page.$eval("#prod_restock_date_input", (input) => input.value), + "2026-11-10", + ); + for (const date of ["2026-12-15", ""]) { + await page.$eval( + "#prod_restock_date_input", + (input, value) => { + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + }, + date, + ); + await page.waitForFunction( + (value) => + document.querySelector("#prod_restock_date_input").value === value, + {}, + date, + ); + await page.click('button[type="submit"]'); + await page.waitForFunction(() => window.location.pathname === "/inventory"); + const expected = { + t_s: date ? Date.parse(date + "T00:00:00Z") / 1000 : "never", + }; + assert.deepEqual( + await page.evaluate(() => window.writes.at(-1).next_restock), + expected, + ); + assert.deepEqual( + await page.evaluate(() => window.saved.next_restock), + expected, + ); + await page.evaluate(() => { + history.replaceState(null, "", "/inventory/tea/edit"); + window.mount(); + }); + await page.waitForSelector("#prod_restock_date_input"); + assert.equal( + await page.$eval("#prod_restock_date_input", (input) => input.value), + date, + "Reopening must show the saved delivery date", + ); + } + assert.deepEqual(errors, []); + console.log("Product delivery date: saving, clearing, and reopening passed."); +} finally { + await browser?.close(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/packages/taler-merchant-webui/package.json b/packages/taler-merchant-webui/package.json @@ -14,6 +14,7 @@ "test": "./test.mjs", "test:save-feedback-browser": "node contrib/qa/save-feedback-check.mjs", "test:inventory-template-browser": "node contrib/qa/inventory-template-check.mjs", + "test:product-restock-browser": "node contrib/qa/product-restock-check.mjs", "test:exchange-status-browser": "node contrib/qa/exchange-status-check.mjs", "test:stagefright": "node ../taler-harness/bin/taler-harness.mjs stagefright merchant", "lint": "../qa-tooling/bin/eslint.mjs .", diff --git a/packages/taler-merchant-webui/src/routes/CreateProductRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateProductRoute.tsx @@ -26,7 +26,11 @@ import { CreateProductScreen } from "../screens/CreateProductScreen.js"; export function CreateProductRoute({ editId }: { editId?: string }): VNode { const { createProduct, updateProduct } = useInventory(); - const { product, resource: productResource } = useProductDetails(editId); + const { + product, + resource: productResource, + mutate: refreshProduct, + } = useProductDetails(editId); const { categories, resource: categoriesResource, @@ -55,7 +59,11 @@ export function CreateProductRoute({ editId }: { editId?: string }): VNode { configResource={configResource} payoutCurrencies={payoutCurrencies} onCreateProduct={createProduct} - onUpdateProduct={updateProduct} + onUpdateProduct={async (id, detail) => { + await updateProduct(id, detail); + // The next editor draft must start from the saved product details. + await refreshProduct(); + }} onCreateCategory={createCategory} /> ); diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.test.tsx @@ -125,6 +125,56 @@ for (const mode of ["untracked", "tracked", "create"] as const) { }); } +for (const editing of [false, true]) { + test(`advanced options expand with stock tracking enabled: ${editing ? "edit" : "create"}`, async () => { + const container = document.body.appendChild(document.createElement("div")); + try { + await act(async () => { + render( + <CreateProductScreen + editId={editing ? product.id : undefined} + initialProduct={ + editing + ? { ...product, stockTracked: true, totalStock: 10 } + : undefined + } + currency="EUR" + categories={[]} + />, + container, + ); + }); + if (!editing) { + const checkbox = Array.from( + container.querySelectorAll<HTMLInputElement>( + 'input[type="checkbox"]', + ), + ).find((input) => + input + .closest("label") + ?.textContent?.includes("Count inventory stock"), + )!; + act(() => checkbox.click()); + } + assert.ok(container.querySelector("#prod_stock_count_input")); + const toggle = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Show advanced options"), + )!; + assert.equal(container.querySelector("#prod_age_input"), null); + act(() => toggle.click()); + assert.ok(container.querySelector("#prod_age_input")); + assert.ok(container.querySelector("#prod_id_override_input")); + act(() => toggle.click()); + assert.equal(container.querySelector("#prod_age_input"), null); + act(() => toggle.click()); + assert.ok(container.querySelector("#prod_age_input")); + } finally { + act(() => render(null, container)); + container.remove(); + } + }); +} + for (const scenario of [ { name: "unchanged price", inputs: [], expected: "EUR:2.50" }, { name: "updated price", inputs: ["3.75"], expected: "EUR:3.75" },