Migrate Default Team settings to use reusable Model Select

This commit is contained in:
yuneng-jiang 2026-02-02 17:57:35 -08:00
parent 8eba641190
commit cf734cb586
4 changed files with 974 additions and 378 deletions

View file

@ -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 (
<div data-testid={dataTestId || "model-select"}>
<select
multiple={mode === "multiple"}
role="listbox"
value={value}
value={visibleValues}
onChange={(e) => {
const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value);
onChange(mode === "multiple" ? selectedValues : selectedValues[0]);
@ -62,6 +69,9 @@ vi.mock("antd", async (importOriginal) => {
</optgroup>
))}
</select>
{shouldShowPlaceholder && maxTagPlaceholder && (
<div data-testid="max-tag-placeholder">{maxTagPlaceholder(omittedValues)}</div>
)}
</div>
);
},
@ -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(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
});
});
it("should show skeleton loader when loading", () => {
mockUseAllProxyModels.mockReturnValue({
data: undefined,
isLoading: true,
} as any);
renderWithProviders(<ModelSelect onChange={mockOnChange} context="user" />);
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(<ModelSelect onChange={mockOnChange} context="team" teamID="team-1" />);
expect(screen.getByTestId("skeleton-input")).toBeInTheDocument();
});
it("should show skeleton loader when organization is loading", () => {
mockUseOrganization.mockReturnValue({
data: undefined,
isLoading: true,
} as any);
renderWithProviders(<ModelSelect onChange={mockOnChange} context="organization" organizationID="org-1" />);
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(<ModelSelect onChange={mockOnChange} context="user" />);
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(
<ModelSelect
onChange={mockOnChange}
context="organization"
organizationID="org-1"
options={{ includeSpecialOptions: true }}
/>,
);
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(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
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(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
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(
<ModelSelect onChange={mockOnChange} context={context} {...props} />,
);
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(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
@ -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(
<ModelSelect
onChange={mockOnChange}
@ -301,187 +232,230 @@ describe("ModelSelect", () => {
);
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(
<ModelSelect
onChange={mockOnChange}
value={["all-proxy-models"]}
context="user"
options={{ showAllProxyModelsOverride: true }}
/>,
);
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: [],
},
];
await waitFor(() => {
const openaiWildcardOption = screen.getByRole("option", { name: "All Openai models" });
expect(openaiWildcardOption).toBeDisabled();
});
for (const testCase of testCases) {
testCase.setup();
const { unmount } = renderWithProviders(
<ModelSelect
onChange={mockOnChange}
context={testCase.context}
options={testCase.options}
{...(testCase.props || {})}
/>,
);
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 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,
};
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,
},
];
mockUseOrganization.mockReturnValue({
data: mockOrganization,
isLoading: false,
} as any);
for (const testCase of testCases) {
testCase.setup();
const { unmount } = renderWithProviders(
<ModelSelect
onChange={mockOnChange}
context={testCase.context}
options={testCase.options}
{...(testCase.props || {})}
/>,
);
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={["all-proxy-models"]}
context="organization"
organizationID="org-1"
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
/>,
);
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();
}
});
await waitFor(() => {
const noDefaultOption = screen.getByRole("option", { name: "No Default Models" });
expect(noDefaultOption).toBeDisabled();
});
});
it("should filter models when showAllProxyModelsOverride is true", async () => {
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
});
});
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);
renderWithProviders(<ModelSelect onChange={mockOnChange} context="organization" organizationID="org-1" />);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
});
});
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,
};
mockUseOrganization.mockReturnValue({
data: mockOrganization,
isLoading: false,
} as any);
renderWithProviders(<ModelSelect onChange={mockOnChange} context="organization" organizationID="org-1" />);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
});
});
it("should use custom dataTestId when provided", async () => {
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
dataTestId="custom-test-id"
context="user"
options={{ showAllProxyModelsOverride: true }}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("custom-test-id")).toBeInTheDocument();
});
});
it("should handle multiple model selections", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
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(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
expect(screen.getByText("All Openai models")).toBeInTheDocument();
expect(screen.getByText("All Anthropic models")).toBeInTheDocument();
});
unmount();
vi.clearAllMocks();
mockUseAllProxyModels.mockReturnValue({
data: { data: mockProxyModels },
isLoading: false,
} as any);
}
});
it("should deduplicate models with same id", async () => {
@ -505,52 +479,29 @@ describe("ModelSelect", () => {
});
});
it("should filter models based on user context with includeUserModels option", async () => {
mockUseCurrentUser.mockReturnValue({
data: { models: ["gpt-4"] },
isLoading: false,
} as any);
renderWithProviders(<ModelSelect onChange={mockOnChange} context="user" options={{ includeUserModels: true }} />);
it("should use custom dataTestId when provided", async () => {
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
dataTestId="custom-test-id"
context="user"
options={{ showAllProxyModelsOverride: true }}
/>,
);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.queryByText("claude-3")).not.toBeInTheDocument();
expect(screen.getByTestId("custom-test-id")).toBeInTheDocument();
});
});
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 return all proxy models for team context when organization has empty models array", async () => {
mockUseTeam.mockReturnValue({
data: mockTeam,
data: { team_id: "team-1", team_alias: "Test Team", models: [] },
isLoading: false,
} as any);
mockUseOrganization.mockReturnValue({
data: mockOrganization,
data: createMockOrganization([]),
isLoading: false,
} as any);
@ -558,7 +509,62 @@ describe("ModelSelect", () => {
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.queryByText("claude-3")).not.toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
});
});
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(
<ModelSelect
onChange={mockOnChange}
value={["all-proxy-models"]}
context="organization"
organizationID="org-1"
options={{ includeSpecialOptions: true }}
/>,
);
await waitFor(() => {
const noDefaultOption = screen.getByRole("option", { name: "No Default Models" });
expect(noDefaultOption).toBeDisabled();
});
});
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",
}));
mockUseAllProxyModels.mockReturnValue({
data: { data: manyModels },
isLoading: false,
} as any);
const selectedValues = manyModels.slice(0, 10).map((m) => m.id);
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={selectedValues}
context="user"
options={{ showAllProxyModelsOverride: true }}
/>,
);
await waitFor(() => {
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();
});
});
});

