commit e55e4f53dd6365f05078500991a529866f0ece4e
parent da4989f6aa179322fa0111146ab18acbc38e667a
Author: Florian Dold <dold@taler.net>
Date: Mon, 2 Nov 2026 20:45:36 +0100
merchant web UI: initialize edit drafts when forms mount
Key forms by backend, account and entity so refreshes preserve drafts
while navigation to another entity or create mode starts fresh.
Diffstat:
7 files changed, 687 insertions(+), 427 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/merchant-webui-transactional-flows.ts b/packages/taler-harness/src/integrationtests/merchant-webui-transactional-flows.ts
@@ -479,8 +479,14 @@ export async function testWebhooksCrud(
"https://api.example.com/webhooks/taler-paid",
);
await editUrlInput.fill("https://example.com/webhook-updated");
- await submitMutation(page, "PATCH", () =>
- page.getByRole("button", { name: /Save Webhook Changes/i }).click(),
+ // Pin the body: a revalidation that lands between filling the form and
+ // saving it used to reset the fields, so the PATCH silently carried the old
+ // URL and only the assertion after the reload noticed.
+ await submitMutation(
+ page,
+ "PATCH",
+ () => page.getByRole("button", { name: /Save Webhook Changes/i }).click(),
+ { url: "https://example.com/webhook-updated" },
);
await page.reload();
await editUrlInput.waitFor({ state: "visible", timeout: 15_000 });
@@ -573,8 +579,11 @@ export async function testAuthenticators(
await menuItem(page, "Edit").click();
const editName = page.getByRole("textbox", { name: "Name" }).first();
await editName.fill("Offline Till Alpha Updated");
- await submitMutation(page, "PATCH", () =>
- page.getByRole("button", { name: "Save Changes" }).click(),
+ await submitMutation(
+ page,
+ "PATCH",
+ () => page.getByRole("button", { name: "Save Changes" }).click(),
+ { otp_device_description: "Offline Till Alpha Updated" },
);
await requireVisibleAfterReload(page, "Offline Till Alpha Updated");
diff --git a/packages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx
@@ -15,7 +15,8 @@
*/
import type { VNode } from "preact";
-import { useEffect, useRef, useState } from "preact/hooks";
+import { session } from "../stores/session.js";
+import { useState } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
@@ -86,7 +87,71 @@ function methodOptions(
];
}
-export function CreateDeviceScreen({
+/** Mount the draft only after authoritative detail is available. */
+export function CreateDeviceScreen(props: CreateDeviceScreenProps): VNode {
+ const { t } = useTranslation();
+ const [, setLocation] = useLocation();
+ const { editId, authenticators = [], devices = [], devicesResource } = props;
+ const isEdit = !!editId;
+ const existingDevice = editId
+ ? authenticators.find((d) => d.id === editId) ||
+ devices.find((d) => d.id === editId)
+ : undefined;
+ if (isEdit && !existingDevice) {
+ return (
+ <div class="space-y-6 max-w-3xl">
+ <Header
+ onBack={() => setLocation("/authenticators")}
+ backLabel={t`Offline payment devices`}
+ title={t`Edit offline payment device`}
+ />
+ {devicesResource?.error ? (
+ <ReadErrorBanner
+ resource={devicesResource}
+ title={t`Offline payment device details could not be loaded`}
+ />
+ ) : devicesResource?.isLoading ? (
+ <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
+ ) : (
+ <ApiErrorBanner
+ error={t`Offline payment device details could not be loaded`}
+ />
+ )}
+ </div>
+ );
+ }
+ if (
+ isEdit &&
+ existingDevice &&
+ "detailError" in existingDevice &&
+ existingDevice.detailError
+ ) {
+ return (
+ <div class="space-y-6 max-w-3xl">
+ <Header
+ onBack={() => setLocation("/authenticators")}
+ backLabel={t`Offline payment devices`}
+ title={t`Edit offline payment device`}
+ />
+ <ApiErrorBanner
+ error={existingDevice.detailError}
+ title={t`Offline payment device details could not be loaded`}
+ />
+ </div>
+ );
+ }
+
+ // A different account, entity, or create/edit mode starts a fresh draft.
+ const formKey = JSON.stringify([
+ session.value.backendBaseUrl,
+ session.value.account,
+ editId ?? null,
+ existingDevice?.id ?? null,
+ ]);
+ return <CreateDeviceForm key={formKey} {...props} />;
+}
+
+function CreateDeviceForm({
editId,
authenticators = [],
onSave,
@@ -122,24 +187,8 @@ export function CreateDeviceScreen({
const [secretKey, setSecretKey] = useState<string>(() =>
existingDevice ? "" : randomRfc3548Base32Key(),
);
- const hydratedEditingId = useRef<string | undefined>(undefined);
const { copied: keyCopied, error: copyError, copy } = useClipboard();
- useEffect(() => {
- if (
- !editingId ||
- !existingDevice ||
- hydratedEditingId.current === editingId
- )
- return;
- setName(existingDevice.name || "");
- setDeviceId(existingDevice.id || editingId);
- setManualId(true);
- setMethod(existingDevice.algorithm ?? 1);
- setSecretKey("");
- hydratedEditingId.current = editingId;
- }, [editingId, existingDevice]);
-
const handleNameChange = (val: string) => {
setName(val);
if (!manualId && !editingId) {
@@ -208,50 +257,6 @@ export function CreateDeviceScreen({
}
};
- if (isEdit && !existingDevice) {
- return (
- <div class="space-y-6 max-w-3xl">
- <Header
- onBack={() => setLocation("/authenticators")}
- backLabel={t`Offline payment devices`}
- title={t`Edit offline payment device`}
- />
- {devicesResource?.error ? (
- <ReadErrorBanner
- resource={devicesResource}
- title={t`Offline payment device details could not be loaded`}
- />
- ) : devicesResource?.isLoading ? (
- <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
- ) : (
- <ApiErrorBanner
- error={t`Offline payment device details could not be loaded`}
- />
- )}
- </div>
- );
- }
- if (
- isEdit &&
- existingDevice &&
- "detailError" in existingDevice &&
- existingDevice.detailError
- ) {
- return (
- <div class="space-y-6 max-w-3xl">
- <Header
- onBack={() => setLocation("/authenticators")}
- backLabel={t`Offline payment devices`}
- title={t`Edit offline payment device`}
- />
- <ApiErrorBanner
- error={existingDevice.detailError}
- title={t`Offline payment device details could not be loaded`}
- />
- </div>
- );
- }
-
return (
<div class="space-y-6 max-w-3xl">
<Header
diff --git a/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateProductScreen.tsx
@@ -15,8 +15,9 @@
*/
import type { VNode } from "preact";
+import { session } from "../stores/session.js";
import { Amounts } from "@gnu-taler/taler-util";
-import { useState, useEffect, useRef } from "preact/hooks";
+import { useEffect, useState } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import { ApiErrorBanner } from "../ui/ApiErrorBanner.js";
@@ -185,7 +186,49 @@ function dateInputTimestamp(value: string): TalerProtocolTimestamp {
return { t_s: Date.parse(`${value}T00:00:00.000Z`) / 1000 };
}
-export function CreateProductScreen({
+/** Mount the draft only after authoritative detail is available. */
+export function CreateProductScreen(props: CreateProductScreenProps): VNode {
+ const { t } = useTranslation();
+ const [, setLocation] = useLocation();
+ const { editId, initialProduct, productResource } = props;
+ const isEditing = !!editId;
+ const existing = initialProduct || productResource?.data;
+ if (isEditing && !existing) {
+ return (
+ <div class="max-w-4xl space-y-6">
+ <Header
+ onBack={() => setLocation("/inventory")}
+ backLabel={t`Inventory`}
+ title={t`Edit Product: ${editId}`}
+ subtitle={t`Manage product definitions, prices, units, and inventory categories.`}
+ />
+ {productResource?.error ? (
+ <ReadErrorBanner
+ resource={productResource}
+ title={t`Product details could not be loaded`}
+ />
+ ) : productResource?.isLoading ? (
+ <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
+ ) : (
+ <ApiErrorBanner error={t`Product details could not be loaded`} />
+ )}
+ </div>
+ );
+ }
+
+ // A different account, entity, or create/edit mode starts a fresh draft.
+ const formKey = JSON.stringify([
+ session.value.backendBaseUrl,
+ session.value.account,
+ editId ?? null,
+ existing?.id ?? null,
+ ]);
+ return (
+ <CreateProductForm key={formKey} {...props} initialProduct={existing} />
+ );
+}
+
+function CreateProductForm({
categories = [],
categoriesResource,
productResource,
@@ -224,7 +267,8 @@ export function CreateProductScreen({
const [image, setImage] = useState<string>(existing?.image || "");
const imageStatus = useImageDataUrlStatus(image);
const [price, setPrice] = useState<string>(
- existing?.price || (currency ? `${currency}:` : ""),
+ existing?.price ||
+ (currency ? `${currency}:${existing ? "2.50" : ""}` : ""),
);
const [unit, setUnit] = useState<string>(
PREDEFINED_UNIT_SLUGS.has(existingUnit) ? existingUnit : "other",
@@ -242,16 +286,28 @@ export function CreateProductScreen({
.map((c: any) => Number(c))
.filter((n: number) => !isNaN(n) && n !== 0);
}
- return [];
+ const category = existing?.category;
+ const match =
+ category &&
+ displayCategories.find(
+ (c) =>
+ c.name.toLowerCase() === category.toLowerCase() ||
+ String(c.id) === String(category),
+ );
+ return match && Number(match.id) !== 0 ? [Number(match.id)] : [];
},
);
// Stock Control (Spec 07 § 7.2)
const [countStock, setCountStock] = useState<boolean>(
- existing?.stockTracked ?? false,
+ Boolean(
+ existing?.stockTracked &&
+ existing.totalStock !== undefined &&
+ existing.totalStock >= 0,
+ ),
);
const [totalStock, setTotalStock] = useState<string>(
- existing?.totalStock === undefined ? "" : String(existing.totalStock),
+ countStock ? String(existing!.totalStock) : "",
);
const [nextRestock, setNextRestock] = useState<string>(
dateInputValue(existing?.nextRestock),
@@ -268,50 +324,6 @@ export function CreateProductScreen({
const [newCategoryName, setNewCategoryName] = useState<string>("");
const [isCreatingCategory, setIsCreatingCategory] = useState<boolean>(false);
const [categoryError, setCategoryError] = useState<string>("");
- const initializedIdRef = useRef<string | null>(null);
-
- useEffect(() => {
- if (existing && initializedIdRef.current !== existing.id) {
- initializedIdRef.current = existing.id;
- setName(existing.name);
- setProductId(existing.id);
- setManualId(true);
- setDescription(existing.description || "");
- setImage(existing.image || "");
- setPrice(existing.price || (currency ? `${currency}:2.50` : ""));
- const loadedUnit = existing.unit || "Piece";
- setUnit(PREDEFINED_UNIT_SLUGS.has(loadedUnit) ? loadedUnit : "other");
- setCustomUnit(PREDEFINED_UNIT_SLUGS.has(loadedUnit) ? "" : loadedUnit);
-
- const parsedStockNum = existing.totalStock;
- const isCounted =
- existing.stockTracked &&
- parsedStockNum !== undefined &&
- parsedStockNum >= 0;
-
- setCountStock(isCounted);
- setTotalStock(isCounted ? String(parsedStockNum) : "");
- setMinimumAge(existing.minimumAge ?? 0);
- setNextRestock(dateInputValue(existing.nextRestock));
-
- if (existing.categories && Array.isArray(existing.categories)) {
- setSelectedCategoryIds(
- existing.categories
- .map((c: unknown) => Number(c))
- .filter((n: number) => !isNaN(n) && n !== 0),
- );
- } else if (existing.category && displayCategories.length > 0) {
- const match = displayCategories.find(
- (c: CategoryItem) =>
- c.name.toLowerCase() === existing.category.toLowerCase() ||
- String(c.id) === String(existing.category),
- );
- if (match && Number(match.id) !== 0) {
- setSelectedCategoryIds([Number(match.id)]);
- }
- }
- }
- }, [existing, currency, displayCategories]);
useEffect(() => {
if (!existing && !price && currency) setPrice(`${currency}:2.50`);
@@ -339,29 +351,6 @@ export function CreateProductScreen({
const units = predefinedUnits(t);
const selectedUnitMeta = units.find((u) => u.slug === unit);
- if (isEditing && !existing) {
- return (
- <div class="max-w-4xl space-y-6">
- <Header
- onBack={() => setLocation("/inventory")}
- backLabel={t`Inventory`}
- title={t`Edit Product: ${editId}`}
- subtitle={t`Manage product definitions, prices, units, and inventory categories.`}
- />
- {productResource?.error ? (
- <ReadErrorBanner
- resource={productResource}
- title={t`Product details could not be loaded`}
- />
- ) : productResource?.isLoading ? (
- <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
- ) : (
- <ApiErrorBanner error={t`Product details could not be loaded`} />
- )}
- </div>
- );
- }
-
const handleSubmit = async (e: Event) => {
e.preventDefault();
if (!currency) {
diff --git a/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx
@@ -15,8 +15,9 @@
*/
import type { VNode } from "preact";
+import { session } from "../stores/session.js";
import { parseStrictDate } from "../utils/runtimeValidation.js";
-import { useState, useEffect, useRef } from "preact/hooks";
+import { useEffect, useRef, useState } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import type { SubscriptionItem } from "./SubscriptionsScreen.js";
@@ -93,7 +94,249 @@ export function currencyFractionalStep(
return digits === 0 ? "1" : `0.${"0".repeat(digits - 1)}1`;
}
-export function CreateSubscriptionScreen({
+interface SubscriptionDraft {
+ name: string;
+ slugId: string;
+ description: string;
+ kind: "subscription" | "discount";
+ validFrom: string;
+ validUntil: string;
+ useValidFrom: boolean;
+ useValidUntil: boolean;
+ duration: string;
+ stepGranularity: string;
+ selectedCategories: CategorySnapshot[];
+ selectedProducts: ProductSnapshot[];
+ redemptionWildcard: boolean;
+ benefitType: "none" | "percentage" | "flat" | "free_item";
+ freeItemPriceSelection: "cheapest" | "most_expensive";
+ percentage: string;
+ roundingMode: "down" | "nearest" | "up";
+ roundingPrecision: string;
+ showRoundingOptions: boolean;
+ flatAmounts: string[];
+ requiredTokens: string;
+ issuanceWildcard: boolean;
+ issuanceCategories: CategorySnapshot[];
+ issuanceProducts: ProductSnapshot[];
+ minimumPurchase: string;
+ issueOnRedemption: boolean;
+ showAdvanced: boolean;
+ manualId: boolean;
+ roundingPrecisionIsDefault: boolean;
+ flatAmountsAreDefault: boolean;
+}
+
+/** Decode stored rules once when the editable form mounts. */
+function initialSubscriptionDraft(
+ existingItem: SubscriptionItem | TokenFamilyItem | undefined,
+ primaryCurrency: string,
+ defaultRoundingPrecision: string,
+): SubscriptionDraft {
+ const draft: SubscriptionDraft = {
+ name: "",
+ slugId: "",
+ description: "",
+ kind: "discount",
+ validFrom: "",
+ validUntil: "",
+ useValidFrom: false,
+ useValidUntil: false,
+ duration: "30d",
+ stepGranularity: "1d",
+ selectedCategories: [],
+ selectedProducts: [],
+ redemptionWildcard: false,
+ benefitType: "percentage",
+ freeItemPriceSelection: "cheapest",
+ percentage: "",
+ roundingMode: "down",
+ roundingPrecision: defaultRoundingPrecision,
+ showRoundingOptions: false,
+ flatAmounts: [`${primaryCurrency}:10`],
+ requiredTokens: "",
+ issuanceWildcard: false,
+ issuanceCategories: [],
+ issuanceProducts: [],
+ minimumPurchase: "",
+ issueOnRedemption: false,
+ showAdvanced: false,
+ manualId: Boolean(existingItem),
+ roundingPrecisionIsDefault: true,
+ flatAmountsAreDefault: true,
+ };
+ if (existingItem) {
+ const slug = "slug" in existingItem ? existingItem.slug : existingItem.id;
+ draft.name = existingItem.name || "";
+ draft.slugId = slug || "";
+ draft.description = existingItem.description || "";
+ draft.kind = existingItem.kind || "subscription";
+ if (existingItem.duration) draft.duration = existingItem.duration;
+ if (existingItem.stepGranularity)
+ draft.stepGranularity = existingItem.stepGranularity;
+ const validAfter =
+ "validAfter" in existingItem
+ ? existingItem.validAfter
+ : "validFrom" in existingItem
+ ? (existingItem as SubscriptionItem).validFrom
+ : undefined;
+ const validBefore =
+ "validBefore" in existingItem
+ ? existingItem.validBefore
+ : "validUntil" in existingItem
+ ? (existingItem as SubscriptionItem).validUntil
+ : undefined;
+ draft.validFrom = validAfter || "";
+ draft.useValidFrom = Boolean(validAfter);
+ draft.validUntil = validBefore || "";
+ draft.useValidUntil = Boolean(validBefore);
+ const extraData =
+ "extraData" in existingItem
+ ? existingItem.extraData
+ : (existingItem as SubscriptionItem).extraData;
+ const parsed = parseTokenSemantics(existingItem.kind, extraData);
+ if (parsed.status === "valid") {
+ if (parsed.semantics.productSelectors === "*") {
+ draft.redemptionWildcard = true;
+ draft.selectedCategories = [];
+ draft.selectedProducts = [];
+ } else {
+ draft.redemptionWildcard = false;
+ draft.selectedCategories = parsed.semantics.productSelectors
+ .filter(
+ (
+ selector,
+ ): selector is Extract<ProductSelector, { type: "category" }> =>
+ selector.type === "category",
+ )
+ .map(({ id, name }) => ({ id, name }));
+ draft.selectedProducts = parsed.semantics.productSelectors
+ .filter(
+ (
+ selector,
+ ): selector is Extract<ProductSelector, { type: "product" }> =>
+ selector.type === "product",
+ )
+ .map(({ id, name }) => ({ id, name }));
+ }
+ draft.benefitType = parsed.semantics.benefitType;
+ if (parsed.semantics.benefitType === "percentage") {
+ draft.percentage = parsed.semantics.percentage;
+ if (parsed.semantics.rounding) {
+ draft.roundingMode = parsed.semantics.rounding.mode;
+ draft.roundingPrecision = parsed.semantics.rounding.precision;
+ draft.roundingPrecisionIsDefault = false;
+ draft.showRoundingOptions =
+ parsed.semantics.rounding.mode !== "down" ||
+ parsed.semantics.rounding.precision !== defaultRoundingPrecision;
+ } else {
+ draft.showRoundingOptions = false;
+ }
+ } else if (parsed.semantics.benefitType === "flat") {
+ draft.flatAmounts = parsed.semantics.amounts;
+ draft.flatAmountsAreDefault = false;
+ } else if (parsed.semantics.benefitType === "free_item") {
+ draft.freeItemPriceSelection = parsed.semantics.priceSelection;
+ }
+ if (parsed.semantics.kind === "discount") {
+ draft.requiredTokens = String(parsed.semantics.requiredTokens);
+ if (parsed.semantics.issuance.productSelectors === "*") {
+ draft.issuanceWildcard = true;
+ draft.issuanceCategories = [];
+ draft.issuanceProducts = [];
+ } else {
+ draft.issuanceWildcard = false;
+ draft.issuanceCategories = parsed.semantics.issuance.productSelectors
+ .filter(
+ (
+ selector,
+ ): selector is Extract<ProductSelector, { type: "category" }> =>
+ selector.type === "category",
+ )
+ .map(({ id, name }) => ({ id, name }));
+ draft.issuanceProducts = parsed.semantics.issuance.productSelectors
+ .filter(
+ (
+ selector,
+ ): selector is Extract<ProductSelector, { type: "product" }> =>
+ selector.type === "product",
+ )
+ .map(({ id, name }) => ({ id, name }));
+ }
+ draft.minimumPurchase = parsed.semantics.issuance.minimumPurchase ?? "";
+ draft.issueOnRedemption = parsed.semantics.issuance.issueOnRedemption;
+ }
+ }
+ }
+ return draft;
+}
+
+/** Mount the draft only after authoritative detail is available. */
+export function CreateSubscriptionScreen(
+ props: CreateSubscriptionScreenProps,
+): VNode {
+ const { t } = useTranslation();
+ const [, setLocation] = useLocation();
+ const { editId, initialItem, familyResource } = props;
+ const isEditing = Boolean(editId || initialItem);
+ const existingItem = initialItem || familyResource?.data;
+ const existingSemantics = existingItem
+ ? parseTokenSemantics(existingItem.kind, existingItem.extraData)
+ : { status: "absent" as const };
+ if (isEditing && !existingItem) {
+ return (
+ <div class="space-y-6">
+ <Header
+ onBack={() => setLocation("/subscriptions")}
+ backLabel={t`Discounts & Passes`}
+ title={t`Edit Discount or Pass`}
+ subtitle={t`Choose how discounts are earned and redeemed, and how long they remain usable.`}
+ />
+ {familyResource?.error ? (
+ <ReadErrorBanner
+ resource={familyResource}
+ title={t`Discount or pass details could not be loaded`}
+ />
+ ) : familyResource?.isLoading ? (
+ <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
+ ) : (
+ <ApiErrorBanner
+ error={t`Discount or pass details could not be loaded`}
+ />
+ )}
+ </div>
+ );
+ }
+
+ if (existingSemantics.status === "invalid") {
+ return (
+ <div class="space-y-6">
+ <Header
+ onBack={() => setLocation("/subscriptions")}
+ backLabel={t`Discounts & Passes`}
+ title={t`Edit Discount or Pass`}
+ subtitle={t`Choose how discounts are earned and redeemed, and how long they remain usable.`}
+ />
+ <ApiErrorBanner
+ error={t`This discount or pass uses rules this portal cannot edit safely.`}
+ />
+ </div>
+ );
+ }
+
+ // A different account, entity, or create/edit mode starts a fresh draft.
+ const formKey = JSON.stringify([
+ session.value.backendBaseUrl,
+ session.value.account,
+ editId ?? null,
+ (existingItem &&
+ ("slug" in existingItem ? existingItem.slug : existingItem.id)) ??
+ null,
+ ]);
+ return <CreateSubscriptionForm key={formKey} {...props} />;
+}
+
+function CreateSubscriptionForm({
onCreateFamily,
onUpdateFamily,
editId,
@@ -138,71 +381,87 @@ export function CreateSubscriptionScreen({
const isEditing = Boolean(editId || initialItem);
const existingItem = initialItem || familyResource?.data;
- const [name, setName] = useState<string>(initialItem?.name || "");
- const [slugId, setSlugId] = useState<string>(initialItem?.id || "");
+ const [initialDraft] = useState(() =>
+ initialSubscriptionDraft(
+ existingItem,
+ primaryCurrency,
+ defaultRoundingPrecision,
+ ),
+ );
+ const [name, setName] = useState<string>(initialDraft.name);
+ const [slugId, setSlugId] = useState<string>(initialDraft.slugId);
const [description, setDescription] = useState<string>(
- initialItem?.description || "",
+ initialDraft.description,
);
const [kind, setKind] = useState<"subscription" | "discount">(
- initialItem?.kind || "discount",
- );
- const [validFrom, setValidFrom] = useState<string>(
- initialItem?.validFrom || "",
- );
- const [validUntil, setValidUntil] = useState<string>(
- initialItem?.validUntil || "",
+ initialDraft.kind,
);
+ const [validFrom, setValidFrom] = useState<string>(initialDraft.validFrom);
+ const [validUntil, setValidUntil] = useState<string>(initialDraft.validUntil);
const [useValidFrom, setUseValidFrom] = useState<boolean>(
- Boolean(initialItem?.validFrom),
+ initialDraft.useValidFrom,
);
const [useValidUntil, setUseValidUntil] = useState<boolean>(
- Boolean(initialItem?.validUntil),
- );
- const [duration, setDuration] = useState<string>(
- initialItem?.duration || "30d",
+ initialDraft.useValidUntil,
);
+ const [duration, setDuration] = useState<string>(initialDraft.duration);
const [stepGranularity, setStepGranularity] = useState<string>(
- initialItem?.stepGranularity || "1d",
+ initialDraft.stepGranularity,
);
const [selectedCategories, setSelectedCategories] = useState<
CategorySnapshot[]
- >([]);
+ >(initialDraft.selectedCategories);
const [selectedProducts, setSelectedProducts] = useState<ProductSnapshot[]>(
- [],
+ initialDraft.selectedProducts,
+ );
+ const [redemptionWildcard, setRedemptionWildcard] = useState<boolean>(
+ initialDraft.redemptionWildcard,
);
- const [redemptionWildcard, setRedemptionWildcard] = useState<boolean>(false);
const [benefitType, setBenefitType] = useState<
"none" | "percentage" | "flat" | "free_item"
- >("percentage");
+ >(initialDraft.benefitType);
const [freeItemPriceSelection, setFreeItemPriceSelection] = useState<
"cheapest" | "most_expensive"
- >("cheapest");
- const [percentage, setPercentage] = useState<string>("");
+ >(initialDraft.freeItemPriceSelection);
+ const [percentage, setPercentage] = useState<string>(initialDraft.percentage);
const [roundingMode, setRoundingMode] = useState<"down" | "nearest" | "up">(
- "down",
+ initialDraft.roundingMode,
);
const [roundingPrecision, setRoundingPrecision] = useState<string>(
- () => defaultRoundingPrecision,
+ initialDraft.roundingPrecision,
+ );
+ const roundingPrecisionIsDefault = useRef(
+ initialDraft.roundingPrecisionIsDefault,
+ );
+ const [showRoundingOptions, setShowRoundingOptions] = useState<boolean>(
+ initialDraft.showRoundingOptions,
+ );
+ const [flatAmounts, setFlatAmounts] = useState<string[]>(
+ initialDraft.flatAmounts,
+ );
+ const flatAmountsAreDefault = useRef(initialDraft.flatAmountsAreDefault);
+ const [requiredTokens, setRequiredTokens] = useState<string>(
+ initialDraft.requiredTokens,
+ );
+ const [issuanceWildcard, setIssuanceWildcard] = useState<boolean>(
+ initialDraft.issuanceWildcard,
);
- const roundingPrecisionIsDefault = useRef(true);
- const [showRoundingOptions, setShowRoundingOptions] =
- useState<boolean>(false);
- const [flatAmounts, setFlatAmounts] = useState<string[]>([
- `${primaryCurrency}:`,
- ]);
- const flatAmountsAreDefault = useRef(true);
- const [requiredTokens, setRequiredTokens] = useState<string>("");
- const [issuanceWildcard, setIssuanceWildcard] = useState<boolean>(false);
const [issuanceCategories, setIssuanceCategories] = useState<
CategorySnapshot[]
- >([]);
+ >(initialDraft.issuanceCategories);
const [issuanceProducts, setIssuanceProducts] = useState<ProductSnapshot[]>(
- [],
+ initialDraft.issuanceProducts,
+ );
+ const [minimumPurchase, setMinimumPurchase] = useState<string>(
+ initialDraft.minimumPurchase,
+ );
+ const [issueOnRedemption, setIssueOnRedemption] = useState<boolean>(
+ initialDraft.issueOnRedemption,
+ );
+ const [showAdvanced, setShowAdvanced] = useState<boolean>(
+ initialDraft.showAdvanced,
);
- const [minimumPurchase, setMinimumPurchase] = useState<string>("");
- const [issueOnRedemption, setIssueOnRedemption] = useState<boolean>(false);
- const [showAdvanced, setShowAdvanced] = useState<boolean>(false);
- const [manualId, setManualId] = useState<boolean>(Boolean(initialItem?.id));
+ const [manualId, setManualId] = useState<boolean>(initialDraft.manualId);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [errorMsg, setErrorMsg] = useState<string>("");
// Kept alongside the message so the banner can offer the underlying
@@ -210,126 +469,6 @@ export function CreateSubscriptionScreen({
const [rawError, setRawError] = useState<unknown>(undefined);
useEffect(() => {
- if (existingItem) {
- setName(existingItem.name || "");
- const slug = "slug" in existingItem ? existingItem.slug : existingItem.id;
- setSlugId(slug || "");
- setDescription(existingItem.description || "");
- setKind(existingItem.kind || "subscription");
- if (existingItem.duration) setDuration(existingItem.duration);
- if (existingItem.stepGranularity)
- setStepGranularity(existingItem.stepGranularity);
- const validAfter =
- "validAfter" in existingItem
- ? existingItem.validAfter
- : "validFrom" in existingItem
- ? (existingItem as SubscriptionItem).validFrom
- : undefined;
- const validBefore =
- "validBefore" in existingItem
- ? existingItem.validBefore
- : "validUntil" in existingItem
- ? (existingItem as SubscriptionItem).validUntil
- : undefined;
- setValidFrom(validAfter || "");
- setUseValidFrom(Boolean(validAfter));
- setValidUntil(validBefore || "");
- setUseValidUntil(Boolean(validBefore));
- const extraData =
- "extraData" in existingItem
- ? existingItem.extraData
- : (existingItem as SubscriptionItem).extraData;
- const parsed = parseTokenSemantics(existingItem.kind, extraData);
- if (parsed.status === "valid") {
- if (parsed.semantics.productSelectors === "*") {
- setRedemptionWildcard(true);
- setSelectedCategories([]);
- setSelectedProducts([]);
- } else {
- setRedemptionWildcard(false);
- setSelectedCategories(
- parsed.semantics.productSelectors
- .filter(
- (
- selector,
- ): selector is Extract<ProductSelector, { type: "category" }> =>
- selector.type === "category",
- )
- .map(({ id, name }) => ({ id, name })),
- );
- setSelectedProducts(
- parsed.semantics.productSelectors
- .filter(
- (
- selector,
- ): selector is Extract<ProductSelector, { type: "product" }> =>
- selector.type === "product",
- )
- .map(({ id, name }) => ({ id, name })),
- );
- }
- setBenefitType(parsed.semantics.benefitType);
- if (parsed.semantics.benefitType === "percentage") {
- setPercentage(parsed.semantics.percentage);
- if (parsed.semantics.rounding) {
- setRoundingMode(parsed.semantics.rounding.mode);
- setRoundingPrecision(parsed.semantics.rounding.precision);
- roundingPrecisionIsDefault.current = false;
- setShowRoundingOptions(
- parsed.semantics.rounding.mode !== "down" ||
- parsed.semantics.rounding.precision !==
- defaultRoundingPrecision,
- );
- } else {
- setShowRoundingOptions(false);
- }
- } else if (parsed.semantics.benefitType === "flat") {
- setFlatAmounts(parsed.semantics.amounts);
- flatAmountsAreDefault.current = false;
- } else if (parsed.semantics.benefitType === "free_item") {
- setFreeItemPriceSelection(parsed.semantics.priceSelection);
- }
- if (parsed.semantics.kind === "discount") {
- setRequiredTokens(String(parsed.semantics.requiredTokens));
- if (parsed.semantics.issuance.productSelectors === "*") {
- setIssuanceWildcard(true);
- setIssuanceCategories([]);
- setIssuanceProducts([]);
- } else {
- setIssuanceWildcard(false);
- setIssuanceCategories(
- parsed.semantics.issuance.productSelectors
- .filter(
- (
- selector,
- ): selector is Extract<
- ProductSelector,
- { type: "category" }
- > => selector.type === "category",
- )
- .map(({ id, name }) => ({ id, name })),
- );
- setIssuanceProducts(
- parsed.semantics.issuance.productSelectors
- .filter(
- (
- selector,
- ): selector is Extract<
- ProductSelector,
- { type: "product" }
- > => selector.type === "product",
- )
- .map(({ id, name }) => ({ id, name })),
- );
- }
- setMinimumPurchase(parsed.semantics.issuance.minimumPurchase ?? "");
- setIssueOnRedemption(parsed.semantics.issuance.issueOnRedemption);
- }
- }
- }
- }, [existingItem, defaultRoundingPrecision]);
-
- useEffect(() => {
if (roundingPrecisionIsDefault.current) {
setRoundingPrecision(defaultRoundingPrecision);
}
@@ -896,47 +1035,6 @@ export function CreateSubscriptionScreen({
? t`Round to nearest`
: t`Round up`;
- if (isEditing && !existingItem) {
- return (
- <div class="space-y-6">
- <Header
- onBack={() => setLocation("/subscriptions")}
- backLabel={t`Discounts & Passes`}
- title={t`Edit Discount or Pass`}
- subtitle={t`Choose how discounts are earned and redeemed, and how long they remain usable.`}
- />
- {familyResource?.error ? (
- <ReadErrorBanner
- resource={familyResource}
- title={t`Discount or pass details could not be loaded`}
- />
- ) : familyResource?.isLoading ? (
- <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
- ) : (
- <ApiErrorBanner
- error={t`Discount or pass details could not be loaded`}
- />
- )}
- </div>
- );
- }
-
- if (existingSemantics.status === "invalid") {
- return (
- <div class="space-y-6">
- <Header
- onBack={() => setLocation("/subscriptions")}
- backLabel={t`Discounts & Passes`}
- title={t`Edit Discount or Pass`}
- subtitle={t`Choose how discounts are earned and redeemed, and how long they remain usable.`}
- />
- <ApiErrorBanner
- error={t`This discount or pass uses rules this portal cannot edit safely.`}
- />
- </div>
- );
- }
-
return (
<div class="space-y-6">
<Header
diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx
@@ -15,7 +15,8 @@
*/
import type { VNode } from "preact";
-import { useState, useEffect, useLayoutEffect, useRef } from "preact/hooks";
+import { session } from "../stores/session.js";
+import { useState, useLayoutEffect, useRef } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import { AmountInput, getOrderedCurrencies } from "../ui/AmountInput.js";
@@ -81,7 +82,49 @@ export interface CreateTemplateScreenProps {
) => Promise<unknown>;
}
-export function CreateTemplateScreen({
+/** Mount the draft only after authoritative detail is available. */
+export function CreateTemplateScreen(props: CreateTemplateScreenProps): VNode {
+ const { t } = useTranslation();
+ const [, setLocation] = useLocation();
+ const { editId, initialTemplate, template, templateResource } = props;
+ const isEditing = !!editId;
+ const existing = initialTemplate || template || templateResource?.data;
+ if (isEditing && !existing) {
+ return (
+ <div class="space-y-6">
+ <Header
+ onBack={() => setLocation("/templates")}
+ backLabel={t`Templates`}
+ title={t`Edit Template`}
+ subtitle={t`Define reusable payment types, fixed-item orders, or donation QR codes.`}
+ />
+ {templateResource?.error ? (
+ <ReadErrorBanner
+ resource={templateResource}
+ title={t`Template details could not be loaded`}
+ />
+ ) : templateResource?.isLoading ? (
+ <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
+ ) : (
+ <ApiErrorBanner error={t`Template details could not be loaded`} />
+ )}
+ </div>
+ );
+ }
+
+ // A different account, entity, or create/edit mode starts a fresh draft.
+ const formKey = JSON.stringify([
+ session.value.backendBaseUrl,
+ session.value.account,
+ editId ?? null,
+ existing?.id ?? null,
+ ]);
+ return (
+ <CreateTemplateForm key={formKey} {...props} initialTemplate={existing} />
+ );
+}
+
+function CreateTemplateForm({
template: fetchedTemplate,
templateResource,
templates = [],
@@ -180,31 +223,6 @@ export function CreateTemplateScreen({
if (idConflict && showAdvanced && !isSubmitting) idInput.current?.focus();
}, [idConflict, showAdvanced, isSubmitting]);
- useEffect(() => {
- if (existing) {
- setSelType(existing.type ?? "fixed");
- setName(existing.name);
- setTemplateId(existing.id);
- setManualId(true);
- setSummary(existing.summary || "");
- setAmount(existing.amount || `${startingCurrency}:`);
- setCurrency(existing.currency || existing.amount?.split(":")[0] || "");
- setPayDurationUs(existing.payDeadlineUs || DEFAULT_PAY_DURATION_US);
- setPayDurationSet(!!existing.payDeadlineUs);
- setMinimumAge(existing.minimumAge || 0);
- setOtpDeviceId(existing.otpDeviceId);
- setSuggestAmount(!!existing.suggestedAmount);
- setSuggestedAmountValue(
- existing.suggestedAmount
- ? (existing.suggestedAmount.split(":")[1] ?? "")
- : "",
- );
- setSuggestSummary(!!existing.suggestedSummary);
- setSuggestedSummaryValue(existing.suggestedSummary || "");
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps -- only a newly loaded template refills the form; depending on startingCurrency would reset what the merchant typed the moment the configuration settles.
- }, [existing]);
-
// What the stored template is. A template of a type this form cannot express
// keeps it: rewriting it as a fixed-order template would throw away the very
// settings that make it what it is.
@@ -386,29 +404,6 @@ export function CreateTemplateScreen({
}
};
- if (isEditing && !existing) {
- return (
- <div class="space-y-6">
- <Header
- onBack={() => setLocation("/templates")}
- backLabel={t`Templates`}
- title={t`Edit Template`}
- subtitle={t`Define reusable payment types, fixed-item orders, or donation QR codes.`}
- />
- {templateResource?.error ? (
- <ReadErrorBanner
- resource={templateResource}
- title={t`Template details could not be loaded`}
- />
- ) : templateResource?.isLoading ? (
- <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
- ) : (
- <ApiErrorBanner error={t`Template details could not be loaded`} />
- )}
- </div>
- );
- }
-
return (
<div class="space-y-6">
<Header
diff --git a/packages/taler-merchant-webui/src/screens/CreateWebhookScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateWebhookScreen.tsx
@@ -15,7 +15,8 @@
*/
import type { VNode } from "preact";
-import { useState, useEffect, useRef } from "preact/hooks";
+import { session } from "../stores/session.js";
+import { useState, useRef } from "preact/hooks";
import { useLocation } from "wouter-preact";
import { Header } from "../ui/Header.js";
import { useTranslation } from "../context/translation.js";
@@ -255,7 +256,53 @@ function templateVariables(
}
}
-export function CreateWebhookScreen({
+/** Mount the draft only after authoritative detail is available. */
+export function CreateWebhookScreen(props: CreateWebhookScreenProps): VNode {
+ const { t } = useTranslation();
+ const [, setLocation] = useLocation();
+ const { editId, initialWebhook, webhookResource } = props;
+ const isEditing = !!editId;
+ const targetWebhook = initialWebhook || webhookResource?.data;
+ if (isEditing && !targetWebhook) {
+ return (
+ <div class="space-y-6">
+ <Header
+ onBack={() => setLocation("/settings/webhooks")}
+ backLabel={t`Webhooks`}
+ title={t`Edit Webhook`}
+ subtitle={t`Configure an HTTP callback for one kind of event: an order, a refund, a product or a category.`}
+ />
+ {webhookResource?.error ? (
+ <ReadErrorBanner
+ resource={webhookResource}
+ title={t`Webhook details could not be loaded`}
+ />
+ ) : webhookResource?.isLoading ? (
+ <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
+ ) : (
+ <ApiErrorBanner error={t`Webhook details could not be loaded`} />
+ )}
+ </div>
+ );
+ }
+
+ // A different account, entity, or create/edit mode starts a fresh draft.
+ const formKey = JSON.stringify([
+ session.value.backendBaseUrl,
+ session.value.account,
+ editId ?? null,
+ targetWebhook?.id ?? null,
+ ]);
+ return (
+ <CreateWebhookForm
+ key={formKey}
+ {...props}
+ initialWebhook={targetWebhook}
+ />
+ );
+}
+
+function CreateWebhookForm({
editId,
initialWebhook,
webhookResource,
@@ -287,17 +334,6 @@ export function CreateWebhookScreen({
// TalerErrorDetail to copy (messages-and-vocabulary.md 13.5).
const [rawError, setRawError] = useState<unknown>(undefined);
- useEffect(() => {
- if (targetWebhook) {
- setWebhookId(targetWebhook.id);
- setEvent(targetWebhook.event);
- setUrl(targetWebhook.url);
- setHttpMethod(targetWebhook.httpMethod || "POST");
- setHeaders(targetWebhook.headers || "");
- setBodyTemplate(targetWebhook.bodyTemplate || "");
- }
- }, [targetWebhook]);
-
const insertVariable = (varName: string) => {
const placeholder = `{{${varName}}}`;
const textarea = bodyTextareaRef.current;
@@ -364,29 +400,6 @@ export function CreateWebhookScreen({
// from another event is never substituted, it is shipped verbatim.
const variables = templateVariables(t, event);
- if (isEditing && !targetWebhook) {
- return (
- <div class="space-y-6">
- <Header
- onBack={() => setLocation("/settings/webhooks")}
- backLabel={t`Webhooks`}
- title={t`Edit Webhook`}
- subtitle={t`Configure an HTTP callback for one kind of event: an order, a refund, a product or a category.`}
- />
- {webhookResource?.error ? (
- <ReadErrorBanner
- resource={webhookResource}
- title={t`Webhook details could not be loaded`}
- />
- ) : webhookResource?.isLoading ? (
- <InitialLoadingState>{t`Loading…`}</InitialLoadingState>
- ) : (
- <ApiErrorBanner error={t`Webhook details could not be loaded`} />
- )}
- </div>
- );
- }
-
return (
<div class="space-y-6">
<Header
diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx
@@ -9202,3 +9202,154 @@ test("a non-Swiss IBAN gets the European code", () => {
assert.match(html, /EPC bank transfer/);
assert.doesNotMatch(html, /Swiss QR-bill/);
});
+
+for (const kind of [
+ "template",
+ "subscription",
+ "webhook",
+ "product",
+ "device",
+] as const) {
+ test(`${kind} form initializes before effects and preserves drafts only for the same entity and account`, async () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const selector = {
+ template: "#tmpl_name_input",
+ subscription: "#sub_name_input",
+ webhook: "#wh_url_input",
+ product: "#prod_name_input",
+ device: "#auth_name",
+ }[kind];
+ const originalSession = session.value;
+ const initial =
+ kind === "webhook" ? "https://example.com/original" : "Original";
+ const draft =
+ kind === "webhook" ? "https://example.com/draft" : "Unsaved draft";
+ const refreshed =
+ kind === "webhook" ? "https://example.com/refreshed" : "Refreshed";
+ const input = () => container.querySelector<HTMLInputElement>(selector)!;
+ const show = (id: string | undefined, value?: string, precision = 2) => {
+ const resource = {
+ data:
+ value === undefined
+ ? undefined
+ : kind === "template"
+ ? {
+ id,
+ name: value,
+ sellsText: "Fixed amount",
+ type: "fixed" as const,
+ amount: "CHF:1",
+ }
+ : kind === "subscription"
+ ? {
+ slug: id,
+ name: value,
+ kind: "subscription" as const,
+ extraData: {
+ experimental_subscription: {
+ type: "flat",
+ amount: ["CHF:10"],
+ product_selectors: "*",
+ },
+ },
+ }
+ : kind === "device"
+ ? [{ id, name: value, algorithm: 2 }]
+ : kind === "product"
+ ? {
+ id,
+ name: value,
+ price: "CHF:5",
+ stockTracked: true,
+ totalStock: 12,
+ }
+ : { id, event: "pay", url: value, httpMethod: "POST" },
+ isLoading: value === undefined,
+ isRefreshing: false,
+ error: undefined,
+ refresh: async () => undefined,
+ };
+ render(
+ kind === "template" ? (
+ <CreateTemplateScreen
+ editId={id}
+ templateResource={resource as any}
+ />
+ ) : kind === "subscription" ? (
+ <CreateSubscriptionScreen
+ editId={id}
+ familyResource={resource as any}
+ primaryCurrency="CHF"
+ currencySpecifications={{ CHF: currencySpec(precision) }}
+ />
+ ) : kind === "device" ? (
+ <CreateDeviceScreen
+ editId={id}
+ devices={(resource.data ?? []) as any}
+ devicesResource={resource as any}
+ />
+ ) : kind === "product" ? (
+ <CreateProductScreen
+ editId={id}
+ currency="CHF"
+ productResource={resource as any}
+ />
+ ) : (
+ <CreateWebhookScreen editId={id} webhookResource={resource as any} />
+ ),
+ container,
+ );
+ };
+ try {
+ act(() => show("first"));
+ assert.equal(input(), null);
+ // Inspect the first render without act(), which would flush effects and
+ // conceal an effect-based initializer. The fields must already be ready.
+ show("first", initial);
+ assert.equal(input().value, initial);
+ if (kind === "device") {
+ assert.equal(
+ container.querySelector<HTMLInputElement>("#auth_method_2")?.checked,
+ true,
+ );
+ }
+ if (kind === "subscription") {
+ assert.equal(
+ container.querySelector<HTMLInputElement>("#sub_discount_flat_0")
+ ?.value,
+ "10",
+ );
+ }
+ act(() => {
+ input().value = draft;
+ input().dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await act(async () => show("first", refreshed));
+ assert.equal(input().value, draft);
+ // Late currency metadata must not rerun subscription hydration either.
+ await act(async () => show("first", refreshed, 8));
+ assert.equal(input().value, draft);
+ await act(async () => show("second", refreshed, 8));
+ assert.equal(input().value, refreshed);
+ act(() => {
+ input().value = draft;
+ input().dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ act(() => {
+ session.value = { ...originalSession, account: "another-edit-account" };
+ show("second", initial);
+ });
+ assert.equal(input().value, initial);
+ // Create mode must not inherit an edit draft.
+ act(() => show(undefined));
+ assert.equal(input().value, "");
+ act(() => show("first", refreshed));
+ assert.equal(input().value, refreshed);
+ } finally {
+ render(null, container);
+ container.remove();
+ session.value = originalSession;
+ }
+ });
+}