From b9a267c69378ef1bfaa7541c521934c6473e5faf Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 18 Aug 2026 22:37:04 -0700 Subject: [PATCH] refactor(ui): migrate the teams form graph off antd Form onto react-hook-form (#37417) * test(ui): pin the teams create and update payloads before the form migration The teams graph (Teams.tsx, TeamInfo.tsx and the MetadataKeyValueFields child they share) is next for the antd Form to react-hook-form migration, and its submit payload is a function of which collapsible sections the user happened to open. Nine sections across the two files use the shadcn Collapsible, none of them passes keepMounted, and Base UI unmounts the closed branch, so a closed section registers nothing and its keys never reach the request body. That matters beyond parity. /team/update reads the body with exclude_unset, so an omitted key is never written, while an explicitly null team member budget key reaches clear_team_member_budget_fields and nulls max_budget, budget_duration, rpm_limit and tpm_limit on the shared budget row. antd cannot reach that today because the field is unregistered rather than null. A port that seeds those fields or coalesces on the way into the payload would turn a save with the section never opened into a silent clear. The coverage that shipped with the team modal reached one of the four gating sections on the create side and asserted key sets rather than the request body, so a null where antd sent undefined would have passed. These cases assert both the raw payload and its JSON round trip with toStrictEqual, which is what separates absent from null from undefined, and they cover every gating section on both screens. Also pinned, because each is a live behaviour a port can quietly change: - the create path sends max_budget, tpm_limit and rpm_limit as strings, while team_member_budget arrives as a number through its normalize prop - an invalid secret manager config blocks the create with its rule message suppressed by the item's help prop, so nothing is shown to the user - the disable global guardrails switch is inert for a non premium user - a value typed into a section survives collapsing and re-expanding it Verified by adding keepMounted to all nine panels, which is the change a porter reaches for on noticing that fields go missing: 35 of 118 went red, including every one of these cases. The files were restored byte identical afterwards. No production file changes here. 145 tests pass across the three files. * refactor(ui): migrate the teams form graph off antd Form onto react-hook-form Teams.tsx and TeamInfo.tsx were the last large antd `Form` graph in the dashboard. Both now use `useZodForm` + `FormField`, with the shared `MetadataKeyValueFields` child converted to a `useFieldArray`. antd only returns the mounted registered fields from `onFinish`, so a closed collapsible contributed no keys at all. react-hook-form keeps unmounted values in the store (and `shouldUnregister: true` would lose them on re-expand), so both forms project the submitted values through the currently mounted section list before handing them to the existing payload builders. Closed sections therefore still produce absent keys rather than nulls, which matters at /team/update where an explicit null clears the shared budget row. Widgets that had no shadcn equivalent are replaced with the existing shared ones: SearchSelect for the organization pickers, MultiSelect for default member models, TagsInput for guardrails/policies, and a new GuardrailsSelect for the grouped global/other guardrail dropdown. * refactor(ui): forward the field ref to NumericalInput in the teams forms staging turned NumericalInput into a forwardRef, so the teams graph can stop dropping the react-hook-form ref on the floor. * test(ui): pin the capability gate, required rules and guardrail kill switch A mutation run over the ported teams forms found five survivors the payload cases did not reach: the viewPolicies gate on both forms, the team name rule on both forms, the guardrail kill switch resync, and the number coercion on a typed model rate limit. Six cases close them. --- .../components/ModelSelect/ModelSelect.tsx | 5 +- .../src/components/Teams.test.tsx | 258 +++ ui/litellm-dashboard/src/components/Teams.tsx | 1247 +++++++------- .../MetadataKeyValueFields.test.tsx | 20 +- .../MetadataKeyValueFields.tsx | 131 +- .../components/shared/form/LabelWithHint.tsx | 34 + .../src/components/team/GuardrailsSelect.tsx | 133 ++ .../src/components/team/TeamInfo.test.tsx | 316 +++- .../src/components/team/TeamInfo.tsx | 1471 ++++++++++------- 9 files changed, 2362 insertions(+), 1253 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/form/LabelWithHint.tsx create mode 100644 ui/litellm-dashboard/src/components/team/GuardrailsSelect.tsx diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index d3ede5f4318..c3936cc456d 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -40,6 +40,7 @@ export const MODEL_SENTINEL_OPTIONS = [ const MAX_VISIBLE_MODEL_CHIPS = 5; export interface ModelSelectProps { + id?: string; teamID?: string; organizationID?: string; options?: { @@ -122,7 +123,7 @@ const filterModels = ( export const ModelSelect = (props: ModelSelectProps) => { const anchor = useComboboxAnchor(); - const { teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props; + const { id, teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props; const { showAllProxyModelsOverride, includeSpecialOptions } = options || {}; const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels(); const { data: team, isLoading: isLoadingTeam } = useTeam(teamID); @@ -256,7 +257,7 @@ export const ModelSelect = (props: ModelSelectProps) => { )} - + No models found diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 0ef4357e73f..d0d41837dce 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -1223,3 +1223,261 @@ describe("Teams - which fields reach the create payload depends on the open sect expect(payload.team_id).toBe("tid-kept"); }); }); + +describe("Teams - the exact bytes the create call sends", () => { + beforeEach(() => { + vi.clearAllMocks(); + can.mockReturnValue(true); + 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" }); + vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any); + mockUseOrganizations.mockReturnValue({ data: null }); + }); + + const openCreateModal = async (options?: { premiumUser?: boolean }) => { + renderWithQueryClient( + , + ); + act(() => { + fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]); + }); + await waitFor(() => { + expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); + }); + fireEvent.change(screen.getByLabelText(/team name/i), { target: { value: "Byte Contract Team" } }); + }; + + 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 wireBody = (payload: Record) => JSON.parse(JSON.stringify(payload)) as Record; + + const openSection = async (title: string, mountedProbe: RegExp | string) => { + fireEvent.click(screen.getByText(title)); + await waitFor(() => { + expect(screen.getAllByText(mountedProbe).length).toBeGreaterThan(0); + }); + }; + + it("sends three keys and nothing else when every section is left closed", async () => { + await openCreateModal(); + + const payload = await submit(); + + expect(payload).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + max_budget: undefined, + budget_duration: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + metadata: undefined, + }); + expect(wireBody(payload)).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + }); + }); + + it("keeps every newly mounted but untouched field out of the request body", async () => { + await openCreateModal(); + + await openSection("Additional Settings", /Team Member Key Duration/); + await openSection("MCP Settings", /Allowed MCP Servers/); + await openSection("Agent Settings", /Allowed Agents/); + await openSection("Search Tool Settings", /Allowed Search Tools/); + + const payload = await submit(); + + expect(payload).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + max_budget: undefined, + budget_duration: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + metadata: undefined, + team_id: undefined, + team_member_budget: undefined, + team_member_key_duration: undefined, + team_member_rpm_limit: undefined, + team_member_tpm_limit: undefined, + secret_manager_settings: undefined, + guardrails: undefined, + disable_global_guardrails: undefined, + policies: undefined, + access_group_ids: undefined, + allowed_vector_store_ids: undefined, + allowed_passthrough_routes: undefined, + allowed_mcp_servers_and_groups: undefined, + mcp_tool_permissions: {}, + allowed_agents_and_groups: undefined, + object_permission_search_tools: undefined, + }); + expect(wireBody(payload)).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + mcp_tool_permissions: {}, + }); + }); + + it.each([ + ["MCP Settings", /Allowed MCP Servers/, ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"]], + ["Agent Settings", /Allowed Agents/, ["allowed_agents_and_groups"]], + ["Search Tool Settings", /Allowed Search Tools/, ["object_permission_search_tools"]], + ])("registers %s fields only while that one section is open", async (title, probe, keys) => { + await openCreateModal(); + + const closedPayload = await submit(); + for (const key of keys as string[]) { + expect(closedPayload).not.toHaveProperty(key); + } + }); + + it("carries every typed value to the payload at the type antd sends today", async () => { + await openCreateModal(); + + fireEvent.change(screen.getByLabelText("Max Budget (USD)"), { target: { value: "150.75" } }); + fireEvent.change(screen.getByLabelText("Tokens per minute Limit (TPM)"), { target: { value: "900" } }); + fireEvent.change(screen.getByLabelText("Requests per minute Limit (RPM)"), { target: { value: "800" } }); + + await openSection("Additional Settings", /Team Member Key Duration/); + + fireEvent.change(screen.getByLabelText("Team ID"), { target: { value: "tid-1" } }); + fireEvent.change(screen.getByLabelText("Team Member Budget (USD)"), { target: { value: "12.5" } }); + fireEvent.change(screen.getByLabelText(/Team Member Key Duration/), { target: { value: "30d" } }); + fireEvent.change(screen.getByLabelText("Team Member RPM Limit"), { target: { value: "7" } }); + fireEvent.change(screen.getByLabelText("Team Member TPM Limit"), { target: { value: "8" } }); + fireEvent.change(screen.getByLabelText("Secret Manager Settings"), { + target: { value: '{"namespace":"admin"}' }, + }); + + const payload = await submit(); + + expect(payload.max_budget).toBe("150.75"); + expect(payload.tpm_limit).toBe("900"); + expect(payload.rpm_limit).toBe("800"); + expect(payload.team_id).toBe("tid-1"); + expect(payload.team_member_budget).toBe(12.5); + expect(payload.team_member_key_duration).toBe("30d"); + expect(payload.team_member_rpm_limit).toBe("7"); + expect(payload.team_member_tpm_limit).toBe("8"); + expect(payload.secret_manager_settings).toStrictEqual({ namespace: "admin" }); + }); + + it("blocks the create on an invalid secret manager config, with the rule message suppressed by help", async () => { + await openCreateModal(); + await openSection("Additional Settings", /Team Member Key Duration/); + + fireEvent.change(screen.getByLabelText("Secret Manager Settings"), { target: { value: " " } }); + + const buttons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(buttons[buttons.length - 1]); + + await waitFor(() => { + expect(screen.getByLabelText("Secret Manager Settings")).toHaveAttribute("aria-invalid", "true"); + }); + expect(teamCreateCall).not.toHaveBeenCalled(); + expect(screen.queryByText("Please enter valid JSON")).not.toBeInTheDocument(); + }); + + it("turns the disable-global-guardrails switch into a boolean for a premium user", async () => { + await openCreateModal({ premiumUser: true }); + await openSection("Additional Settings", /Team Member Key Duration/); + + const switches = screen.getAllByRole("switch"); + fireEvent.click(switches[switches.length - 1]); + + const payload = await submit(); + + expect(payload.disable_global_guardrails).toBe(true); + }); + + it("leaves the disable-global-guardrails switch inert for a non-premium user", async () => { + await openCreateModal(); + await openSection("Additional Settings", /Team Member Key Duration/); + + const switches = screen.getAllByRole("switch"); + fireEvent.click(switches[switches.length - 1]); + + const payload = await submit(); + + expect(payload.disable_global_guardrails).toBeUndefined(); + }); + + it.each([ + ["MCP Settings", /Allowed MCP Servers/, ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"]], + ["Agent Settings", /Allowed Agents/, ["allowed_agents_and_groups"]], + ["Search Tool Settings", /Allowed Search Tools/, ["object_permission_search_tools"]], + ])("adds the %s keys as soon as that one section is opened", async (title, probe, keys) => { + await openCreateModal(); + + await openSection(title as string, probe as RegExp); + const payload = await submit(); + + for (const key of keys as string[]) { + expect(payload).toHaveProperty(key); + } + }); + + it("leaves policies out of the request body for a caller without the viewPolicies capability", async () => { + can.mockReturnValue(false); + + await openCreateModal(); + await openSection("Additional Settings", /Team Member Key Duration/); + + const payload = await submit(); + + expect(payload).toStrictEqual({ + team_alias: "Byte Contract Team", + organization_id: null, + models: ["no-default-models"], + max_budget: undefined, + budget_duration: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + metadata: undefined, + team_id: undefined, + team_member_budget: undefined, + team_member_key_duration: undefined, + team_member_rpm_limit: undefined, + team_member_tpm_limit: undefined, + secret_manager_settings: undefined, + guardrails: undefined, + disable_global_guardrails: undefined, + access_group_ids: undefined, + allowed_vector_store_ids: undefined, + allowed_passthrough_routes: undefined, + }); + }); + + it("blocks the create on an empty team name and names the rule", async () => { + renderWithQueryClient(); + act(() => { + fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]); + }); + await waitFor(() => { + expect(screen.getByLabelText(/team name/i)).toBeInTheDocument(); + }); + + const buttons = screen.getAllByRole("button", { name: /create team/i }); + fireEvent.click(buttons[buttons.length - 1]); + + expect(await screen.findByText("Please input a team name")).toBeInTheDocument(); + expect(teamCreateCall).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index b1f96f51028..94e84c6e721 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -4,12 +4,21 @@ import AvailableTeamsPanel from "@/components/team/AvailableTeamsPanel"; import TeamInfoView from "@/components/team/TeamInfo"; import TeamSSOSettings from "@/components/TeamSSOSettings"; import { isProxyAdminRole } from "@/utils/roles"; -import { InfoCircleOutlined } from "@ant-design/icons"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input as UIInput } from "@/components/ui/input"; -import { Button, Form, Input, Layout, Modal, Select, Switch, Tabs, theme, Tooltip, Typography } from "antd"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/shared/form/field"; +import { FormField } from "@/components/shared/form/FormField"; +import { SearchSelect } from "@/components/shared/SearchSelect"; +import { labelWithDocsHint, labelWithHint } from "@/components/shared/form/LabelWithHint"; +import { useZodForm } from "@/lib/forms/useZodForm"; +import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; +import { Layout, Modal, Tabs, theme } from "antd"; import { ChevronDown, Plus, Users } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { z } from "zod/v4"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { PageHeader } from "@/components/shared/PageHeader"; import { Button as UIButton } from "@/components/ui/button"; @@ -17,7 +26,10 @@ import { teamsTableKeys } from "@/app/(dashboard)/hooks/teams/useTeams"; import { parseAsString, useQueryState } from "nuqs"; import { TeamsTable } from "./TeamsPage/TeamsTable"; import AccessGroupSelector from "./common_components/AccessGroupSelector"; -import MetadataKeyValueFields, { metadataPairsToObject } from "./common_components/MetadataKeyValueFields"; +import MetadataKeyValueFields, { + metadataPairsSchema, + metadataPairsToObject, +} from "./common_components/MetadataKeyValueFields"; import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema"; import PassThroughRoutesSelector from "./common_components/PassThroughRoutesSelector"; import AgentSelector from "./agent_management/AgentSelector"; @@ -51,6 +63,102 @@ import { teamCreateCall } from "./networking"; import { normalizeTeamModelSelection } from "./team/teamModelAccess"; import { ModelSelect } from "./ModelSelect/ModelSelect"; +const SUPPRESSED_BY_DESCRIPTION = ""; + +const numericInputSchema = z.union([z.string(), z.number()]).optional(); + +const teamCreateFieldsSchema = z.object({ + team_alias: z.string().min(1, "Please input a team name"), + organization_id: z.string().nullish(), + models: z.array(z.string()).optional(), + max_budget: numericInputSchema, + budget_duration: z.string().nullish(), + tpm_limit: numericInputSchema, + rpm_limit: numericInputSchema, + metadata: metadataPairsSchema.optional(), + team_id: z.string().optional(), + team_member_budget: z.number().optional(), + team_member_key_duration: z.string().optional(), + team_member_rpm_limit: numericInputSchema, + team_member_tpm_limit: numericInputSchema, + secret_manager_settings: z.string().optional(), + guardrails: z.array(z.string()).optional(), + disable_global_guardrails: z.boolean().optional(), + policies: z.array(z.string()).optional(), + access_group_ids: z.array(z.string()).optional(), + allowed_vector_store_ids: z.array(z.string()).optional(), + allowed_passthrough_routes: z.array(z.string()).optional(), + allowed_mcp_servers_and_groups: z + .object({ + servers: z.array(z.string()), + accessGroups: z.array(z.string()), + toolsets: z.array(z.string()).optional(), + }) + .optional(), + mcp_tool_permissions: z.record(z.string(), z.array(z.string())).optional(), + allowed_agents_and_groups: z.object({ agents: z.array(z.string()), accessGroups: z.array(z.string()) }).optional(), + object_permission_search_tools: z.array(z.string()).optional(), +}); + +type TeamCreateFormValues = z.infer; + +const EMPTY_TEAM_CREATE_VALUES: TeamCreateFormValues = { + team_alias: "", + organization_id: null, + models: [], + max_budget: undefined, + budget_duration: undefined, + tpm_limit: undefined, + rpm_limit: undefined, + metadata: [], + team_id: undefined, + team_member_budget: undefined, + team_member_key_duration: undefined, + team_member_rpm_limit: undefined, + team_member_tpm_limit: undefined, + secret_manager_settings: undefined, + guardrails: undefined, + disable_global_guardrails: undefined, + policies: undefined, + access_group_ids: undefined, + allowed_vector_store_ids: undefined, + allowed_passthrough_routes: undefined, + allowed_mcp_servers_and_groups: undefined, + mcp_tool_permissions: {}, + allowed_agents_and_groups: undefined, + object_permission_search_tools: undefined, +}; + +const ADDITIONAL_SETTINGS_FIELDS = [ + "team_id", + "team_member_budget", + "team_member_key_duration", + "team_member_rpm_limit", + "team_member_tpm_limit", + "secret_manager_settings", + "guardrails", + "disable_global_guardrails", + "policies", + "access_group_ids", + "allowed_vector_store_ids", + "allowed_passthrough_routes", +] as const; +const MCP_SETTINGS_FIELDS = ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"] as const; +const AGENT_SETTINGS_FIELDS = ["allowed_agents_and_groups"] as const; +const SEARCH_TOOL_SETTINGS_FIELDS = ["object_permission_search_tools"] as const; + +const isParsableJson = (value: string | undefined): boolean => { + if (!value) { + return true; + } + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; + const canCreateOrManageTeams = ( userRole: string | null, userID: string | null, @@ -101,7 +209,29 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser const [currentOrg] = useState(null); const [currentOrgForCreateTeam, setCurrentOrgForCreateTeam] = useState(null); - const [form] = Form.useForm(); + const isOrgAdmin = userRole !== "Admin"; + const [additionalSettingsOpen, setAdditionalSettingsOpen] = useState(false); + const [mcpSettingsOpen, setMcpSettingsOpen] = useState(false); + const [agentSettingsOpen, setAgentSettingsOpen] = useState(false); + const [searchToolSettingsOpen, setSearchToolSettingsOpen] = useState(false); + + const teamCreateSchema = useMemo( + () => + teamCreateFieldsSchema.superRefine((values, ctx) => { + if (isOrgAdmin && !values.organization_id) { + ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["organization_id"] }); + } + if (additionalSettingsOpen && !isParsableJson(values.secret_manager_settings)) { + ctx.addIssue({ code: "custom", message: SUPPRESSED_BY_DESCRIPTION, path: ["secret_manager_settings"] }); + } + }), + [isOrgAdmin, additionalSettingsOpen], + ); + + const form = useZodForm(teamCreateSchema, { defaultValues: EMPTY_TEAM_CREATE_VALUES }); + const watchedOrganizationId = form.watch("organization_id"); + const watchedMcpSelection = form.watch("allowed_mcp_servers_and_groups"); + const watchedToolPermissions = form.watch("mcp_tool_permissions"); const [selectedTeam, setSelectedTeam] = useState(null); const [selectedTeamId, setSelectedTeamId] = useQueryState("team", parseAsString.withOptions({ history: "push" })); @@ -134,27 +264,26 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser : "n/a"; useEffect(() => { - form.setFieldValue("models", []); + form.setValue("models", []); }, [currentOrgForCreateTeam, userModels]); // Handle organization preselection when modal opens useEffect(() => { if (isTeamModalVisible) { const adminOrgs = getAdminOrganizations(userRole, userID, organizations); - const isOrgAdmin = userRole !== "Admin"; // Org admins must scope a team to an org, so with exactly one we preselect it. // Proxy admins can create org-less teams, so the field stays optional regardless of org count. if (isOrgAdmin && adminOrgs.length === 1) { const org = adminOrgs[0]; - form.setFieldValue("organization_id", org.organization_id); + form.setValue("organization_id", org.organization_id); setCurrentOrgForCreateTeam(org); } else { - form.setFieldValue("organization_id", currentOrg?.organization_id || null); + form.setValue("organization_id", currentOrg?.organization_id || null); setCurrentOrgForCreateTeam(currentOrg); } } - }, [isTeamModalVisible, userRole, userID, organizations, currentOrg]); + }, [isTeamModalVisible, isOrgAdmin, userRole, userID, organizations, currentOrg]); // Add this useEffect to fetch guardrails useEffect(() => { @@ -190,22 +319,26 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser if (canViewPolicies) fetchPolicies(); }, [accessToken, canViewPolicies]); - const handleOk = () => { - setIsTeamModalVisible(false); - form.resetFields(); + const resetCreateForm = () => { + form.reset(EMPTY_TEAM_CREATE_VALUES); + setAdditionalSettingsOpen(false); + setMcpSettingsOpen(false); + setAgentSettingsOpen(false); + setSearchToolSettingsOpen(false); setLoggingSettings([]); setModelAliases({}); setRouterSettings(null); setRouterSettingsKey((prev) => prev + 1); }; + const handleOk = () => { + setIsTeamModalVisible(false); + resetCreateForm(); + }; + const handleCancel = () => { setIsTeamModalVisible(false); - form.resetFields(); - setLoggingSettings([]); - setModelAliases({}); - setRouterSettings(null); - setRouterSettingsKey((prev) => prev + 1); + resetCreateForm(); }; const handleDelete = async (team: Team) => { @@ -378,11 +511,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser await teamCreateCall(accessToken, { ...formValues, models: normalizeTeamModelSelection(formValues.models) }); toast.success("Team created"); await refreshTeams(); - form.resetFields(); - setLoggingSettings([]); - setModelAliases({}); - setRouterSettings(null); - setRouterSettingsKey((prev) => prev + 1); + resetCreateForm(); setIsTeamModalVisible(false); } } catch (error) { @@ -391,6 +520,19 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser } }; + const mountedCreateValues = (values: TeamCreateFormValues): Record => { + const unmounted = new Set([ + ...(additionalSettingsOpen ? [] : ADDITIONAL_SETTINGS_FIELDS), + ...(additionalSettingsOpen && canViewPolicies ? [] : ["policies"]), + ...(mcpSettingsOpen ? [] : MCP_SETTINGS_FIELDS), + ...(agentSettingsOpen ? [] : AGENT_SETTINGS_FIELDS), + ...(searchToolSettingsOpen ? [] : SEARCH_TOOL_SETTINGS_FIELDS), + ]); + return Object.fromEntries(Object.entries(values).filter(([key]) => !unmounted.has(key))); + }; + + const onCreateSubmit = (values: TeamCreateFormValues) => handleCreate(mountedCreateValues(values)); + const is_team_admin = (team: any) => { if (team == null || team.members_with_roles == null) { return false; @@ -405,7 +547,6 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser }; const { token } = theme.useToken(); - const { Text } = Typography; const { Content } = Layout; const tabItems = [ @@ -531,556 +672,538 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser onCancel={handleCancel} destroyOnHidden > -
- <> - - - - {(() => { - const adminOrgs = getAdminOrganizations(userRole, userID, organizations); - const isOrgAdmin = userRole !== "Admin"; - const isSingleOrg = adminOrgs.length === 1; - const hasNoOrgs = adminOrgs.length === 0; - - return ( - <> - - Organization{" "} - - Organizations can have multiple teams. Learn more about{" "} - e.stopPropagation()} - > - user management hierarchy - - - } - > - - - - } - name="organization_id" - initialValue={currentOrg ? currentOrg.organization_id : null} - className="mt-8" - rules={ - isOrgAdmin - ? [ - { - required: true, - message: "Please select an organization", - }, - ] - : [] - } - help={ - isOrgAdmin && isSingleOrg - ? "You can only create teams within this organization" - : isOrgAdmin - ? "required" - : "" - } - > - - - - {/* Show message when org admin needs to select organization */} - {isOrgAdmin && !isSingleOrg && adminOrgs.length > 1 && ( -
- - Please select an organization to create a team for. You can only create teams within - organizations where you are an admin. - -
- )} - - ); - })()} - - Models{" "} - - - - - } - name="models" - > - form.setFieldValue("models", values)} - organizationID={form.getFieldValue("organization_id")} - options={{ - includeSpecialOptions: true, - showAllProxyModelsOverride: !form.getFieldValue("organization_id"), - }} - context="team" - dataTestId="create-team-models-select" - /> - - - - - - - - - - - - - - - - - - - - - Additional Settings - - - - - { - e.target.value = e.target.value.trim(); - }} - /> - - (value ? Number(value) : undefined)} - tooltip="This is the individual budget for a user in the team." - > - - - - - - - - - - - - { - if (!value) { - return Promise.resolve(); - } - try { - JSON.parse(value); - return Promise.resolve(); - } catch (error) { - return Promise.reject(new Error("Please enter valid JSON")); - } - }, - }, - ]} - > - - - - Guardrails{" "} - - e.stopPropagation()} - > - - - - - } - name="guardrails" - className="mt-8" - help="Select existing guardrails or enter new ones" - > - ({ - value: name, - label: name, - }))} - /> - + + + + + {({ ref, value, ...field }) => ( + )} - - Access Groups{" "} - - - - - } - name="access_group_ids" - className="mt-8" - help="Select access groups to assign to this team" - > - - - - Allowed Vector Stores{" "} - - - - - } - name="allowed_vector_store_ids" - className="mt-8" - help="Select vector stores this team can access. Leave empty for access to all vector stores" - > - form.setFieldValue("allowed_vector_store_ids", values)} - value={form.getFieldValue("allowed_vector_store_ids")} - accessToken={accessToken || ""} - placeholder="Select vector stores (optional)" - /> - - - - - - + + {(() => { + const adminOrgs = getAdminOrganizations(userRole, userID, organizations); + const isSingleOrg = adminOrgs.length === 1; + const hasNoOrgs = adminOrgs.length === 0; - - - MCP Settings - - - - - Allowed MCP Servers{" "} - - - - - } - name="allowed_mcp_servers_and_groups" - className="mt-4" - help="Select MCP servers or access groups this team can access" - > - form.setFieldValue("allowed_mcp_servers_and_groups", val)} - value={form.getFieldValue("allowed_mcp_servers_and_groups")} - accessToken={accessToken || ""} - placeholder="Select MCP servers or access groups (optional)" - allowAllProxyMcpServers={isProxyAdminRole(userRole || "")} + return ( + <> + + {({ id, value, onChange }) => ( + ({ + value: org.organization_id ?? "", + label: org.organization_alias ?? "", + sublabel: org.organization_id ?? "", + }))} + disabled={isOrgAdmin && isSingleOrg} + allowClear={!isOrgAdmin} + placeholder={hasNoOrgs ? "No organizations available" : "Search or select an Organization"} + emptyText="No organizations available" + onValueChange={(next) => { + onChange(next === "" ? null : next); + setCurrentOrgForCreateTeam(adminOrgs.find((org) => org.organization_id === next) ?? null); + }} + /> + )} + + + {isOrgAdmin && !isSingleOrg && adminOrgs.length > 1 && ( +
+ + Please select an organization to create a team for. You can only create teams within + organizations where you are an admin. + +
+ )} + + ); + })()} + + {({ id, value, onChange }) => ( + -
+ )} + - {/* Hidden field to register mcp_tool_permissions with the form */} - + + {({ ref, value, ...field }) => ( + + )} + + + {({ id, value, onChange }) => ( + + )} + + + {({ ref, value, ...field }) => ( + + )} + + + {({ ref, value, ...field }) => ( + + )} + + + Metadata + + + Values are saved as text. Enter JSON for typed values, e.g. 3, true, or {'{"region": "us"}'}. + + - - prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups || - prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions - } - > - {() => ( -
- + + Additional Settings + + + + + + {({ ref, value, ...field }) => } + + + {({ ref, value, onChange, ...field }) => ( + ) => + onChange(event.target.value ? Number(event.target.value) : undefined) + } + step={0.01} + precision={2} + width={200} + /> + )} + + + {({ ref, value, ...field }) => ( + + )} + + + {({ ref, value, ...field }) => ( + + )} + + + {({ ref, value, ...field }) => ( + + )} + + + {({ ref, value, ...field }) => ( +