mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 11:05:09 -05:00
Fix array form field removing items in the middle bugging out
Should not use "idx" as a key like that. React gets confused.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user