commit 6e73359692af26551ef2974058e93acd9fca7828
parent a651ff0f5982d7b08e904f850e0fb6966484c0ab
Author: Florian Dold <dold@taler.net>
Date: Sun, 23 Aug 2026 17:42:38 +0200
web-util: avoid duplicate list add action
Diffstat:
2 files changed, 50 insertions(+), 32 deletions(-)
diff --git a/packages/web-util/src/forms/fields/InputArray.test.tsx b/packages/web-util/src/forms/fields/InputArray.test.tsx
@@ -95,6 +95,7 @@ test("array items can be added, edited, cancelled, and deleted", async () => {
const originalAddButton = view.getByRole("button", { name: "Add item" });
originalAddButton.click();
await view.findByRole("button", { name: "Cancel" });
+ assert.equal(view.getAllByRole("button", { name: "Add item" }).length, 1);
const nameInput =
view.container.querySelector<HTMLInputElement>('input[name="name"]');
@@ -115,15 +116,10 @@ test("array items can be added, edited, cancelled, and deleted", async () => {
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;
+ const confirmAddButton = view.getByRole("button", { name: "Add item" });
await eventually(() => {
- const addButtons = view.getAllByRole("button", { name: "Add item" });
- confirmAddButton = addButtons.find(
- (button: HTMLElement) => !(button as HTMLButtonElement).disabled,
- );
- assert.ok(confirmAddButton);
+ assert.equal((confirmAddButton as HTMLButtonElement).disabled, false);
});
- assert.ok(confirmAddButton);
confirmAddButton.click();
await eventually(() =>
@@ -135,15 +131,14 @@ test("array items can be added, edited, cancelled, and deleted", async () => {
]),
);
- await eventually(() =>
- assert.equal(
- (view.getByRole("button", { name: "Add item" }) as HTMLButtonElement)
- .disabled,
- false,
- ),
- );
+ await eventually(() => {
+ const addButton = view.getByRole("button", { name: "Add item" });
+ assert.equal((addButton as HTMLButtonElement).disabled, false);
+ assert.equal(window.document.activeElement === addButton, true);
+ });
view.getByRole("button", { name: "Add item" }).click();
await view.findByRole("button", { name: "Cancel" });
+ assert.equal(view.getAllByRole("button", { name: "Add item" }).length, 1);
const secondNameInput =
view.container.querySelector<HTMLInputElement>('input[name="name"]');
assert.ok(secondNameInput);
@@ -151,6 +146,10 @@ test("array items can be added, edited, cancelled, and deleted", async () => {
secondNameInput.dispatchEvent(new window.Event("change", { bubbles: true }));
view.getByRole("button", { name: "Cancel" }).click();
await eventually(() => assert.equal(result.people.length, 1));
+ await eventually(() => {
+ const addButton = view.getByRole("button", { name: "Add item" });
+ assert.equal(window.document.activeElement === addButton, true);
+ });
await eventually(() =>
assert.equal(
(
diff --git a/packages/web-util/src/forms/fields/InputArray.tsx b/packages/web-util/src/forms/fields/InputArray.tsx
@@ -32,6 +32,7 @@ function ArrayForm({
onRemove,
onConfirm,
isNew,
+ showTopBorder,
}: {
fields: UIFormElementConfig[];
selected: Record<string, unknown> | undefined;
@@ -39,6 +40,7 @@ function ArrayForm({
onRemove: () => void;
onConfirm: (r: RecursivePartial<FormType>) => void;
isNew: boolean;
+ showTopBorder: boolean;
}): VNode {
const { i18n } = useTranslationContext();
const design = useMemo(
@@ -58,7 +60,14 @@ function ArrayForm({
}, []);
return (
- <div ref={editorRef} class="border-t border-gray-200 px-3 py-4 sm:px-4">
+ <div
+ ref={editorRef}
+ class={
+ showTopBorder
+ ? "border-t border-gray-200 px-3 py-4 sm:px-4"
+ : "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)}
@@ -142,6 +151,7 @@ export function InputArray(
const [dirty, setDirty] = useState(false);
const [editor, setEditor] = useState<Editor | undefined>();
const returnFocusRef = useRef<HTMLButtonElement | null>(null);
+ const addButtonRef = useRef<HTMLButtonElement | null>(null);
const generatedId = useId();
const fieldId = `array-${generatedId}`;
const labelId = `${fieldId}-label`;
@@ -168,9 +178,15 @@ export function InputArray(
.join(" ");
function closeEditor(): void {
+ const restoreAddFocus = editor?.type === "add";
setDirty(true);
setEditor(undefined);
- setTimeout(() => returnFocusRef.current?.focus(), 0);
+ setTimeout(() => {
+ (restoreAddFocus
+ ? addButtonRef.current
+ : returnFocusRef.current
+ )?.focus();
+ }, 0);
}
function labelForItem(item: Record<string, unknown>, index: number) {
@@ -194,6 +210,7 @@ export function InputArray(
key={isNew ? "add" : `edit-${editor.index}`}
fields={fields}
isNew={isNew}
+ showTopBorder={isNew ? list.length > 0 : true}
selected={selected}
onRemove={() => {
if (editor.type !== "edit") return;
@@ -280,23 +297,25 @@ export function InputArray(
</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"
+ {!props.disabled &&
+ (editor?.type === "add" ? (
+ renderEditor(undefined)
+ ) : (
+ <div
+ class={list.length > 0 ? "border-t border-gray-200 p-3" : "p-3"}
>
- <i18n.Translate>Add item</i18n.Translate>
- </button>
- {editor?.type === "add" ? renderEditor(undefined) : undefined}
- </div>
- )}
+ <button
+ ref={addButtonRef}
+ id={`${fieldId}-add`}
+ type="button"
+ disabled={editor !== undefined}
+ onClick={() => 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>
+ </div>
+ ))}
</div>
</div>
);