taler-typescript-core

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

commit 20a9728339141d4eb1daf23e3c7dde3c0c60cbe0
parent a4b084464d5895d6694d56f4fd0461e2c23e3039
Author: Florian Dold <dold@taler.net>
Date:   Wed,  4 Nov 2026 23:52:00 +0100

merchant web UI: support inline category creation

Resolve new category IDs from the refreshed catalogue. Reuse matching
names on retries so a failed refresh does not create duplicate
categories.

Diffstat:
Mpackages/taler-merchant-webui/src/api/cache.test.tsx | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/api/hooks/useInventory.ts | 52++++++++++++++++++++++++++++++++++++++--------------
2 files changed, 94 insertions(+), 14 deletions(-)

diff --git a/packages/taler-merchant-webui/src/api/cache.test.tsx b/packages/taler-merchant-webui/src/api/cache.test.tsx @@ -23,6 +23,62 @@ import { useHttpLibForTesting } from "./client.js"; import { FakeHttpLib, noContent, ok } from "../testing/fake-http.js"; import { signIn, signOut } from "../stores/session.js"; +test("inline category creation resolves its ID and retries a failed refresh without creating twice", async () => { + let created = false; + let failRefresh = true; + const http = new FakeHttpLib().handle((request) => { + if (request.method === "POST") { + assert.deepEqual(request.body, { name: "Desserts" }); + created = true; + return ok({}); + } + if (created && failRefresh) { + failRefresh = false; + throw new Error("Refresh failed"); + } + return ok({ + categories: created + ? [{ category_id: 9, name: "Desserts", product_count: 0 }] + : [], + }); + }); + const restoreHttp = useHttpLibForTesting(http); + signIn("shop", "secret-token:test", "https://inline-category.example.test/"); + const container = document.body.appendChild(document.createElement("div")); + let create!: ReturnType<typeof useCategories>["createOrGetCategory"]; + function Probe() { + create = useCategories({ enabled: false }).createOrGetCategory; + return null; + } + try { + await act(async () => render(<Probe />, container)); + await assert.rejects(create(" Desserts "), /Refresh failed/); + assert.deepEqual(await create("Desserts"), { + id: 9, + name: "Desserts", + productCount: 0, + }); + assert.equal( + http.requests.filter((request) => request.method === "POST").length, + 1, + ); + assert.deepEqual(await create("desserts"), { + id: 9, + name: "Desserts", + productCount: 0, + }); + assert.equal( + http.requests.filter((request) => request.method === "POST").length, + 1, + ); + } finally { + act(() => render(null, container)); + container.remove(); + signOut(); + restoreHttp(); + } +}); + for (const entity of [ "product", "webhook", diff --git a/packages/taler-merchant-webui/src/api/hooks/useInventory.ts b/packages/taler-merchant-webui/src/api/hooks/useInventory.ts @@ -291,24 +291,20 @@ export function useCategories({ enabled = true }: { enabled?: boolean } = {}) { const token = session.value.token; const config = getClientConfig(); + const loadCategories = async (): Promise<CategoryItem[]> => { + const client = merchantClient(config); + const body = unwrap(await client.listCategories(token as AccessToken)); + return (body.categories ?? []).map((c) => ({ + id: c.category_id, + name: c.category_id === 0 || c.name === "0" ? t`no category` : c.name, + productCount: c.product_count || 0, + })); + }; const swr = useSWR( token && enabled ? ["listCategories", config.rootUrl.href, config.account, token] : null, - async () => { - const client = merchantClient(config); - const res = await client.listCategories(token as AccessToken); - const body = unwrap(res); - if (!body || !body.categories) return []; - - return body.categories.map( - (c): CategoryItem => ({ - id: c.category_id, - name: c.category_id === 0 || c.name === "0" ? t`no category` : c.name, - productCount: c.product_count || 0, - }), - ); - }, + loadCategories, ); const { data, error, isLoading, mutate } = swr; @@ -320,6 +316,33 @@ export function useCategories({ enabled = true }: { enabled?: boolean } = {}) { await mutate(); }; + // Creation does not return an ID. Resolve it from the refreshed catalogue; + // checking first also makes retrying after a failed refresh safe. + const createOrGetCategory = async (name: string): Promise<CategoryItem> => { + if (!token) throw new Error(t`Not signed in, so there is no token.`); + const trimmed = name.trim(); + const find = (items: CategoryItem[]) => + items.find( + (item) => + item.name.trim().toLocaleLowerCase() === trimmed.toLocaleLowerCase(), + ); + let items = await loadCategories(); + if (!find(items)) { + const client = merchantClient(config); + unwrapEmpty( + await client.addCategory(token as AccessToken, { name: trimmed }), + ); + items = await loadCategories(); + } + await mutate(items, { revalidate: false }); + const category = find(items); + if (!category) + throw new Error( + t`The category could not be found after creation. Try searching again.`, + ); + return category; + }; + const updateCategory = async (categoryId: number, name: string) => { if (!token) return; const client = merchantClient(config); @@ -353,6 +376,7 @@ export function useCategories({ enabled = true }: { enabled?: boolean } = {}) { mutate, resource: remoteResource(swr), createCategory, + createOrGetCategory, updateCategory, deleteCategory, };