From aebdba510fe96aca6b2b278ea5f1d977ea908700 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 15:22:12 -0700 Subject: [PATCH 1/3] 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. --- .../user_search_modal.test.tsx | 83 ++++++- .../common_components/user_search_modal.tsx | 207 +++++++++++------- 2 files changed, 215 insertions(+), 75 deletions(-) diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx index 634c78d28d0..ec63dcdebb9 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx @@ -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(); + return { user, onSubmit }; + }; + + const save = () => screen.getByRole("button", { name: /add member/i }); + + const searchByEmail = async (user: ReturnType, 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)); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 9c1d64a1f86..1a55fa18191 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -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 = ({ defaultRole = "user", teamId, }) => { - const [form] = Form.useForm(); + const emptyValues: FormValues = { user_email: undefined, user_id: undefined, role: defaultRole }; + const form = useForm({ defaultValues: emptyValues }); const [userOptions, setUserOptions] = useState([]); const [loading, setLoading] = useState(false); const [selectedField, setSelectedField] = useState<"user_email" | "user_id">("user_email"); @@ -104,13 +121,11 @@ const UserSearchModal: React.FC = ({ 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 => { @@ -123,81 +138,125 @@ const UserSearchModal: React.FC = ({ }; 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 ( +
+ { + 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} + > + + + {loading ? "Loading..." : "No results"} + + {(option: UserOption) => ( + + {option.label} + + )} + + + +
+ ); + }; + return ( - - form={form} - onFinish={handleSubmit} - labelCol={{ span: 8 }} - wrapperCol={{ span: 16 }} - labelAlign="left" - initialValues={{ - role: defaultRole, - }} - > - - - - handleSearch(value, "user_id")} - onSelect={(value, option) => handleSelect(value, option as UserOption)} - options={selectedField === "user_id" ? userOptions : []} - loading={loading} - allowClear - /> - +
OR
- - - + + {({ id, value, onChange }) => renderUserSearch("user_id", "Search by user ID", { id, value, onChange })} + -
- -
- + + {({ id, value, onChange }) => ( + + )} + + + +
+ +
+ +
); }; From e8f698ad65a30a47e148f4ad4daf5d22df331703 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 15:49:05 -0700 Subject: [PATCH 2/3] test(ui): pin the section-gated team create and update payloads The team create and edit forms send a different set of keys depending on which collapsible sections the user opened, because a closed section is unmounted and its values never reach the request. Nothing covered that, so a form rewrite could change the request body without failing a test. Pins the exact key set the create form sends with every section closed, the keys Additional Settings adds once opened, and that a value typed then re-hidden is dropped while a reopened one is restored. Does the same for the team member and search tool sections on the edit form, asserting absence at the wire level rather than just comparing values. Also hardens two option queries in the member modal suite onto the option role, and lifts the duplicated mock seeding in the team info suite into one function both blocks call. --- .../src/components/Teams.test.tsx | 117 +++++++++++++ .../user_search_modal.test.tsx | 6 +- .../src/components/team/TeamInfo.test.tsx | 163 ++++++++++++++---- 3 files changed, 248 insertions(+), 38 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 6f15f23b193..0ef4357e73f 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -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(); + 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; + }; + + 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"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx index ec63dcdebb9..0e18c495145 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx @@ -98,8 +98,7 @@ describe("UserSearchModal submit payload", () => { 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]); + await user.click(await screen.findByRole("option", { name: "picked@example.com" })); }; it("submits every registered field, with the untouched identity fields undefined", async () => { @@ -132,8 +131,7 @@ describe("UserSearchModal submit payload", () => { 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(await screen.findByRole("option", { name: /^admin/ })); await user.click(save()); await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 1d6c46dc0c9..9df13a982ed 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -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) => { + 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(); + 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) => { + 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; + }; + + 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"); + }); +}); From 09ad62a40d09093b312aab8cfe813deec7162a6d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 18 Aug 2026 15:58:16 -0700 Subject: [PATCH 3/3] fix(ui): stop the placeholder option clearing the picked member identity The Base UI combobox only renders a selected value that is present in its item list, so the port synthesizes an item for the current value when the search results no longer contain it. That synthetic item carried an empty user, and selecting it ran the same handler as a real result, wiping both the email and the user id before submit. The synthetic item now carries no user at all and the select handler ignores it, so reselecting the value already in the field leaves both identity fields alone. antd needed none of this: its Select renders a value that is absent from its options. --- .../user_search_modal.test.tsx | 23 +++++++++++++++++++ .../common_components/user_search_modal.tsx | 11 ++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx index 0e18c495145..d17948b599e 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.test.tsx @@ -138,6 +138,29 @@ describe("UserSearchModal submit payload", () => { 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(); diff --git a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx index 1a55fa18191..801d19244f8 100644 --- a/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx +++ b/ui/litellm-dashboard/src/components/common_components/user_search_modal.tsx @@ -29,7 +29,7 @@ interface User { interface UserOption { label: string; value: string; - user: User; + user: User | null; } interface Role { @@ -122,10 +122,9 @@ const UserSearchModal: React.FC = ({ }; const handleSelect = (option: UserOption | null): void => { - if (option === null) return; - const selectedUser = option.user; - form.setValue("user_email", selectedUser.user_email); - form.setValue("user_id", selectedUser.user_id); + 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 => { @@ -150,7 +149,7 @@ const UserSearchModal: React.FC = ({ 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]; + return [{ label: value, value, user: null }, ...visible]; }; const renderUserSearch = (