Merge pull request #36908 from BerriAI/litellm_shadcn_aihub_0814

refactor(ui): migrate AI Hub off antd and tremor to shadcn
This commit is contained in:
yuneng-jiang 2026-08-14 09:39:00 -07:00 committed by GitHub
commit de57ebbad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 815 additions and 973 deletions

View file

@ -1709,54 +1709,26 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"prefer-const": {
"count": 4
}
},
"src/components/AIHub/SkillHubDashboard.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/AIHub/UsefulLinksManagement.tsx": {
"no-restricted-imports": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/AIHub/forms/MakeAgentPublicForm.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/AIHub/forms/MakeMCPPublicForm.test.tsx": {
"react/display-name": {
"count": 1
}
},
"src/components/AIHub/forms/MakeMCPPublicForm.tsx": {
"no-nested-ternary": {
"count": 2
},
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/AIHub/forms/MakeModelPublicForm.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,14 @@
import React, { useMemo, useState } from "react";
import { SearchOutlined } from "@ant-design/icons";
import { SortingState } from "@tanstack/react-table";
import { Input, Select } from "antd";
import { Inbox } from "lucide-react";
import { Inbox, Search, X } from "lucide-react";
import { Plugin } from "@/components/claude_code_plugins/types";
import { DataTable } from "@/components/shared/DataTable";
import { getSkillHubTableColumns } from "@/components/AIHub/SkillHubTableColumns";
import SkillDetail from "@/components/claude_code_plugins/skill_detail";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const ALL_DOMAINS = "__all_domains__";
interface SkillHubDashboardProps {
skills: Plugin[];
@ -48,7 +50,10 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
// Derived stats
const totalSkills = skills.length;
const domains = useMemo(() => [...new Set(skills.map((s) => s.domain).filter(Boolean))], [skills]);
const domains = useMemo(
() => [...new Set(skills.map((s) => s.domain).filter((domain): domain is string => Boolean(domain)))],
[skills],
);
const namespaces = useMemo(() => [...new Set(skills.map((s) => s.namespace).filter(Boolean))], [skills]);
// Filtered table data
@ -73,6 +78,11 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
const columns = useMemo(() => getSkillHubTableColumns({ onSkillClick: setSelectedSkill }), []);
const domainItems = useMemo(
() => [{ value: ALL_DOMAINS, label: "All Domains" }, ...domains.map((d) => ({ value: d, label: d }))],
[domains],
);
const hasActiveFilter = search.trim().length > 0 || domainFilter != null;
if (selectedSkill) {
@ -111,21 +121,43 @@ const SkillHubDashboard: React.FC<SkillHubDashboardProps> = ({
<h3 className="text-sm font-semibold text-gray-700">All {publicPage ? "Public " : ""}Skills</h3>
<div className="flex items-center gap-2">
<Select
placeholder="All Domains"
allowClear
value={domainFilter}
onChange={(val) => setDomainFilter(val)}
style={{ width: 160 }}
options={domains.map((d) => ({ label: d, value: d }))}
/>
<Input
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="Search by name, namespace, or tag…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 280 }}
allowClear
/>
items={domainItems}
value={domainFilter ?? ALL_DOMAINS}
onValueChange={(val) => setDomainFilter(val === null || val === ALL_DOMAINS ? undefined : val)}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{domainItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<InputGroup className="w-[280px]">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search by name, namespace, or tag…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search !== "" && (
<InputGroupAddon align="inline-end">
<InputGroupButton
size="icon-xs"
variant="ghost"
aria-label="Clear search"
onClick={() => setSearch("")}
>
<X className="size-3.5" />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
</div>
</div>
<DataTable

View file

@ -2,7 +2,8 @@ import TableIconActionButton from "@/components/common_components/IconActionButt
import NotificationsManager from "@/components/molecules/notifications_manager";
import { isAdminRole } from "@/utils/roles";
import { ChevronDownIcon, ChevronRightIcon, ExternalLinkIcon, PlusCircleIcon } from "@heroicons/react/outline";
import { Card, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text, Title } from "@tremor/react";
import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { getProxyBaseUrl, getPublicModelHubInfo, updateUsefulLinksCall } from "../networking";
@ -223,10 +224,10 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
};
return (
<Card className="mb-6">
<Card className="mb-6 px-6">
<div className="flex items-center justify-between cursor-pointer" onClick={() => setIsExpanded(!isExpanded)}>
<div className="flex flex-col">
<Title className="mb-0">Link Management</Title>
<h3 className="mb-0 text-lg font-semibold">Link Management</h3>
<p className="text-sm text-gray-500">
Manage the links that are displayed under &apos;Useful Links&apos; on the public model hub.
</p>
@ -243,7 +244,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
{isExpanded && (
<div className="mt-4">
<div className="mb-6">
<Text className="text-sm font-medium text-gray-700 mb-2">Add New Link</Text>
<p className="text-sm font-medium text-gray-700 mb-2">Add New Link</p>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Display Name</label>
@ -288,7 +289,7 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
</div>
</div>
<div className="flex items-center justify-between mb-2">
<Text className="text-sm font-medium text-gray-700">Manage Existing Links</Text>
<p className="text-sm font-medium text-gray-700">Manage Existing Links</p>
<div className="flex items-center space-x-2">
<Link
href={`${getProxyBaseUrl()}/ui/model_hub_table`}
@ -328,13 +329,13 @@ const UsefulLinksManagement: React.FC<UsefulLinksManagementProps> = ({ accessTok
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell className="py-1 h-8">Display Name</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">URL</TableHeaderCell>
<TableHeaderCell className="py-1 h-8">Actions</TableHeaderCell>
<TableHead className="py-1 h-8">Display Name</TableHead>
<TableHead className="py-1 h-8">URL</TableHead>
<TableHead className="py-1 h-8">Actions</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{links.map((link, index) => (
<TableRow key={link.id} className="h-8">

View file

@ -12,67 +12,8 @@ vi.mock("../../networking", () => ({
import { makeAgentsPublicCall } from "../../networking";
const mockMakeAgentsPublicCall = vi.mocked(makeAgentsPublicCall);
// Mock antd components
vi.mock("antd", () => ({
Modal: ({ open, title, children, onCancel, footer }: any) =>
open ? (
<div data-testid="modal">
<div>{title}</div>
{children}
{footer}
</div>
) : null,
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
useForm: () => [
{
resetFields: vi.fn(),
validateFields: vi.fn(),
getFieldsValue: vi.fn(),
setFieldsValue: vi.fn(),
},
vi.fn(),
],
Item: ({ children }: any) => <div>{children}</div>,
}),
Steps: Object.assign(
({ children, current, className }: any) => (
<div data-testid="steps" className={className}>
{children}
</div>
),
{
Step: ({ title }: any) => <div>{title}</div>,
},
),
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
{children}
</button>
),
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
disabled={disabled}
data-indeterminate={indeterminate}
/>
{children}
</label>
),
}));
// Mock @tremor/react components
vi.mock("@tremor/react", () => ({
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
Title: ({ children }: any) => <h3>{children}</h3>,
Badge: ({ children, color, size }: any) => (
<span data-color={color} data-size={size}>
{children}
</span>
),
}));
const expectDisabledControl = (element: HTMLElement) =>
expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true);
describe("MakeAgentPublicForm", () => {
const mockProps = {
@ -143,7 +84,7 @@ describe("MakeAgentPublicForm", () => {
expect(screen.getByText("Select Agents to Make Public")).toBeInTheDocument();
// Select all agents using the select all checkbox
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
await act(async () => {
fireEvent.click(selectAllCheckbox);
});
@ -169,12 +110,11 @@ describe("MakeAgentPublicForm", () => {
render(<MakeAgentPublicForm {...mockProps} />);
// Select all agents
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
await act(async () => {
fireEvent.click(selectAllCheckbox);
});
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -185,7 +125,6 @@ describe("MakeAgentPublicForm", () => {
expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
@ -232,6 +171,8 @@ describe("MakeAgentPublicForm", () => {
const checkboxes = screen.getAllByRole("checkbox");
await act(async () => {
fireEvent.click(checkboxes[0]); // Click select all to select all
});
await act(async () => {
fireEvent.click(checkboxes[0]); // Click select all again to deselect all
});
@ -256,8 +197,8 @@ describe("MakeAgentPublicForm", () => {
expect(screen.getByText("No agents available.")).toBeInTheDocument();
// Select All checkbox should be disabled
const selectAllCheckbox = screen.getByLabelText("Select All");
expect(selectAllCheckbox).toBeDisabled();
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" });
expectDisabledControl(selectAllCheckbox);
// Next button should be disabled
const nextButton = screen.getByRole("button", { name: "Next" });
@ -332,7 +273,7 @@ describe("MakeAgentPublicForm", () => {
// Select all should be indeterminate now
const selectAllCheckbox = checkboxes[0];
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
expect(selectAllCheckbox).toBePartiallyChecked();
});
it("should display skills overflow text when agent has more than 3 skills", () => {
@ -369,7 +310,6 @@ describe("MakeAgentPublicForm", () => {
render(<MakeAgentPublicForm {...mockProps} />);
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -379,7 +319,6 @@ describe("MakeAgentPublicForm", () => {
expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
@ -395,7 +334,7 @@ describe("MakeAgentPublicForm", () => {
expect(mockProps.onClose).not.toHaveBeenCalled();
});
it("should show loading state during submit", async () => {
it("should not complete the flow until the submit request resolves", async () => {
let resolvePromise: (value: any) => void = () => {};
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
@ -404,7 +343,6 @@ describe("MakeAgentPublicForm", () => {
render(<MakeAgentPublicForm {...mockProps} />);
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -414,17 +352,20 @@ describe("MakeAgentPublicForm", () => {
expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
});
// Check loading state
expect(submitButton).toHaveAttribute("data-loading", "true");
expect(submitButton).toBeDisabled();
expectDisabledControl(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(mockMakeAgentsPublicCall).toHaveBeenCalledTimes(1);
expect(mockProps.onSuccess).not.toHaveBeenCalled();
expect(mockProps.onClose).not.toHaveBeenCalled();
expect(screen.getByText("Confirm Making Agents Public")).toBeInTheDocument();
// Resolve the promise
resolvePromise({});
await waitFor(() => {
expect(mockProps.onSuccess).toHaveBeenCalled();
@ -441,7 +382,7 @@ describe("MakeAgentPublicForm", () => {
render(<MakeAgentPublicForm {...invisibleProps} />);
// Modal should not be rendered
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("Make Agents Public")).not.toBeInTheDocument();
});
@ -500,6 +441,6 @@ describe("MakeAgentPublicForm", () => {
// Select all should be indeterminate
const selectAllCheckbox = checkboxes[0];
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
expect(selectAllCheckbox).toBePartiallyChecked();
});
});

View file

@ -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 { makeAgentsPublicCall } from "../../networking";
import NotificationsManager from "../../molecules/notifications_manager";
import { AgentHubData } from "@/components/AIHub/AgentHubTableColumns";
const { Step } = Steps;
const STEP_TITLES = ["Select Agents", "Confirm"];
interface MakeAgentPublicFormProps {
visible: boolean;
@ -25,12 +29,10 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
const [currentStep, setCurrentStep] = useState(0);
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleClose = () => {
setCurrentStep(0);
setSelectedAgents(new Set());
form.resetFields();
onClose();
};
@ -113,29 +115,30 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Agents to Make Public</Title>
<h3 className="text-lg font-semibold">Select Agents to Make Public</h3>
<div className="flex items-center space-x-2">
<Checkbox
checked={allAgentsSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={agentHubData.length === 0}
>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={allAgentsSelected}
indeterminate={isIndeterminate}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={agentHubData.length === 0}
/>
Select All {agentHubData.length > 0 && `(${agentHubData.length})`}
</Checkbox>
</label>
</div>
</div>
<Text className="text-sm text-gray-600">
<p className="text-sm text-gray-600">
Select the agents you want to be visible on the public model hub. Users will still require a valid Virtual Key
to use these agents.
</Text>
</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{agentHubData.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No agents available.</Text>
<p>No agents available.</p>
</div>
) : (
agentHubData.map((agent) => {
@ -144,25 +147,23 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
<div key={agentId} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
<Checkbox
checked={selectedAgents.has(agentId)}
onChange={(e) => handleAgentSelection(agentId, e.target.checked)}
onCheckedChange={(checked) => handleAgentSelection(agentId, checked === true)}
/>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<Text className="font-medium">{agent.name}</Text>
<Badge color="blue" size="sm">
v{agent.version}
</Badge>
<p className="font-medium break-words">{agent.name}</p>
<Badge variant="secondary">v{agent.version}</Badge>
</div>
<Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>
<p className="text-xs text-gray-600 mt-1 break-words">{agent.description}</p>
{agent.skills && agent.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{agent.skills.slice(0, 3).map((skill) => (
<Badge key={skill.id} color="purple" size="xs">
<Badge key={skill.id} variant="outline">
{skill.name}
</Badge>
))}
{agent.skills.length > 3 && (
<Text className="text-xs text-gray-500">+{agent.skills.length - 3} more</Text>
<p className="text-xs text-gray-500">+{agent.skills.length - 3} more</p>
)}
</div>
)}
@ -176,9 +177,9 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
{selectedAgents.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>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} selected
</Text>
</p>
</div>
)}
</div>
@ -188,33 +189,31 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<Title>Confirm Making Agents Public</Title>
<h3 className="text-lg font-semibold">Confirm Making Agents Public</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>Warning:</strong> Once you make these agents public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
</Text>
</p>
</div>
<div className="space-y-3">
<Text className="font-medium">Agents to be made public:</Text>
<p className="font-medium">Agents to be made public:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedAgents).map((agentId) => {
const agent = agentHubData.find((a) => (a.agent_id || a.name) === agentId);
return (
<div key={agentId} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<Text className="font-medium">{agent?.name || agentId}</Text>
{agent && (
<Badge color="blue" size="xs">
v{agent.version}
</Badge>
)}
<p className="font-medium break-words">{agent?.name || agentId}</p>
{agent && <Badge variant="secondary">v{agent.version}</Badge>}
</div>
{agent?.description && <Text className="text-xs text-gray-600 mt-1">{agent.description}</Text>}
{agent?.description && (
<p className="text-xs text-gray-600 mt-1 break-words">{agent.description}</p>
)}
</div>
</div>
);
@ -224,10 +223,10 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
</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>{selectedAgents.size}</strong> agent{selectedAgents.size !== 1 ? "s" : ""} will be made
public
</Text>
</p>
</div>
</div>
);
@ -247,7 +246,7 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
const renderStepButtons = () => {
return (
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
@ -259,7 +258,8 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
</Button>
)}
@ -269,24 +269,42 @@ const MakeAgentPublicForm: React.FC<MakeAgentPublicFormProps> = ({
};
return (
<Modal
title="Make Agents Public"
open={visible}
onCancel={handleClose}
footer={null}
width={1200}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Agents" />
<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-[1200px]">
<DialogHeader>
<DialogTitle>Make Agents Public</DialogTitle>
</DialogHeader>
{renderStepContent()}
{renderStepButtons()}
</Form>
</Modal>
<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>
{renderStepContent()}
{renderStepButtons()}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -12,83 +12,8 @@ vi.mock("../../networking", () => ({
import { makeMCPPublicCall } from "../../networking";
const mockMakeMCPPublicCall = vi.mocked(makeMCPPublicCall);
// Mock antd components
vi.mock("antd", () => ({
Modal: ({ open, title, children, onCancel, footer }: any) =>
open ? (
<div data-testid="modal">
<div>{title}</div>
{children}
{footer}
</div>
) : null,
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
useForm: () => [
{
resetFields: vi.fn(),
validateFields: vi.fn(),
getFieldsValue: vi.fn(),
setFieldsValue: vi.fn(),
},
vi.fn(),
],
Item: ({ children }: any) => <div>{children}</div>,
}),
Steps: Object.assign(
({ children, current, className }: any) => (
<div data-testid="steps" className={className}>
{children}
</div>
),
{
Step: ({ title }: any) => <div>{title}</div>,
},
),
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
{children}
</button>
),
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
disabled={disabled}
data-indeterminate={indeterminate}
/>
{children}
</label>
),
}));
// Additional @tremor/react mocks.
// NOTE: the comment used to say "Button is already mocked globally" — that was
// incorrect. A file-level vi.mock fully replaces the setup-level mock from
// tests/setupTests.ts, so we must re-apply the Button/Tooltip overrides here.
// Without them, the real Tremor Button leaks through and its useTooltip(300)
// schedules a native setTimeout that can fire post-teardown -> "window is not defined".
vi.mock("@tremor/react", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tremor/react")>();
const React = await import("react");
return {
...actual,
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
Title: ({ children }: any) => <h3>{children}</h3>,
Badge: ({ children, color, size }: any) => (
<span data-color={color} data-size={size}>
{children}
</span>
),
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) => (
<button {...props} ref={ref}>
{children}
</button>
)),
Tooltip: ({ children }: any) => <>{children}</>,
};
});
const expectDisabledControl = (element: HTMLElement) =>
expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true);
describe("MakeMCPPublicForm", () => {
const mockProps = {
@ -182,7 +107,7 @@ describe("MakeMCPPublicForm", () => {
expect(screen.getByText("Select MCP Servers to Make Public")).toBeInTheDocument();
// Select all servers using the select all checkbox
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
await act(async () => {
fireEvent.click(selectAllCheckbox);
});
@ -208,12 +133,11 @@ describe("MakeMCPPublicForm", () => {
render(<MakeMCPPublicForm {...mockProps} />);
// Select all servers
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
await act(async () => {
fireEvent.click(selectAllCheckbox);
});
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -224,7 +148,6 @@ describe("MakeMCPPublicForm", () => {
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
@ -271,6 +194,8 @@ describe("MakeMCPPublicForm", () => {
const checkboxes = screen.getAllByRole("checkbox");
await act(async () => {
fireEvent.click(checkboxes[0]); // Click select all to select all
});
await act(async () => {
fireEvent.click(checkboxes[0]); // Click select all again to deselect all
});
@ -295,8 +220,8 @@ describe("MakeMCPPublicForm", () => {
expect(screen.getByText("No MCP servers available.")).toBeInTheDocument();
// Select All checkbox should be disabled
const selectAllCheckbox = screen.getByLabelText("Select All");
expect(selectAllCheckbox).toBeDisabled();
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" });
expectDisabledControl(selectAllCheckbox);
// Next button should be disabled
const nextButton = screen.getByRole("button", { name: "Next" });
@ -371,7 +296,7 @@ describe("MakeMCPPublicForm", () => {
// Select all should be indeterminate now
const selectAllCheckbox = checkboxes[0];
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
expect(selectAllCheckbox).toBePartiallyChecked();
});
it("should display tools overflow text when server has more than 3 tools", () => {
@ -402,7 +327,6 @@ describe("MakeMCPPublicForm", () => {
render(<MakeMCPPublicForm {...mockProps} />);
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -412,7 +336,6 @@ describe("MakeMCPPublicForm", () => {
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
@ -428,7 +351,7 @@ describe("MakeMCPPublicForm", () => {
expect(mockProps.onClose).not.toHaveBeenCalled();
});
it("should show loading state during submit", async () => {
it("should not complete the flow until the submit request resolves", async () => {
let resolvePromise: (value: any) => void = () => {};
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
@ -437,7 +360,6 @@ describe("MakeMCPPublicForm", () => {
render(<MakeMCPPublicForm {...mockProps} />);
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -447,17 +369,20 @@ describe("MakeMCPPublicForm", () => {
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
});
// Check loading state
expect(submitButton).toHaveAttribute("data-loading", "true");
expect(submitButton).toBeDisabled();
expectDisabledControl(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(mockMakeMCPPublicCall).toHaveBeenCalledTimes(1);
expect(mockProps.onSuccess).not.toHaveBeenCalled();
expect(mockProps.onClose).not.toHaveBeenCalled();
expect(screen.getByText("Confirm Making MCP Servers Public")).toBeInTheDocument();
// Resolve the promise
resolvePromise({});
await waitFor(() => {
expect(mockProps.onSuccess).toHaveBeenCalled();
@ -474,7 +399,7 @@ describe("MakeMCPPublicForm", () => {
render(<MakeMCPPublicForm {...invisibleProps} />);
// Modal should not be rendered
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("Make MCP Servers Public")).not.toBeInTheDocument();
});
@ -569,6 +494,6 @@ describe("MakeMCPPublicForm", () => {
// Select all should be indeterminate
const selectAllCheckbox = checkboxes[0];
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
expect(selectAllCheckbox).toBePartiallyChecked();
});
});

View file

@ -1,11 +1,25 @@
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 { makeMCPPublicCall } from "../../networking";
import NotificationsManager from "../../molecules/notifications_manager";
import { MCPServerData } from "@/components/AIHub/MCPHubTableColumns";
const { Step } = Steps;
const STEP_TITLES = ["Select Servers", "Confirm"];
const statusVariant = (status?: string) => {
if (status === "active" || status === "healthy") {
return "default" as const;
}
if (status === "inactive" || status === "unhealthy") {
return "destructive" as const;
}
return "outline" as const;
};
interface MakeMCPPublicFormProps {
visible: boolean;
@ -25,12 +39,10 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
const [currentStep, setCurrentStep] = useState(0);
const [selectedServers, setSelectedServers] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleClose = () => {
setCurrentStep(0);
setSelectedServers(new Set());
form.resetFields();
onClose();
};
@ -114,29 +126,30 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select MCP Servers to Make Public</Title>
<h3 className="text-lg font-semibold">Select MCP Servers to Make Public</h3>
<div className="flex items-center space-x-2">
<Checkbox
checked={allServersSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={mcpHubData.length === 0}
>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={allServersSelected}
indeterminate={isIndeterminate}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={mcpHubData.length === 0}
/>
Select All {mcpHubData.length > 0 && `(${mcpHubData.length})`}
</Checkbox>
</label>
</div>
</div>
<Text className="text-sm text-gray-600">
<p className="text-sm text-gray-600">
Select the MCP servers you want to be visible on the public model hub. Users will still require a valid
Virtual Key to use these servers.
</Text>
</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{mcpHubData.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No MCP servers available.</Text>
<p>No MCP servers available.</p>
</div>
) : (
mcpHubData.map((server) => {
@ -148,42 +161,25 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
>
<Checkbox
checked={selectedServers.has(server.server_id)}
onChange={(e) => handleServerSelection(server.server_id, e.target.checked)}
onCheckedChange={(checked) => handleServerSelection(server.server_id, checked === true)}
/>
<div className="flex-1">
<div className="flex items-center space-x-2">
<Text className="font-medium">{server.server_name}</Text>
{isPublic && (
<Badge color="emerald" size="sm">
Public
</Badge>
)}
<Badge color="blue" size="sm">
{server.transport}
</Badge>
<Badge
color={
server.status === "active" || server.status === "healthy"
? "green"
: server.status === "inactive" || server.status === "unhealthy"
? "red"
: "gray"
}
size="sm"
>
{server.status || "unknown"}
</Badge>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">{server.server_name}</p>
{isPublic && <Badge>Public</Badge>}
<Badge variant="secondary">{server.transport}</Badge>
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
</div>
<Text className="text-xs text-gray-600 mt-1">{server.description || server.url}</Text>
<p className="text-xs text-gray-600 mt-1 break-words">{server.description || server.url}</p>
{server.allowed_tools && server.allowed_tools.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{server.allowed_tools.slice(0, 3).map((tool, idx) => (
<Badge key={idx} color="purple" size="xs">
<Badge key={idx} variant="outline">
{tool}
</Badge>
))}
{server.allowed_tools.length > 3 && (
<Text className="text-xs text-gray-500">+{server.allowed_tools.length - 3} more</Text>
<p className="text-xs text-gray-500">+{server.allowed_tools.length - 3} more</p>
)}
</div>
)}
@ -197,9 +193,9 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
{selectedServers.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>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} selected
</Text>
</p>
</div>
)}
</div>
@ -209,48 +205,37 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<Title>Confirm Making MCP Servers Public</Title>
<h3 className="text-lg font-semibold">Confirm Making MCP Servers Public</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>Warning:</strong> Once you make these MCP servers public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
</Text>
</p>
</div>
<div className="space-y-3">
<Text className="font-medium">MCP Servers to be made public:</Text>
<p className="font-medium">MCP Servers to be made public:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedServers).map((serverId) => {
const server = mcpHubData.find((s) => s.server_id === serverId);
return (
<div key={serverId} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<div className="flex-1">
<div className="flex items-center space-x-2">
<Text className="font-medium">{server?.server_name || serverId}</Text>
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">{server?.server_name || serverId}</p>
{server && (
<>
<Badge color="blue" size="xs">
{server.transport}
</Badge>
<Badge
color={
server.status === "active" || server.status === "healthy"
? "green"
: server.status === "inactive" || server.status === "unhealthy"
? "red"
: "gray"
}
size="xs"
>
{server.status || "unknown"}
</Badge>
<Badge variant="secondary">{server.transport}</Badge>
<Badge variant={statusVariant(server.status)}>{server.status || "unknown"}</Badge>
</>
)}
</div>
{server?.description && <Text className="text-xs text-gray-600 mt-1">{server.description}</Text>}
{server?.url && <Text className="text-xs text-gray-500 mt-1">{server.url}</Text>}
{server?.description && (
<p className="text-xs text-gray-600 mt-1 break-words">{server.description}</p>
)}
{server?.url && <p className="text-xs text-gray-500 mt-1 break-words">{server.url}</p>}
</div>
</div>
);
@ -260,10 +245,10 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
</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>{selectedServers.size}</strong> MCP server{selectedServers.size !== 1 ? "s" : ""} will be
made public
</Text>
</p>
</div>
</div>
);
@ -283,7 +268,7 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
const renderStepButtons = () => {
return (
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
@ -295,7 +280,8 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
</Button>
)}
@ -305,24 +291,42 @@ const MakeMCPPublicForm: React.FC<MakeMCPPublicFormProps> = ({
};
return (
<Modal
title="Make MCP Servers Public"
open={visible}
onCancel={handleClose}
footer={null}
width={1200}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Servers" />
<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-[1200px]">
<DialogHeader>
<DialogTitle>Make MCP Servers Public</DialogTitle>
</DialogHeader>
{renderStepContent()}
{renderStepButtons()}
</Form>
</Modal>
<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>
{renderStepContent()}
{renderStepButtons()}
</div>
</DialogContent>
</Dialog>
);
};

View file

@ -29,67 +29,8 @@ vi.mock("../../networking", () => ({
import { makeModelGroupPublic } from "../../networking";
const mockMakeModelGroupPublic = vi.mocked(makeModelGroupPublic);
// Mock antd components
vi.mock("antd", () => ({
Modal: ({ open, title, children, onCancel, footer }: any) =>
open ? (
<div data-testid="modal">
<div>{title}</div>
{children}
{footer}
</div>
) : null,
Form: Object.assign(({ children, form }: any) => <form data-testid="form">{children}</form>, {
useForm: () => [
{
resetFields: vi.fn(),
validateFields: vi.fn(),
getFieldsValue: vi.fn(),
setFieldsValue: vi.fn(),
},
vi.fn(),
],
Item: ({ children }: any) => <div>{children}</div>,
}),
Steps: Object.assign(
({ children, current, className }: any) => (
<div data-testid="steps" className={className}>
{children}
</div>
),
{
Step: ({ title }: any) => <div>{title}</div>,
},
),
Button: ({ children, onClick, disabled, loading, ...props }: any) => (
<button onClick={onClick} disabled={disabled || loading} data-loading={loading} {...props}>
{children}
</button>
),
Checkbox: ({ checked, indeterminate, onChange, children, disabled }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
disabled={disabled}
data-indeterminate={indeterminate}
/>
{children}
</label>
),
}));
// Mock @tremor/react components
vi.mock("@tremor/react", () => ({
Text: ({ children, className }: any) => <span className={className}>{children}</span>,
Title: ({ children }: any) => <h3>{children}</h3>,
Badge: ({ children, color, size }: any) => (
<span data-color={color} data-size={size}>
{children}
</span>
),
}));
const expectDisabledControl = (element: HTMLElement) =>
expect(element.hasAttribute("disabled") || element.getAttribute("aria-disabled") === "true").toBe(true);
// Mock ModelFilters component
vi.mock("../../model_filters", () => ({
@ -190,7 +131,7 @@ describe("MakeModelPublicForm", () => {
expect(screen.getByText("Select Models to Make Public")).toBeInTheDocument();
// Select all models using the select all checkbox
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
await act(async () => {
fireEvent.click(selectAllCheckbox);
});
@ -216,12 +157,11 @@ describe("MakeModelPublicForm", () => {
render(<MakeModelPublicForm {...mockProps} />);
// Select all models
const selectAllCheckbox = screen.getByLabelText("Select All (2)");
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All (2)" });
await act(async () => {
fireEvent.click(selectAllCheckbox);
});
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -232,7 +172,6 @@ describe("MakeModelPublicForm", () => {
expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
@ -279,6 +218,8 @@ describe("MakeModelPublicForm", () => {
const checkboxes = screen.getAllByRole("checkbox");
await act(async () => {
fireEvent.click(checkboxes[0]); // Click select all to select all
});
await act(async () => {
fireEvent.click(checkboxes[0]); // Click select all again to deselect all
});
@ -303,8 +244,8 @@ describe("MakeModelPublicForm", () => {
expect(screen.getByText("No models match the current filters.")).toBeInTheDocument();
// Select All checkbox should be disabled
const selectAllCheckbox = screen.getByLabelText("Select All");
expect(selectAllCheckbox).toBeDisabled();
const selectAllCheckbox = screen.getByRole("checkbox", { name: "Select All" });
expectDisabledControl(selectAllCheckbox);
// Next button should be disabled
const nextButton = screen.getByRole("button", { name: "Next" });
@ -379,7 +320,7 @@ describe("MakeModelPublicForm", () => {
// Select all should be indeterminate now
const selectAllCheckbox = checkboxes[0];
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
expect(selectAllCheckbox).toBePartiallyChecked();
});
it("should display model badges and information", () => {
@ -402,7 +343,6 @@ describe("MakeModelPublicForm", () => {
render(<MakeModelPublicForm {...mockProps} />);
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -412,7 +352,6 @@ describe("MakeModelPublicForm", () => {
expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
@ -428,7 +367,7 @@ describe("MakeModelPublicForm", () => {
expect(mockProps.onClose).not.toHaveBeenCalled();
});
it("should show loading state during submit", async () => {
it("should not complete the flow until the submit request resolves", async () => {
let resolvePromise: (value: any) => void = () => {};
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve;
@ -437,7 +376,6 @@ describe("MakeModelPublicForm", () => {
render(<MakeModelPublicForm {...mockProps} />);
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);
@ -447,17 +385,20 @@ describe("MakeModelPublicForm", () => {
expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
});
// Submit
const submitButton = screen.getByRole("button", { name: "Make Public" });
await act(async () => {
fireEvent.click(submitButton);
});
// Check loading state
expect(submitButton).toHaveAttribute("data-loading", "true");
expect(submitButton).toBeDisabled();
expectDisabledControl(submitButton);
await act(async () => {
fireEvent.click(submitButton);
});
expect(mockMakeModelGroupPublic).toHaveBeenCalledTimes(1);
expect(mockProps.onSuccess).not.toHaveBeenCalled();
expect(mockProps.onClose).not.toHaveBeenCalled();
expect(screen.getByText("Confirm Making Models Public")).toBeInTheDocument();
// Resolve the promise
resolvePromise({});
await waitFor(() => {
expect(mockProps.onSuccess).toHaveBeenCalled();
@ -474,7 +415,7 @@ describe("MakeModelPublicForm", () => {
render(<MakeModelPublicForm {...invisibleProps} />);
// Modal should not be rendered
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByText("Make Models Public")).not.toBeInTheDocument();
});
@ -521,21 +462,19 @@ describe("MakeModelPublicForm", () => {
// Select all should be indeterminate
const selectAllCheckbox = checkboxes[0];
expect(selectAllCheckbox).toHaveAttribute("data-indeterminate", "true");
expect(selectAllCheckbox).toBePartiallyChecked();
});
it("should show selected count", () => {
render(<MakeModelPublicForm {...mockProps} />);
// Should show that 1 model is selected (gpt-3.5-turbo is preselected)
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.getByText("model selected")).toBeInTheDocument();
expect(screen.getByText("model selected")).toHaveTextContent("1 model selected");
});
it("should show confirmation step with selected models", async () => {
render(<MakeModelPublicForm {...mockProps} />);
// Navigate to confirm step
const nextButton = screen.getByRole("button", { name: "Next" });
await act(async () => {
fireEvent.click(nextButton);

View file

@ -1,11 +1,15 @@
import React, { useState, useCallback, 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 { makeModelGroupPublic } from "../../networking";
import ModelFilters from "../../model_filters";
import NotificationsManager from "../../molecules/notifications_manager";
const { Step } = Steps;
const STEP_TITLES = ["Select Models", "Confirm"];
interface ModelGroupInfo {
model_group: string;
@ -44,13 +48,11 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [filteredData, setFilteredData] = useState<ModelGroupInfo[]>([]);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleClose = () => {
setCurrentStep(0);
setSelectedModels(new Set());
setFilteredData([]);
form.resetFields();
onClose();
};
@ -138,23 +140,24 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Models to Make Public</Title>
<h3 className="text-lg font-semibold">Select Models to Make Public</h3>
<div className="flex items-center space-x-2">
<Checkbox
checked={allModelsSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={filteredData.length === 0}
>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={allModelsSelected}
indeterminate={isIndeterminate}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={filteredData.length === 0}
/>
Select All {filteredData.length > 0 && `(${filteredData.length})`}
</Checkbox>
</label>
</div>
</div>
<Text className="text-sm text-gray-600">
<p className="text-sm text-gray-600">
Select the models you want to be visible on the public model hub. Users will still require a valid Virtual Key
to use these models.
</Text>
</p>
{/* Filters */}
<ModelFilters
@ -168,7 +171,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
<div className="space-y-3">
{filteredData.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No models match the current filters.</Text>
<p>No models match the current filters.</p>
</div>
) : (
filteredData.map((model) => (
@ -178,20 +181,16 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
>
<Checkbox
checked={selectedModels.has(model.model_group)}
onChange={(e) => handleModelSelection(model.model_group, e.target.checked)}
onCheckedChange={(checked) => handleModelSelection(model.model_group, checked === true)}
/>
<div className="flex-1">
<div className="flex items-center space-x-2">
<Text className="font-medium">{model.model_group}</Text>
{model.mode && (
<Badge color="green" size="sm">
{model.mode}
</Badge>
)}
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">{model.model_group}</p>
{model.mode && <Badge>{model.mode}</Badge>}
</div>
<div className="flex flex-wrap gap-1 mt-1">
{model.providers.map((provider) => (
<Badge key={provider} color="blue" size="xs">
<Badge key={provider} variant="secondary">
{provider}
</Badge>
))}
@ -205,9 +204,9 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
{selectedModels.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>{selectedModels.size}</strong> model{selectedModels.size !== 1 ? "s" : ""} selected
</Text>
</p>
</div>
)}
</div>
@ -217,29 +216,29 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const renderStep2Content = () => {
return (
<div className="space-y-4">
<Title>Confirm Making Models Public</Title>
<h3 className="text-lg font-semibold">Confirm Making Models Public</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>Warning:</strong> Once you make these models public, anyone who can go to the{" "}
<code>/ui/model_hub_table</code> will be able to know they exist on the proxy.
</Text>
</p>
</div>
<div className="space-y-3">
<Text className="font-medium">Models to be made public:</Text>
<p className="font-medium">Models to be made public:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedModels).map((modelGroup) => {
const model = modelHubData.find((m) => m.model_group === modelGroup);
return (
<div key={modelGroup} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<div>
<Text className="font-medium">{modelGroup}</Text>
<div className="min-w-0">
<p className="font-medium break-words">{modelGroup}</p>
{model && (
<div className="flex flex-wrap gap-1 mt-1">
{model.providers.map((provider) => (
<Badge key={provider} color="blue" size="xs">
<Badge key={provider} variant="secondary">
{provider}
</Badge>
))}
@ -254,10 +253,10 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
</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>{selectedModels.size}</strong> model{selectedModels.size !== 1 ? "s" : ""} will be made
public
</Text>
</p>
</div>
</div>
);
@ -277,7 +276,7 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
const renderStepButtons = () => {
return (
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : handlePrevious}>
<Button variant="outline" onClick={currentStep === 0 ? handleClose : handlePrevious}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
@ -289,7 +288,8 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Make Public
</Button>
)}
@ -299,24 +299,42 @@ const MakeModelPublicForm: React.FC<MakeModelPublicFormProps> = ({
};
return (
<Modal
title="Make Models Public"
open={visible}
onCancel={handleClose}
footer={null}
width={1200}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Models" />
<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-[1200px]">
<DialogHeader>
<DialogTitle>Make Models Public</DialogTitle>
</DialogHeader>
{renderStepContent()}
{renderStepButtons()}
</Form>
</Modal>
<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>
{renderStepContent()}
{renderStepButtons()}
</div>
</DialogContent>
</Dialog>
);
};