taler-typescript-core

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

commit e1b15434b132cf02a3d1321e7830fc3d0818ab6e
parent 58edf272f56a3474a9ff6e7f0ba2aadb38ccffcb
Author: Florian Dold <dold@taler.net>
Date:   Thu, 10 Sep 2026 15:21:14 +0200

merchant web UI: restore template OTP device selection

Offer the shared searchable device selector under advanced options for
new and existing templates. Preserve assignments when device loading
fails, and allow merchants to replace or clear the selected device.

Filter shared choices as the user types and handle empty search results
without submitting the form. Resolve currency configuration arriving
after the form opens and keep amount currency changes in sync.

Diffstat:
Mpackages/taler-merchant-webui/src/routes/CreateTemplateRoute.tsx | 4++++
Apackages/taler-merchant-webui/src/screens/CreateTemplateScreen.test.tsx | 329+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mpackages/web-util/src/forms/fields/InputSelectOne.test.tsx | 4++--
Mpackages/web-util/src/forms/fields/InputSelectOne.tsx | 29++++++++++++++++++++---------
5 files changed, 433 insertions(+), 22 deletions(-)

diff --git a/packages/taler-merchant-webui/src/routes/CreateTemplateRoute.tsx b/packages/taler-merchant-webui/src/routes/CreateTemplateRoute.tsx @@ -20,6 +20,7 @@ import { useTemplateDetails, useMerchantConfig, usePayoutAccounts, + useOtpDevices, } from "../api/hooks.js"; import { CreateTemplateScreen } from "../screens/CreateTemplateScreen.js"; @@ -32,6 +33,7 @@ export function CreateTemplateRoute({ editId }: { editId?: string }): VNode { resource: configResource, } = useMerchantConfig(); const { accounts } = usePayoutAccounts(); + const { devices, resource: devicesResource } = useOtpDevices(); const payoutCurrencies = (accounts ?? []) .map((a: { currency?: string }) => a.currency) @@ -46,6 +48,8 @@ export function CreateTemplateRoute({ editId }: { editId?: string }): VNode { 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) } diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.test.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.test.tsx @@ -0,0 +1,329 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { + CreateTemplateScreen, + type CreateTemplateScreenProps, +} from "./CreateTemplateScreen.js"; +import { normalizeApiFailure } from "../api/failure.js"; +import type { TemplateWriteExtras } from "../api/hooks.js"; +import type { TemplateItem } from "./TemplatesScreen.js"; + +const devices = [ + { id: "counter-one", name: "Front counter" }, + { id: "counter-two", name: "Front counter" }, + { id: "terrace", name: "Outside" }, +]; +const template: TemplateItem = { + id: "coffee", + name: "Coffee", + type: "fixed", + amount: "CHF:5", + otpDeviceId: "counter-one", +}; + +function button(container: HTMLElement, text: string): HTMLButtonElement { + const found = [...container.querySelectorAll("button")].find( + (b) => + b.textContent?.trim() === text || b.getAttribute("aria-label") === text, + ); + assert.ok(found, `button ${text} exists`); + return found; +} + +function click(container: HTMLElement, text: string): void { + act(() => button(container, text).click()); +} + +function search(container: HTMLElement, query: string): HTMLInputElement { + const input = container.querySelector<HTMLInputElement>('[role="combobox"]')!; + assert.ok(input); + act(() => { + input.focus(); + input.value = query; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + return input; +} + +function key(input: HTMLInputElement, key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + act(() => { + input.dispatchEvent(event); + }); + return event; +} + +async function submit(container: HTMLElement): Promise<void> { + await act(async () => { + container + .querySelector("form")! + .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); +} + +async function withScreen( + props: CreateTemplateScreenProps, + check: (container: HTMLElement) => Promise<void>, +): Promise<void> { + const container = document.createElement("div"); + document.body.appendChild(container); + try { + await act(async () => + render( + <CreateTemplateScreen + defaultCurrency="CHF" + devices={devices} + {...props} + />, + container, + ), + ); + await check(container); + } finally { + act(() => render(null, container)); + container.remove(); + } +} + +test("template creation searches OTP names and IDs as typed and saves the chosen device", async () => { + const writes: TemplateWriteExtras[] = []; + const saved: TemplateItem[] = []; + await withScreen( + { + onCreateTemplate: async (_id, _name, _contract, extras) => { + writes.push(extras as TemplateWriteExtras); + }, + onSave: (value) => saved.push(value), + }, + async (container) => { + assert.equal(container.querySelector('[role="combobox"]'), null); + click(container, "Show advanced options"); + search(container, "FRONT"); + assert.deepEqual( + [...container.querySelectorAll('[role="option"]')].map((o) => + o.textContent?.trim(), + ), + ["Front counter (counter-one)", "Front counter (counter-two)"], + ); + const input = search(container, "COUNTER-TWO"); + assert.equal(container.querySelectorAll('[role="option"]').length, 1); + assert.equal(key(input, "Enter").defaultPrevented, true); + assert.match(container.textContent!, /Front counter \(counter-two\)/); + assert.equal(writes.length, 0, "selection does not submit the form"); + for (const [id, value] of [ + ["tmpl_name_input", "Coffee"], + ["tmpl_amount_input", "5"], + ] as const) { + act(() => { + const field = container.querySelector<HTMLInputElement>(`#${id}`)!; + field.value = value; + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + click(container, "Hide advanced options"); + await submit(container); + assert.equal(writes.length, 1); + assert.equal(writes[0]!.otpDeviceId, "counter-two"); + assert.equal(saved[0]!.otpDeviceId, "counter-two"); + }, + ); +}); + +test("OTP search supports arrow keys, mouse selection, clearing and dismissing no matches", async () => { + await withScreen({}, async (container) => { + click(container, "Show advanced options"); + const input = search(container, "counter"); + key(input, "ArrowDown"); + const active = container.querySelector('[aria-selected="true"]')!; + assert.match(active.textContent!, /counter-two/); + assert.equal(input.getAttribute("aria-activedescendant"), active.id); + key(input, "ArrowUp"); + key(input, "Enter"); + assert.match(container.textContent!, /Front counter \(counter-one\)/); + click(container, "Clear selection"); + search(container, "terrace"); + click(container, "Outside (terrace)"); + click(container, "Clear selection"); + const empty = search(container, "missing"); + assert.equal( + container.querySelectorAll('[role="option"]:not([aria-disabled="true"])') + .length, + 0, + ); + assert.equal(key(empty, "Enter").defaultPrevented, true); + key(empty, "Escape"); + assert.equal(empty.getAttribute("aria-expanded"), "false"); + search(container, ""); + click(container, "No device"); + assert.ok(container.querySelector('[role="combobox"]')); + }); +}); + +for (const action of ["preserve", "replace", "clear"] as const) { + test(`template editing can ${action} the assigned OTP device`, async () => { + const writes: TemplateWriteExtras[] = []; + await withScreen( + { + editId: template.id, + template, + onUpdateTemplate: async (_id, _name, _contract, extras) => { + writes.push(extras as TemplateWriteExtras); + }, + }, + async (container) => { + click(container, "Show advanced options"); + assert.match(container.textContent!, /Front counter \(counter-one\)/); + if (action !== "preserve") { + click(container, "Clear selection"); + search(container, ""); + click( + container, + action === "replace" ? "Outside (terrace)" : "No device", + ); + } + await submit(container); + assert.equal(writes.length, 1); + assert.equal( + writes[0]!.otpDeviceId, + action === "preserve" + ? "counter-one" + : action === "replace" + ? "terrace" + : undefined, + ); + }, + ); + }); +} + +for (const state of ["loading", "error", "empty"] as const) { + test(`device list ${state} preserves a missing assignment and allows saving`, async () => { + let refreshes = 0; + const writes: TemplateWriteExtras[] = []; + const props: CreateTemplateScreenProps = { + editId: template.id, + template, + devices: [], + devicesResource: { + data: state === "empty" ? [] : undefined, + isLoading: state === "loading", + error: + state === "error" + ? normalizeApiFailure(new Error("offline")) + : undefined, + isRefreshing: false, + refresh: async () => { + refreshes++; + }, + }, + onUpdateTemplate: async (_id, _name, _contract, extras) => { + writes.push(extras as TemplateWriteExtras); + }, + }; + await withScreen(props, async (container) => { + click(container, "Show advanced options"); + assert.match(container.textContent!, /counter-one/); + assert.match( + container.textContent!, + state === "loading" + ? /Loading devices/ + : state === "error" + ? /Devices could not be loaded/ + : /No OTP devices configured/, + ); + if (state === "error") { + click(container, "Refresh"); + assert.equal(refreshes, 1); + } + await submit(container); + assert.equal(writes[0]!.otpDeviceId, "counter-one"); + click(container, "Clear selection"); + // A list refresh must not restore the assignment the merchant cleared. + await act(async () => + render( + <CreateTemplateScreen + defaultCurrency="CHF" + {...props} + devices={devices} + devicesResource={undefined} + />, + container, + ), + ); + await submit(container); + assert.equal(writes[1]!.otpDeviceId, undefined); + }); + }); +} + +test("template creation keeps its OTP choice when currency configuration arrives", async () => { + const writes: { contract: unknown; extras: TemplateWriteExtras }[] = []; + const onCreateTemplate: CreateTemplateScreenProps["onCreateTemplate"] = + async (_id, _name, contract, extras) => { + writes.push({ contract, extras: extras as TemplateWriteExtras }); + }; + await withScreen( + { defaultCurrency: undefined, onCreateTemplate }, + async (container) => { + click(container, "Show advanced options"); + search(container, "counter-two"); + click(container, "Front counter (counter-two)"); + await act(async () => + render( + <CreateTemplateScreen + defaultCurrency="CHF" + configuredCurrencies={["CHF", "EUR"]} + devices={devices} + onCreateTemplate={onCreateTemplate} + />, + container, + ), + ); + for (const [id, value] of [ + ["tmpl_name_input", "Coffee"], + ["tmpl_amount_input", "5"], + ] as const) { + act(() => { + const field = container.querySelector<HTMLInputElement>(`#${id}`)!; + field.value = value; + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + await submit(container); + assert.equal(writes[0]!.extras.otpDeviceId, "counter-two"); + assert.equal((writes[0]!.contract as { amount: string }).amount, "CHF:5"); + const currency = container.querySelector<HTMLSelectElement>( + 'select[aria-label="Currency"]', + )!; + act(() => { + currency.value = "EUR"; + currency.dispatchEvent(new Event("change", { bubbles: true })); + }); + await submit(container); + assert.equal(writes[1]!.extras.otpDeviceId, "counter-two"); + assert.equal((writes[1]!.contract as { amount: string }).amount, "EUR:5"); + }, + ); +}); diff --git a/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateTemplateScreen.tsx @@ -20,10 +20,12 @@ import { useLocation } from "wouter-preact"; import { Header } from "../ui/Header.js"; import { AmountInput, getOrderedCurrencies } from "../ui/AmountInput.js"; import { DurationInput, type DurationValue } from "../ui/DurationInput.js"; -import type { AmountString } from "@gnu-taler/taler-util"; +import type { AmountString, TranslatedString } from "@gnu-taler/taler-util"; import { Amounts, TalerMerchantApi, TemplateType } from "@gnu-taler/taler-util"; +import { InputSelectOne } from "@gnu-taler/web-util/browser"; import type { TemplateWriteExtras } from "../api/hooks.js"; +import type { OtpDeviceItemHook } from "../api/hooks/useOtpDevices.js"; import type { TemplateItem } from "./TemplatesScreen.js"; import { useTranslation } from "../context/translation.js"; import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; @@ -56,6 +58,8 @@ export interface CreateTemplateScreenProps { configuredCurrencies?: string[]; configResource?: RemoteResource<{ currency?: string }>; payoutCurrencies?: string[]; + devices?: OtpDeviceItemHook[]; + devicesResource?: RemoteResource<OtpDeviceItemHook[]>; onCreateTemplate?: ( id: string, name: string, @@ -81,6 +85,8 @@ export function CreateTemplateScreen({ configuredCurrencies = [], configResource, payoutCurrencies = [], + devices = [], + devicesResource, onCreateTemplate, onUpdateTemplate, editId, @@ -113,10 +119,14 @@ export function CreateTemplateScreen({ const [amount, setAmount] = useState<string>( existing?.amount || `${startingCurrency}:`, ); - const [currency, setCurrency] = useState<string>( - existing?.currency || startingCurrency, + const [selectedCurrency, setCurrency] = useState<string>( + existing?.currency || existing?.amount?.split(":")[0] || startingCurrency, ); + const currency = selectedCurrency || startingCurrency; const [showAdvanced, setShowAdvanced] = useState<boolean>(false); + const [otpDeviceId, setOtpDeviceId] = useState<string | undefined>( + existing?.otpDeviceId, + ); const [payDurationUs, setPayDurationUs] = useState<DurationValue>( existing?.payDeadlineUs || DEFAULT_PAY_DURATION_US, ); @@ -163,10 +173,11 @@ export function CreateTemplateScreen({ setManualId(true); setSummary(existing.summary || ""); setAmount(existing.amount || `${startingCurrency}:`); - if (existing.currency) setCurrency(existing.currency); + 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 @@ -228,7 +239,6 @@ export function CreateTemplateScreen({ return; } setErrorMsg(""); - setIsSubmitting(true); // Printed under the QR code, so it is translated and the amount is // formatted rather than left in the "CHF:5.00" protocol spelling. @@ -301,12 +311,11 @@ export function CreateTemplateScreen({ const extras: TemplateWriteExtras = { editableDefaults: pruneConflictingDefaults(contract, defaults), - // Carried through untouched: this form does not offer the verification - // device, and a PATCH that omits it would unlink the one already set. - otpDeviceId: existing?.otpDeviceId, + otpDeviceId, }; try { + setIsSubmitting(true); if (isEditing && editId) { await onUpdateTemplate?.(editId, name, contract, extras); } else { @@ -327,7 +336,7 @@ export function CreateTemplateScreen({ suggestedSummary, payDeadlineUs: payDurationUs, minimumAge, - otpDeviceId: existing?.otpDeviceId, + otpDeviceId, }); } @@ -499,7 +508,10 @@ export function CreateTemplateScreen({ label={t`Fixed Amount`} required value={amount} - onChange={(val) => setAmount(val)} + onChange={(val) => { + setAmount(val); + setCurrency(val.split(":")[0] || startingCurrency); + }} primaryCurrency={startingCurrency} payoutCurrencies={payoutCurrencies} currencies={availableCurrencies} @@ -514,7 +526,7 @@ export function CreateTemplateScreen({ <div class="flex items-center justify-between"> <div> <h2 class="text-base font-bold text-gray-900">{t`3. Advanced Options`}</h2> - <p class="text-xs text-gray-500 mt-0.5">{t`Template identifier, payment expiration, and age limits.`}</p> + <p class="text-xs text-gray-500 mt-0.5">{t`Template identifier, OTP device, payment expiration, and age limits.`}</p> </div> <button type="button" @@ -566,6 +578,61 @@ export function CreateTemplateScreen({ </p> </div> + <div class="space-y-2"> + <InputSelectOne<string> + name="otpDeviceId" + label={t`OTP device` as TranslatedString} + placeholder={ + t`Search devices by name or ID` as TranslatedString + } + help={ + t`Optional. Select the device used to verify payments from this template.` as TranslatedString + } + disabled={isSubmitting} + handler={{ + field: { + name: "otpDeviceId", + value: otpDeviceId, + onChange: (id) => setOtpDeviceId(id || undefined), + }, + }} + choices={[ + { 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, + })), + // 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, + }] + : []), + ]} + /> + {devicesResource?.isLoading && ( + <InitialLoadingState>{t`Loading devices…`}</InitialLoadingState> + )} + {devicesResource?.error && ( + <ReadErrorBanner + resource={devicesResource} + title={t`Devices could not be loaded`} + /> + )} + {!devicesResource?.isLoading && + !devicesResource?.error && + devices.length === 0 && ( + <p class="text-xs text-gray-500">{t`No OTP devices configured.`}</p> + )} + </div> + <div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <DurationInput id="tmpl_time_pay_input" diff --git a/packages/web-util/src/forms/fields/InputSelectOne.test.tsx b/packages/web-util/src/forms/fields/InputSelectOne.test.tsx @@ -129,7 +129,7 @@ test("single-select searches descriptions and has a neutral empty state", async const input = view.getByRole("combobox") as HTMLInputElement; input.focus(); input.value = "passport"; - input.dispatchEvent(new Event("change", { bubbles: true })); + input.dispatchEvent(new Event("input", { bubbles: true })); await eventually(() => { const options = view.getAllByRole("option"); assert.equal(options.length, 1); @@ -137,7 +137,7 @@ test("single-select searches descriptions and has a neutral empty state", async }); input.value = "does not exist"; - input.dispatchEvent(new Event("change", { bubbles: true })); + input.dispatchEvent(new Event("input", { bubbles: true })); await eventually(() => { const empty = view.getByRole("option", { name: "No element found" }); assert.equal(empty.getAttribute("aria-disabled"), "true"); diff --git a/packages/web-util/src/forms/fields/InputSelectOne.tsx b/packages/web-util/src/forms/fields/InputSelectOne.tsx @@ -63,6 +63,10 @@ export function InputSelectOne<Choices>( filter === undefined ? undefined : filteredChoices === undefined || !filteredChoices.length; + const activeChoiceIndex = Math.min( + activeIndex, + (filteredChoices?.length ?? 0) - 1, + ); return ( <div class="sm:col-span-6"> <LabelWithTooltipMaybeRequired @@ -103,12 +107,21 @@ export function InputSelectOne<Choices>( type="text" value={filter ?? ""} disabled={props.disabled} - onChange={(e) => { + onInput={(e) => { setFilter(e.currentTarget.value); setActiveIndex(0); setDirty(true); }} onKeyDown={(event) => { + if (filter === undefined) return; + if (event.key === "Escape") { + event.preventDefault(); + setFilter(undefined); + return; + } + // Searching must not submit the surrounding form, including + // when no option matches the query. + if (event.key === "Enter") event.preventDefault(); if (!filteredChoices?.length) return; switch (event.key) { case "ArrowDown": @@ -119,18 +132,14 @@ export function InputSelectOne<Choices>( break; case "ArrowUp": event.preventDefault(); - setActiveIndex((current) => Math.max(current - 1, 0)); + setActiveIndex(Math.max(activeChoiceIndex - 1, 0)); break; case "Enter": event.preventDefault(); setFilter(undefined); - onChange(filteredChoices[activeIndex].value as any); + onChange(filteredChoices[activeChoiceIndex].value as any); setDirty(true); break; - case "Escape": - event.preventDefault(); - setFilter(undefined); - break; } }} onBlur={(e) => { @@ -138,6 +147,7 @@ export function InputSelectOne<Choices>( }} onFocus={(e) => { setFilter(""); + setActiveIndex(0); }} placeholder={placeholder} class="w-full rounded-md border-0 bg-white py-1.5 pl-3 pr-12 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-indigo-600 dark:bg-gray-950 dark:text-gray-100 dark:ring-gray-600 sm:text-sm sm:leading-6" @@ -147,7 +157,7 @@ export function InputSelectOne<Choices>( aria-autocomplete="list" aria-activedescendant={ filteredChoices?.length - ? `${id}-option-${activeIndex}` + ? `${id}-option-${activeChoiceIndex}` : undefined } aria-describedby={ @@ -161,6 +171,7 @@ export function InputSelectOne<Choices>( /> <button type="button" + disabled={props.disabled} onMouseDown={(e) => { // Input element should not lose focus e.preventDefault(); @@ -211,7 +222,7 @@ export function InputSelectOne<Choices>( role="listbox" > {filteredChoices.map((v, idx) => { - const active = idx === activeIndex; + const active = idx === activeChoiceIndex; return ( <li key={String(v.value)} role="none"> <button