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:
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" },