diff --git a/app/form/SendouForm.browser.test.tsx b/app/form/SendouForm.browser.test.tsx index 03f6736b4..4ca45efa5 100644 --- a/app/form/SendouForm.browser.test.tsx +++ b/app/form/SendouForm.browser.test.tsx @@ -18,8 +18,9 @@ import { textFieldRequired, timeRangeOptional, toggle as toggleField, + userSearch, } from "./fields"; -import { SendouForm } from "./SendouForm"; +import { SendouForm, useFormFieldContext } from "./SendouForm"; let mockFetcherData: { fieldErrors?: Record } | undefined; @@ -33,6 +34,7 @@ vi.mock("react-router", async () => { }, state: "idle", submit: vi.fn(), + load: vi.fn(), }), }; }); @@ -1147,4 +1149,89 @@ describe("SendouForm", () => { await expect.element(inputB).toHaveValue("Value B"); }); }); + + describe("array field item removal preserves remaining items", () => { + test("removing a middle member preserves userSearch values of members below", async () => { + let latestValues: Record = {}; + + function ValueCapture() { + const ctx = useFormFieldContext(); + latestValues = ctx.values; + return null; + } + + const schema = z.object({ + members: array({ + label: "labels.members", + max: 10, + field: fieldset({ + fields: z.object({ + userId: userSearch({ label: "labels.orgMemberUser" }), + role: select({ + label: "labels.orgMemberRole", + items: [ + { label: "options.orgRole.ADMIN", value: "ADMIN" }, + { label: "options.orgRole.MEMBER", value: "MEMBER" }, + ], + }), + }), + }), + }), + }); + + const defaultValues = { + members: [ + { userId: 10, role: "ADMIN" }, + { userId: 20, role: "MEMBER" }, + { userId: 30, role: "MEMBER" }, + { userId: 40, role: "MEMBER" }, + { userId: 50, role: "MEMBER" }, + ], + }; + + const router = createMemoryRouter( + [ + { + path: "/", + element: ( + + {({ names }) => ( + <> + + + + )} + + ), + }, + ], + { initialEntries: ["/"] }, + ); + + const screen = await render(); + + // Verify initial state - 5 members rendered + const removeButtons = screen.container.querySelectorAll( + 'button[aria-label="Remove item"]', + ); + expect(removeButtons.length).toBe(5); + + // Remove the 3rd member (index 2, userId: 30) + await userEvent.click(removeButtons[2]); + + // Wait for React effects to settle + await new Promise((resolve) => setTimeout(resolve, 200)); + + const members = latestValues.members as Array<{ + userId: number | null; + role: string; + }>; + expect(members).toHaveLength(4); + expect(members[0].userId).toBe(10); + expect(members[1].userId).toBe(20); + // Bug: UserSearch cleanup effect clears userId for shifted items + expect(members[2].userId).toBe(40); + expect(members[3].userId).toBe(50); + }); + }); }); diff --git a/app/form/SendouForm.tsx b/app/form/SendouForm.tsx index c23e8ed75..0ce680427 100644 --- a/app/form/SendouForm.tsx +++ b/app/form/SendouForm.tsx @@ -421,7 +421,18 @@ function buildInitialValues( const defaultValue = defaultValues?.[key as keyof typeof defaultValues]; if (defaultValue !== undefined) { - result[key] = defaultValue; + if (formField?.type === "array" && Array.isArray(defaultValue)) { + result[key] = (defaultValue as unknown[]).map((item) => + typeof item === "object" && item !== null + ? { + ...(item as Record), + _key: crypto.randomUUID(), + } + : item, + ); + } else { + result[key] = defaultValue; + } } else if (formField) { result[key] = formField.initialValue; } diff --git a/app/form/fields/ArrayFormField.tsx b/app/form/fields/ArrayFormField.tsx index 541d2274e..3d3d4a1ad 100644 --- a/app/form/fields/ArrayFormField.tsx +++ b/app/form/fields/ArrayFormField.tsx @@ -39,12 +39,19 @@ export function ArrayFormField({ const count = value.length; const handleAdd = () => { - const newItemValue = + const baseValue = itemInitialValue !== undefined ? itemInitialValue : isObjectArray ? {} : undefined; + const newItemValue = + typeof baseValue === "object" && baseValue !== null + ? { + ...(baseValue as Record), + _key: crypto.randomUUID(), + } + : baseValue; onChange([...value, newItemValue]); }; @@ -52,6 +59,11 @@ export function ArrayFormField({ onChange(value.filter((_, i) => i !== index)); }; + const itemKey = (idx: number) => { + if (!isObjectArray) return idx; + return ((value[idx] as Record)?._key as string) ?? idx; + }; + return (
{translatedLabel ? ( @@ -60,7 +72,7 @@ export function ArrayFormField({ {Array.from({ length: count }).map((_, idx) => isObjectArray ? ( min} onRemove={() => handleRemoveAt(idx)} @@ -69,7 +81,10 @@ export function ArrayFormField({ {renderItem(idx, `${name}[${idx}]`)} ) : ( -
+
{renderItem(idx, `${name}[${idx}]`)}