mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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:
commit
edf3167f79
4 changed files with 483 additions and 111 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue