taler-typescript-core

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

commit 912f4894e24f02cb4f90a5e584b9035f28de6402
parent a2b410c5a31dc054b99b278732035f083b75e849
Author: Florian Dold <dold@taler.net>
Date:   Sun, 23 Aug 2026 16:52:47 +0200

web-util: improve list field editing

Diffstat:
Apackages/web-util/src/forms/fields/InputArray.test.tsx | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/forms/fields/InputArray.tsx | 410+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/web-util/src/forms/fields/InputLine.tsx | 10+++++++++-
Mpackages/web-util/src/forms/gana/VQF_902_11_customer.stories.tsx | 18+++++++++++++++++-
4 files changed, 477 insertions(+), 199 deletions(-)

diff --git a/packages/web-util/src/forms/fields/InputArray.test.tsx b/packages/web-util/src/forms/fields/InputArray.test.tsx @@ -0,0 +1,238 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { setupI18n, TranslatedString } from "@gnu-taler/taler-util"; +import { Window } from "happy-dom"; +import { h } from "preact"; +import { useForm } from "../../hooks/useForm.js"; +import { FormUI } from "../forms-ui.js"; +import { FormDesign } from "../forms-types.js"; + +setupI18n("en", {}); + +function installDom() { + const window = new Window({ url: "https://kyc.example/" }); + for (const [key, value] of Object.entries({ + window, + document: window.document, + navigator: window.navigator, + Node: window.Node, + Element: window.Element, + Event: window.Event, + MouseEvent: window.MouseEvent, + KeyboardEvent: window.KeyboardEvent, + HTMLElement: window.HTMLElement, + HTMLButtonElement: window.HTMLButtonElement, + HTMLInputElement: window.HTMLInputElement, + MutationObserver: window.MutationObserver, + })) { + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + } + return window; +} + +async function eventually(assertion: () => void): Promise<void> { + let lastError: unknown; + for (let attempt = 0; attempt < 50; attempt++) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + throw lastError; +} + +const design: FormDesign = { + type: "single-column", + fields: [ + { + type: "array", + id: "people", + label: "People" as TranslatedString, + labelFieldId: "name", + validator(value) { + return value?.length + ? undefined + : ("Can't be empty" as TranslatedString); + }, + fields: [ + { + type: "text", + id: "name", + label: "Name" as TranslatedString, + required: true, + }, + { + type: "textArea", + id: "address", + label: "Address" as TranslatedString, + required: true, + }, + ], + }, + ], +}; + +test("array items can be added, edited, cancelled, and deleted", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + let result: Record<string, any> = {}; + + function Harness() { + const form = useForm<Record<string, any>>(design, {}); + result = form.status.result; + return <FormUI design={design} model={form.model} />; + } + + const view = render(<Harness />); + assert.equal(view.queryAllByRole("radio").length, 0); + const originalAddButton = view.getByRole("button", { name: "Add item" }); + originalAddButton.click(); + await view.findByRole("button", { name: "Cancel" }); + + const nameInput = + view.container.querySelector<HTMLInputElement>('input[name="name"]'); + const addressInput = view.container.querySelector<HTMLTextAreaElement>( + 'textarea[name="address"]', + ); + assert.ok(nameInput); + assert.ok(addressInput); + assert.equal(view.getByRole("textbox", { name: "Name" }) === nameInput, true); + assert.equal( + view.getByRole("textbox", { name: "Address" }) === addressInput, + true, + ); + + nameInput.value = "Alex Example"; + nameInput.dispatchEvent(new window.Event("change", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + addressInput.value = "Main Street 12, 8000 Zürich"; + addressInput.dispatchEvent(new window.Event("change", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + let confirmAddButton: HTMLElement | undefined; + await eventually(() => { + const addButtons = view.getAllByRole("button", { name: "Add item" }); + confirmAddButton = addButtons.find( + (button: HTMLElement) => !(button as HTMLButtonElement).disabled, + ); + assert.ok(confirmAddButton); + }); + assert.ok(confirmAddButton); + confirmAddButton.click(); + + await eventually(() => + assert.deepEqual(result.people, [ + { + name: "Alex Example", + address: "Main Street 12, 8000 Zürich", + }, + ]), + ); + + await eventually(() => + assert.equal( + (view.getByRole("button", { name: "Add item" }) as HTMLButtonElement) + .disabled, + false, + ), + ); + view.getByRole("button", { name: "Add item" }).click(); + await view.findByRole("button", { name: "Cancel" }); + const secondNameInput = + view.container.querySelector<HTMLInputElement>('input[name="name"]'); + assert.ok(secondNameInput); + secondNameInput.value = "Discarded"; + secondNameInput.dispatchEvent(new window.Event("change", { bubbles: true })); + view.getByRole("button", { name: "Cancel" }).click(); + await eventually(() => assert.equal(result.people.length, 1)); + await eventually(() => + assert.equal( + ( + view.getByRole("button", { + name: "Edit Alex Example", + }) as HTMLButtonElement + ).disabled, + false, + ), + ); + + const editButton = view.getByRole("button", { + name: "Edit Alex Example", + }); + editButton.click(); + await view.findByRole("button", { name: "Save changes" }); + const editNameInput = + view.container.querySelector<HTMLInputElement>('input[name="name"]'); + assert.ok(editNameInput); + assert.equal(editNameInput.value, "Alex Example"); + editNameInput.value = "Alex Updated"; + editNameInput.dispatchEvent(new window.Event("change", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + view.getByRole("button", { name: "Save changes" }).click(); + await eventually(() => assert.equal(result.people[0].name, "Alex Updated")); + await eventually(() => + assert.equal( + ( + view.getByRole("button", { + name: "Edit Alex Updated", + }) as HTMLButtonElement + ).disabled, + false, + ), + ); + + view.getByRole("button", { name: "Edit Alex Updated" }).click(); + (await view.findByRole("button", { name: "Delete" })).click(); + assert.equal( + (await view.findByRole("alert")).textContent?.includes("Delete this item?"), + true, + ); + view.getByRole("button", { name: "Keep item" }).click(); + assert.ok(await view.findByRole("button", { name: "Save changes" })); + + view.getByRole("button", { name: "Delete" }).click(); + (await view.findByRole("button", { name: "Delete item" })).click(); + await eventually(() => assert.deepEqual(result.people, [])); + + cleanup(); + await window.happyDOM.abort(); +}); + +test("array validation and disabled presentation remain available", async () => { + const window = installDom(); + const { cleanup, render } = await import("@testing-library/preact"); + + function EmptyHarness() { + const form = useForm<Record<string, any>>(design, {}); + return <FormUI design={design} model={form.model} />; + } + + const emptyView = render(<EmptyHarness />); + emptyView.getByRole("button", { name: "Add item" }).click(); + (await emptyView.findByRole("button", { name: "Cancel" })).click(); + assert.equal( + (await emptyView.findByRole("alert")).textContent, + "Can't be empty", + ); + cleanup(); + + function DisabledHarness() { + const form = useForm<Record<string, any>>(design, { + people: [{ name: "Alex Example", address: "Example address" }], + } as any); + return <FormUI design={design} model={form.model} disabled />; + } + + const disabledView = render(<DisabledHarness />); + assert.ok(disabledView.getByText("Alex Example")); + assert.equal(disabledView.queryAllByRole("button").length, 0); + + cleanup(); + await window.happyDOM.abort(); +}); diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx @@ -1,15 +1,16 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; -import { useId, useMemo, useState } from "preact/hooks"; +import { useEffect, useId, useMemo, useRef, useState } from "preact/hooks"; import { getValueFromPath, RecursivePartial, useForm, } from "../../hooks/useForm.js"; import { useTranslationContext } from "../../context/translation.js"; -import { SingleColumnFormSectionUI } from "../forms-ui.js"; +import { RenderAllFieldsByUiConfig } from "../forms-ui.js"; import { UIFormProps } from "../FormProvider.js"; import { UIFormElementConfig } from "../forms-types.js"; +import { convertFormConfigToUiField } from "../forms-utils.js"; import { LabelWithTooltipMaybeRequired } from "./InputLine.js"; export function noHandlerPropsAndNoContextForField( @@ -21,6 +22,7 @@ export function noHandlerPropsAndNoContextForField( } type FormType = {}; +type Editor = { type: "add" } | { type: "edit"; index: number }; const EMPTY_FORM = {}; function ArrayForm({ @@ -29,14 +31,14 @@ function ArrayForm({ onClose, onRemove, onConfirm, - name, + isNew, }: { fields: UIFormElementConfig[]; - selected: Record<string, string | undefined> | undefined; + selected: Record<string, unknown> | undefined; onClose: () => void; onRemove: () => void; onConfirm: (r: RecursivePartial<FormType>) => void; - name: string; + isNew: boolean; }): VNode { const { i18n } = useTranslationContext(); const design = useMemo( @@ -44,55 +46,84 @@ function ArrayForm({ [fields], ); const form = useForm<FormType>(design, selected ?? EMPTY_FORM); + const editorRef = useRef<HTMLDivElement>(null); + const [confirmingRemoval, setConfirmingRemoval] = useState(false); + + useEffect(() => { + editorRef.current + ?.querySelector<HTMLElement>( + "input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled])", + ) + ?.focus(); + }, []); return ( - <div class="px-4 py-6"> - <div class="grid grid-cols-1 gap-y-8 "> - <SingleColumnFormSectionUI - fields={fields} - model={form.model} - name={name} + <div ref={editorRef} class="border-t border-gray-200 px-3 py-4 sm:px-4"> + <div class="grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6"> + <RenderAllFieldsByUiConfig + fields={convertFormConfigToUiField(i18n, "root", fields, form.model)} /> </div> - {/* <pre>{JSON.stringify(form.status, undefined, 2)}</pre> */} - <div class="flex items-center justify-end gap-x-6 mt-4"> - <button - type="button" - onClick={onClose} - class="block px-3 py-2 text-sm font-semibold leading-6 text-gray-900" + {confirmingRemoval ? ( + <div + class="mt-5 rounded-md border border-red-200 bg-red-50 p-3" + role="alert" > - <i18n.Translate>Cancel</i18n.Translate> - </button> + <p class="text-sm font-medium text-red-800"> + <i18n.Translate>Delete this item?</i18n.Translate> + </p> + <div class="mt-3 flex flex-wrap items-center justify-end gap-3"> + <button + type="button" + onClick={() => setConfirmingRemoval(false)} + class="rounded-md px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Keep item</i18n.Translate> + </button> + <button + type="button" + onClick={onRemove} + class="rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + > + <i18n.Translate>Delete item</i18n.Translate> + </button> + </div> + </div> + ) : ( + <div class="mt-5 flex flex-wrap items-center justify-end gap-3"> + <button + type="button" + onClick={onClose} + class="rounded-md px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-gray-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + <i18n.Translate>Cancel</i18n.Translate> + </button> - <button - type="button" - disabled={selected === undefined} - onClick={() => { - onRemove(); - }} - // onClick={() => { - // const newValue = [...list]; - // newValue.splice(selectedIndex, 1); - // onChange(newValue as any); - // // setSelectedIndex(undefined); - // }} - class="block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200" - > - <i18n.Translate>Remove</i18n.Translate> - </button> + {!isNew && ( + <button + type="button" + onClick={() => setConfirmingRemoval(true)} + class="rounded-md px-3 py-2 text-sm font-semibold text-red-700 hover:bg-red-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600" + > + <i18n.Translate>Delete</i18n.Translate> + </button> + )} - <button - type="button" - disabled={form.status.status !== "ok"} - onClick={() => { - onConfirm(form.status.result); - }} - class="block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-indigo-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200" - > - <i18n.Translate>Confirm</i18n.Translate> - </button> - </div> + <button + type="button" + disabled={form.status.status !== "ok"} + onClick={() => onConfirm(form.status.result)} + class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-100 disabled:text-gray-500 disabled:shadow-none" + > + {isNew ? ( + <i18n.Translate>Add item</i18n.Translate> + ) : ( + <i18n.Translate>Save changes</i18n.Translate> + )} + </button> + </div> + )} </div> ); } @@ -108,20 +139,87 @@ export function InputArray( const { value, onChange, error } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); - const [dirty, setDirty] = useState<boolean>(); // FIXME: dirty state should come from handler + const [dirty, setDirty] = useState(false); + const [editor, setEditor] = useState<Editor | undefined>(); + const returnFocusRef = useRef<HTMLButtonElement | null>(null); + const generatedId = useId(); + const fieldId = `array-${generatedId}`; + const labelId = `${fieldId}-label`; + const helpId = `${fieldId}-help`; + const errorId = `${fieldId}-error`; - //@ts-ignore - const list = (value ?? []) as Array<Record<string, string>>; - const [selectedIndex, setSelectedIndex] = useState<number | undefined>( - undefined, - ); - const optionGroup = `array-${useId()}`; + const list = (value ?? []) as unknown as Array<Record<string, unknown>>; + + useEffect(() => { + if (editor?.type === "edit" && editor.index >= list.length) { + setEditor(undefined); + } + }, [editor, list.length]); if (hidden) { return <Fragment />; } - const selected = - selectedIndex === undefined ? undefined : list[selectedIndex]; + + const describedBy = [ + help ? helpId : undefined, + dirty && error ? errorId : undefined, + ] + .filter(Boolean) + .join(" "); + + function closeEditor(): void { + setDirty(true); + setEditor(undefined); + setTimeout(() => returnFocusRef.current?.focus(), 0); + } + + function labelForItem(item: Record<string, unknown>, index: number) { + const labelValue = getValueFromPath(item, labelField.split(".")); + if (Array.isArray(labelValue)) { + return labelValue.length > 0 + ? labelValue.join(", ") + : i18n.str`Item ${index + 1}`; + } + if (labelValue === undefined || labelValue === null || labelValue === "") { + return i18n.str`Item ${index + 1}`; + } + return String(labelValue) as TranslatedString; + } + + function renderEditor(selected: Record<string, unknown> | undefined) { + if (!editor || props.disabled) return undefined; + const isNew = editor.type === "add"; + return ( + <ArrayForm + key={isNew ? "add" : `edit-${editor.index}`} + fields={fields} + isNew={isNew} + selected={selected} + onRemove={() => { + if (editor.type !== "edit") return; + const newValue = [...list]; + newValue.splice(editor.index, 1); + onChange(newValue as any); + closeEditor(); + }} + onClose={closeEditor} + onConfirm={(newItem) => { + const newValue = [...list]; + if (editor.type === "add") { + newValue.push(newItem as Record<string, unknown>); + } else { + newValue.splice( + editor.index, + 1, + newItem as Record<string, unknown>, + ); + } + onChange(newValue as any); + closeEditor(); + }} + /> + ); + } return ( <div class="sm:col-span-6"> @@ -129,159 +227,77 @@ export function InputArray( label={label} required={required} tooltip={tooltip} - name={String(props.name)} + labelId={labelId} /> {help && ( - <p class="mt-2 text-sm text-gray-500" id="email-description"> + <p class="mt-2 text-sm text-gray-500" id={helpId}> {help} </p> )} - {dirty !== undefined && error && ( - <p class="mt-2 text-sm text-red-600" id="email-error"> + {dirty && error && ( + <p class="mt-2 text-sm text-red-600" id={errorId} role="alert"> {error} </p> )} - <div class="overflow-visible ring-1 ring-gray-900/5 rounded-xl p-4"> - <div class="-space-y-px rounded-md bg-white "> - {list.map((v, idx) => { - const labelValue = - getValueFromPath(v, labelField.split(".")) ?? - `<<Item ${idx + 1}>>`; - const label = Array.isArray(labelValue) - ? labelValue.join(", ") - : labelValue; - return ( - <Option - id={`${optionGroup}-${idx}`} - groupName={optionGroup} - label={label as TranslatedString} - key={idx} - isSelected={selectedIndex === idx} - isLast={idx === list.length - 1} - disabled={ - props.disabled || - (selectedIndex !== undefined && selectedIndex !== idx) - } - isFirst={idx === 0} - onClick={() => { - setSelectedIndex(selectedIndex === idx ? undefined : idx); - }} - /> - ); - })} - {!props.disabled && ( - <div class="pt-2"> - <Option - id={`${optionGroup}-new`} - groupName={optionGroup} - label={i18n.str`Add new...`} - isSelected={selectedIndex === list.length} - isLast - isFirst - disabled={ - selectedIndex !== undefined && selectedIndex !== list.length - } - onClick={() => { - setSelectedIndex( - selectedIndex === list.length ? undefined : list.length, - ); - }} - /> - </div> - )} - </div> - {!props.disabled && selectedIndex !== undefined && ( - <ArrayForm - name={props.name as string} - fields={fields} - onRemove={() => { - const newValue = [...list]; - newValue.splice(selectedIndex, 1); - onChange(newValue as any); - setDirty(true); - setSelectedIndex(undefined); - }} - onClose={() => { - setDirty(true); - setSelectedIndex(undefined); - }} - onConfirm={(value) => { - const newValue = [...list]; - newValue.splice(selectedIndex, 1, value); - onChange(newValue as any); - setDirty(true); - setSelectedIndex(undefined); - }} - selected={selected} - /> + <div + class="mt-2 overflow-hidden rounded-lg border border-gray-200 bg-white" + role="group" + aria-labelledby={labelId} + aria-describedby={describedBy || undefined} + > + {list.length > 0 && ( + <ul class="divide-y divide-gray-200"> + {list.map((item, index) => { + const itemLabel = labelForItem(item, index); + const isEditing = + editor?.type === "edit" && editor.index === index; + return ( + <li key={index} class={isEditing ? "bg-indigo-50/50" : ""}> + <div class="flex min-h-14 items-center justify-between gap-3 px-3 py-2 sm:px-4"> + <span class="min-w-0 break-words text-sm font-medium text-gray-900"> + {itemLabel} + </span> + {!props.disabled && ( + <button + type="button" + disabled={editor !== undefined} + aria-label={i18n.str`Edit ${itemLabel}`} + onClick={(event) => { + returnFocusRef.current = event.currentTarget; + setEditor({ type: "edit", index }); + }} + class="shrink-0 rounded-md px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:text-gray-400" + > + <i18n.Translate>Edit</i18n.Translate> + </button> + )} + </div> + {isEditing ? renderEditor(item) : undefined} + </li> + ); + })} + </ul> + )} + + {!props.disabled && ( + <div class={list.length > 0 ? "border-t border-gray-200 p-3" : "p-3"}> + <button + id={`${fieldId}-add`} + type="button" + disabled={editor !== undefined} + onClick={(event) => { + returnFocusRef.current = event.currentTarget; + setEditor({ type: "add" }); + }} + class="w-full rounded-md border border-dashed border-gray-300 px-3 py-2.5 text-sm font-semibold text-indigo-700 hover:border-indigo-300 hover:bg-indigo-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-400" + > + <i18n.Translate>Add item</i18n.Translate> + </button> + {editor?.type === "add" ? renderEditor(undefined) : undefined} + </div> )} </div> </div> ); } - -function Option({ - id, - groupName, - label, - disabled, - isFirst, - isLast, - isSelected, - onClick, -}: { - id: string; - groupName: string; - label: TranslatedString; - isFirst?: boolean; - isLast?: boolean; - isSelected?: boolean; - disabled?: boolean; - onClick: () => void; -}): VNode { - let clazz = "relative flex border p-4 focus:outline-none disabled:text-grey"; - if (isFirst) { - clazz += " rounded-tl-md rounded-tr-md "; - } - if (isLast) { - clazz += " rounded-bl-md rounded-br-md "; - } - if (isSelected) { - clazz += " z-10 border-indigo-200 bg-indigo-50 "; - } else { - clazz += " border-gray-200"; - } - if (disabled) { - clazz += - " cursor-not-allowed bg-gray-50 text-gray-500 ring-gray-200 text-gray"; - } else { - clazz += " cursor-pointer"; - } - return ( - <label class={clazz} for={id}> - <input - id={id} - type="radio" - name={groupName} - checked={isSelected} - disabled={disabled} - onClick={onClick} - class="mt-0.5 h-4 w-4 shrink-0 text-indigo-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200 focus:ring-indigo-600" - aria-labelledby={`${id}-label`} - /> - <span class="ml-3 flex flex-col"> - <span id={`${id}-label`} disabled class="block text-sm font-medium"> - {label} - </span> - {/* <!-- Checked: "text-indigo-700", Not Checked: "text-gray-500" --> */} - {/* <span - id="privacy-setting-0-description" - class="block text-sm" - > - This project would be available to anyone who has the link - </span> */} - </span> - </label> - ); -} diff --git a/packages/web-util/src/forms/fields/InputLine.tsx b/packages/web-util/src/forms/fields/InputLine.tsx @@ -1,6 +1,6 @@ import { TranslatedString } from "@gnu-taler/taler-util"; import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useEffect, useRef } from "preact/hooks"; +import { useEffect, useId, useRef } from "preact/hooks"; import { composeRef, saveRef } from "../../components/utils.js"; import { Addon, UIFormProps } from "../FormProvider.js"; import { noHandlerPropsAndNoContextForField } from "./InputArray.js"; @@ -26,15 +26,18 @@ export function LabelWithTooltipMaybeRequired({ required, tooltip, name, + labelId, }: { label: TranslatedString; required?: boolean; tooltip?: TranslatedString; name?: string; + labelId?: string; }): VNode { const Label = ( <div class="flex justify-between"> <label + id={labelId} for={name} class="block text-sm font-medium leading-6 text-gray-900" > @@ -181,6 +184,7 @@ export function InputLine<Value = string>( hidden, } = props; const input = useRef<HTMLTextAreaElement | HTMLInputElement>(); + const inputId = `input-${useId()}`; const { value, onChange, error } = props.handler ?? noHandlerPropsAndNoContextForField(props.name); @@ -248,11 +252,13 @@ export function InputLine<Value = string>( return ( <InputWrapper {...props} + name={inputId} help={props.help} disabled={disabled ?? false} error={showError ? error : undefined} > <textarea + id={inputId} rows={4} ref={composeRef(saveRef(input))} // ref={composeRef(saveRef(input), doAutoFocus)} FIXME: enable autofocus when form has focus @@ -276,11 +282,13 @@ export function InputLine<Value = string>( return ( <InputWrapper {...props} + name={inputId} help={props.help} disabled={disabled ?? false} error={showError ? error : undefined} > <input + id={inputId} name={String(name)} ref={composeRef(saveRef(input))} // ref={composeRef(saveRef(input), doAutoFocus)} FIXME: enable autofocus when form has focus diff --git a/packages/web-util/src/forms/gana/VQF_902_11_customer.stories.tsx b/packages/web-util/src/forms/gana/VQF_902_11_customer.stories.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { i18n, setupI18n } from "@gnu-taler/taler-util"; +import { i18n, setupI18n, TalerFormAttributes } from "@gnu-taler/taler-util"; import * as tests from "../../tests/hook.js"; import { DefaultForm as TestedComponent } from "../forms-ui.js"; import { VQF_902_11_customer } from "./VQF_902_11_customer.js"; @@ -33,3 +33,19 @@ export const EmptyForm = tests.createExample(TestedComponent, { initial: {}, design: VQF_902_11_customer(i18n), }); + +export const PrefilledForm = tests.createExample(TestedComponent, { + initial: { + [TalerFormAttributes.IDENTITY_LIST]: [ + { + [TalerFormAttributes.FULL_NAME]: "Alex Example", + [TalerFormAttributes.DOMICILE_ADDRESS]: "Main Street 12, 8000 Zürich", + }, + { + [TalerFormAttributes.FULL_NAME]: "Sam Sample", + [TalerFormAttributes.DOMICILE_ADDRESS]: "Market Square 4, 3011 Bern", + }, + ], + }, + design: VQF_902_11_customer(i18n), +});