mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
Merge pull request #36918 from BerriAI/litellm_shadcn_modelhub_0814
refactor(ui): move the model hub and model select onto shadcn primitives
This commit is contained in:
commit
c9917cbf99
8 changed files with 1278 additions and 1223 deletions
|
|
@ -47,10 +47,10 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
// Fill Team Name — the input has id="team_alias"
|
||||
await dialog.locator("#team_alias").fill(uniqueAlias);
|
||||
|
||||
// Select models — the models multi-select is inside the modal
|
||||
// Click to open dropdown, select "All Proxy Models"
|
||||
await dialog.locator(".ant-select-selection-overflow").first().click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("All Proxy Models").click();
|
||||
// Select models — the models multi-select is inside the modal. Its popup is
|
||||
// portaled to the body, so scope the option lookup to the page, not the dialog.
|
||||
await dialog.getByTestId("create-team-models-select").getByRole("combobox").click();
|
||||
await page.getByRole("option", { name: "All Proxy Models", exact: true }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Submit — click the submit button inside the dialog (not the header button)
|
||||
|
|
@ -191,11 +191,11 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
const modelsSelect = page.locator("[data-testid='models-select']");
|
||||
await expect(modelsSelect).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const anthropicTag = modelsSelect
|
||||
.locator(".ant-select-selection-item")
|
||||
const anthropicChip = modelsSelect
|
||||
.locator('[data-slot="combobox-chip"]')
|
||||
.filter({ hasText: "fake-anthropic-claude" });
|
||||
await expect(anthropicTag).toBeVisible({ timeout: 5_000 });
|
||||
await anthropicTag.locator(".ant-select-selection-item-remove").click();
|
||||
await expect(anthropicChip).toBeVisible({ timeout: 5_000 });
|
||||
await anthropicChip.locator('[data-slot="combobox-chip-remove"]').click();
|
||||
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
|
|
|
|||
|
|
@ -1772,9 +1772,6 @@
|
|||
"src/components/GuardrailsMonitor/LogViewer.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/HelpLink.test.tsx": {
|
||||
|
|
@ -1782,11 +1779,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/ModelSelect/ModelSelect.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": {
|
||||
"max-nested-callbacks": {
|
||||
"count": 12
|
||||
|
|
@ -2239,9 +2231,6 @@
|
|||
}
|
||||
},
|
||||
"src/components/claude_code_plugins/MakeSkillPublicForm.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
|
|
@ -2760,9 +2749,6 @@
|
|||
},
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/query_param_input.tsx": {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { CheckCircleOutlined, CloseOutlined, DownOutlined, WarningOutlined } from "@ant-design/icons";
|
||||
import { CircleCheck, ChevronDown, TriangleAlert, X } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import moment from "moment";
|
||||
import { Button, Spin } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
import { uiSpendLogsCall } from "@/components/networking";
|
||||
import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer";
|
||||
import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns";
|
||||
|
|
@ -13,21 +14,21 @@ const actionConfig: Record<
|
|||
{ icon: React.ElementType; color: string; bg: string; border: string; label: string }
|
||||
> = {
|
||||
blocked: {
|
||||
icon: CloseOutlined,
|
||||
icon: X,
|
||||
color: "text-red-600",
|
||||
bg: "bg-red-50",
|
||||
border: "border-red-200",
|
||||
label: "Blocked",
|
||||
},
|
||||
passed: {
|
||||
icon: CheckCircleOutlined,
|
||||
icon: CircleCheck,
|
||||
color: "text-green-600",
|
||||
bg: "bg-green-50",
|
||||
border: "border-green-200",
|
||||
label: "Passed",
|
||||
},
|
||||
flagged: {
|
||||
icon: WarningOutlined,
|
||||
icon: TriangleAlert,
|
||||
color: "text-amber-600",
|
||||
bg: "bg-amber-50",
|
||||
border: "border-amber-200",
|
||||
|
|
@ -125,8 +126,8 @@ export function LogViewer({
|
|||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
type={activeFilter === f ? "primary" : "default"}
|
||||
size="small"
|
||||
variant={activeFilter === f ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setActiveFilter(f)}
|
||||
>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
|
|
@ -139,8 +140,8 @@ export function LogViewer({
|
|||
{sampleSizes.map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
type={sampleSize === size ? "primary" : "default"}
|
||||
size="small"
|
||||
variant={sampleSize === size ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setSampleSize(size)}
|
||||
>
|
||||
{size}
|
||||
|
|
@ -154,7 +155,7 @@ export function LogViewer({
|
|||
|
||||
{logsLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spin />
|
||||
<UiLoadingSpinner className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
{!logsLoading && displayLogs.length === 0 && (
|
||||
|
|
@ -182,11 +183,11 @@ export function LogViewer({
|
|||
</span>
|
||||
<span className="text-xs text-gray-400">{log.timestamp}</span>
|
||||
<span className="text-xs text-gray-400">·</span>
|
||||
{log.model && <span className="text-xs text-gray-500">{log.model}</span>}
|
||||
{log.model && <span className="min-w-0 text-xs break-words text-gray-500">{log.model}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-gray-800 truncate">{log.input_snippet ?? log.input ?? "—"}</p>
|
||||
</div>
|
||||
<DownOutlined className="w-4 h-4 text-gray-400 shrink-0 mt-1" />
|
||||
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0 mt-1" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ProxyModel } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import type { Organization } from "@/components/networking";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { screen } 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";
|
||||
|
|
@ -22,64 +22,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
|
|||
useCurrentUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("antd", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("antd")>();
|
||||
return {
|
||||
...actual,
|
||||
Select: ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
"data-testid": dataTestId,
|
||||
allowClear,
|
||||
maxTagCount,
|
||||
maxTagPlaceholder,
|
||||
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={visibleValues}
|
||||
onChange={(e) => {
|
||||
const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value);
|
||||
onChange(mode === "multiple" ? selectedValues : selectedValues[0]);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{options?.map((group: any) => (
|
||||
<optgroup
|
||||
key={group.label?.props?.children || group.title}
|
||||
label={group.title || group.label?.props?.children}
|
||||
>
|
||||
{group.options?.map((option: any) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{typeof option.label === "string" ? option.label : option.label?.props?.children}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{shouldShowPlaceholder && maxTagPlaceholder && (
|
||||
<div data-testid="max-tag-placeholder">{maxTagPlaceholder(omittedValues)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Skeleton: {
|
||||
Input: ({ active, block }: any) => <div data-testid="skeleton-input" data-active={active} data-block={block} />,
|
||||
},
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
||||
import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels";
|
||||
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
|
@ -108,6 +50,14 @@ const createMockOrganization = (models: string[]): Organization => ({
|
|||
members: null,
|
||||
});
|
||||
|
||||
const openModelList = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getAllByRole("combobox")[0]);
|
||||
await screen.findByRole("listbox");
|
||||
};
|
||||
|
||||
const expectOffered = (label: string) => expect(screen.queryAllByText(label).length).toBeGreaterThan(0);
|
||||
const expectNotOffered = (label: string) => expect(screen.queryAllByText(label)).toHaveLength(0);
|
||||
|
||||
describe("ModelSelect", () => {
|
||||
const mockProxyModels: ProxyModel[] = [
|
||||
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
|
||||
|
|
@ -138,21 +88,26 @@ describe("ModelSelect", () => {
|
|||
} as any);
|
||||
});
|
||||
|
||||
it("should render with all option groups", async () => {
|
||||
it("should offer every model and wildcard under its group heading", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("model-select")).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
await openModelList(user);
|
||||
|
||||
expectOffered("Wildcard Options");
|
||||
expectOffered("gpt-4");
|
||||
expectOffered("claude-3");
|
||||
expectOffered("All Openai models");
|
||||
expectOffered("All Anthropic models");
|
||||
});
|
||||
|
||||
it("should show skeleton loader when any data is loading", () => {
|
||||
it("should offer nothing to select while any dependency is loading", () => {
|
||||
const { unmount: unmountReady } = renderWithProviders(<ModelSelect onChange={mockOnChange} context="user" />);
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(1);
|
||||
unmountReady();
|
||||
|
||||
const loadingScenarios = [
|
||||
{ hook: mockUseAllProxyModels, context: "user" as const },
|
||||
{ hook: mockUseTeam, context: "team" as const, props: { teamID: "team-1" } },
|
||||
|
|
@ -168,30 +123,41 @@ describe("ModelSelect", () => {
|
|||
|
||||
const { unmount } = renderWithProviders(<ModelSelect onChange={mockOnChange} context={context} {...props} />);
|
||||
|
||||
expect(screen.getByTestId("skeleton-input")).toBeInTheDocument();
|
||||
expect(screen.queryAllByRole("combobox")).toHaveLength(0);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle model selection and onChange", async () => {
|
||||
it("should report the picked model to onChange", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("model-select")).toBeInTheDocument();
|
||||
});
|
||||
await openModelList(user);
|
||||
await user.click(screen.getAllByText("gpt-4")[0]);
|
||||
|
||||
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 handle special options correctly", async () => {
|
||||
it("should append a second model to the existing selection", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModelSelect
|
||||
onChange={mockOnChange}
|
||||
value={["gpt-4"]}
|
||||
context="user"
|
||||
options={{ showAllProxyModelsOverride: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await openModelList(user);
|
||||
await user.click(screen.getAllByText("claude-3")[0]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(["gpt-4", "claude-3"]);
|
||||
});
|
||||
|
||||
it("should offer both special options when they are enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseOrganization.mockReturnValue({
|
||||
data: createMockOrganization(["all-proxy-models"]),
|
||||
|
|
@ -207,33 +173,32 @@ describe("ModelSelect", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
|
||||
expect(screen.getByText("No Default Models")).toBeInTheDocument();
|
||||
});
|
||||
await openModelList(user);
|
||||
|
||||
const select = screen.getByRole("listbox");
|
||||
await user.selectOptions(select, ["all-proxy-models", "no-default-models"]);
|
||||
expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]);
|
||||
expectOffered("Special Options");
|
||||
expectOffered("All Proxy Models");
|
||||
expectOffered("No Default Models");
|
||||
});
|
||||
|
||||
it("should disable models when special option is selected", async () => {
|
||||
it("should replace an existing selection when a special option is picked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<ModelSelect
|
||||
onChange={mockOnChange}
|
||||
value={["all-proxy-models"]}
|
||||
value={["gpt-4"]}
|
||||
context="user"
|
||||
options={{ showAllProxyModelsOverride: true }}
|
||||
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("option", { name: "gpt-4" })).toBeDisabled();
|
||||
expect(screen.getByRole("option", { name: "All Openai models" })).toBeDisabled();
|
||||
});
|
||||
await openModelList(user);
|
||||
await user.click(screen.getAllByText("No Default Models")[0]);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]);
|
||||
});
|
||||
|
||||
it("should filter models based on context", async () => {
|
||||
it("should filter the offered models by context", async () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: "user context with includeUserModels",
|
||||
|
|
@ -340,6 +305,7 @@ describe("ModelSelect", () => {
|
|||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const user = userEvent.setup();
|
||||
testCase.setup();
|
||||
const { unmount } = renderWithProviders(
|
||||
<ModelSelect
|
||||
|
|
@ -350,14 +316,9 @@ describe("ModelSelect", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
testCase.expectedVisible.forEach((model) => {
|
||||
expect(screen.getByText(model)).toBeInTheDocument();
|
||||
});
|
||||
testCase.expectedHidden.forEach((model) => {
|
||||
expect(screen.queryByText(model)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
await openModelList(user);
|
||||
testCase.expectedVisible.forEach(expectOffered);
|
||||
testCase.expectedHidden.forEach(expectNotOffered);
|
||||
|
||||
unmount();
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -368,7 +329,7 @@ describe("ModelSelect", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("should show All Proxy Models option based on conditions", async () => {
|
||||
it("should offer All Proxy Models only when the context allows it", async () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: "when showAllProxyModelsOverride is true",
|
||||
|
|
@ -426,6 +387,7 @@ describe("ModelSelect", () => {
|
|||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const user = userEvent.setup();
|
||||
testCase.setup();
|
||||
const { unmount } = renderWithProviders(
|
||||
<ModelSelect
|
||||
|
|
@ -436,14 +398,13 @@ describe("ModelSelect", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
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 openModelList(user);
|
||||
if (testCase.shouldShow) {
|
||||
expectOffered("All Proxy Models");
|
||||
} else {
|
||||
expectNotOffered("All Proxy Models");
|
||||
expectOffered("No Default Models");
|
||||
}
|
||||
|
||||
unmount();
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -454,27 +415,6 @@ describe("ModelSelect", () => {
|
|||
}
|
||||
});
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
mockUseAllProxyModels.mockReturnValue({
|
||||
data: { data: duplicateModels },
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
renderWithProviders(
|
||||
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const gpt4Options = screen.getAllByText("gpt-4");
|
||||
expect(gpt4Options.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should use custom dataTestId when provided", async () => {
|
||||
renderWithProviders(
|
||||
<ModelSelect
|
||||
|
|
@ -485,12 +425,11 @@ describe("ModelSelect", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("custom-test-id")).toBeInTheDocument();
|
||||
});
|
||||
expect(await screen.findByTestId("custom-test-id")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should return all proxy models for team context when organization has empty models array", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseTeam.mockReturnValue({
|
||||
data: { team_id: "team-1", team_alias: "Test Team", models: [] },
|
||||
isLoading: false,
|
||||
|
|
@ -503,52 +442,66 @@ describe("ModelSelect", () => {
|
|||
|
||||
renderWithProviders(<ModelSelect onChange={mockOnChange} context="team" teamID="team-1" organizationID="org-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-3")).toBeInTheDocument();
|
||||
});
|
||||
await openModelList(user);
|
||||
|
||||
expectOffered("gpt-4");
|
||||
expectOffered("claude-3");
|
||||
});
|
||||
|
||||
it("should disable No Default Models when all-proxy-models is selected", async () => {
|
||||
mockUseOrganization.mockReturnValue({
|
||||
data: createMockOrganization(["all-proxy-models"]),
|
||||
isLoading: false,
|
||||
} as any);
|
||||
it("should not offer a special options group when includeSpecialOptions is omitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ModelSelect onChange={mockOnChange} context="global" />);
|
||||
|
||||
await openModelList(user);
|
||||
|
||||
expectNotOffered("Special Options");
|
||||
expectNotOffered("All Proxy Models");
|
||||
expectNotOffered("No Default Models");
|
||||
expectOffered("Models");
|
||||
});
|
||||
|
||||
it("should mark models and wildcards unselectable while a special option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ModelSelect
|
||||
onChange={mockOnChange}
|
||||
value={["all-proxy-models"]}
|
||||
context="organization"
|
||||
organizationID="org-1"
|
||||
options={{ includeSpecialOptions: true }}
|
||||
context="user"
|
||||
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const noDefaultOption = screen.getByRole("option", { name: "No Default Models" });
|
||||
expect(noDefaultOption).toBeDisabled();
|
||||
});
|
||||
await openModelList(user);
|
||||
|
||||
expect(screen.getByRole("option", { name: "gpt-4" })).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByRole("option", { name: "All Openai models" })).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByRole("option", { name: "No Default Models" })).toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByRole("option", { name: "All Proxy Models" })).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should not render an empty optgroup when includeSpecialOptions is omitted", async () => {
|
||||
renderWithProviders(<ModelSelect onChange={mockOnChange} context="global" />);
|
||||
it("should list a duplicated proxy model only once", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseAllProxyModels.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
|
||||
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("model-select")).toBeInTheDocument();
|
||||
});
|
||||
renderWithProviders(
|
||||
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
|
||||
);
|
||||
|
||||
const optgroups = document.querySelectorAll("optgroup");
|
||||
// Wildcard Options + Models — no blank leading group
|
||||
expect(optgroups.length).toBe(2);
|
||||
optgroups.forEach((g) => {
|
||||
expect(g.getAttribute("label")).toBeTruthy();
|
||||
});
|
||||
await openModelList(user);
|
||||
|
||||
expect(screen.getAllByRole("option", { name: "gpt-4" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
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) => ({
|
||||
it("should collapse selections past the chip limit into a labelled overflow count", async () => {
|
||||
const manyModels: ProxyModel[] = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: `model-${i}`,
|
||||
object: "model",
|
||||
created: 1234567890,
|
||||
|
|
@ -560,22 +513,18 @@ describe("ModelSelect", () => {
|
|||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
const selectedValues = manyModels.slice(0, 10).map((m) => m.id);
|
||||
|
||||
renderWithProviders(
|
||||
<ModelSelect
|
||||
onChange={mockOnChange}
|
||||
value={selectedValues}
|
||||
value={manyModels.map((m) => m.id)}
|
||||
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();
|
||||
});
|
||||
expect(await screen.findByText("+3 more")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("model-0")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("model-4")).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("model-5")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,22 @@ import { ProxyModel, useAllProxyModels } from "@/app/(dashboard)/hooks/models/us
|
|||
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
|
||||
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { Select, Skeleton, Tooltip } from "antd";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxCollection,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxGroup,
|
||||
ComboboxItem,
|
||||
ComboboxLabel,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Organization, Team } from "../networking";
|
||||
import { splitWildcardModels } from "./modelUtils";
|
||||
|
||||
|
|
@ -21,6 +36,8 @@ export const MODEL_SENTINEL_OPTIONS = [
|
|||
MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE,
|
||||
] as const;
|
||||
|
||||
const MAX_VISIBLE_MODEL_CHIPS = 5;
|
||||
|
||||
export interface ModelSelectProps {
|
||||
teamID?: string;
|
||||
organizationID?: string;
|
||||
|
|
@ -37,6 +54,17 @@ export interface ModelSelectProps {
|
|||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
type ModelOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type ModelOptionGroup = {
|
||||
label: string;
|
||||
items: ModelOption[];
|
||||
};
|
||||
|
||||
type FilterContextArgs = {
|
||||
allProxyModels: string[];
|
||||
selectedTeam?: Team;
|
||||
|
|
@ -109,10 +137,11 @@ export const ModelSelect = (props: ModelSelectProps) => {
|
|||
showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global";
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton.Input active block />;
|
||||
return <Skeleton className="h-9 w-full" />;
|
||||
}
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
const handleChange = (selected: ModelOption[]) => {
|
||||
const values = selected.map((option) => option.value);
|
||||
const specialValues = values.filter(isSpecialOption);
|
||||
|
||||
let finalValues: string[];
|
||||
|
|
@ -133,85 +162,122 @@ export const ModelSelect = (props: ModelSelectProps) => {
|
|||
});
|
||||
|
||||
const { wildcard, regular } = splitWildcardModels(filteredModels);
|
||||
return (
|
||||
<Select
|
||||
data-testid={dataTestId}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
style={style}
|
||||
options={[
|
||||
...(includeSpecialOptions
|
||||
? [
|
||||
{
|
||||
label: <span>Special Options</span>,
|
||||
title: "Special Options",
|
||||
options: [
|
||||
...(shouldShowAllProxyModels
|
||||
? [
|
||||
{
|
||||
label: <span>All Proxy Models</span>,
|
||||
value: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
|
||||
disabled:
|
||||
value.length > 0 &&
|
||||
value.some(
|
||||
(v) => isSpecialOption(v) && v !== MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
|
||||
),
|
||||
key: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: <span>No Default Models</span>,
|
||||
value: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
|
||||
disabled:
|
||||
value.length > 0 &&
|
||||
value.some((v) => isSpecialOption(v) && v !== MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value),
|
||||
key: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(wildcard.length > 0
|
||||
? [
|
||||
{
|
||||
label: <span>Wildcard Options</span>,
|
||||
title: "Wildcard Options",
|
||||
options: wildcard.map((model) => {
|
||||
const provider = model.replace("/*", "");
|
||||
const capitalizedProvider = provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
|
||||
return {
|
||||
label: <span>{`All ${capitalizedProvider} models`}</span>,
|
||||
value: model,
|
||||
disabled: hasSpecialOptionSelected,
|
||||
};
|
||||
}),
|
||||
const groups: ModelOptionGroup[] = [
|
||||
...(includeSpecialOptions
|
||||
? [
|
||||
{
|
||||
label: "Special Options",
|
||||
items: [
|
||||
...(shouldShowAllProxyModels
|
||||
? [
|
||||
{
|
||||
label: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.label,
|
||||
value: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
|
||||
disabled:
|
||||
value.length > 0 &&
|
||||
value.some(
|
||||
(v) => isSpecialOption(v) && v !== MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.label,
|
||||
value: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
|
||||
disabled:
|
||||
value.length > 0 &&
|
||||
value.some((v) => isSpecialOption(v) && v !== MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: <span>Models</span>,
|
||||
title: "Models",
|
||||
options: regular.map((model) => ({
|
||||
label: <span>{model}</span>,
|
||||
value: model,
|
||||
disabled: hasSpecialOptionSelected,
|
||||
})),
|
||||
},
|
||||
]}
|
||||
mode="multiple"
|
||||
placeholder="Select Models"
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
maxTagPlaceholder={(omittedValues) => (
|
||||
<Tooltip
|
||||
styles={{ root: { pointerEvents: "none" } }}
|
||||
title={omittedValues.map(({ value }) => value).join(", ")}
|
||||
>
|
||||
<span>+{omittedValues.length} more</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(wildcard.length > 0
|
||||
? [
|
||||
{
|
||||
label: "Wildcard Options",
|
||||
items: wildcard.map((model) => {
|
||||
const provider = model.replace("/*", "");
|
||||
const capitalizedProvider = provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
|
||||
return {
|
||||
label: `All ${capitalizedProvider} models`,
|
||||
value: model,
|
||||
disabled: hasSpecialOptionSelected,
|
||||
};
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Models",
|
||||
items: regular.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
disabled: hasSpecialOptionSelected,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const optionsByValue = new Map(groups.flatMap((group) => group.items).map((option) => [option.value, option]));
|
||||
const selectedOptions = value.map((v) => optionsByValue.get(v) ?? { label: v, value: v });
|
||||
const overflowOptions = selectedOptions.slice(MAX_VISIBLE_MODEL_CHIPS);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Combobox
|
||||
multiple
|
||||
items={groups}
|
||||
value={selectedOptions}
|
||||
onValueChange={handleChange}
|
||||
isItemEqualToValue={(option: ModelOption, selected: ModelOption) => option.value === selected.value}
|
||||
itemToStringLabel={(option: ModelOption) => option.label}
|
||||
>
|
||||
<ComboboxChips data-testid={dataTestId} style={style} className="w-full">
|
||||
<ComboboxValue>
|
||||
{(selected: ModelOption[]) => (
|
||||
<>
|
||||
{selected.slice(0, MAX_VISIBLE_MODEL_CHIPS).map((option) => (
|
||||
<ComboboxChip key={option.value} aria-label={option.label}>
|
||||
{option.label}
|
||||
</ComboboxChip>
|
||||
))}
|
||||
{overflowOptions.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<span className="px-1 text-xs text-muted-foreground" />}
|
||||
>{`+${overflowOptions.length} more`}</TooltipTrigger>
|
||||
<TooltipContent>{overflowOptions.map((option) => option.value).join(", ")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
placeholder="Select Models"
|
||||
aria-label="Select Models"
|
||||
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>No models found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(group: ModelOptionGroup) => (
|
||||
<ComboboxGroup key={group.label} items={group.items}>
|
||||
<ComboboxLabel>{group.label}</ComboboxLabel>
|
||||
<ComboboxCollection>
|
||||
{(option: ModelOption) => (
|
||||
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
|
||||
<span className="min-w-0 break-words">{option.label}</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxCollection>
|
||||
</ComboboxGroup>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Modal, Form, Steps, Button, Checkbox } from "antd";
|
||||
import { Text, Title, Badge } from "@tremor/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { enableClaudeCodePlugin, disableClaudeCodePlugin } from "../networking";
|
||||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { Plugin } from "./types";
|
||||
|
||||
const { Step } = Steps;
|
||||
const STEP_TITLES = ["Select Skills", "Confirm"];
|
||||
|
||||
interface MakeSkillPublicFormProps {
|
||||
visible: boolean;
|
||||
|
|
@ -25,12 +29,10 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0);
|
||||
setSelectedSkills(new Set());
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -106,52 +108,44 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
const renderStep1 = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Title>Select Skills to Publish</Title>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
disabled={skillsList.length === 0}
|
||||
>
|
||||
<h3 className="text-lg font-semibold">Select Skills to Publish</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
indeterminate={isIndeterminate}
|
||||
onCheckedChange={(checked) => handleSelectAll(checked === true)}
|
||||
disabled={skillsList.length === 0}
|
||||
/>
|
||||
Select All ({skillsList.length})
|
||||
</Checkbox>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Text className="text-sm text-gray-600">
|
||||
<p className="text-sm text-gray-600">
|
||||
Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished.
|
||||
</Text>
|
||||
</p>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
<div className="space-y-3">
|
||||
{skillsList.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Text>No skills registered yet.</Text>
|
||||
<p>No skills registered yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
skillsList.map((skill) => (
|
||||
<div key={skill.name} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
|
||||
<Checkbox
|
||||
aria-label={skill.name}
|
||||
checked={selectedSkills.has(skill.name)}
|
||||
onChange={(e) => handleSkillSelection(skill.name, e.target.checked)}
|
||||
onCheckedChange={(checked) => handleSkillSelection(skill.name, checked === true)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="font-medium font-mono text-sm">{skill.name}</Text>
|
||||
{skill.enabled && (
|
||||
<Badge color="green" size="xs">
|
||||
Public
|
||||
</Badge>
|
||||
)}
|
||||
<p className="font-medium font-mono text-sm break-words">{skill.name}</p>
|
||||
{skill.enabled && <Badge variant="secondary">Public</Badge>}
|
||||
</div>
|
||||
{skill.description && (
|
||||
<Text className="text-xs text-gray-500 truncate max-w-sm">{skill.description}</Text>
|
||||
)}
|
||||
{skill.description && <p className="text-xs text-gray-500 truncate max-w-sm">{skill.description}</p>}
|
||||
</div>
|
||||
{skill.domain && (
|
||||
<Badge color="blue" size="xs">
|
||||
{skill.domain}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.domain && <Badge variant="outline">{skill.domain}</Badge>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
|
@ -160,9 +154,9 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
|
||||
{selectedSkills.size > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-sm text-blue-800">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -170,29 +164,25 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
|
||||
const renderStep2 = () => (
|
||||
<div className="space-y-4">
|
||||
<Title>Confirm Publish to Skill Hub</Title>
|
||||
<h3 className="text-lg font-semibold">Confirm Publish to Skill Hub</h3>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<Text className="text-sm text-yellow-800">
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Note:</strong> Published skills will be visible to all users in the Skill Hub tab. Skills not in the
|
||||
list below will be unpublished.
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Text className="font-medium">Skills to be published:</Text>
|
||||
<p className="font-medium">Skills to be published:</p>
|
||||
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
|
||||
<div className="space-y-2">
|
||||
{Array.from(selectedSkills).map((name) => {
|
||||
const skill = skillsList.find((s) => s.name === name);
|
||||
return (
|
||||
<div key={name} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
|
||||
<Text className="font-mono text-sm">{name}</Text>
|
||||
{skill?.domain && (
|
||||
<Badge color="blue" size="xs">
|
||||
{skill.domain}
|
||||
</Badge>
|
||||
)}
|
||||
<div key={name} className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-sm">
|
||||
<p className="font-mono text-sm min-w-0 break-words">{name}</p>
|
||||
{skill?.domain && <Badge variant="outline">{skill.domain}</Badge>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -201,49 +191,68 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<Text className="text-sm text-blue-800">
|
||||
<p className="text-sm text-blue-800">
|
||||
Total: <strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
|
||||
</Text>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Publish to Skill Hub"
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
width={700}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Steps current={currentStep} className="mb-6">
|
||||
<Step title="Select Skills" />
|
||||
<Step title="Confirm" />
|
||||
</Steps>
|
||||
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish to Skill Hub</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{currentStep === 0 ? renderStep1() : renderStep2()}
|
||||
<div>
|
||||
<ol className="mb-6 flex items-center gap-6">
|
||||
{STEP_TITLES.map((title, index) => (
|
||||
<li
|
||||
key={title}
|
||||
className="flex items-center gap-2"
|
||||
aria-current={currentStep === index ? "step" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded-full border text-xs",
|
||||
currentStep === index
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
|
||||
{title}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
|
||||
{currentStep === 0 ? "Cancel" : "Previous"}
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
{currentStep === 0 && (
|
||||
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Button onClick={handleSubmit} loading={loading}>
|
||||
Publish to Hub
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 0 ? renderStep1() : renderStep2()}
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button variant="outline" onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
|
||||
{currentStep === 0 ? "Cancel" : "Previous"}
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
{currentStep === 0 && (
|
||||
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||
Publish to Hub
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -226,4 +226,19 @@ describe("public hub MCP details modal", () => {
|
|||
await screen.findByText("Server Overview");
|
||||
expect(screen.queryByText(PUBLIC_SERVER_URL)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes the server details modal from its close control", async () => {
|
||||
const networkingModule = await import("./networking");
|
||||
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);
|
||||
|
||||
render(<PublicModelHub />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "exa_test" }));
|
||||
await screen.findByText("Server Overview");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText("Server Overview")).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue