diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx index 3052f790098..6da2f82a2f1 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.test.tsx @@ -37,12 +37,19 @@ vi.mock("antd", async (importOriginal) => { mode, ...props }: any) => { + // Simulate maxTagCount responsive behavior - if value length > 5, call maxTagPlaceholder + const shouldShowPlaceholder = maxTagCount === "responsive" && Array.isArray(value) && value.length > 5; + const visibleValues = shouldShowPlaceholder ? value.slice(0, 5) : value; + const omittedValues = shouldShowPlaceholder + ? value.slice(5).map((v: string) => ({ value: v, label: v })) + : []; + return (
+ {shouldShowPlaceholder && maxTagPlaceholder && ( +
{maxTagPlaceholder(omittedValues)}
+ )}
); }, @@ -82,6 +92,24 @@ const mockUseTeam = vi.mocked(useTeam); const mockUseOrganization = vi.mocked(useOrganization); const mockUseCurrentUser = vi.mocked(useCurrentUser); +const createMockOrganization = (models: string[]): Organization => ({ + organization_id: "org-1", + organization_alias: "Test Org", + budget_id: "budget-1", + metadata: {}, + models, + spend: 0, + model_spend: {}, + created_at: "2024-01-01", + created_by: "user-1", + updated_at: "2024-01-01", + updated_by: "user-1", + litellm_budget_table: null, + teams: null, + users: null, + members: null, +}); + describe("ModelSelect", () => { const mockProxyModels: ProxyModel[] = [ { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, @@ -112,125 +140,44 @@ describe("ModelSelect", () => { } as any); }); - it("should render", async () => { + it("should render with all option groups", async () => { renderWithProviders( , ); await waitFor(() => { expect(screen.getByTestId("model-select")).toBeInTheDocument(); - }); - }); - - it("should show skeleton loader when loading", () => { - mockUseAllProxyModels.mockReturnValue({ - data: undefined, - isLoading: true, - } as any); - - renderWithProviders(); - - expect(screen.getByTestId("skeleton-input")).toBeInTheDocument(); - expect(screen.queryByTestId("model-select")).not.toBeInTheDocument(); - }); - - it("should show skeleton loader when team is loading", () => { - mockUseTeam.mockReturnValue({ - data: undefined, - isLoading: true, - } as any); - - renderWithProviders(); - - expect(screen.getByTestId("skeleton-input")).toBeInTheDocument(); - }); - - it("should show skeleton loader when organization is loading", () => { - mockUseOrganization.mockReturnValue({ - data: undefined, - isLoading: true, - } as any); - - renderWithProviders(); - - expect(screen.getByTestId("skeleton-input")).toBeInTheDocument(); - }); - - it("should show skeleton loader when current user is loading", () => { - mockUseCurrentUser.mockReturnValue({ - data: undefined, - isLoading: true, - } as any); - - renderWithProviders(); - - expect(screen.getByTestId("skeleton-input")).toBeInTheDocument(); - }); - - it("should render special options group", async () => { - const mockOrganization: Organization = { - organization_id: "org-1", - organization_alias: "Test Org", - budget_id: "budget-1", - metadata: {}, - models: ["all-proxy-models"], - spend: 0, - model_spend: {}, - created_at: "2024-01-01", - created_by: "user-1", - updated_at: "2024-01-01", - updated_by: "user-1", - litellm_budget_table: null, - teams: null, - users: null, - members: null, - }; - - mockUseOrganization.mockReturnValue({ - data: mockOrganization, - isLoading: false, - } as any); - - renderWithProviders( - , - ); - - await waitFor(() => { - const select = screen.getByTestId("model-select"); - expect(select).toBeInTheDocument(); - expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); - expect(screen.getByText("No Default Models")).toBeInTheDocument(); - }); - }); - - it("should render wildcard options group", async () => { - renderWithProviders( - , - ); - - await waitFor(() => { + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("claude-3")).toBeInTheDocument(); expect(screen.getByText("All Openai models")).toBeInTheDocument(); expect(screen.getByText("All Anthropic models")).toBeInTheDocument(); }); }); - it("should render regular models group", async () => { - renderWithProviders( - , - ); + it("should show skeleton loader when any data is loading", () => { + const loadingScenarios = [ + { hook: mockUseAllProxyModels, context: "user" as const }, + { hook: mockUseTeam, context: "team" as const, props: { teamID: "team-1" } }, + { hook: mockUseOrganization, context: "organization" as const, props: { organizationID: "org-1" } }, + { hook: mockUseCurrentUser, context: "user" as const }, + ]; - await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-3")).toBeInTheDocument(); + loadingScenarios.forEach(({ hook, context, props = {} }) => { + hook.mockReturnValue({ + data: undefined, + isLoading: true, + } as any); + + const { unmount } = renderWithProviders( + , + ); + + expect(screen.getByTestId("skeleton-input")).toBeInTheDocument(); + unmount(); }); }); - it("should call onChange when selecting a regular model", async () => { + it("should handle model selection and onChange", async () => { const user = userEvent.setup(); renderWithProviders( , @@ -242,32 +189,16 @@ describe("ModelSelect", () => { const select = screen.getByRole("listbox"); await user.selectOptions(select, "gpt-4"); - expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]); + + await user.selectOptions(select, ["gpt-4", "claude-3"]); + expect(mockOnChange).toHaveBeenCalled(); }); - it("should call onChange with only last special option when multiple special options are selected", async () => { + it("should handle special options correctly", async () => { const user = userEvent.setup(); - const mockOrganization: Organization = { - organization_id: "org-1", - organization_alias: "Test Org", - budget_id: "budget-1", - metadata: {}, - models: ["all-proxy-models"], - spend: 0, - model_spend: {}, - created_at: "2024-01-01", - created_by: "user-1", - updated_at: "2024-01-01", - updated_by: "user-1", - litellm_budget_table: null, - teams: null, - users: null, - members: null, - }; - mockUseOrganization.mockReturnValue({ - data: mockOrganization, + data: createMockOrganization(["all-proxy-models"]), isLoading: false, } as any); @@ -281,16 +212,16 @@ describe("ModelSelect", () => { ); await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + expect(screen.getByText("No Default Models")).toBeInTheDocument(); }); const select = screen.getByRole("listbox"); await user.selectOptions(select, ["all-proxy-models", "no-default-models"]); - expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]); }); - it("should disable regular models when special option is selected", async () => { + it("should disable models when special option is selected", async () => { renderWithProviders( { ); await waitFor(() => { - const gpt4Option = screen.getByRole("option", { name: "gpt-4" }); - expect(gpt4Option).toBeDisabled(); + expect(screen.getByRole("option", { name: "gpt-4" })).toBeDisabled(); + expect(screen.getByRole("option", { name: "All Openai models" })).toBeDisabled(); }); }); - it("should disable wildcard models when special option is selected", async () => { - renderWithProviders( - , - ); - - await waitFor(() => { - const openaiWildcardOption = screen.getByRole("option", { name: "All Openai models" }); - expect(openaiWildcardOption).toBeDisabled(); - }); - }); - - it("should disable other special options when one special option is selected", async () => { - const mockOrganization: Organization = { - organization_id: "org-1", - organization_alias: "Test Org", - budget_id: "budget-1", - metadata: {}, - models: ["all-proxy-models"], - spend: 0, - model_spend: {}, - created_at: "2024-01-01", - created_by: "user-1", - updated_at: "2024-01-01", - updated_by: "user-1", - litellm_budget_table: null, - teams: null, - users: null, - members: null, - }; - - mockUseOrganization.mockReturnValue({ - data: mockOrganization, - isLoading: false, - } as any); - - renderWithProviders( - , - ); - - await waitFor(() => { - const noDefaultOption = screen.getByRole("option", { name: "No Default Models" }); - expect(noDefaultOption).toBeDisabled(); - }); - }); + it("should filter models based on context", async () => { + const testCases = [ + { + name: "user context with includeUserModels", + context: "user" as const, + options: { includeUserModels: true }, + setup: () => { + mockUseCurrentUser.mockReturnValue({ + data: { models: ["gpt-4"] }, + isLoading: false, + } as any); + }, + expectedVisible: ["gpt-4"], + expectedHidden: ["claude-3"], + }, + { + name: "user context without includeUserModels", + context: "user" as const, + options: {}, + setup: () => { + mockUseCurrentUser.mockReturnValue({ + data: { models: ["gpt-4"] }, + isLoading: false, + } as any); + }, + expectedVisible: [], + expectedHidden: ["gpt-4", "claude-3"], + }, + { + name: "team context without organization", + context: "team" as const, + options: {}, + props: { teamID: "team-1" }, + setup: () => { + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", team_alias: "Test Team", models: [] }, + isLoading: false, + } as any); + mockUseOrganization.mockReturnValue({ + data: undefined, + isLoading: false, + } as any); + }, + expectedVisible: ["gpt-4", "claude-3"], + expectedHidden: [], + }, + { + name: "team context with organization having all-proxy-models", + context: "team" as const, + options: {}, + props: { teamID: "team-1", organizationID: "org-1" }, + setup: () => { + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", team_alias: "Test Team", models: [] }, + isLoading: false, + } as any); + mockUseOrganization.mockReturnValue({ + data: createMockOrganization(["all-proxy-models"]), + isLoading: false, + } as any); + }, + expectedVisible: ["gpt-4", "claude-3"], + expectedHidden: [], + }, + { + name: "team context with organization filtering models", + context: "team" as const, + options: {}, + props: { teamID: "team-1", organizationID: "org-1" }, + setup: () => { + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", team_alias: "Test Team", models: [] }, + isLoading: false, + } as any); + mockUseOrganization.mockReturnValue({ + data: createMockOrganization(["gpt-4"]), + isLoading: false, + } as any); + }, + expectedVisible: ["gpt-4"], + expectedHidden: ["claude-3"], + }, + { + name: "organization context", + context: "organization" as const, + options: {}, + props: { organizationID: "org-1" }, + setup: () => { + mockUseOrganization.mockReturnValue({ + data: createMockOrganization(["gpt-4"]), + isLoading: false, + } as any); + }, + expectedVisible: ["gpt-4", "claude-3"], + expectedHidden: [], + }, + { + name: "global context", + context: "global" as const, + options: {}, + setup: () => { }, + expectedVisible: ["gpt-4", "claude-3"], + expectedHidden: [], + }, + ]; - it("should filter models when showAllProxyModelsOverride is true", async () => { - renderWithProviders( - , - ); + for (const testCase of testCases) { + testCase.setup(); + const { unmount } = renderWithProviders( + , + ); - await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-3")).toBeInTheDocument(); - }); + await waitFor(() => { + testCase.expectedVisible.forEach((model) => { + expect(screen.getByText(model)).toBeInTheDocument(); + }); + testCase.expectedHidden.forEach((model) => { + expect(screen.queryByText(model)).not.toBeInTheDocument(); + }); + }); + + unmount(); + vi.clearAllMocks(); + mockUseAllProxyModels.mockReturnValue({ + data: { data: mockProxyModels }, + isLoading: false, + } as any); + } }); - it("should filter models when organization has all-proxy-models in models array", async () => { - const mockOrganization: Organization = { - organization_id: "org-1", - organization_alias: "Test Org", - budget_id: "budget-1", - metadata: {}, - models: ["all-proxy-models"], - spend: 0, - model_spend: {}, - created_at: "2024-01-01", - created_by: "user-1", - updated_at: "2024-01-01", - updated_by: "user-1", - litellm_budget_table: null, - teams: null, - users: null, - members: null, - }; - - mockUseOrganization.mockReturnValue({ - data: mockOrganization, - isLoading: false, - } as any); + it("should show All Proxy Models option based on conditions", async () => { + const testCases = [ + { + name: "when showAllProxyModelsOverride is true", + context: "user" as const, + options: { showAllProxyModelsOverride: true, includeSpecialOptions: true }, + setup: () => { }, + shouldShow: true, + }, + { + name: "when organization has all-proxy-models", + context: "organization" as const, + options: { includeSpecialOptions: true }, + props: { organizationID: "org-1" }, + setup: () => { + mockUseOrganization.mockReturnValue({ + data: createMockOrganization(["all-proxy-models"]), + isLoading: false, + } as any); + }, + shouldShow: true, + }, + { + name: "when organization has empty models array", + context: "organization" as const, + options: { includeSpecialOptions: true }, + props: { organizationID: "org-1" }, + setup: () => { + mockUseOrganization.mockReturnValue({ + data: createMockOrganization([]), + isLoading: false, + } as any); + }, + shouldShow: true, + }, + { + name: "when context is global", + context: "global" as const, + options: { includeSpecialOptions: true }, + setup: () => { }, + shouldShow: true, + }, + { + name: "when organization has specific models", + context: "organization" as const, + options: { includeSpecialOptions: true }, + props: { organizationID: "org-1" }, + setup: () => { + mockUseOrganization.mockReturnValue({ + data: createMockOrganization(["gpt-4"]), + isLoading: false, + } as any); + }, + shouldShow: false, + }, + ]; - renderWithProviders(); + for (const testCase of testCases) { + testCase.setup(); + const { unmount } = renderWithProviders( + , + ); - await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-3")).toBeInTheDocument(); - }); + await waitFor(() => { + if (testCase.shouldShow) { + expect(screen.getByText("All Proxy Models")).toBeInTheDocument(); + } else { + expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument(); + expect(screen.getByText("No Default Models")).toBeInTheDocument(); + } + }); + + unmount(); + vi.clearAllMocks(); + mockUseAllProxyModels.mockReturnValue({ + data: { data: mockProxyModels }, + isLoading: false, + } as any); + } }); - it("should show all models when organization context is used", async () => { - const mockOrganization: Organization = { - organization_id: "org-1", - organization_alias: "Test Org", - budget_id: "budget-1", - metadata: {}, - models: ["gpt-4"], - spend: 0, - model_spend: {}, - created_at: "2024-01-01", - created_by: "user-1", - updated_at: "2024-01-01", - updated_by: "user-1", - litellm_budget_table: null, - teams: null, - users: null, - members: null, - }; + it("should deduplicate models with same id", async () => { + const duplicateModels: ProxyModel[] = [ + { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, + { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, + ]; - mockUseOrganization.mockReturnValue({ - data: mockOrganization, + mockUseAllProxyModels.mockReturnValue({ + data: { data: duplicateModels }, isLoading: false, } as any); - renderWithProviders(); + renderWithProviders( + , + ); await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.getByText("claude-3")).toBeInTheDocument(); + const gpt4Options = screen.getAllByText("gpt-4"); + expect(gpt4Options.length).toBeGreaterThan(0); }); }); @@ -452,113 +494,77 @@ describe("ModelSelect", () => { }); }); - it("should handle multiple model selections", async () => { - const user = userEvent.setup(); - renderWithProviders( - , - ); - - await waitFor(() => { - expect(screen.getByTestId("model-select")).toBeInTheDocument(); - }); - - const select = screen.getByRole("listbox"); - await user.selectOptions(select, "gpt-4"); - expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]); - - await user.selectOptions(select, "claude-3"); - expect(mockOnChange).toHaveBeenCalled(); - const allCalls = mockOnChange.mock.calls.map((call) => call[0]); - expect(allCalls.some((call) => Array.isArray(call) && call.includes("gpt-4"))).toBe(true); - expect(allCalls.some((call) => Array.isArray(call) && call.includes("claude-3"))).toBe(true); - }); - - it("should capitalize provider name in wildcard options", async () => { - renderWithProviders( - , - ); - - await waitFor(() => { - expect(screen.getByText("All Openai models")).toBeInTheDocument(); - expect(screen.getByText("All Anthropic models")).toBeInTheDocument(); - }); - }); - - it("should deduplicate models with same id", async () => { - const duplicateModels: ProxyModel[] = [ - { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, - { id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" }, - ]; + it("should return all proxy models for team context when organization has empty models array", async () => { + mockUseTeam.mockReturnValue({ + data: { team_id: "team-1", team_alias: "Test Team", models: [] }, + isLoading: false, + } as any); - mockUseAllProxyModels.mockReturnValue({ - data: { data: duplicateModels }, + mockUseOrganization.mockReturnValue({ + data: createMockOrganization([]), isLoading: false, } as any); - renderWithProviders( - , - ); + renderWithProviders(); await waitFor(() => { - const gpt4Options = screen.getAllByText("gpt-4"); - expect(gpt4Options.length).toBeGreaterThan(0); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("claude-3")).toBeInTheDocument(); }); }); - it("should filter models based on user context with includeUserModels option", async () => { - mockUseCurrentUser.mockReturnValue({ - data: { models: ["gpt-4"] }, + it("should disable No Default Models when all-proxy-models is selected", async () => { + mockUseOrganization.mockReturnValue({ + data: createMockOrganization(["all-proxy-models"]), isLoading: false, } as any); - renderWithProviders(); + renderWithProviders( + , + ); await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.queryByText("claude-3")).not.toBeInTheDocument(); + const noDefaultOption = screen.getByRole("option", { name: "No Default Models" }); + expect(noDefaultOption).toBeDisabled(); }); }); - it("should filter models based on team context", async () => { - const mockTeam = { - team_id: "team-1", - team_alias: "Test Team", - models: ["gpt-4"], - }; - - const mockOrganization: Organization = { - organization_id: "org-1", - organization_alias: "Test Org", - budget_id: "budget-1", - metadata: {}, - models: ["gpt-4"], - spend: 0, - model_spend: {}, - created_at: "2024-01-01", - created_by: "user-1", - updated_at: "2024-01-01", - updated_by: "user-1", - litellm_budget_table: null, - teams: null, - users: null, - members: null, - }; + it("should render maxTagPlaceholder when many items are selected", async () => { + // Create many models to trigger maxTagCount responsive behavior + const manyModels: ProxyModel[] = Array.from({ length: 20 }, (_, i) => ({ + id: `model-${i}`, + object: "model", + created: 1234567890, + owned_by: "test", + })); - mockUseTeam.mockReturnValue({ - data: mockTeam, + mockUseAllProxyModels.mockReturnValue({ + data: { data: manyModels }, isLoading: false, } as any); - mockUseOrganization.mockReturnValue({ - data: mockOrganization, - isLoading: false, - } as any); + const selectedValues = manyModels.slice(0, 10).map((m) => m.id); - renderWithProviders(); + renderWithProviders( + , + ); await waitFor(() => { - expect(screen.getByText("gpt-4")).toBeInTheDocument(); - expect(screen.queryByText("claude-3")).not.toBeInTheDocument(); + expect(screen.getByTestId("model-select")).toBeInTheDocument(); + // Verify maxTagPlaceholder is rendered with omitted values + expect(screen.getByTestId("max-tag-placeholder")).toBeInTheDocument(); + expect(screen.getByText(/\+5 more/)).toBeInTheDocument(); }); }); }); diff --git a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx index 78ccdddd81b..2b7399c4565 100644 --- a/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx +++ b/ui/litellm-dashboard/src/components/ModelSelect/ModelSelect.tsx @@ -30,10 +30,11 @@ export interface ModelSelectProps { showAllProxyModelsOverride?: boolean; includeSpecialOptions?: boolean; }; - context: "team" | "organization" | "user"; + context: "team" | "organization" | "user" | "global"; dataTestId?: string; value?: string[]; onChange: (values: string[]) => void; + style?: React.CSSProperties; } type FilterContextArgs = { @@ -65,6 +66,10 @@ const contextFilters: Record { return allProxyModels; }, + + global: ({ allProxyModels }) => { + return allProxyModels; + }, }; const filterModels = ( @@ -84,7 +89,7 @@ const filterModels = ( }; export const ModelSelect = (props: ModelSelectProps) => { - const { teamID, organizationID, options, context, dataTestId, value = [], onChange } = props; + const { teamID, organizationID, options, context, dataTestId, value = [], onChange, style } = props; const { includeUserModels, showAllTeamModelsOption, showAllProxyModelsOverride, includeSpecialOptions } = options || {}; const { data: allProxyModels, isLoading: isLoadingAllProxyModels } = useAllProxyModels(); @@ -98,7 +103,7 @@ export const ModelSelect = (props: ModelSelectProps) => { const organizationHasAllProxyModels = organization?.models.includes(MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value) || organization?.models.length === 0; const shouldShowAllProxyModels = showAllProxyModelsOverride || - (organizationHasAllProxyModels && includeSpecialOptions); + (organizationHasAllProxyModels && includeSpecialOptions) || context === "global"; if (isLoading) { return ; @@ -134,6 +139,7 @@ export const ModelSelect = (props: ModelSelectProps) => { data-testid={dataTestId} value={value} onChange={handleChange} + style={style} options={[ includeSpecialOptions ? { diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx index f5e43fc3d5f..34085df8f10 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.test.tsx @@ -1,63 +1,653 @@ -import { screen } from "@testing-library/react"; +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; import TeamSSOSettings from "./TeamSSOSettings"; import * as networking from "./networking"; +import NotificationsManager from "./molecules/notifications_manager"; -// Mock the networking functions vi.mock("./networking"); -// Mock the budget duration dropdown +vi.mock("@tremor/react", async (importOriginal) => { + const actual = await importOriginal(); + const React = await import("react"); + return { + ...actual, + Card: ({ children }: { children: React.ReactNode }) => React.createElement("div", { "data-testid": "card" }, children), + Title: ({ children }: { children: React.ReactNode }) => React.createElement("h2", {}, children), + Text: ({ children }: { children: React.ReactNode }) => React.createElement("span", {}, children), + Divider: () => React.createElement("hr", {}), + TextInput: ({ value, onChange, placeholder, className }: any) => + React.createElement("input", { + type: "text", + value: value || "", + onChange, + placeholder, + className, + }), + }; +}); + vi.mock("./common_components/budget_duration_dropdown", () => ({ default: ({ value, onChange }: { value: string | null; onChange: (value: string) => void }) => ( - onChange(e.target.value)} + aria-label="Budget duration" + > ), - getBudgetDurationLabel: vi.fn((value: string) => value), + getBudgetDurationLabel: vi.fn((value: string) => `Budget: ${value}`), })); -// Mock the model display name helper vi.mock("./key_team_helpers/fetch_available_models_team_key", () => ({ getModelDisplayName: vi.fn((model: string) => model), })); +vi.mock("./ModelSelect/ModelSelect", () => ({ + ModelSelect: ({ value, onChange }: { value: string[]; onChange: (value: string[]) => void }) => ( + + ), +})); + +vi.mock("antd", async (importOriginal) => { + const actual = await importOriginal(); + const React = await import("react"); + const SelectComponent = ({ + value, + onChange, + mode, + children, + className, + }: { + value: any; + onChange: (value: any) => void; + mode?: string; + children: React.ReactNode; + className?: string; + }) => { + const isMultiple = mode === "multiple"; + const selectValue = isMultiple ? (Array.isArray(value) ? value : []) : value || ""; + return React.createElement( + "select", + { + multiple: isMultiple, + value: selectValue, + onChange: (e: React.ChangeEvent) => { + const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value); + onChange(isMultiple ? selectedValues : selectedValues[0] || undefined); + }, + className, + "aria-label": "Select", + role: "listbox", + }, + children, + ); + }; + SelectComponent.Option = ({ value: optionValue, children: optionChildren }: { value: string; children: React.ReactNode }) => + React.createElement("option", { value: optionValue }, optionChildren); + return { + ...actual, + Spin: ({ size }: { size?: string }) => React.createElement("div", { "data-testid": "spinner", "data-size": size }), + Switch: ({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) => + React.createElement("input", { + type: "checkbox", + role: "switch", + checked: checked, + onChange: (e) => onChange(e.target.checked), + "aria-label": "Toggle switch", + }), + Select: SelectComponent, + Typography: { + Paragraph: ({ children }: { children: React.ReactNode }) => React.createElement("p", {}, children), + }, + }; +}); + +const mockGetDefaultTeamSettings = vi.mocked(networking.getDefaultTeamSettings); +const mockUpdateDefaultTeamSettings = vi.mocked(networking.updateDefaultTeamSettings); +const mockModelAvailableCall = vi.mocked(networking.modelAvailableCall); +const mockNotificationsManager = vi.mocked(NotificationsManager); + describe("TeamSSOSettings", () => { + const defaultProps = { + accessToken: "test-token", + userID: "test-user", + userRole: "admin", + }; + + const mockSettings = { + values: { + budget_duration: "monthly", + max_budget: 1000, + enabled: true, + allowed_models: ["gpt-4", "claude-3"], + models: ["gpt-4"], + status: "active", + }, + field_schema: { + description: "Default team settings schema", + properties: { + budget_duration: { + type: "string", + description: "Budget duration setting", + }, + max_budget: { + type: "number", + description: "Maximum budget amount", + }, + enabled: { + type: "boolean", + description: "Enable feature", + }, + allowed_models: { + type: "array", + items: { + enum: ["gpt-4", "claude-3", "gpt-3.5-turbo"], + }, + description: "Allowed models", + }, + models: { + type: "array", + description: "Selected models", + }, + status: { + type: "string", + enum: ["active", "inactive", "pending"], + description: "Status", + }, + }, + }, + }; + beforeEach(() => { vi.clearAllMocks(); + mockModelAvailableCall.mockResolvedValue({ + data: [{ id: "gpt-4" }, { id: "claude-3" }], + }); + }); + + it("should render", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Default Team Settings")).toBeInTheDocument(); + }); + }); + + it("should show loading spinner while fetching settings", () => { + mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => { })); + + renderWithProviders(); + + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("should display message when no settings are available", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(null as any); + + renderWithProviders(); + + await waitFor(() => { + expect( + screen.getByText("No team settings available or you do not have permission to view them."), + ).toBeInTheDocument(); + }); + }); + + it("should not fetch settings when access token is null", async () => { + renderWithProviders(); + + await waitFor(() => { + expect(mockGetDefaultTeamSettings).not.toHaveBeenCalled(); + }); }); - it("renders the component", async () => { - // Mock successful API responses - vi.mocked(networking.getDefaultTeamSettings).mockResolvedValue({ + it("should display settings fields with correct values", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Budget Duration")).toBeInTheDocument(); + expect(screen.getByText("Max Budget")).toBeInTheDocument(); + }); + + expect(screen.getByText("Budget: monthly")).toBeInTheDocument(); + expect(screen.getByText("1000")).toBeInTheDocument(); + const enabledTexts = screen.getAllByText("Enabled"); + expect(enabledTexts.length).toBeGreaterThan(0); + }); + + it("should display 'Not set' for null values", async () => { + const settingsWithNulls = { + ...mockSettings, values: { - budget_duration: "monthly", - max_budget: 1000, + ...mockSettings.values, + max_budget: null, }, + }; + mockGetDefaultTeamSettings.mockResolvedValue(settingsWithNulls); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Not set")).toBeInTheDocument(); + }); + }); + + it("should toggle edit mode when edit button is clicked", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Edit Settings" })).not.toBeInTheDocument(); + }); + + it("should cancel edit mode and reset values", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await userEvent.click(cancelButton); + + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument(); + }); + + it("should save settings when save button is clicked", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + mockUpdateDefaultTeamSettings.mockResolvedValue({ + settings: mockSettings.values, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole("button", { name: "Save Changes" }); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(mockUpdateDefaultTeamSettings).toHaveBeenCalledWith("test-token", mockSettings.values); + }); + + expect(mockNotificationsManager.success).toHaveBeenCalledWith("Default team settings updated successfully"); + }); + + it("should show error notification when save fails", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + mockUpdateDefaultTeamSettings.mockRejectedValue(new Error("Save failed")); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole("button", { name: "Save Changes" }); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to update team settings"); + }); + }); + + it("should render boolean field as switch in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + const switchElement = screen.getByRole("switch"); + expect(switchElement).toBeInTheDocument(); + expect(switchElement).toBeChecked(); + }); + }); + + it("should update boolean value when switch is toggled", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + expect(screen.getByRole("switch")).toBeInTheDocument(); + }); + + const switchElement = screen.getByRole("switch"); + await userEvent.click(switchElement); + + expect(switchElement).not.toBeChecked(); + }); + + it("should render budget duration dropdown in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + expect(screen.getByLabelText("Budget duration")).toBeInTheDocument(); + }); + }); + + it("should update budget duration when dropdown value changes", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + expect(screen.getByLabelText("Budget duration")).toBeInTheDocument(); + }); + + const dropdown = screen.getByLabelText("Budget duration"); + await userEvent.selectOptions(dropdown, "daily"); + + expect(dropdown).toHaveValue("daily"); + }); + + it("should render text input for string fields in edit mode", async () => { + const settingsWithString = { + ...mockSettings, field_schema: { - description: "Default team settings", + ...mockSettings.field_schema, properties: { - budget_duration: { + ...mockSettings.field_schema.properties, + team_name: { type: "string", - description: "Budget duration", + description: "Team name", }, - max_budget: { + }, + }, + values: { + ...mockSettings.values, + team_name: "Test Team", + }, + }; + mockGetDefaultTeamSettings.mockResolvedValue(settingsWithString); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + const textInput = screen.getByDisplayValue("Test Team"); + expect(textInput).toBeInTheDocument(); + }); + }); + + it("should render enum select for string enum fields in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + const statusSelect = screen.getAllByRole("listbox")[0]; + expect(statusSelect).toBeInTheDocument(); + }); + }); + + it("should render multi-select for array enum fields in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + const multiSelects = screen.getAllByRole("listbox"); + expect(multiSelects.length).toBeGreaterThan(0); + }); + }); + + it("should render ModelSelect for models field in edit mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + expect(screen.getByTestId("model-select")).toBeInTheDocument(); + }); + }); + + it("should display models as badges in view mode", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + const gpt4Elements = screen.getAllByText("gpt-4"); + expect(gpt4Elements.length).toBeGreaterThan(0); + }); + }); + + it("should display 'None' for empty arrays in view mode", async () => { + const settingsWithEmptyArray = { + ...mockSettings, + values: { + ...mockSettings.values, + models: [], + }, + }; + mockGetDefaultTeamSettings.mockResolvedValue(settingsWithEmptyArray); + + renderWithProviders(); + + await waitFor(() => { + const noneTexts = screen.getAllByText("None"); + expect(noneTexts.length).toBeGreaterThan(0); + }); + }); + + it("should display schema description when available", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Default team settings schema")).toBeInTheDocument(); + }); + }); + + it("should show error notification when fetching settings fails", async () => { + mockGetDefaultTeamSettings.mockRejectedValue(new Error("Fetch failed")); + + renderWithProviders(); + + await waitFor(() => { + expect(mockNotificationsManager.fromBackend).toHaveBeenCalledWith("Failed to fetch team settings"); + }); + }); + + it("should handle model fetch error gracefully", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + mockModelAvailableCall.mockRejectedValue(new Error("Model fetch failed")); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Default Team Settings")).toBeInTheDocument(); + }); + }); + + it("should disable cancel button while saving", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + mockUpdateDefaultTeamSettings.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve({ settings: mockSettings.values }), 100)), + ); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument(); + }); + + const editButton = screen.getByRole("button", { name: "Edit Settings" }); + await userEvent.click(editButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Save Changes" })).toBeInTheDocument(); + }); + + const saveButton = screen.getByRole("button", { name: "Save Changes" }); + await userEvent.click(saveButton); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + expect(cancelButton).toBeDisabled(); + }); + + it("should display field descriptions", async () => { + mockGetDefaultTeamSettings.mockResolvedValue(mockSettings); + + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Budget duration setting")).toBeInTheDocument(); + expect(screen.getByText("Maximum budget amount")).toBeInTheDocument(); + }); + }); + + it("should format field names by replacing underscores and capitalizing", async () => { + const settingsWithUnderscores = { + ...mockSettings, + field_schema: { + ...mockSettings.field_schema, + properties: { + ...mockSettings.field_schema.properties, + max_budget_per_user: { type: "number", - description: "Maximum budget", + description: "Max budget per user", }, }, }, - }); + values: { + ...mockSettings.values, + max_budget_per_user: 500, + }, + }; + mockGetDefaultTeamSettings.mockResolvedValue(settingsWithUnderscores); - vi.mocked(networking.modelAvailableCall).mockResolvedValue({ - data: [{ id: "gpt-4" }, { id: "claude-3" }], + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText("Max Budget Per User")).toBeInTheDocument(); }); + }); + + it("should display 'No schema information available' when schema is missing", async () => { + const settingsWithoutSchema = { + values: {}, + field_schema: null, + }; + mockGetDefaultTeamSettings.mockResolvedValue(settingsWithoutSchema); - renderWithProviders(); + renderWithProviders(); - const container = await screen.findByText("Default Team Settings"); - expect(container).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("No schema information available")).toBeInTheDocument(); + }); }); }); diff --git a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx index 8537b108cdc..33bfc783afd 100644 --- a/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/TeamSSOSettings.tsx @@ -5,6 +5,7 @@ import { getDefaultTeamSettings, updateDefaultTeamSettings, modelAvailableCall } import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown"; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; import NotificationsManager from "./molecules/notifications_manager"; +import { ModelSelect } from "./ModelSelect/ModelSelect"; interface TeamSSOSettingsProps { accessToken: string | null; @@ -116,22 +117,15 @@ const TeamSSOSettings: React.FC = ({ accessToken, userID, ); } else if (key === "models") { return ( - + context="global" + style={{ width: "100%" }} + options={{ + includeSpecialOptions: true, + }} + /> ); } else if (type === "string" && property.enum) { return (