Fix array form field removing items in the middle bugging out
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

Should not use "idx" as a key like that. React gets confused.
This commit is contained in:
Kalle
2026-02-26 20:17:01 +02:00
parent e6d0a8189f
commit eb949bb5de
3 changed files with 118 additions and 5 deletions

View File

@@ -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<string, string> } | 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<string, unknown> = {};
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: (
<SendouForm schema={schema} defaultValues={defaultValues}>
{({ names }) => (
<>
<FormField name={names.members} />
<ValueCapture />
</>
)}
</SendouForm>
),
},
],
{ initialEntries: ["/"] },
);
const screen = await render(<RouterProvider router={router} />);
// 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);
});
});
});

View File

@@ -421,7 +421,18 @@ function buildInitialValues<T extends z.ZodRawShape>(
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<string, unknown>),
_key: crypto.randomUUID(),
}
: item,
);
} else {
result[key] = defaultValue;
}
} else if (formField) {
result[key] = formField.initialValue;
}

View File

@@ -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<string, unknown>),
_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<string, unknown>)?._key as string) ?? idx;
};
return (
<div className="stack md w-full">
{translatedLabel ? (
@@ -60,7 +72,7 @@ export function ArrayFormField({
{Array.from({ length: count }).map((_, idx) =>
isObjectArray ? (
<ArrayItemFieldset
key={idx}
key={itemKey(idx)}
index={idx}
canRemove={count > min}
onRemove={() => handleRemoveAt(idx)}
@@ -69,7 +81,10 @@ export function ArrayFormField({
{renderItem(idx, `${name}[${idx}]`)}
</ArrayItemFieldset>
) : (
<div key={idx} className="stack horizontal sm items-center w-full">
<div
key={itemKey(idx)}
className="stack horizontal sm items-center w-full"
>
<div className={styles.itemInput}>
{renderItem(idx, `${name}[${idx}]`)}
</div>