refactor(ui): move the team member search modal off antd Form

Ports user_search_modal from antd Form to react-hook-form plus the shadcn
kit, keeping the antd Modal and Alert shells. Payload parity was proven by
rendering the antd original beside the migration in one describe.each: an
untouched submit yields the same three keys with the identity fields
undefined, and picking an option yields the same email and id on both sides.

antd Select swallows Enter, so the original never submitted from a field.
The Base UI combobox does not, which added an Enter-to-submit path; the
inputs now swallow Enter and both sides measure zero submits from every
field with one from the button.
This commit is contained in:
Yuneng Jiang 2026-08-18 15:22:12 -07:00
parent b13fabe9c2
commit aebdba510f
No known key found for this signature in database
2 changed files with 215 additions and 75 deletions

View file

@ -1,4 +1,5 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import UserSearchModal from "./user_search_modal";
import { userFilterUICall } from "@/components/networking";
@ -76,3 +77,83 @@ describe("UserSearchModal", () => {
expect(notice.className).toMatch(/ant-alert-info/);
});
});
describe("UserSearchModal submit payload", () => {
beforeEach(() => {
vi.mocked(userFilterUICall).mockReset();
vi.mocked(userFilterUICall).mockResolvedValue([{ user_id: "u-1", user_email: "picked@example.com" }] as never);
});
const setup = () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<UserSearchModal isVisible onCancel={vi.fn()} onSubmit={onSubmit} accessToken="sk-test" />);
return { user, onSubmit };
};
const save = () => screen.getByRole("button", { name: /add member/i });
const searchByEmail = async (user: ReturnType<typeof userEvent.setup>, text: string) => {
const input = getEmailSearchInput();
await user.click(input);
await user.type(input, text);
await waitFor(() => expect(userFilterUICall).toHaveBeenCalled(), { timeout: 3000 });
const matches = await screen.findAllByText("picked@example.com");
await user.click(matches[matches.length - 1]);
};
it("submits every registered field, with the untouched identity fields undefined", async () => {
const { user, onSubmit } = setup();
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const values = onSubmit.mock.calls[0][0];
expect(Object.keys(values).sort()).toEqual(["role", "user_email", "user_id"]);
expect(values).toStrictEqual({ user_email: undefined, user_id: undefined, role: "user" });
});
it("carries the picked user's email and id into the payload", async () => {
const { user, onSubmit } = setup();
await searchByEmail(user, "pick");
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toStrictEqual({
user_email: "picked@example.com",
user_id: "u-1",
role: "user",
});
});
it("carries a role changed off its default into the payload", async () => {
const { onSubmit } = setup();
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
await user.click(screen.getByLabelText("Member Role"));
const options = await screen.findAllByText("admin");
await user.click(options[options.length - 1]);
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({ role: "admin" });
});
it("does not submit on Enter in any field, while the button still does", async () => {
const { user, onSubmit } = setup();
await user.click(getEmailSearchInput());
await user.keyboard("{Enter}");
await user.click(screen.getByLabelText("User ID"));
await user.keyboard("{Enter}");
await user.click(screen.getByLabelText("Member Role"));
await user.keyboard("{Escape}");
await user.keyboard("{Enter}");
expect(onSubmit).not.toHaveBeenCalled();
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
});
});

View file