View file

@ -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<ModelSelectProps["context"], (args: FilterContextAr
organization: ({ allProxyModels }) => {
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 <Skeleton.Input active block />;
@ -134,6 +139,7 @@ export const ModelSelect = (props: ModelSelectProps) => {
data-testid={dataTestId}
value={value}
onChange={handleChange}
style={style}
options={[
includeSpecialOptions
? {

View file

@ -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<typeof import("@tremor/react")>();
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 }) => (
<select data-testid="budget-duration-dropdown" value={value || ""} onChange={(e) => onChange(e.target.value)}>
<select
data-testid="budget-duration-dropdown"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
aria-label="Budget duration"
>
<option value="">Select duration</option>
<option value="daily">Daily</option>
<option value="monthly">Monthly</option>
</select>
),
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 }) => (
<select
data-testid="model-select"
multiple
value={value || []}
onChange={(e) => {
const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value);
onChange(selectedValues);
}}
aria-label="Models"
>
<option value="gpt-4">gpt-4</option>
<option value="claude-3">claude-3</option>
</select>
),
}));
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
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<HTMLSelectElement>) => {
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("renders the component", async () => {
// Mock successful API responses
vi.mocked(networking.getDefaultTeamSettings).mockResolvedValue({
it("should render", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Default Team Settings")).toBeInTheDocument();
});
});
it("should show loading spinner while fetching settings", () => {
mockGetDefaultTeamSettings.mockImplementation(() => new Promise(() => { }));
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
expect(screen.getByTestId("spinner")).toBeInTheDocument();
});
it("should display message when no settings are available", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(null as any);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings accessToken={null} userID="test-user" userRole="admin" />);
await waitFor(() => {
expect(mockGetDefaultTeamSettings).not.toHaveBeenCalled();
});
});
it("should display settings fields with correct values", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("Not set")).toBeInTheDocument();
});
});
it("should toggle edit mode when edit button is clicked", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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",
},
max_budget: {
type: "number",
description: "Maximum budget",
description: "Team name",
},
},
},
values: {
...mockSettings.values,
team_name: "Test Team",
},
};
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithString);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
vi.mocked(networking.modelAvailableCall).mockResolvedValue({
data: [{ id: "gpt-4" }, { id: "claude-3" }],
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(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Edit Settings" })).toBeInTheDocument();
});
renderWithProviders(<TeamSSOSettings accessToken="test-token" userID="test-user" userRole="admin" />);
const editButton = screen.getByRole("button", { name: "Edit Settings" });
await userEvent.click(editButton);
const container = await screen.findByText("Default Team Settings");
expect(container).toBeInTheDocument();
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
const noneTexts = screen.getAllByText("None");
expect(noneTexts.length).toBeGreaterThan(0);
});
});
it("should display schema description when available", async () => {
mockGetDefaultTeamSettings.mockResolvedValue(mockSettings);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
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: "Max budget per user",
},
},
},
values: {
...mockSettings.values,
max_budget_per_user: 500,
},
};
mockGetDefaultTeamSettings.mockResolvedValue(settingsWithUnderscores);
renderWithProviders(<TeamSSOSettings {...defaultProps} />);
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(<TeamSSOSettings {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText("No schema information available")).toBeInTheDocument();
});
});
});

View file

@ -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<TeamSSOSettingsProps> = ({ accessToken, userID,
);
} else if (key === "models") {
return (
<Select
mode="multiple"
style={{ width: "100%" }}
<ModelSelect
value={editedValues[key] || []}
onChange={(value) => handleTextInputChange(key, value)}
className="mt-2"
>
<Option key="no-default-models" value="no-default-models">
No Default Models
</Option>
{availableModels.map((model: string) => (
<Option key={model} value={model}>
{getModelDisplayName(model)}
</Option>
))}
</Select>
context="global"
style={{ width: "100%" }}
options={{
includeSpecialOptions: true,
}}
/>
);
} else if (type === "string" && property.enum) {
return (