commit a02c4ac697261be248b5891e3aed7dc171dde7be
parent 8eb0b7febe6eb248941b5d0493516782afbcc421
Author: Florian Dold <dold@taler.net>
Date: Fri, 11 Sep 2026 21:08:39 +0200
merchant web UI: show available template IDs and manual conflicts
Pass automatic and manual ID modes through the creation form and save
the ID returned by the creation operation. Refresh suggestions as names
or existing templates change while preserving IDs entered explicitly.
Reveal and focus the ID field when a manual ID is occupied. Prevent
overlapping submissions while creation and collision retries run.
Issue: https://bugs.taler.net/n/10620
Diffstat:
3 files changed, 286 insertions(+), 31 deletions(-)
diff --git a/packages/taler-merchant-webui/src/routes/CreateTemplateRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateTemplateRoute.tsx
@@ -25,7 +25,7 @@ import {
import { CreateTemplateScreen } from "../screens/CreateTemplateScreen.js";
export function CreateTemplateRoute({ editId }: { editId?: string }): VNode {
- const { createTemplate, updateTemplate } = useTemplates();
+ const { templates, createTemplate, updateTemplate } = useTemplates();
const { template, resource: templateResource } = useTemplateDetails(editId);
const {
primaryCurrency,
@@ -44,14 +44,15 @@ export function CreateTemplateRoute({ editId }: { editId?: string }): VNode {
editId={editId}
template={template}
templateResource={templateResource}
+ templates={templates}
defaultCurrency={primaryCurrency}
configuredCurrencies={Object.keys(currencies ?? {})}
configResource={configResource}
payoutCurrencies={payoutCurrencies}
devices={devices}
devicesResource={devicesResource}
- onCreateTemplate={(id, name, contract, extras) =>
- createTemplate(id, name, contract as never, extras as never)
+ onCreateTemplate={(id, name, contract, extras, options) =>
+ createTemplate(id, name, contract as never, extras as never, options)
}
onUpdateTemplate={(id, name, contract, extras) =>
updateTemplate(id, name, contract as never, extras as never)
diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.test.tsx
@@ -18,6 +18,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import { render } from "preact";
import { act } from "preact/test-utils";
+import { TalerErrorCode } from "@gnu-taler/taler-util";
import {
CreateTemplateScreen,
type CreateTemplateScreenProps,
@@ -80,9 +81,217 @@ async function submit(container: HTMLElement): Promise<void> {
container
.querySelector("form")!
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ await new Promise<void>((resolve) => setImmediate(resolve));
});
}
+function setField(container: HTMLElement, id: string, value: string): void {
+ act(() => {
+ const field = container.querySelector<HTMLInputElement>(`#${id}`)!;
+ assert.ok(field, `input ${id} exists`);
+ field.value = value;
+ field.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+}
+
+function fillNewTemplate(container: HTMLElement): void {
+ setField(container, "tmpl_name_input", "Coffee");
+ setField(container, "tmpl_amount_input", "5");
+}
+
+test("template ID suggestions follow name and list changes without overwriting manual IDs", async () => {
+ await withScreen({}, async (container) => {
+ click(container, "Show advanced options");
+ fillNewTemplate(container);
+ const id = () =>
+ container.querySelector<HTMLInputElement>("#tmpl_id_input")!.value;
+ assert.equal(id(), "tmpl_coffee");
+ const props = {
+ defaultCurrency: "CHF",
+ templates: [
+ { id: "tmpl_coffee", name: "Coffee" },
+ { id: "tmpl_coffee_1", name: "Coffee 1" },
+ { id: "tmpl_coffee_3", name: "Coffee 3" },
+ ],
+ };
+ await act(async () =>
+ render(<CreateTemplateScreen {...props} />, container),
+ );
+ assert.equal(id(), "tmpl_coffee_2");
+ setField(container, "tmpl_name_input", "Coffee!");
+ assert.equal(id(), "tmpl_coffee_2");
+ setField(container, "tmpl_name_input", "Tea");
+ assert.equal(id(), "tmpl_tea");
+ setField(container, "tmpl_id_input", "my-printed-code");
+ setField(container, "tmpl_name_input", "Coffee");
+ await act(async () =>
+ render(<CreateTemplateScreen {...props} templates={[]} />, container),
+ );
+ assert.equal(id(), "my-printed-code");
+ click(container, "Reset to suggested");
+ assert.equal(id(), "tmpl_coffee");
+ await act(async () =>
+ render(<CreateTemplateScreen {...props} />, container),
+ );
+ assert.equal(id(), "tmpl_coffee_2");
+ setField(container, "tmpl_id_input", "another-code");
+ setField(container, "tmpl_id_input", "");
+ assert.equal(id(), "tmpl_coffee_2");
+ });
+});
+
+test("automatic creation passes the base ID and saves the ID returned after allocation", async () => {
+ const calls: unknown[][] = [];
+ const saved: TemplateItem[] = [];
+ await withScreen(
+ {
+ templates: [{ id: "tmpl_coffee", name: "Coffee" }],
+ onCreateTemplate: async (...args) => {
+ calls.push(args);
+ return "tmpl_coffee_2";
+ },
+ onSave: (value) => saved.push(value),
+ },
+ async (container) => {
+ fillNewTemplate(container);
+ await submit(container);
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0]![0], "tmpl_coffee");
+ assert.deepEqual(calls[0]![4], { idMode: "automatic" });
+ assert.equal(saved[0]!.id, "tmpl_coffee_2");
+ assert.equal(saved[0]!.amount, "CHF:5");
+ },
+ );
+});
+
+test("typing the suggested ID explicitly selects manual mode", async () => {
+ const calls: unknown[][] = [];
+ await withScreen(
+ {
+ onCreateTemplate: async (...args) => {
+ calls.push(args);
+ return args[0];
+ },
+ },
+ async (container) => {
+ fillNewTemplate(container);
+ click(container, "Show advanced options");
+ setField(container, "tmpl_id_input", "tmpl_coffee");
+ setField(container, "tmpl_name_input", "Tea");
+ await submit(container);
+ assert.equal(calls[0]![0], "tmpl_coffee");
+ assert.deepEqual(calls[0]![4], { idMode: "manual" });
+ },
+ );
+});
+
+test("a manual collision expands advanced options, focuses the ID, and allows correction", async () => {
+ const ids: string[] = [];
+ const saved: TemplateItem[] = [];
+ await withScreen(
+ {
+ onCreateTemplate: async (id) => {
+ ids.push(id);
+ if (id === "printed-code") {
+ throw normalizeApiFailure({
+ type: "fail",
+ case: 409,
+ detail: {
+ code: TalerErrorCode.MERCHANT_PRIVATE_POST_TEMPLATES_CONFLICT_TEMPLATE_EXISTS,
+ },
+ });
+ }
+ return id;
+ },
+ onSave: (value) => saved.push(value),
+ },
+ async (container) => {
+ fillNewTemplate(container);
+ click(container, "Show advanced options");
+ setField(container, "tmpl_id_input", "printed-code");
+ click(container, "Hide advanced options");
+ await submit(container);
+ const field =
+ container.querySelector<HTMLInputElement>("#tmpl_id_input")!;
+ assert.equal(field.value, "printed-code");
+ assert.equal(document.activeElement, field);
+ assert.equal(field.getAttribute("aria-invalid"), "true");
+ assert.equal(field.getAttribute("aria-describedby"), "tmpl_id_error");
+ assert.equal(
+ container.querySelector("#tmpl_id_error")?.textContent,
+ "This template ID is already in use. Choose another ID.",
+ );
+ assert.deepEqual(ids, ["printed-code"]);
+ assert.equal(saved.length, 0);
+ setField(container, "tmpl_id_input", "unused-code");
+ assert.equal(container.querySelector("#tmpl_id_error"), null);
+ await submit(container);
+ assert.deepEqual(ids, ["printed-code", "unused-code"]);
+ assert.equal(saved[0]!.id, "unused-code");
+ },
+ );
+});
+
+test("an unrelated manual creation error is not presented as an ID collision", async () => {
+ await withScreen(
+ {
+ onCreateTemplate: async () => {
+ throw new TypeError("offline");
+ },
+ },
+ async (container) => {
+ fillNewTemplate(container);
+ click(container, "Show advanced options");
+ setField(container, "tmpl_id_input", "printed-code");
+ click(container, "Hide advanced options");
+ await submit(container);
+ assert.equal(container.querySelector("#tmpl_id_error"), null);
+ assert.equal(container.querySelector("#tmpl_id_input"), null);
+ assert.match(container.textContent!, /offline/);
+ },
+ );
+});
+
+test("overlapping form submissions start only one creation operation", async () => {
+ let complete!: (id: string) => void;
+ const pending = new Promise<string>((resolve) => {
+ complete = resolve;
+ });
+ let creates = 0;
+ const saved: TemplateItem[] = [];
+ await withScreen(
+ {
+ onCreateTemplate: async () => {
+ creates++;
+ return pending;
+ },
+ onSave: (value) => saved.push(value),
+ },
+ async (container) => {
+ fillNewTemplate(container);
+ await act(async () => {
+ const form = container.querySelector("form")!;
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
+ });
+ assert.equal(creates, 1);
+ assert.equal(button(container, "Saving...").disabled, true);
+ await submit(container);
+ assert.equal(creates, 1);
+ await act(async () => {
+ complete("tmpl_coffee_1");
+ await new Promise<void>((resolve) => setImmediate(resolve));
+ });
+ assert.equal(saved.length, 1);
+ assert.equal(saved[0]!.id, "tmpl_coffee_1");
+ },
+ );
+});
+
async function withScreen(
props: CreateTemplateScreenProps,
check: (container: HTMLElement) => Promise<void>,
diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx
@@ -15,7 +15,7 @@
*/
import type { VNode } from "preact";
-import { useState, useEffect } from "preact/hooks";
+import { useState, useEffect, useLayoutEffect, useRef } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import { AmountInput, getOrderedCurrencies } from "../ui/AmountInput.js";
@@ -31,10 +31,16 @@ import { useTranslation } from "../context/translation.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
import { formatErrorMessage } from "../utils/errors.js";
import {
+ availableTemplateId,
buildTemplateContract,
getTemplateSellsOptions,
pruneConflictingDefaults,
+ templateIdFromName,
} from "../utils/templates.js";
+import {
+ isTemplateIdConflict,
+ type TemplateCreationOptions,
+} from "../api/templateCreation.js";
import { DisclosureChevron } from "../ui/DisclosureChevron.js";
import type { RemoteResource } from "../api/contracts.js";
import { ReadErrorBanner } from "../ui/ReadErrorBanner.js";
@@ -52,7 +58,7 @@ export interface CreateTemplateScreenProps {
/** Authoritative template detail supplied directly by callers or a route. */
template?: TemplateItem;
templateResource?: RemoteResource<TemplateItem>;
- /** List data is display-only and is never used to hydrate an editor. */
+ /** Used for ID suggestions, never to hydrate an editor. */
templates?: TemplateItem[];
defaultCurrency?: string;
configuredCurrencies?: string[];
@@ -65,7 +71,8 @@ export interface CreateTemplateScreenProps {
name: string,
contract: unknown,
extras: unknown,
- ) => Promise<unknown>;
+ options: TemplateCreationOptions,
+ ) => Promise<string | void>;
onUpdateTemplate?: (
id: string,
name: string,
@@ -77,6 +84,7 @@ export interface CreateTemplateScreenProps {
export function CreateTemplateScreen({
template: fetchedTemplate,
templateResource,
+ templates = [],
// No hardcoded "EUR": a new template used to be filled in with EUR 5.00 in
// a portal whose configured currency is CHF. The route supplies the
// merchant's own primary currency; failing that, take what the deployment
@@ -160,11 +168,18 @@ export function CreateTemplateScreen({
);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
+ const submitting = useRef(false);
+ const idInput = useRef<HTMLInputElement>(null);
+ const [idConflict, setIdConflict] = useState(false);
const [errorMsg, setErrorMsg] = useState<string>("");
// Kept alongside the message so the banner can offer the underlying
// TalerErrorDetail to copy (messages-and-vocabulary.md 13.5).
const [rawError, setRawError] = useState<unknown>(undefined);
+ useLayoutEffect(() => {
+ if (idConflict && showAdvanced && !isSubmitting) idInput.current?.focus();
+ }, [idConflict, showAdvanced, isSubmitting]);
+
useEffect(() => {
if (existing) {
setSelType(existing.type ?? "fixed");
@@ -205,27 +220,30 @@ export function CreateTemplateScreen({
const amountIsOpen = canChooseWhatItSells && selType === "custom_amount";
const summaryIsOpen = !summary.trim();
- const slugify = (text: string) =>
- text
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, "_")
- .replace(/^_+|_+$/g, "") || "tmpl_new";
+ const baseId = templateIdFromName(name);
+ const suggestedId = availableTemplateId(
+ baseId,
+ new Set(templates.map((t) => t.id)),
+ );
+ const displayedId =
+ isEditing || manualId ? templateId : name ? suggestedId : "";
const handleNameChange = (val: string) => {
setName(val);
- if (!manualId && !isEditing) {
- setTemplateId(`tmpl_${slugify(val)}`);
- }
+ if (!manualId) setIdConflict(false);
};
const handleTemplateIdChange = (val: string) => {
setTemplateId(val);
- const suggested = `tmpl_${slugify(name)}`;
- setManualId(Boolean(val && val !== suggested));
+ setManualId(Boolean(val));
+ setIdConflict(false);
};
const handleSubmit = async (e: Event) => {
e.preventDefault();
+ if (submitting.current) return;
+ setRawError(undefined);
+ setIdConflict(false);
if (!startingCurrency) {
setErrorMsg(t`Currency configuration is unavailable.`);
return;
@@ -315,17 +333,25 @@ export function CreateTemplateScreen({
};
try {
+ submitting.current = true;
setIsSubmitting(true);
+ let finalId = editId || (manualId ? templateId : suggestedId);
if (isEditing && editId) {
await onUpdateTemplate?.(editId, name, contract, extras);
} else {
- const finalId = templateId || `tmpl_${slugify(name)}`;
- await onCreateTemplate?.(finalId, name, contract, extras);
+ const createdId = await onCreateTemplate?.(
+ manualId ? templateId : baseId,
+ name,
+ contract,
+ extras,
+ { idMode: manualId ? "manual" : "automatic" },
+ );
+ finalId = createdId ?? finalId;
}
if (onSave) {
onSave({
- id: editId || templateId || `tmpl_${slugify(name)}`,
+ id: finalId,
name,
sellsText,
type: selType,
@@ -342,6 +368,10 @@ export function CreateTemplateScreen({
setLocation("/templates");
} catch (err: unknown) {
+ if (!isEditing && manualId && isTemplateIdConflict(err)) {
+ setIdConflict(true);
+ setShowAdvanced(true);
+ }
setErrorMsg(
formatErrorMessage(
err,
@@ -351,6 +381,7 @@ export function CreateTemplateScreen({
);
setRawError(err);
} finally {
+ submitting.current = false;
setIsSubmitting(false);
}
};
@@ -555,8 +586,10 @@ export function CreateTemplateScreen({
type="button"
onClick={() => {
setManualId(false);
- setTemplateId(`tmpl_${slugify(name)}`);
+ setTemplateId("");
+ setIdConflict(false);
}}
+ disabled={isSubmitting}
class="text-2xs text-blue-600 hover:underline capitalize font-normal"
>
{t`Reset to suggested`}
@@ -565,14 +598,26 @@ export function CreateTemplateScreen({
</label>
<input
id="tmpl_id_input"
+ ref={idInput}
type="text"
- disabled={isEditing}
- value={templateId}
+ disabled={isEditing || isSubmitting}
+ value={displayedId}
+ aria-invalid={idConflict || undefined}
+ aria-describedby={idConflict ? "tmpl_id_error" : undefined}
onInput={(e) =>
handleTemplateIdChange((e.target as HTMLInputElement).value)
}
class="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg font-mono disabled:bg-gray-100 disabled:text-gray-500"
/>
+ {idConflict && (
+ <p
+ id="tmpl_id_error"
+ role="alert"
+ class="text-xs text-red-600 mt-1"
+ >
+ {t`This template ID is already in use. Choose another ID.`}
+ </p>
+ )}
<p class="text-xs text-gray-500 mt-1">
{t`Appears in web addresses and printed QR codes. Cannot be changed once created.`}
</p>
@@ -600,20 +645,20 @@ export function CreateTemplateScreen({
{ value: "", label: t`No device` as TranslatedString },
...devices.map((device) => ({
value: device.id,
- label: (
- device.name && device.name !== device.id
- ? `${device.name} (${device.id})`
- : device.id
- ) as TranslatedString,
+ label: (device.name && device.name !== device.id
+ ? `${device.name} (${device.id})`
+ : device.id) as TranslatedString,
})),
// Keep the assigned ID visible even if the list fails or
// no longer contains it. Only an explicit choice unlinks it.
...(otpDeviceId &&
!devices.some((device) => device.id === otpDeviceId)
- ? [{
- value: otpDeviceId,
- label: otpDeviceId as TranslatedString,
- }]
+ ? [
+ {
+ value: otpDeviceId,
+ label: otpDeviceId as TranslatedString,
+ },
+ ]
: []),
]}
/>