taler-typescript-core

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

commit 519800af0da61bcda22b44b34c7d6d89d42f45ef
parent 37605197af72b4afa5fc41cfd2db1bc319a2ff19
Author: Florian Dold <dold@taler.net>
Date:   Wed,  4 Nov 2026 23:54:02 +0100

merchant web UI: add multi-select product and category popovers

Keep selections as chips and open a stable popover from a separate Add
row. Support checkbox toggles, keyboard navigation, Done, and optional
inline category creation with pending and failure feedback.

Diffstat:
Apackages/taler-merchant-webui/src/screens/token-setup/TokenSelector.test.tsx | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx | 483+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/stories/TokenSelectorStory.tsx | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/stories/story-data.tsx | 20++++++++++++++++++++
Mpackages/taler-merchant-webui/src/stories/story-messages.ts | 22++++++++++++++++++++++
5 files changed, 865 insertions(+), 0 deletions(-)

diff --git a/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.test.tsx b/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.test.tsx @@ -0,0 +1,222 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + SPDX-License-Identifier: GPL-3.0-or-later + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { render } from "preact"; +import { useState } from "preact/hooks"; +import { act } from "preact/test-utils"; +import { TokenSelector } from "./TokenSelector.js"; +import { normalizeApiFailure } from "../../api/failure.js"; +import { STORIES } from "../../stories/story-data.js"; + +function mount(node: preact.VNode) { + const root = document.createElement("div"); + document.body.appendChild(root); + act(() => render(node, root)); + return { + root, + close: () => { + act(() => render(null, root)); + root.remove(); + }, + }; +} +function search(root: HTMLElement, value: string) { + if (!root.querySelector('input[role="combobox"]')) { + act(() => + root + .querySelector<HTMLButtonElement>('button[aria-haspopup="dialog"]')! + .click(), + ); + } + const input = root.querySelector<HTMLInputElement>('input[role="combobox"]')!; + act(() => { + input.focus(); + input.value = value; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + return input; +} +function key(input: HTMLElement, key: string) { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + act(() => { + input.dispatchEvent(event); + }); + return event; +} +const options = [ + { id: "a", name: "Coffee" }, + { id: "b", name: "Coffee" }, + { id: "cake", name: "Cake" }, +]; +function Fixture() { + const [selected, setSelected] = useState<typeof options>([]); + return ( + <form> + <TokenSelector + selected={selected} + onChange={setSelected} + options={options} + label="Search" + addLabel="+ Add item" + emptyMessage="Empty" + loading={false} + unavailable={false} + /> + <button type="button">Outside</button> + </form> + ); +} + +test("multi-select searches by name and ID, preserves chips, and supports keyboard toggling", () => { + const { root, close } = mount(<Fixture />); + try { + assert.equal(root.querySelector('[role="listbox"]'), null); + const trigger = root.querySelector<HTMLButtonElement>( + 'button[aria-haspopup="dialog"]', + )!; + const input = search(root, "COFFEE"); + assert.equal(document.activeElement, input); + assert.equal(root.querySelectorAll('[role="option"]').length, 2); + key(input, "ArrowDown"); + assert.ok(input.getAttribute("aria-activedescendant")); + assert.equal(key(input, "Enter").defaultPrevented, true); + key(input, "ArrowDown"); + key(input, "Enter"); + assert.equal(root.querySelectorAll("[data-chip-remove]").length, 2); + assert.equal(root.querySelectorAll('[aria-selected="true"]').length, 2); + assert.equal(input.value, "COFFEE"); + search(root, "cake"); + assert.equal(root.querySelectorAll('[role="option"]').length, 1); + assert.equal(root.querySelectorAll("[data-chip-remove]").length, 2); + search(root, "b"); + assert.equal(root.querySelectorAll('[role="option"]').length, 1); + key(input, "ArrowUp"); + key(input, "Enter"); + assert.equal(root.querySelectorAll("[data-chip-remove]").length, 1); + key(input, "Escape"); + assert.equal(trigger.getAttribute("aria-expanded"), "false"); + assert.equal(root.querySelector('[role="combobox"]'), null); + assert.equal(document.activeElement, trigger); + assert.equal(search(root, "").value, ""); + key(root.querySelector<HTMLInputElement>('[role="combobox"]')!, "Escape"); + act(() => + root.querySelector<HTMLButtonElement>("[data-chip-remove]")!.click(), + ); + assert.equal(root.querySelectorAll("[data-chip-remove]").length, 0); + assert.equal(document.activeElement, trigger); + search(root, "missing"); + assert.match(root.textContent!, /No matches/); + act(() => { + document.body.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true }), + ); + }); + assert.equal(trigger.getAttribute("aria-expanded"), "false"); + assert.equal(root.querySelector('[role="combobox"]'), null); + search(root, ""); + act(() => root.querySelector<HTMLButtonElement>("form > button")!.focus()); + assert.equal(trigger.getAttribute("aria-expanded"), "false"); + assert.equal(root.querySelector('[role="combobox"]'), null); + } finally { + close(); + } +}); + +test("catalogue datasets reset selection and expose loading, missing data and retry", async () => { + const story = STORIES.find((s) => s.id === "ui-token-selector")!; + assert.equal(story.category, "UI Components"); + assert.equal(story.dataSets?.length, 6); + const { root, close } = mount(story.render("preselected")); + try { + const inputs = root.querySelectorAll<HTMLButtonElement>( + 'button[aria-haspopup="dialog"]', + ); + assert.equal(inputs.length, 2); + assert.notEqual( + inputs[0]!.getAttribute("aria-controls"), + inputs[1]!.getAttribute("aria-controls"), + ); + assert.equal(root.querySelectorAll("[data-chip-remove]").length, 5); + act(() => render(story.render("empty-selection"), root)); + assert.equal(root.querySelectorAll("[data-chip-remove]").length, 0); + search(root, "Hot"); + act(() => root.querySelector<HTMLElement>('[role="option"]')!.click()); + assert.equal(root.querySelectorAll("[data-chip-remove]").length, 1); + act(() => render(story.render("loading"), root)); + search(root, ""); + assert.match(root.textContent!, /Loading/); + assert.doesNotMatch(root.textContent!, /Unavailable —/); + assert.equal(root.querySelectorAll("[data-chip-remove]").length, 5); + act(() => render(story.render("unavailable"), root)); + assert.match(root.textContent!, /Retired coffee blend/); + assert.match(root.textContent!, /Unavailable — remove before saving/); + act(() => render(story.render("empty-catalog"), root)); + search(root, ""); + assert.match(root.textContent!, /No product categories are available/); + act(() => render(story.render("failure"), root)); + assert.doesNotMatch(root.textContent!, /Unavailable —/); + const retry = [...root.querySelectorAll<HTMLButtonElement>("button")].find( + (b) => b.textContent?.includes("Refresh"), + ); + assert.ok(retry); + await act(async () => retry.click()); + search(root, "Hot"); + assert.equal(root.querySelectorAll('[role="option"]').length, 1); + assert.doesNotMatch(root.textContent!, /Could not load/); + } finally { + close(); + } +}); + +test("refresh failures retain cached choices and refreshed chip names without discarding saved IDs", () => { + const selected = [{ id: "a", name: "Old coffee" }]; + const view = ( + data: typeof options | undefined, + loading = false, + unavailable = false, + failed = false, + ) => ( + <TokenSelector + selected={selected} + onChange={() => {}} + options={data ?? []} + resource={{ + data, + error: failed ? normalizeApiFailure(new Error("Offline")) : undefined, + isLoading: loading, + isRefreshing: false, + refresh: async () => {}, + }} + loading={loading} + unavailable={unavailable} + label="Search" + addLabel="+ Add item" + emptyMessage="Empty" + /> + ); + const { root, close } = mount(view(undefined, true)); + try { + assert.match(root.textContent!, /Old coffee/); + assert.doesNotMatch(root.textContent!, /Unavailable —/); + act(() => render(view(undefined, false, true), root)); + assert.doesNotMatch(root.textContent!, /Unavailable —/); + act(() => render(view(options, false, false, true), root)); + assert.ok(root.querySelector('[aria-label="Remove Coffee (a)"]')); + search(root, "Coffee"); + assert.equal(root.querySelectorAll('[role="option"]').length, 2); + assert.equal(selected[0]!.id, "a"); + act(() => render(view([]), root)); + assert.match(root.textContent!, /Old coffee/); + assert.match(root.textContent!, /Unavailable — remove before saving/); + } finally { + close(); + } +}); diff --git a/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx b/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx @@ -0,0 +1,483 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + SPDX-License-Identifier: GPL-3.0-or-later + */ +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "preact/hooks"; +import type { RemoteResource } from "../../api/contracts.js"; +import { useTranslation } from "../../context/translation.js"; +import { ReadErrorBanner } from "../../ui/ReadErrorBanner.js"; +import { formatErrorMessage } from "../../utils/errors.js"; +import { useOutsideDismiss } from "../../ui/dropdownHooks.js"; + +export function TokenSelector<T extends { id: string | number; name: string }>({ + selected, + onChange, + onCreateCategory, + options, + resource, + loading, + unavailable, + emptyMessage, + label, + addLabel, +}: { + selected: T[]; + onChange: (value: T[]) => void; + onCreateCategory?: (name: string) => Promise<T>; + options: T[]; + resource?: RemoteResource<T[]>; + loading: boolean; + unavailable: boolean; + emptyMessage: string; + label: string; + addLabel: string; +}) { + const { t } = useTranslation(); + const id = useId(); + const wrapper = useRef<HTMLDivElement>(null); + const trigger = useRef<HTMLButtonElement>(null); + const input = useRef<HTMLInputElement>(null); + const list = useRef<HTMLDivElement>(null); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [active, setActive] = useState(-1); + const [reservedHeight, setReservedHeight] = useState(0); + const [placement, setPlacement] = useState({ + top: 0, + left: 0, + width: 320, + height: 340, + }); + const [announcement, setAnnouncement] = useState(""); + const [creating, setCreating] = useState(false); + const creatingRef = useRef(false); + const [creationError, setCreationError] = useState(""); + const mounted = useRef(true); + useEffect( + () => () => { + mounted.current = false; + }, + [], + ); + const latest = useRef({ selected, onChange, open }); + latest.current = { selected, onChange, open }; + const query = search.trim().toLocaleLowerCase(); + const choices = + loading || unavailable + ? [] + : options.filter((entry) => + `${entry.name} ${entry.id}`.toLocaleLowerCase().includes(query), + ); + const close = useCallback(() => { + setOpen(false); + setSearch(""); + setActive(-1); + setCreationError(""); + }, []); + const finish = useCallback(() => { + close(); + trigger.current?.focus({ preventScroll: true }); + }, [close]); + useOutsideDismiss(open, wrapper, close); + useEffect(() => { + if (!open) return; + const element = wrapper.current; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + finish(); + } + }; + element?.addEventListener("keydown", onKeyDown); + return () => element?.removeEventListener("keydown", onKeyDown); + }, [open, finish]); + useLayoutEffect(() => { + // Removing chips near the page bottom must not shrink the document and + // clamp its scroll position while someone is using the open menu. + setReservedHeight((previous) => + open + ? Math.max( + previous, + wrapper.current?.getBoundingClientRect().height ?? 0, + ) + : 0, + ); + }, [open, selected, options, loading, unavailable]); + useLayoutEffect(() => { + if (!open) return; + const rect = trigger.current?.getBoundingClientRect(); + const parent = wrapper.current?.getBoundingClientRect(); + if (!rect || !parent) return; + // Anchor to the opening position within the selector. Adding chips moves + // the Add button, but must not move a row out from under the pointer. + let anchor = { + top: rect.top - parent.top, + left: rect.left - parent.left, + height: rect.height, + }; + const position = () => { + const bounds = wrapper.current?.getBoundingClientRect(); + if (!bounds) return; + const top = bounds.top + anchor.top; + const bottom = top + anchor.height; + const below = window.innerHeight - bottom - 12; + const above = top - 12; + const upwards = below < 340 && above > below; + const width = Math.min(360, window.innerWidth - 24); + const height = Math.max(0, Math.min(340, upwards ? above : below)); + setPlacement({ + top: Math.max( + 12, + Math.min( + upwards ? top - height - 4 : bottom + 4, + window.innerHeight - height - 12, + ), + ), + left: Math.max( + 12, + Math.min(bounds.left + anchor.left, window.innerWidth - width - 12), + ), + width, + height, + }); + }; + const resize = () => { + const rect = trigger.current?.getBoundingClientRect(); + const bounds = wrapper.current?.getBoundingClientRect(); + if (rect && bounds) + anchor = { + top: rect.top - bounds.top, + left: rect.left - bounds.left, + height: rect.height, + }; + position(); + }; + const scroll = (event: Event) => { + // Scrolling search results must not reposition the popover itself. + if (event.target === list.current) return; + position(); + }; + position(); + window.addEventListener("resize", resize); + window.addEventListener("scroll", scroll, true); + return () => { + window.removeEventListener("resize", resize); + window.removeEventListener("scroll", scroll, true); + }; + }, [open]); + useLayoutEffect(() => { + if (open) input.current?.focus({ preventScroll: true }); + }, [open]); + useEffect(() => { + const container = list.current; + const option = container?.querySelector<HTMLElement>( + `[data-index="${active}"]`, + ); + if (!container || !option) return; + const bounds = container.getBoundingClientRect(); + const item = option.getBoundingClientRect(); + if (item.top < bounds.top) container.scrollTop -= bounds.top - item.top; + else if (item.bottom > bounds.bottom) + container.scrollTop += item.bottom - bounds.bottom; + }, [active]); + const toggle = (entry: T) => { + const exists = selected.some((s) => s.id === entry.id); + onChange( + exists ? selected.filter((s) => s.id !== entry.id) : [...selected, entry], + ); + setAnnouncement( + exists ? t`Removed ${entry.name}` : t`Selected ${entry.name}`, + ); + }; + const canCreate = + !!onCreateCategory && + !!query && + choices.length === 0 && + !loading && + !unavailable; + const createCategory = async () => { + if (!canCreate || !onCreateCategory || creatingRef.current) return; + creatingRef.current = true; + setCreating(true); + setCreationError(""); + input.current?.focus({ preventScroll: true }); + try { + const category = await onCreateCategory(search.trim()); + if (!mounted.current) return; + const current = latest.current; + if (!current.selected.some((item) => item.id === category.id)) { + current.onChange([...current.selected, category]); + } + setSearch(""); + setActive(-1); + setAnnouncement(t`Selected ${category.name}`); + if (current.open) input.current?.focus({ preventScroll: true }); + } catch (cause) { + if (mounted.current) + setCreationError( + formatErrorMessage(cause, t`Could not create the category`, t), + ); + } finally { + creatingRef.current = false; + if (mounted.current) setCreating(false); + } + }; + const activeChoice = + open && active >= 0 && active < choices.length ? active : -1; + return ( + <div + ref={wrapper} + class="relative space-y-2" + style={{ + overflowAnchor: "none", + minHeight: open ? reservedHeight : undefined, + }} + onFocusOut={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) + close(); + }} + > + {resource && ( + <ReadErrorBanner + resource={resource} + title={t`Could not load eligible products or categories`} + /> + )} + {!open && (loading || (!unavailable && options.length === 0)) && ( + <p role="status" class="text-sm text-gray-500"> + {loading ? t`Loading…` : emptyMessage} + </p> + )} + {selected.length > 0 && ( + <ul + aria-label={t`Selected items: ${label}`} + class="flex flex-wrap gap-2" + > + {selected.map((snapshot, index) => { + const entry = + options.find((option) => option.id === snapshot.id) ?? snapshot; + const missing = + (!resource || resource.data !== undefined) && + !loading && + !unavailable && + !options.some((option) => option.id === snapshot.id); + return ( + <li + key={entry.id} + class={`inline-flex max-w-full items-center gap-1 rounded-lg border py-1 pl-2 pr-1 text-sm ${missing ? "border-amber-300 bg-amber-50" : "border-blue-200 bg-blue-50"}`} + > + <span class="min-w-0 break-words"> + {entry.name} + <span class="block text-xs text-gray-500">{entry.id}</span> + {missing && ( + <span class="block text-xs text-amber-800">{t`Unavailable — remove before saving`}</span> + )} + </span> + <button + type="button" + aria-label={t`Remove ${entry.name} (${entry.id})`} + class="shrink-0 rounded p-2 text-gray-600 hover:bg-blue-100 focus:ring-2 focus:ring-blue-500" + onClick={() => { + const buttons = + wrapper.current?.querySelectorAll<HTMLButtonElement>( + "[data-chip-remove]", + ); + const next = buttons?.[index + 1] ?? buttons?.[index - 1]; + if (next) next.focus({ preventScroll: true }); + else trigger.current?.focus({ preventScroll: true }); + toggle(entry); + }} + data-chip-remove + > + × + </button> + </li> + ); + })} + </ul> + )} + <div> + <button + ref={trigger} + type="button" + aria-expanded={open} + aria-haspopup="dialog" + aria-controls={`${id}-popover`} + onClick={() => (open ? close() : setOpen(true))} + class="rounded-lg border border-dashed border-blue-300 px-3 py-2 text-sm text-blue-700 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500" + > + {addLabel} + </button> + </div> + {open && ( + <div + id={`${id}-popover`} + role="dialog" + aria-label={addLabel} + class="fixed z-40 flex flex-col gap-2 rounded-lg border border-gray-300 bg-white p-2 shadow-lg" + style={{ + marginTop: 0, + top: `${placement.top}px`, + left: `${placement.left}px`, + width: `${placement.width}px`, + height: `${placement.height}px`, + }} + > + <input + ref={input} + type="search" + role="combobox" + aria-label={label} + aria-expanded={open} + aria-controls={`${id}-options`} + aria-autocomplete="list" + aria-activedescendant={ + activeChoice < 0 ? undefined : `${id}-option-${activeChoice}` + } + placeholder={`${label}…`} + autoComplete="off" + readOnly={creating} + value={search} + onFocus={() => setOpen(true)} + onClick={() => setOpen(true)} + onInput={(event) => { + setSearch(event.currentTarget.value); + setCreationError(""); + setActive(-1); + setOpen(true); + }} + onKeyDown={(event) => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + const count = choices.length; + setActive( + count === 0 + ? -1 + : event.key === "ArrowDown" + ? (activeChoice + 1) % count + : activeChoice < 0 + ? count - 1 + : (activeChoice + count - 1) % count, + ); + } else if (event.key === "Enter") { + event.preventDefault(); + if (activeChoice >= 0) toggle(choices[activeChoice]!); + else setOpen(true); + } + }} + class="w-full shrink-0 rounded-lg border border-transparent bg-gray-50 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-gray-300 focus:outline-none" + /> + <div + ref={list} + tabIndex={-1} + class="min-h-0 flex-1 overflow-auto overscroll-contain" + > + <div + id={`${id}-options`} + role="listbox" + aria-label={label} + aria-multiselectable="true" + aria-busy={loading} + > + {choices.map((entry, index) => { + const checked = selected.some((s) => s.id === entry.id); + return ( + <div + tabIndex={-1} + key={entry.id} + id={`${id}-option-${index}`} + data-index={index} + role="option" + aria-selected={checked} + class={`flex w-full text-left cursor-pointer items-start gap-2 px-3 py-2 text-sm ${activeChoice === index ? "bg-blue-100" : checked ? "bg-blue-50" : "hover:bg-gray-50"}`} + onMouseDown={(event) => event.preventDefault()} + onClick={() => { + setActive(index); + toggle(entry); + input.current?.focus({ preventScroll: true }); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setActive(index); + toggle(entry); + input.current?.focus({ preventScroll: true }); + } + }} + > + {/* Selection is exposed by the listbox option's aria-selected; + the native checkbox supplies its familiar visual state. */} + <input + type="checkbox" + checked={checked} + readOnly + tabIndex={-1} + aria-hidden="true" + class="pointer-events-none mt-1 h-4 w-4 shrink-0 accent-blue-600" + /> + <span class="min-w-0 break-words"> + {entry.name} + <span class="block text-xs text-gray-500"> + {entry.id} + </span> + </span> + </div> + ); + })} + {choices.length === 0 && ( + <p role="status" class="px-3 py-3 text-sm text-gray-500"> + {loading + ? t`Loading…` + : unavailable + ? t`Could not load eligible products or categories` + : options.length + ? t`No matches` + : emptyMessage} + </p> + )} + </div> + {creationError && ( + <p role="alert" class="px-3 py-2 text-sm text-red-800"> + {creationError} + </p> + )} + {canCreate && ( + <button + type="button" + disabled={creating} + data-create-category + onClick={createCategory} + class="w-full rounded-lg px-3 py-2 text-left text-sm font-semibold text-blue-700 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 disabled:opacity-50" + > + {creating + ? t`Creating category…` + : t`Create category “${search.trim()}”`} + </button> + )} + </div> + <div class="flex shrink-0 justify-end"> + <button + type="button" + onClick={finish} + class="rounded-lg px-3 py-1.5 text-sm font-semibold text-blue-700 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500" + >{t`Done`}</button> + </div> + </div> + )} + + <span class="sr-only" role="status" aria-live="polite"> + {announcement} + </span> + </div> + ); +} diff --git a/packages/taler-merchant-webui/src/stories/TokenSelectorStory.tsx b/packages/taler-merchant-webui/src/stories/TokenSelectorStory.tsx @@ -0,0 +1,118 @@ +/* + This file is part of GNU Taler + (C) 2026 Taler Systems S.A. + SPDX-License-Identifier: GPL-3.0-or-later + */ +import { useState } from "preact/hooks"; +import { TokenSelector } from "../screens/token-setup/TokenSelector.js"; +import { useTranslation } from "../context/translation.js"; +import { normalizeApiFailure } from "../api/failure.js"; + +const categories = [ + { id: 1, name: "Hot drinks" }, + { id: 2, name: "Pastries" }, + { id: 3, name: "Merchandise" }, + { id: 4, name: "Cold drinks" }, + { id: 5, name: "Seasonal specials" }, +]; +const products = [ + { id: "espresso", name: "Espresso" }, + { id: "cappuccino-small", name: "Cappuccino" }, + { id: "cappuccino-large", name: "Cappuccino" }, + { id: "croissant", name: "Croissant" }, + { id: "mug", name: "Coffee mug" }, + { + id: "coffee-box", + name: "Seasonal single-origin coffee tasting selection with reusable gift box", + }, + ...[ + "Latte", + "Mocha", + "Americano", + "Tea", + "Iced coffee", + "Hot chocolate", + "Cookie", + "Brownie", + "Baguette", + "Lemonade", + ].map((name, index) => ({ id: `cafe-${index}`, name })), +]; +export function TokenSelectorStory({ + dataSet = "preselected", +}: { + dataSet?: string; +}) { + const { t } = useTranslation(); + const [selectedCategories, setCategories] = useState( + dataSet === "empty-selection" || dataSet === "empty-catalog" + ? [] + : categories.slice(0, 2), + ); + const [selectedProducts, setProducts] = useState( + dataSet === "empty-selection" || dataSet === "empty-catalog" + ? [] + : dataSet === "unavailable" + ? [products[0]!, { id: "retired", name: "Retired coffee blend" }] + : [products[0]!, products[1]!, products[5]!], + ); + const [categoryOptions, setCategoryOptions] = useState( + dataSet === "empty-catalog" ? [] : categories, + ); + const [recovered, setRecovered] = useState(false); + const loading = dataSet === "loading"; + const unavailable = dataSet === "failure" && !recovered; + const empty = dataSet === "empty-catalog"; + const error = unavailable + ? normalizeApiFailure(new Error("Sample catalog is unavailable")) + : undefined; + const resource = <T,>(options: T[]) => ({ + data: loading || unavailable ? undefined : options, + error, + isLoading: loading, + isRefreshing: false, + refresh: async () => { + setRecovered(true); + }, + }); + return ( + <div class="max-w-2xl space-y-6 pb-16"> + <section class="space-y-2"> + <h3 class="text-sm font-bold">{t`Product categories`}</h3> + <TokenSelector + selected={selectedCategories} + onChange={setCategories} + onCreateCategory={async (name) => { + const category = { + id: Math.max(0, ...categoryOptions.map((item) => item.id)) + 1, + name, + }; + setCategoryOptions((items) => [...items, category]); + return category; + }} + options={categoryOptions} + resource={resource(categoryOptions)} + loading={loading} + unavailable={unavailable} + emptyMessage={t`No product categories are available. Create a category or select an individual product.`} + label={t`Search categories`} + addLabel={t`+ Add product category`} + /> + </section> + <section class="space-y-2"> + <h3 class="text-sm font-bold">{t`Individual products`}</h3> + <TokenSelector + selected={selectedProducts} + onChange={setProducts} + options={empty ? [] : products} + resource={resource(empty ? [] : products)} + loading={loading} + unavailable={unavailable} + emptyMessage={t`No inventory products are available. Add a product or select a product category.`} + label={t`Search products`} + addLabel={t`+ Add inventory product`} + /> + </section> + </div> + ); +} diff --git a/packages/taler-merchant-webui/src/stories/story-data.tsx b/packages/taler-merchant-webui/src/stories/story-data.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { TokenSelectorStory } from "./TokenSelectorStory.js"; import type { VNode } from "preact"; import { useState } from "preact/hooks"; import { @@ -669,6 +670,25 @@ const storyResource = <T,>(data: T) => ({ export const STORIES: Story[] = [ { + id: "ui-token-selector", + category: "UI Components", + name: "Product and category multi-select", + description: + "Search products and categories, select multiple items, and remove selected chips.", + dataSets: [ + { id: "preselected", label: "Preselected" }, + { id: "empty-selection", label: "Empty selection" }, + { id: "unavailable", label: "Unavailable selection" }, + { id: "loading", label: "Loading" }, + { id: "empty-catalog", label: "Empty catalog" }, + { id: "failure", label: "Load failure" }, + ], + defaultDataSetId: "preselected", + render: (dataSetId = "preselected") => ( + <TokenSelectorStory key={dataSetId} dataSet={dataSetId} /> + ), + }, + { id: "administration-accounts", category: "Administration", name: "Merchant Account Administration", diff --git a/packages/taler-merchant-webui/src/stories/story-messages.ts b/packages/taler-merchant-webui/src/stories/story-messages.ts @@ -16,6 +16,22 @@ import type { TranslateFn } from "../context/translation.js"; */ export function translateStoryMetadata(source: string, t: TranslateFn): string { switch (source) { + case "Load failure": + return t`Load failure`; + case "Empty catalog": + return t`Empty catalog`; + case "Loading": + return t`Loading`; + case "Unavailable selection": + return t`Unavailable selection`; + case "Empty selection": + return t`Empty selection`; + case "Preselected": + return t`Preselected`; + case "Search products and categories, select multiple items, and remove selected chips.": + return t`Search products and categories, select multiple items, and remove selected chips.`; + case "Product and category multi-select": + return t`Product and category multi-select`; case "3x4 touch numeric numpad for ad-hoc quick charge payments.": return t`3x4 touch numeric numpad for ad-hoc quick charge payments.`; case "4-step setup status guide summarizing business info, payout accounts, verification, and selling options.": @@ -78,6 +94,12 @@ export function translateStoryMetadata(source: string, t: TranslateFn): string { return t`Copyable Account`; case "Create Access Token": return t`Create Access Token`; + case "Guided Discount Setup": + return t`Guided Discount Setup`; + case "Guided setup for discount stamps and passes.": + return t`Guided setup for discount stamps and passes.`; + case "Create Discount Stamps": + return t`Create Discount Stamps`; case "Create Discount": return t`Create Discount`; case "Create Merchant Account":