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