@ -1,9 +1,25 @@
import { useState } from "react";
import { Modal, Form, Button, Select, Tooltip, Alert } from "antd";
import { Modal, Alert } from "antd";
import { UserAddOutlined } from "@ant-design/icons";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { useForm } from "react-hook-form";
import { userFilterUICall } from "@/components/networking";
import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants";
import { FieldGroup } from "@/components/shared/form/field";
import { FormField } from "@/components/shared/form/FormField";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
interface User {
user_id: string;
user_email: string;
@ -23,8 +39,8 @@ interface Role {
}
interface FormValues {
user_email: string;
user_id: string;
user_email: string | undefined;
user_id: string | undefined;
role: string;
}
@ -56,7 +72,8 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
defaultRole = "user",
teamId,
}) => {
const [form] = Form.useForm<FormValues>();
const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole };
const form = useForm<FormValues>({ defaultValues: emptyValues });
const [userOptions, setUserOptions] = useState<UserOption[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [selectedField, setSelectedField] = useState<"user_email" | "user_id">("user_email");
@ -104,13 +121,11 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
debouncedSearch(value, fieldName);
};
const handleSelect = (_value: string, option: UserOption): void => {
const handleSelect = (option: UserOption | null): void => {
if (option === null) return;
const selectedUser = option.user;
form.setFieldsValue({
user_email: selectedUser.user_email,
user_id: selectedUser.user_id,
role: form.getFieldValue("role"), // Preserve current role selection
});
form.setValue("user_email", selectedUser.user_email);
form.setValue("user_id", selectedUser.user_id);
};
const handleSubmit = async (values: FormValues): Promise<void> => {
@ -123,81 +138,125 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
};
const handleClose = (): void => {
form.resetFields();
form.reset(emptyValues);
setUserOptions([]);
onCancel();
};
const swallowEnter = (event: React.KeyboardEvent): void => {
if (event.key === "Enter") event.preventDefault();
};
const optionsFor = (fieldName: "user_email" | "user_id", value: string | undefined): UserOption[] => {
const visible = selectedField === fieldName ? userOptions : [];
if (value == null || value === "" || visible.some((option) => option.value === value)) return visible;
return [{ label: value, value, user: { user_id: "", user_email: "" } }, ...visible];
};
const renderUserSearch = (
fieldName: "user_email" | "user_id",
placeholder: string,
controlProps: { id: string; value: string | undefined; onChange: (value: string | undefined) => void },
testId?: string,
) => {
const items = optionsFor(fieldName, controlProps.value);
const selected = items.find((option) => option.value === controlProps.value) ?? null;
return (
<div data-testid={testId}>
<Combobox
items={items}
value={selected}
filter={null}
onValueChange={(option: UserOption | null) => {
controlProps.onChange(option?.value);
handleSelect(option);
}}
onInputValueChange={(text: string) => handleSearch(text, fieldName)}
isItemEqualToValue={(a: UserOption, b: UserOption) => a.value === b.value}
itemToStringLabel={(option: UserOption) => option.label}
>
<ComboboxInput
id={controlProps.id}
placeholder={placeholder}
showClear={selected !== null}
onKeyDown={swallowEnter}
/>
<ComboboxContent>
<ComboboxEmpty>{loading ? "Loading..." : "No results"}</ComboboxEmpty>
<ComboboxList>
{(option: UserOption) => (
<ComboboxItem key={option.value} value={option}>
{option.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
);
};
return (
<Modal title={title} open={isVisible} onCancel={handleClose} footer={null} width={800} maskClosable={!isSubmitting}>
<Form<FormValues>
form={form}
onFinish={handleSubmit}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
initialValues={{
role: defaultRole,
}}
>
<Alert
type="info"
showIcon
className="mb-4"
message="Search selects from users that already exist. To add someone new, ask a proxy admin to create their account first."
data-testid="member-existing-users-notice"
/>
<Form.Item label="Email" name="user_email" className="mb-4">
<Select
showSearch
className="w-full"
placeholder="Search by email"
filterOption={false}
onSearch={(value) => handleSearch(value, "user_email")}
onSelect={(value, option) => handleSelect(value, option as UserOption)}
options={selectedField === "user_email" ? userOptions : []}
loading={loading}
allowClear
data-testid="member-email-search"
<TooltipProvider>
<form onSubmit={form.handleSubmit(handleSubmit)} noValidate>
<Alert
type="info"
showIcon
className="mb-4"
message="Search selects from users that already exist. To add someone new, ask a proxy admin to create their account first."
data-testid="member-existing-users-notice"
/>
</Form.Item>
<div className="text-center mb-4">OR</div>
<FieldGroup>
<FormField control={form.control} name="user_email" label="Email">
{({ id, value, onChange }) =>
renderUserSearch("user_email", "Search by email", { id, value, onChange }, "member-email-search")
}
</FormField>
<Form.Item label="User ID" name="user_id" className="mb-4">
<Select
showSearch
className="w-full"
placeholder="Search by user ID"
filterOption={false}
onSearch={(value) => handleSearch(value, "user_id")}
onSelect={(value, option) => handleSelect(value, option as UserOption)}
options={selectedField === "user_id" ? userOptions : []}
loading={loading}
allowClear
/>
</Form.Item>
<div className="text-center">OR</div>
<Form.Item label="Member Role" name="role" className="mb-4">
<Select defaultValue={defaultRole}>
{roles.map((role) => (
<Select.Option key={role.value} value={role.value}>
<Tooltip title={role.description}>
<span className="font-medium">{role.label}</span>
<span className="ml-2 text-gray-500 text-sm">- {role.description}</span>
</Tooltip>
</Select.Option>
))}
</Select>
</Form.Item>
<FormField control={form.control} name="user_id" label="User ID">
{({ id, value, onChange }) => renderUserSearch("user_id", "Search by user ID", { id, value, onChange })}
</FormField>
<div className="text-right mt-4">
<Button type="primary" htmlType="submit" icon={<UserAddOutlined />} loading={isSubmitting}>
{isSubmitting ? "Adding..." : "Add Member"}
</Button>
</div>
</Form>
<FormField control={form.control} name="role" label="Member Role">
{({ id, value, onChange }) => (
<Select items={roles} value={value} onValueChange={(next) => onChange(next as string)}>
<SelectTrigger id={id}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.value} value={role.value}>
<Tooltip>
<TooltipTrigger
render={
<span>
<span className="font-medium">{role.label}</span>
<span className="ml-2 text-sm text-muted-foreground">- {role.description}</span>
</span>
}
/>
<TooltipContent>{role.description}</TooltipContent>
</Tooltip>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormField>
</FieldGroup>
<div className="mt-4 text-right">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <UiLoadingSpinner className="size-4" /> : <UserAddOutlined />}
{isSubmitting ? "Adding..." : "Add Member"}
</Button>
</div>
</form>
</TooltipProvider>
</Modal>
);
};