taler-typescript-core

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

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:
Mpackages/taler-harness/src/integrationtests/merchant-webui-transactional-flows.ts | 17+++++++++++++----
Mpackages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx | 129+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/taler-merchant-webui/src/screens/CreateProductScreen.tsx | 135++++++++++++++++++++++++++++++++++++-------------------------------------------
Mpackages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx | 502+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mpackages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx | 95+++++++++++++++++++++++++++++++++++++------------------------------------------
Mpackages/taler-merchant-webui/src/screens/CreateWebhookScreen.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/taler-merchant-webui/src/screens/screens.test.tsx | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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; + } + }); +}