Merge pull request #37383 from BerriAI/litellm_/competent-lewin-1c8fd9

refactor(ui): move the team member search modal off antd Form
This commit is contained in:
yuneng-jiang 2026-08-18 16:11:28 -07:00 committed by GitHub
commit edf3167f79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 483 additions and 111 deletions

View file

@ -1106,3 +1106,120 @@ describe("Teams - policies field is gated on the viewPolicies capability", () =>
expect(screen.queryByText("Policies")).not.toBeInTheDocument();
});
});
describe("Teams - which fields reach the create payload depends on the open sections", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTeamInfoView.mockClear();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(getPoliciesList).mockResolvedValue({ policies: [] });
vi.mocked(getDefaultTeamSettings).mockResolvedValue({ values: {} });
vi.mocked(teamCreateCall).mockResolvedValue({ team_id: "new-team-1" });
mockUseOrganizations.mockReturnValue({ data: null });
});
const openCreateModal = async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
act(() => {
fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]);
});
await waitFor(() => {
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
});
};
const submit = async () => {
const buttons = screen.getAllByRole("button", { name: /create team/i });
fireEvent.click(buttons[buttons.length - 1]);
await waitFor(() => {
expect(teamCreateCall).toHaveBeenCalled();
});
return vi.mocked(teamCreateCall).mock.calls[0][1] as Record<string, unknown>;
};
const toggleAdditionalSettings = () => fireEvent.click(screen.getByText("Additional Settings"));
it("sends only the always-visible fields when every section is left closed", async () => {
await openCreateModal();
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Closed Sections Team" } });
const payload = await submit();
expect(Object.keys(payload).sort()).toEqual([
"budget_duration",
"max_budget",
"metadata",
"models",
"organization_id",
"rpm_limit",
"team_alias",
"tpm_limit",
]);
expect(payload.team_alias).toBe("Closed Sections Team");
});
it("adds the Additional Settings fields to the payload once that section is opened", async () => {
await openCreateModal();
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Open Section Team" } });
toggleAdditionalSettings();
await waitFor(() => {
expect(screen.getByLabelText("Team ID")).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-open" } });
fireEvent.change(screen.getByLabelText("Team Member Budget (USD)"), { target: { value: "12.5" } });
const payload = await submit();
expect(payload.team_id).toBe("tid-open");
expect(payload.team_member_budget).toBe(12.5);
expect(Object.keys(payload)).toEqual(
expect.arrayContaining(["access_group_ids", "guardrails", "secret_manager_settings", "team_member_key_duration"]),
);
});
it("drops a value typed in Additional Settings when that section is closed again before saving", async () => {
await openCreateModal();
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Reclosed Team" } });
toggleAdditionalSettings();
await waitFor(() => {
expect(screen.getByLabelText("Team ID")).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-dropped" } });
toggleAdditionalSettings();
await waitFor(() => {
expect(screen.queryByLabelText("Team ID")).not.toBeInTheDocument();
});
const payload = await submit();
expect(payload).not.toHaveProperty("team_id");
});
it("restores and sends the typed value when Additional Settings is reopened before saving", async () => {
await openCreateModal();
fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Reopened Team" } });
toggleAdditionalSettings();
await waitFor(() => {
expect(screen.getByLabelText("Team ID")).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-kept" } });
toggleAdditionalSettings();
await waitFor(() => {
expect(screen.queryByLabelText("Team ID")).not.toBeInTheDocument();
});
toggleAdditionalSettings();
await waitFor(() => {
expect(screen.getByLabelText("Team ID")).toBeInTheDocument();
});
expect(screen.getByLabelText("Team ID")).toHaveValue("tid-kept");
const payload = await submit();
expect(payload.team_id).toBe("tid-kept");
});
});

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,104 @@ 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 });
await user.click(await screen.findByRole("option", { name: "picked@example.com" }));
};
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"));
await user.click(await screen.findByRole("option", { name: /^admin/ }));
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({ role: "admin" });
});
it("keeps the picked identity when the option showing the current value is reselected", async () => {
const { user, onSubmit } = setup();
await searchByEmail(user, "pick");
vi.mocked(userFilterUICall).mockResolvedValue([] as never);
const idInput = screen.getByLabelText("User ID");
await user.click(idInput);
await user.type(idInput, "zzz");
await waitFor(() => expect(userFilterUICall).toHaveBeenCalledTimes(2), { timeout: 3000 });
await user.click(screen.getByPlaceholderText("Search by email"));
await user.click(await screen.findByRole("option", { name: "picked@example.com" }));
await user.click(save());
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit.mock.calls[0][0]).toMatchObject({
user_email: "picked@example.com",
user_id: "u-1",
});
});
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;
@ -13,7 +29,7 @@ interface User {
interface UserOption {
label: string;
value: string;
user: User;
user: User | null;
}
interface Role {
@ -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,10 @@ const UserSearchModal: React.FC<UserSearchModalProps> = ({
debouncedSearch(value, fieldName);
};
const handleSelect = (_value: string, option: UserOption): void => {
const selectedUser = option.user;
form.setFieldsValue({
user_email: selectedUser.user_email,
user_id: selectedUser.user_id,
role: form.getFieldValue("role"), // Preserve current role selection
});
const handleSelect = (option: UserOption | null): void => {
if (option?.user == null) return;
form.setValue("user_email", option.user.user_email);
form.setValue("user_id", option.user.user_id);
};
const handleSubmit = async (values: FormValues): Promise<void> => {
@ -123,81 +137,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: null }, ...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>
);
};

View file

@ -209,6 +209,41 @@ const createMockTeamData = (overrides = {}) => ({
team_memberships: [],
});
const seedDefaultMocks = () => {
mockUseAllProxyModels.mockReturnValue({
data: { data: [] },
isLoading: false,
} as any);
mockUseTeam.mockReturnValue({
data: undefined,
isLoading: false,
} as any);
mockUseOrganization.mockReturnValue({
data: undefined,
isLoading: false,
} as any);
mockUseCurrentUser.mockReturnValue({
data: { models: [] },
isLoading: false,
} as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
can.mockReturnValue(true);
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(networking.getTeamPermissionsCall).mockResolvedValue({
all_available_permissions: [],
team_member_permissions: [],
});
};
describe("TeamInfoView", () => {
const defaultProps = {
teamId: "123",
@ -222,40 +257,7 @@ describe("TeamInfoView", () => {
premiumUser: false,
};
beforeEach(() => {
mockUseAllProxyModels.mockReturnValue({
data: { data: [] },
isLoading: false,
} as any);
mockUseTeam.mockReturnValue({
data: undefined,
isLoading: false,
} as any);
mockUseOrganization.mockReturnValue({
data: undefined,
isLoading: false,
} as any);
mockUseCurrentUser.mockReturnValue({
data: { models: [] },
isLoading: false,
} as any);
mockUseKeys.mockReturnValue({
data: { keys: [], total_count: 0, current_page: 1, total_pages: 1 },
isPending: false,
isFetching: false,
refetch: vi.fn(),
} as any);
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
can.mockReturnValue(true);
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(networking.getTeamPermissionsCall).mockResolvedValue({
all_available_permissions: [],
team_member_permissions: [],
});
});
beforeEach(seedDefaultMocks);
afterEach(() => {
vi.clearAllMocks();
@ -1565,3 +1567,96 @@ describe("TeamInfoView", () => {
});
});
});
describe("TeamInfoView - which team member fields reach the update payload depends on the open sections", () => {
const props = {
teamId: "123",
onUpdate: vi.fn(),
onClose: vi.fn(),
accessToken: "test-token",
is_team_admin: true,
is_proxy_admin: true,
userModels: ["gpt-4"],
editTeam: false,
};
beforeEach(seedDefaultMocks);
afterEach(() => {
vi.clearAllMocks();
});
const openEditor = async (user: ReturnType<typeof userEvent.setup>) => {
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
team_member_budget_table: { max_budget: 42, budget_duration: "30d", tpm_limit: 11, rpm_limit: 22 },
default_team_member_models: ["gpt-4"],
}),
);
vi.mocked(networking.teamUpdateCall).mockResolvedValue({ data: {}, team_id: "123" } as any);
renderWithProviders(<TeamInfoView {...props} />);
await waitFor(() => expect(screen.queryAllByText("Test Team").length).toBeGreaterThan(0));
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await screen.findByLabelText("Team Name");
};
const save = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => expect(networking.teamUpdateCall).toHaveBeenCalled());
return vi.mocked(networking.teamUpdateCall).mock.calls[0][1] as Record<string, unknown>;
};
it("omits every stored team member field when Team Member Settings is left closed", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
const payload = await save(user);
expect(payload.team_member_budget_duration).toBeUndefined();
expect(payload).not.toHaveProperty("team_member_budget");
expect(payload).not.toHaveProperty("team_member_tpm_limit");
expect(payload).not.toHaveProperty("team_member_rpm_limit");
expect(payload).not.toHaveProperty("default_team_member_models");
const wireBody = JSON.parse(JSON.stringify(payload));
expect(Object.keys(wireBody).filter((key) => key.startsWith("team_member"))).toEqual([]);
expect(wireBody).not.toHaveProperty("default_team_member_models");
});
it("resends every stored team member field once Team Member Settings is opened", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Team Member Settings"));
await screen.findByLabelText("Default Budget (USD)");
const payload = await save(user);
expect(payload.team_member_budget_duration).toBe("30d");
expect(payload.team_member_budget).toBe(42);
expect(payload.team_member_tpm_limit).toBe(11);
expect(payload.team_member_rpm_limit).toBe(22);
expect(payload.default_team_member_models).toEqual(["gpt-4"]);
});
it("omits object_permission.search_tools while Search Tool Settings is closed", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
const payload = await save(user);
expect(payload.object_permission).not.toHaveProperty("search_tools");
});
it("includes object_permission.search_tools once Search Tool Settings is opened", async () => {
const user = userEvent.setup({ delay: null });
await openEditor(user);
await user.click(screen.getByText("Search Tool Settings"));
await screen.findByPlaceholderText("Select search tools (optional, empty = all allowed)");
const payload = await save(user);
expect(payload.object_permission).toHaveProperty("search_tools");
});
});