diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 508015dcac8..9de171397db 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1085,14 +1085,6 @@ "count": 2 } }, - "src/app/(dashboard)/prompts/_components/prompt_table.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/router-settings/_components/general_settings.tsx": { "no-nested-ternary": { "count": 3 @@ -1140,14 +1132,6 @@ "count": 1 } }, - "src/app/(dashboard)/skills/_components/plugin_table.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx": { "no-restricted-imports": { "count": 1 @@ -1307,11 +1291,6 @@ "count": 1 } }, - "src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/vector-stores/_components/index.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx new file mode 100644 index 00000000000..52efd6407f8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.test.tsx @@ -0,0 +1,104 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PromptSpec } from "@/components/networking"; + +import PromptTable from "./PromptTable"; + +vi.mock("@/components/networking", () => ({ + modelHubCall: vi.fn().mockResolvedValue({ data: [] }), +})); + +const mockPrompts: PromptSpec[] = [ + { + prompt_id: "prompt-newer", + litellm_params: { prompt_id: "prompt-newer" }, + prompt_info: { prompt_type: "dotprompt" }, + created_at: "2025-01-15T10:30:00Z", + updated_at: "2025-01-15T11:00:00Z", + environment: "production", + created_by: "user-1", + }, + { + prompt_id: "prompt-older", + litellm_params: { prompt_id: "prompt-older" }, + prompt_info: { prompt_type: "dotprompt" }, + created_at: "2024-01-10T09:15:00Z", + updated_at: "2024-01-12T14:20:00Z", + }, +]; + +const mockOnPromptClick = vi.fn(); +const mockOnDeleteClick = vi.fn(); + +const defaultProps = { + promptsList: mockPrompts, + isLoading: false, + onPromptClick: mockOnPromptClick, + onDeleteClick: mockOnDeleteClick, + accessToken: null, + isAdmin: true, +}; + +describe("PromptTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render every column header", () => { + render(); + for (const header of ["Prompt ID", "Model", "Created At", "Updated At", "Environment", "Created By", "Type"]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } + }); + + it("should display the empty state when data is empty", () => { + render(); + expect(screen.getByText("No prompts yet")).toBeInTheDocument(); + }); + + it("should sort by created date descending by default", () => { + render(); + const rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("prompt-newer")).toBeInTheDocument(); + expect(within(rows[1]).getByText("prompt-older")).toBeInTheDocument(); + }); + + it("should call onPromptClick when the prompt ID is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "prompt-newer" })); + expect(mockOnPromptClick).toHaveBeenCalledWith("prompt-newer"); + }); + + it("should label the environment and default missing environments to development", () => { + render(); + expect(screen.getByText("production")).toBeInTheDocument(); + expect(screen.getByText("development")).toBeInTheDocument(); + }); + + it("should delete a prompt through the actions menu when admin", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("prompt-actions-prompt-newer")); + await user.click(await screen.findByTestId("prompt-action-delete")); + expect(mockOnDeleteClick).toHaveBeenCalledWith("prompt-newer", "prompt-newer"); + }); + + it("should copy the prompt ID through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("prompt-actions-prompt-newer")); + await user.click(await screen.findByTestId("prompt-action-copy")); + expect(await window.navigator.clipboard.readText()).toBe("prompt-newer"); + }); + + it("should hide the delete action for non-admins but keep copy available", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("prompt-actions-prompt-newer")); + expect(await screen.findByTestId("prompt-action-copy")).toBeInTheDocument(); + expect(screen.queryByTestId("prompt-action-delete")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx new file mode 100644 index 00000000000..47d4f64f254 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; +import { modelHubCall, PromptSpec } from "@/components/networking"; + +import { getPromptTableColumns } from "./PromptTableColumns"; +import { ModelGroupInfo } from "./prompt_utils"; + +interface PromptTableProps { + promptsList: PromptSpec[]; + isLoading: boolean; + onPromptClick?: (id: string) => void; + onDeleteClick?: (id: string, name: string) => void; + accessToken: string | null; + isAdmin: boolean; +} + +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +function EmptyState() { + return ( +
+
+ +
+
No prompts yet
+
Add a prompt to start managing reusable templates.
+
+ ); +} + +const PromptTable: React.FC = ({ + promptsList, + isLoading, + onPromptClick, + onDeleteClick, + accessToken, + isAdmin, +}) => { + const [sorting, setSorting] = useState(DEFAULT_SORTING); + const [modelHubData, setModelHubData] = useState>(new Map()); + + useEffect(() => { + const fetchModelHubData = async () => { + if (!accessToken) return; + + try { + const response = await modelHubCall(accessToken); + if (response?.data) { + const modelMap = new Map(); + response.data.forEach((model: ModelGroupInfo) => { + modelMap.set(model.model_group, model); + }); + setModelHubData(modelMap); + } + } catch (error) { + console.error("Error fetching model hub data:", error); + } + }; + + fetchModelHubData(); + }, [accessToken]); + + const columns = useMemo( + () => getPromptTableColumns({ modelHubData, isAdmin, onPromptClick, onDeleteClick }), + [modelHubData, isAdmin, onPromptClick, onDeleteClick], + ); + + return ( + prompt.prompt_id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading prompts…" + noDataMessage={} + size="compact" + /> + ); +}; + +export default PromptTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx new file mode 100644 index 00000000000..ae584ef6df6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTableColumns.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, MoreHorizontal, Trash2 } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, DateCell, IdentityCell, StatusBadge, StatusTone } from "@/components/shared/table_cells"; +import { PromptSpec } from "@/components/networking"; +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +import { extractModel, getProviderFromModelHub, ModelGroupInfo } from "./prompt_utils"; + +const ENVIRONMENT_TONE: Record = { + production: "error", + staging: "warning", + development: "success", +}; + +function PromptModelCell({ prompt, modelHubData }: { prompt: PromptSpec; modelHubData: Map }) { + const model = extractModel(prompt); + if (!model) { + return -; + } + + const provider = getProviderFromModelHub(model, modelHubData); + const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" }; + + return ( + + {logo ? ( + { + (event.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + {provider?.charAt(0) || "-"} + + )} + {model} + + } + /> + ); +} + +interface PromptRowActionsProps { + prompt: PromptSpec; + isAdmin: boolean; + onDeleteClick?: (id: string, name: string) => void; +} + +function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsProps) { + return ( + + + + + + void copyToClipboard(prompt.prompt_id, "Prompt ID copied")} + > + + Copy prompt ID + + {isAdmin && ( + <> + + onDeleteClick?.(prompt.prompt_id, prompt.prompt_id || "Unknown Prompt")} + > + + Delete + + + )} + + + ); +} + +interface PromptTableColumnsDeps { + modelHubData: Map; + isAdmin: boolean; + onPromptClick?: (id: string) => void; + onDeleteClick?: (id: string, name: string) => void; +} + +export const getPromptTableColumns = ({ + modelHubData, + isAdmin, + onPromptClick, + onDeleteClick, +}: PromptTableColumnsDeps): ColumnDef[] => [ + { + id: "prompt_id", + accessorKey: "prompt_id", + meta: { title: "Prompt ID" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + onPromptClick(row.original.prompt_id) : undefined} + /> + ), + }, + { + id: "model", + meta: { title: "Model" }, + header: "Model", + size: 200, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + sortingFn: "datetime", + meta: { title: "Created At" }, + header: ({ column }) => , + size: 160, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "updated_at", + accessorKey: "updated_at", + sortingFn: "datetime", + meta: { title: "Updated At" }, + header: ({ column }) => , + size: 160, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "environment", + accessorKey: "environment", + meta: { title: "Environment", skeleton: "badge" }, + header: "Environment", + size: 130, + enableSorting: false, + cell: ({ row }) => { + const environment = row.original.environment || "development"; + return ; + }, + }, + { + id: "created_by", + accessorKey: "created_by", + meta: { title: "Created By" }, + header: "Created By", + size: 160, + enableSorting: false, + cell: ({ row }) => { + const createdBy = row.original.created_by; + return ( + + {createdBy || "-"} + + ); + }, + }, + { + id: "prompt_type", + accessorKey: "prompt_info.prompt_type", + meta: { title: "Type" }, + header: "Type", + size: 140, + enableSorting: false, + cell: ({ row }) => { + const promptType = row.original.prompt_info.prompt_type; + return ( + + {promptType} + + ); + }, + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx new file mode 100644 index 00000000000..99c58e2b98f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPromptsList } from "@/components/networking"; + +import PromptsPanel from "./index"; + +vi.mock("@/components/networking", () => ({ + getPromptsList: vi.fn(), + deletePromptCall: vi.fn(), +})); + +vi.mock("./PromptTable", () => ({ + __esModule: true, + default: ({ isLoading }: { isLoading: boolean }) => ( +
{isLoading ? "table-loading" : "table-loaded"}
+ ), +})); + +vi.mock("./prompt_info", () => ({ __esModule: true, default: () => null })); +vi.mock("./add_prompt_form", () => ({ __esModule: true, default: () => null })); +vi.mock("./prompt_editor_view", () => ({ __esModule: true, default: () => null })); + +const mockGetPromptsList = vi.mocked(getPromptsList); + +describe("PromptsPanel loading state", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { + render(); + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockGetPromptsList).not.toHaveBeenCalled(); + }); + + it("should show the loading state until the prompt fetch settles", async () => { + let resolveFetch: (value: { prompts: never[] }) => void = () => {}; + mockGetPromptsList.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + render(); + expect(screen.getByText("table-loading")).toBeInTheDocument(); + + resolveFetch({ prompts: [] }); + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockGetPromptsList).toHaveBeenCalledWith("sk-test", undefined); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx index 6e0e13d5181..de461ebd86d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/index.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { Button } from "@tremor/react"; import { Modal, Select } from "antd"; import { getPromptsList, PromptSpec, ListPromptsResponse, deletePromptCall } from "@/components/networking"; -import PromptTable from "./prompt_table"; +import PromptTable from "./PromptTable"; import PromptInfoView from "./prompt_info"; import AddPromptForm from "./add_prompt_form"; import PromptEditorView from "./prompt_editor_view"; @@ -17,7 +17,7 @@ interface PromptsProps { const PromptsPanel: React.FC = ({ accessToken, userRole }) => { const [promptsList, setPromptsList] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [selectedEnvironment, setSelectedEnvironment] = useState(undefined); const [selectedPromptId, setSelectedPromptId] = useState(null); const [isAddModalVisible, setIsAddModalVisible] = useState(false); @@ -32,6 +32,7 @@ const PromptsPanel: React.FC = ({ accessToken, userRole }) => { const fetchPrompts = async () => { if (!accessToken) { + setIsLoading(false); return; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_table.tsx deleted file mode 100644 index 51a03d19e83..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_table.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Button } from "@tremor/react"; -import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, TrashIcon } from "@heroicons/react/outline"; -import { Tooltip } from "antd"; -import { PromptSpec, modelHubCall } from "@/components/networking"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getSortedRowModel, - SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { getProviderLogoAndName } from "@/components/provider_info_helpers"; -import { extractModel, getProviderFromModelHub } from "./prompt_utils"; - -interface PromptTableProps { - promptsList: PromptSpec[]; - isLoading: boolean; - onPromptClick?: (id: string) => void; - onDeleteClick?: (id: string, name: string) => void; - accessToken: string | null; - isAdmin: boolean; -} - -interface ModelGroupInfo { - model_group: string; - providers: string[]; - [key: string]: any; -} - -const PromptTable: React.FC = ({ - promptsList, - isLoading, - onPromptClick, - onDeleteClick, - accessToken, - isAdmin, -}) => { - const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); - const [modelHubData, setModelHubData] = useState>(new Map()); - - useEffect(() => { - const fetchModelHubData = async () => { - if (!accessToken) return; - - try { - const response = await modelHubCall(accessToken); - if (response?.data) { - const modelMap = new Map(); - response.data.forEach((model: ModelGroupInfo) => { - modelMap.set(model.model_group, model); - }); - setModelHubData(modelMap); - } - } catch (error) { - console.error("Error fetching model hub data:", error); - } - }; - - fetchModelHubData(); - }, [accessToken]); - - const columns: ColumnDef[] = [ - { - header: "Prompt ID", - accessorKey: "prompt_id", - cell: (info: any) => , - }, - { - header: "Model", - accessorKey: "model", - cell: ({ row }) => { - const prompt = row.original; - const model = extractModel(prompt); - - if (!model) { - return -; - } - - const provider = getProviderFromModelHub(model, modelHubData); - const { logo } = getProviderLogoAndName(provider || ""); - - return ( - -
- {/* Provider Icon */} -
- {provider && logo ? ( - {`${provider} { - const target = e.currentTarget as HTMLImageElement; - const parent = target.parentElement; - if (!parent || !parent.contains(target)) { - return; - } - - try { - const fallbackDiv = document.createElement("div"); - fallbackDiv.className = - "w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs"; - fallbackDiv.textContent = provider?.charAt(0) || "-"; - parent.replaceChild(fallbackDiv, target); - } catch (error) { - console.error("Failed to replace provider logo fallback:", error); - } - }} - /> - ) : ( -
-
- )} -
- - {/* Model Name */} - {model} -
-
- ); - }, - }, - { - header: "Created At", - accessorKey: "created_at", - cell: ({ row }) => , - }, - { - header: "Updated At", - accessorKey: "updated_at", - cell: ({ row }) => , - }, - { - header: "Environment", - accessorKey: "environment", - cell: ({ row }) => { - const prompt = row.original; - const env = prompt.environment || "development"; - const colorMap: Record = { - production: "text-red-600 bg-red-50", - staging: "text-yellow-600 bg-yellow-50", - development: "text-green-600 bg-green-50", - }; - return ( - {env} - ); - }, - }, - { - header: "Created By", - accessorKey: "created_by", - cell: ({ row }) => { - const prompt = row.original; - return {prompt.created_by || "-"}; - }, - }, - { - header: "Type", - accessorKey: "prompt_info.prompt_type", - cell: ({ row }) => { - const prompt = row.original; - return ( - - {prompt.prompt_info.prompt_type} - - ); - }, - }, - ...(isAdmin - ? [ - { - header: "Actions", - id: "actions", - enableSorting: false, - cell: ({ row }: any) => { - const prompt = row.original; - const promptName = prompt.prompt_id || "Unknown Prompt"; - - return ( -
- -
- ); - }, - }, - ] - : []), - ]; - - const table = useReactTable({ - data: promptsList, - columns, - state: { - sorting, - }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - }); - - return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
-
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
-
-
- ))} -
- ))} -
- - {isLoading ? ( - - -
-

Loading...

-
-
-
- ) : promptsList.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No prompts found

-
-
-
- )} -
-
-
-
- ); -}; - -export default PromptTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_utils.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_utils.tsx index 6e0bd096af0..5c21a7d9ac6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_utils.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_utils.tsx @@ -1,7 +1,7 @@ import { PromptSpec } from "@/components/networking"; import { getVersionNumber } from "./prompt_editor_view/utils"; -interface ModelGroupInfo { +export interface ModelGroupInfo { model_group: string; providers: string[]; [key: string]: any; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx new file mode 100644 index 00000000000..52f3dc21b7a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getClaudeCodePluginsList } from "@/components/networking"; + +import ClaudeCodePluginsPanel from "./ClaudeCodePluginsPanel"; + +vi.mock("@/components/networking", () => ({ + getClaudeCodePluginsList: vi.fn(), + deleteClaudeCodePlugin: vi.fn(), +})); + +vi.mock("./PluginTable", () => ({ + __esModule: true, + default: ({ isLoading }: { isLoading: boolean }) => ( +
{isLoading ? "table-loading" : "table-loaded"}
+ ), +})); + +vi.mock("./add_plugin_form", () => ({ __esModule: true, default: () => null })); +vi.mock("@/components/claude_code_plugins/skill_detail", () => ({ __esModule: true, default: () => null })); + +const mockGetClaudeCodePluginsList = vi.mocked(getClaudeCodePluginsList); + +describe("ClaudeCodePluginsPanel loading state", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { + render(); + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockGetClaudeCodePluginsList).not.toHaveBeenCalled(); + }); + + it("should show the loading state until the skills fetch settles", async () => { + let resolveFetch: (value: { plugins: never[]; count: number }) => void = () => {}; + mockGetClaudeCodePluginsList.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + render(); + expect(screen.getByText("table-loading")).toBeInTheDocument(); + + resolveFetch({ plugins: [], count: 0 }); + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx index 178cd36857f..5a638f9ae79 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/ClaudeCodePluginsPanel.tsx @@ -3,7 +3,7 @@ import { Button } from "@tremor/react"; import { Modal } from "antd"; import { getClaudeCodePluginsList, deleteClaudeCodePlugin } from "@/components/networking"; import AddPluginForm from "./add_plugin_form"; -import PluginTable from "./plugin_table"; +import PluginTable from "./PluginTable"; import SkillDetail from "@/components/claude_code_plugins/skill_detail"; import { isAdminRole } from "@/utils/roles"; import NotificationsManager from "@/components/molecules/notifications_manager"; @@ -17,7 +17,7 @@ interface ClaudeCodePluginsPanelProps { const ClaudeCodePluginsPanel: React.FC = ({ accessToken, userRole }) => { const [pluginsList, setPluginsList] = useState([]); const [isAddModalVisible, setIsAddModalVisible] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [isDeleting, setIsDeleting] = useState(false); const [pluginToDelete, setPluginToDelete] = useState<{ name: string; @@ -28,7 +28,10 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT const isAdmin = userRole ? isAdminRole(userRole) : false; const fetchPlugins = async () => { - if (!accessToken) return; + if (!accessToken) { + setIsLoading(false); + return; + } setIsLoading(true); try { @@ -95,7 +98,6 @@ const ClaudeCodePluginsPanel: React.FC = ({ accessT pluginsList={pluginsList} isLoading={isLoading} onDeleteClick={handleDeleteClick} - accessToken={accessToken} isAdmin={isAdmin} onPluginClick={(id) => { const skill = pluginsList.find((p) => p.id === id); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.test.tsx new file mode 100644 index 00000000000..66a4d7e2524 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.test.tsx @@ -0,0 +1,113 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { Plugin } from "@/components/claude_code_plugins/types"; + +import PluginTable from "./PluginTable"; + +const mockPlugins: Plugin[] = [ + { + id: "plugin-id-newer", + name: "newer-skill", + version: "1.2.0", + description: "A skill for testing", + source: { source: "github", repo: "org/newer-skill" }, + category: "development", + enabled: true, + created_at: "2025-01-15T10:30:00Z", + }, + { + id: "plugin-id-older", + name: "older-skill", + source: { source: "github", repo: "org/older-skill" }, + enabled: false, + created_at: "2024-01-10T09:15:00Z", + }, +]; + +const mockOnDeleteClick = vi.fn(); +const mockOnPluginClick = vi.fn(); + +const defaultProps = { + pluginsList: mockPlugins, + isLoading: false, + onDeleteClick: mockOnDeleteClick, + isAdmin: true, + onPluginClick: mockOnPluginClick, +}; + +describe("PluginTable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render every column header", () => { + render(); + for (const header of ["Skill Name", "Version", "Description", "Category", "Public", "Created At"]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } + }); + + it("should display the empty state when data is empty", () => { + render(); + expect(screen.getByText("No skills found")).toBeInTheDocument(); + }); + + it("should sort by created date descending by default", () => { + render(); + const rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("newer-skill")).toBeInTheDocument(); + expect(within(rows[1]).getByText("older-skill")).toBeInTheDocument(); + }); + + it("should call onPluginClick with the plugin ID when the skill name is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "newer-skill" })); + expect(mockOnPluginClick).toHaveBeenCalledWith("plugin-id-newer"); + }); + + it("should not navigate when clicking elsewhere in the row", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText("A skill for testing")); + expect(mockOnPluginClick).not.toHaveBeenCalled(); + }); + + it("should badge the category and fall back to Uncategorized", () => { + render(); + expect(screen.getByText("development")).toBeInTheDocument(); + expect(screen.getByText("Uncategorized")).toBeInTheDocument(); + }); + + it("should show whether the skill is public", () => { + render(); + expect(screen.getByText("Yes")).toBeInTheDocument(); + expect(screen.getByText("No")).toBeInTheDocument(); + }); + + it("should delete a skill through the actions menu when admin", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-newer-skill")); + await user.click(await screen.findByTestId("plugin-action-delete")); + expect(mockOnDeleteClick).toHaveBeenCalledWith("newer-skill", "newer-skill"); + }); + + it("should copy the skill ID through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-newer-skill")); + await user.click(await screen.findByTestId("plugin-action-copy")); + expect(await window.navigator.clipboard.readText()).toBe("plugin-id-newer"); + }); + + it("should hide the delete action for non-admins but keep copy available", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("plugin-actions-newer-skill")); + expect(await screen.findByTestId("plugin-action-copy")).toBeInTheDocument(); + expect(screen.queryByTestId("plugin-action-delete")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx new file mode 100644 index 00000000000..c581b0dfdeb --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; +import { Plugin } from "@/components/claude_code_plugins/types"; + +import { getPluginTableColumns } from "./PluginTableColumns"; + +interface PluginTableProps { + pluginsList: Plugin[]; + isLoading: boolean; + onDeleteClick: (pluginName: string, displayName: string) => void; + isAdmin: boolean; + onPluginClick: (pluginId: string) => void; +} + +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; + +function EmptyState() { + return ( +
+
+ +
+
No skills found
+
Add one to get started.
+
+ ); +} + +const PluginTable: React.FC = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => { + const [sorting, setSorting] = useState(DEFAULT_SORTING); + + const columns = useMemo( + () => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }), + [isAdmin, onPluginClick, onDeleteClick], + ); + + return ( + plugin.id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading skills…" + noDataMessage={} + size="compact" + /> + ); +}; + +export default PluginTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx new file mode 100644 index 00000000000..95c9924b375 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTableColumns.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, MoreHorizontal, Trash2 } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells"; +import { getCategoryBadgeColor } from "@/components/claude_code_plugins/helpers"; +import { Plugin } from "@/components/claude_code_plugins/types"; +import { Badge } from "@/components/ui/badge"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +const CATEGORY_BADGE_CLASS: Record, string> = { + blue: "border-blue-200 bg-blue-50 text-blue-600", + green: "border-green-200 bg-green-50 text-green-600", + purple: "border-purple-200 bg-purple-50 text-purple-600", + red: "border-red-200 bg-red-50 text-red-600", + orange: "border-orange-200 bg-orange-50 text-orange-600", + yellow: "border-yellow-200 bg-yellow-50 text-yellow-600", + gray: "border-gray-200 bg-gray-50 text-gray-600", +}; + +function PluginCategoryBadge({ category }: { category?: string }) { + return ( + + {category || "Uncategorized"} + + ); +} + +interface PluginRowActionsProps { + plugin: Plugin; + isAdmin: boolean; + onDeleteClick: (pluginName: string, displayName: string) => void; +} + +function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsProps) { + return ( + + + + + + void copyToClipboard(plugin.id, "Skill ID copied")} + > + + Copy skill ID + + {isAdmin && ( + <> + + onDeleteClick(plugin.name, plugin.name)} + > + + Delete + + + )} + + + ); +} + +interface PluginTableColumnsDeps { + isAdmin: boolean; + onPluginClick: (pluginId: string) => void; + onDeleteClick: (pluginName: string, displayName: string) => void; +} + +export const getPluginTableColumns = ({ + isAdmin, + onPluginClick, + onDeleteClick, +}: PluginTableColumnsDeps): ColumnDef[] => [ + { + id: "name", + accessorKey: "name", + meta: { title: "Skill Name" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + onPluginClick(row.original.id)} + /> + ), + }, + { + id: "version", + accessorKey: "version", + meta: { title: "Version" }, + header: "Version", + size: 100, + enableSorting: false, + cell: ({ row }) => {row.original.version || "N/A"}, + }, + { + id: "description", + accessorKey: "description", + meta: { title: "Description" }, + header: "Description", + size: 300, + enableSorting: false, + cell: ({ row }) => { + const description = row.original.description; + return ( + + {description || "No description"} + + ); + }, + }, + { + id: "category", + accessorKey: "category", + meta: { title: "Category", skeleton: "badge" }, + header: "Category", + size: 150, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "enabled", + accessorKey: "enabled", + meta: { title: "Public", skeleton: "badge" }, + header: "Public", + size: 100, + enableSorting: false, + cell: ({ row }) => ( + + ), + }, + { + id: "created_at", + accessorKey: "created_at", + sortingFn: "datetime", + meta: { title: "Created At" }, + header: ({ column }) => , + size: 160, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/plugin_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/plugin_table.tsx deleted file mode 100644 index eb1c495374a..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/plugin_table.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { CopyOutlined } from "@ant-design/icons"; -import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon, TrashIcon } from "@heroicons/react/outline"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getSortedRowModel, - SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; -import { Tooltip } from "antd"; -import React, { useState } from "react"; -import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells"; -import NotificationsManager from "@/components/molecules/notifications_manager"; -import { getCategoryBadgeColor } from "@/components/claude_code_plugins/helpers"; -import { Plugin } from "@/components/claude_code_plugins/types"; - -interface PluginTableProps { - pluginsList: Plugin[]; - isLoading: boolean; - onDeleteClick: (pluginName: string, displayName: string) => void; - accessToken: string | null; - isAdmin: boolean; - onPluginClick: (pluginId: string) => void; -} - -const PluginTable: React.FC = ({ - pluginsList, - isLoading, - onDeleteClick, - accessToken, - isAdmin, - onPluginClick, -}) => { - const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); - - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - NotificationsManager.success("Copied to clipboard!"); - }; - - const columns: ColumnDef[] = [ - { - header: "Skill Name", - accessorKey: "name", - cell: ({ row }) => { - const plugin = row.original; - return ( -
- onPluginClick(plugin.id)} /> - - { - e.stopPropagation(); - copyToClipboard(plugin.id); - }} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- ); - }, - }, - { - header: "Version", - accessorKey: "version", - cell: ({ row }) => { - const version = row.original.version || "N/A"; - return {version}; - }, - }, - { - header: "Description", - accessorKey: "description", - cell: ({ row }) => { - const description = row.original.description || "No description"; - return ( - - {description} - - ); - }, - }, - { - header: "Category", - accessorKey: "category", - cell: ({ row }) => { - const category = row.original.category; - if (!category) { - return ( - - Uncategorized - - ); - } - const badgeColor = getCategoryBadgeColor(category); - return ( - - {category} - - ); - }, - }, - { - header: "Public", - accessorKey: "enabled", - cell: ({ row }) => { - const plugin = row.original; - return ; - }, - }, - { - header: "Created At", - accessorKey: "created_at", - cell: ({ row }) => , - }, - ...(isAdmin - ? [ - { - header: "Actions", - id: "actions", - enableSorting: false, - cell: ({ row }: any) => { - const plugin = row.original; - - return ( -
- -
- ); - }, - }, - ] - : []), - ]; - - const table = useReactTable({ - data: pluginsList, - columns, - state: { - sorting, - }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - }); - - return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.column.getCanSort() && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
-
- ))} -
- ))} -
- - {isLoading ? ( - - -
-

Loading...

-
-
-
- ) : pluginsList && pluginsList.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - onPluginClick(row.original.id)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No skills found. Add one to get started.

-
-
-
- )} -
-
-
-
- ); -}; - -export default PluginTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.test.tsx index 45ca40152fa..7446d0efe3c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.test.tsx @@ -1,90 +1,46 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import VectorStoreTable from "./VectorStoreTable"; + import { VectorStore } from "@/components/vector_store_management/types"; -// Mock dependencies -const mockGetProviderLogoAndName = vi.fn(); -const mockTableIconActionButton = vi.fn(); +import VectorStoreTable from "./VectorStoreTable"; vi.mock("@/components/provider_info_helpers", () => ({ - getProviderLogoAndName: (...args: any[]) => mockGetProviderLogoAndName(...args), -})); - -vi.mock("@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton", () => ({ - default: (props: any) => { - mockTableIconActionButton(props); - return ( - - ); + getProviderLogoAndName: (provider: string) => { + const providerMap: Record = { + openai: { displayName: "OpenAI", logo: "/openai-logo.png" }, + azure: { displayName: "Azure", logo: "/azure-logo.png" }, + }; + return providerMap[provider] || { displayName: provider, logo: "" }; }, })); -// Mock Tremor components to avoid complex styling issues -vi.mock("@tremor/react", () => ({ - Table: ({ children, ...props }: any) => {children}
, - TableHead: ({ children, ...props }: any) => {children}, - TableBody: ({ children, ...props }: any) => {children}, - TableRow: ({ children, ...props }: any) => {children}, - TableHeaderCell: ({ children, ...props }: any) => {children}, - TableCell: ({ children, ...props }: any) => {children}, -})); - -// Mock antd Tooltip -vi.mock("antd", () => ({ - Tooltip: ({ children, title }: any) => ( -
- {children} -
- ), -})); - -// Mock Heroicons -vi.mock("@heroicons/react/outline", () => ({ - ChevronDownIcon: (props: any) =>
, - ChevronUpIcon: (props: any) =>
, - SwitchVerticalIcon: (props: any) =>
, -})); - -// Test data const mockVectorStores: VectorStore[] = [ { - vector_store_id: "short-id", + vector_store_id: "vs-newer", custom_llm_provider: "openai", vector_store_name: "My OpenAI Store", vector_store_description: "A store for OpenAI vectors", + vector_store_metadata: { + ingested_files: [ + { filename: "a.pdf", ingested_at: "2024-01-15T10:00:00Z" }, + { filename: "b.pdf", ingested_at: "2024-01-15T10:00:00Z" }, + ], + }, created_at: "2024-01-15T10:30:00Z", updated_at: "2024-01-15T11:00:00Z", - created_by: "user-1", - updated_by: "user-1", }, { - vector_store_id: "very-long-vector-store-id-that-should-be-truncated", + vector_store_id: "vs-older", custom_llm_provider: "azure", - vector_store_name: undefined, // Test missing name - vector_store_description: "A store for Azure vectors with a very long description that should show a tooltip", + vector_store_name: undefined, + vector_store_description: undefined, created_at: "2024-01-10T09:15:00Z", updated_at: "2024-01-12T14:20:00Z", }, - { - vector_store_id: "store-3", - custom_llm_provider: "pg_vector", - vector_store_name: "PostgreSQL Store", - vector_store_description: undefined, // Test missing description - created_at: "2024-01-05T08:00:00Z", - updated_at: "2024-01-08T16:45:00Z", - }, ]; -// Mock functions const mockOnView = vi.fn(); const mockOnEdit = vi.fn(); const mockOnDelete = vi.fn(); @@ -96,319 +52,71 @@ const defaultProps = { onDelete: mockOnDelete, }; -// Helper function to render component -const renderComponent = (props = {}) => { - return render(); -}; - describe("VectorStoreTable", () => { beforeEach(() => { vi.clearAllMocks(); - - // Setup default mock returns for getProviderLogoAndName - mockGetProviderLogoAndName.mockImplementation((provider: string) => { - const providerMap: Record = { - openai: { displayName: "OpenAI", logo: "/openai-logo.png" }, - azure: { displayName: "Azure", logo: "/azure-logo.png" }, - pg_vector: { displayName: "PostgreSQL Vector", logo: "/pg-logo.png" }, - }; - return providerMap[provider] || { displayName: provider, logo: "" }; - }); }); - describe("Rendering", () => { - it("should render the table with data", () => { - renderComponent(); - expect(screen.getByRole("table")).toBeInTheDocument(); - }); - - it("should render table headers", () => { - renderComponent(); - expect(screen.getByText("Vector Store ID")).toBeInTheDocument(); - expect(screen.getByText("Name")).toBeInTheDocument(); - expect(screen.getByText("Description")).toBeInTheDocument(); - expect(screen.getByText("Provider")).toBeInTheDocument(); - expect(screen.getByText("Created At")).toBeInTheDocument(); - expect(screen.getByText("Updated At")).toBeInTheDocument(); - // Check that we have the expected number of header cells (7 data + 1 actions) - const headers = screen.getAllByRole("columnheader"); - expect(headers).toHaveLength(8); - }); - - it("should render all vector store rows", () => { - renderComponent(); - expect(screen.getAllByRole("row")).toHaveLength(mockVectorStores.length + 1); // +1 for header row - }); - - it("should render empty state when no data", () => { - renderComponent({ data: [] }); - expect(screen.getByText("No vector stores found")).toBeInTheDocument(); - }); + it("should render every column header", () => { + render(); + for (const header of ["Vector Store ID", "Name", "Description", "Files", "Provider", "Created At", "Updated At"]) { + expect(screen.getByText(header)).toBeInTheDocument(); + } }); - describe("Vector Store ID Column", () => { - it("should render short vector store IDs fully", () => { - renderComponent(); - expect(screen.getByText("short-id")).toBeInTheDocument(); - }); - - it("should truncate long vector store IDs", () => { - renderComponent(); - const idButton = screen.getByText("very-long-vector-store-id-that-should-be-truncated"); - expect(idButton).toHaveClass("truncate", "max-w-[15ch]"); - }); - - it("should make vector store ID clickable", async () => { - const user = userEvent.setup(); - renderComponent(); - const idButton = screen.getByText("short-id"); - await user.click(idButton); - expect(mockOnView).toHaveBeenCalledWith("short-id"); - }); - - it("should have correct styling for vector store ID button", () => { - renderComponent(); - const idButton = screen.getByText("short-id").closest("button"); - expect(idButton).toHaveClass("font-mono", "text-blue-500", "bg-blue-50", "hover:bg-blue-100"); - }); + it("should display the empty state when data is empty", () => { + render(); + expect(screen.getByText("No vector stores")).toBeInTheDocument(); }); - describe("Name Column", () => { - it("should render vector store name", () => { - renderComponent(); - expect(screen.getByText("My OpenAI Store")).toBeInTheDocument(); - }); - - it("should render fallback for missing name", () => { - renderComponent(); - const fallbackElements = screen.getAllByText("-"); - expect(fallbackElements.length).toBe(5); // One for missing name, one for missing description, three for missing files (one per store) - }); - - it("should wrap name in tooltip", () => { - renderComponent(); - const tooltips = screen.getAllByTestId("tooltip"); - const nameTooltip = tooltips.find((t) => t.getAttribute("data-title") === "My OpenAI Store"); - expect(nameTooltip).toBeInTheDocument(); - }); + it("should sort by created date descending by default", () => { + render(); + const rows = screen.getAllByRole("row").slice(1); + expect(within(rows[0]).getByText("vs-newer")).toBeInTheDocument(); + expect(within(rows[1]).getByText("vs-older")).toBeInTheDocument(); }); - describe("Description Column", () => { - it("should render vector store description", () => { - renderComponent(); - expect(screen.getByText("A store for OpenAI vectors")).toBeInTheDocument(); - }); - - it("should render fallback for missing description", () => { - renderComponent(); - const fallbackElements = screen.getAllByText("-"); - expect(fallbackElements.length).toBe(5); // One for missing name, one for missing description, three for missing files (one per store) - }); - - it("should wrap description in tooltip", () => { - renderComponent(); - const tooltips = screen.getAllByTestId("tooltip"); - const descTooltip = tooltips.find( - (t) => - t.getAttribute("data-title") === - "A store for Azure vectors with a very long description that should show a tooltip", - ); - expect(descTooltip).toBeInTheDocument(); - }); + it("should call onView when the vector store ID is clicked", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "vs-newer" })); + expect(mockOnView).toHaveBeenCalledWith("vs-newer"); }); - describe("Provider Column", () => { - it("should render provider display name", () => { - renderComponent(); - expect(screen.getByText("OpenAI")).toBeInTheDocument(); - expect(screen.getByText("Azure")).toBeInTheDocument(); - expect(screen.getByText("PostgreSQL Vector")).toBeInTheDocument(); - }); - - it("should render provider logo when available", () => { - renderComponent(); - const logos = screen.getAllByRole("img"); - expect(logos).toHaveLength(3); // All providers have logos in our mock - expect(logos[0]).toHaveAttribute("src", "/openai-logo.png"); - expect(logos[0]).toHaveAttribute("alt", "OpenAI"); - }); - - it("should call getProviderLogoAndName for each provider", () => { - renderComponent(); - expect(mockGetProviderLogoAndName).toHaveBeenCalledWith("openai"); - expect(mockGetProviderLogoAndName).toHaveBeenCalledWith("azure"); - expect(mockGetProviderLogoAndName).toHaveBeenCalledWith("pg_vector"); - }); + it("should render provider display names", () => { + render(); + expect(screen.getByText("OpenAI")).toBeInTheDocument(); + expect(screen.getByText("Azure")).toBeInTheDocument(); }); - describe("Date Columns", () => { - it("should render created at dates", () => { - renderComponent(); - const dateElements = screen.getAllByText(/Jan \d+, 2024/); - expect(dateElements.length).toBe(6); // 3 created_at + 3 updated_at dates - }); - - it("should render updated at dates", () => { - renderComponent(); - const dateElements = screen.getAllByText(/Jan \d+, 2024/); - expect(dateElements.length).toBe(6); // 3 created_at + 3 updated_at dates - }); + it("should summarize ingested files and fall back to a dash without files", () => { + render(); + expect(screen.getByText("2 files")).toBeInTheDocument(); + const olderRow = screen.getAllByRole("row").slice(1)[1]; + expect(within(olderRow).getAllByText("-").length).toBeGreaterThan(0); }); - describe("Actions Column", () => { - it("should render edit and delete action buttons for each row", () => { - renderComponent(); - expect(screen.getAllByTestId("action-button-edit")).toHaveLength(mockVectorStores.length); - expect(screen.getAllByTestId("action-button-delete")).toHaveLength(mockVectorStores.length); - }); - - it("should call onEdit when edit button is clicked", async () => { - const user = userEvent.setup(); - renderComponent(); - const editButtons = screen.getAllByTestId("action-button-edit"); - await user.click(editButtons[0]); - expect(mockOnEdit).toHaveBeenCalledWith("short-id"); - }); - - it("should call onDelete when delete button is clicked", async () => { - const user = userEvent.setup(); - renderComponent(); - const deleteButtons = screen.getAllByTestId("action-button-delete"); - await user.click(deleteButtons[0]); - expect(mockOnDelete).toHaveBeenCalledWith("short-id"); - }); - - it("should pass correct props to TableIconActionButton", () => { - renderComponent(); - expect(mockTableIconActionButton).toHaveBeenCalledWith( - expect.objectContaining({ - variant: "Edit", - tooltipText: "Edit vector store", - onClick: expect.any(Function), - }), - ); - expect(mockTableIconActionButton).toHaveBeenCalledWith( - expect.objectContaining({ - variant: "Delete", - tooltipText: "Delete vector store", - onClick: expect.any(Function), - }), - ); - }); + it("should edit a vector store through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("vector-store-actions-vs-newer")); + await user.click(await screen.findByTestId("vector-store-action-edit")); + expect(mockOnEdit).toHaveBeenCalledWith("vs-newer"); }); - describe("Sorting", () => { - it("should initialize with created_at descending sort", () => { - renderComponent(); - // The table should initialize with sorting state - expect(screen.getByTestId("chevron-down")).toBeInTheDocument(); - }); - - it("should render sort icons for sortable columns", () => { - renderComponent(); - // Should have sort icons for Created At and Updated At columns - const sortIcons = screen.getAllByTestId(/^chevron-(up|down)$|^switch-vertical$/); - expect(sortIcons.length).toBeGreaterThan(0); - }); - - it("should make header cells clickable for sorting", () => { - renderComponent(); - const headerCells = screen.getAllByRole("columnheader"); - const sortableHeaders = headerCells.filter((cell) => cell.textContent !== ""); - expect(sortableHeaders.length).toBeGreaterThan(0); - }); - - it("should show ascending icon when sorted ascending", () => { - renderComponent(); - // Initially shows descending, but we can test the logic by checking the icons are present - expect(screen.getByTestId("chevron-down")).toBeInTheDocument(); - }); + it("should delete a vector store through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("vector-store-actions-vs-newer")); + await user.click(await screen.findByTestId("vector-store-action-delete")); + expect(mockOnDelete).toHaveBeenCalledWith("vs-newer"); }); - describe("Styling and Layout", () => { - it("should apply correct CSS classes to table container", () => { - renderComponent(); - const tableContainer = screen.getByRole("table").parentElement?.parentElement; - expect(tableContainer).toHaveClass("rounded-lg", "custom-border", "relative"); - }); - - it("should apply overflow styling to table wrapper", () => { - renderComponent(); - const tableWrapper = screen.getByRole("table").parentElement; - expect(tableWrapper).toHaveClass("overflow-x-auto"); - }); - - it("should apply sticky styling to actions column", () => { - renderComponent(); - const headerCells = screen.getAllByRole("columnheader"); - const actionsHeader = headerCells[headerCells.length - 1]; - expect(actionsHeader).toHaveClass("sticky", "right-0", "bg-white"); - }); - - it("should apply sticky styling to action cells", () => { - renderComponent(); - const rows = screen.getAllByRole("row").slice(1); // Skip header row - rows.forEach((row) => { - const cells = row.querySelectorAll("td"); - const lastCell = cells[cells.length - 1]; - expect(lastCell).toHaveClass("sticky", "right-0", "bg-white"); - }); - }); - }); - - describe("Table Row Styling", () => { - it("should apply correct height to table rows", () => { - renderComponent(); - const rows = screen.getAllByRole("row").slice(1); // Skip header row - rows.forEach((row) => { - expect(row).toHaveClass("h-8"); - }); - }); - - it("should apply correct cell padding and styling", () => { - renderComponent(); - const cells = screen.getAllByRole("cell"); - cells.forEach((cell) => { - expect(cell).toHaveClass("py-0.5", "max-h-8", "overflow-hidden", "text-ellipsis", "whitespace-nowrap"); - }); - }); - }); - - describe("Empty State", () => { - it("should render single row with centered message when no data", () => { - renderComponent({ data: [] }); - const rows = screen.getAllByRole("row"); - expect(rows).toHaveLength(2); // Header + empty state row - expect(screen.getByText("No vector stores found")).toBeInTheDocument(); - }); - - it("should span all columns in empty state", () => { - renderComponent({ data: [] }); - const emptyCell = screen.getByText("No vector stores found").closest("td"); - expect(emptyCell).toHaveAttribute("colSpan", "8"); // 7 data columns + 1 actions column - }); - }); - - describe("Data Edge Cases", () => { - it("should handle vector stores with minimal data", () => { - const minimalData: VectorStore[] = [ - { - vector_store_id: "minimal", - custom_llm_provider: "test", - created_at: "2024-01-01T00:00:00Z", - updated_at: "2024-01-01T00:00:00Z", - }, - ]; - - renderComponent({ data: minimalData }); - expect(screen.getByText("minimal")).toBeInTheDocument(); - expect(screen.getAllByText("-")).toHaveLength(3); // Name, description, and files fallbacks - }); - - it("should handle single vector store", () => { - const singleData = [mockVectorStores[0]]; - renderComponent({ data: singleData }); - expect(screen.getAllByRole("row")).toHaveLength(2); // Header + 1 data row - }); + it("should copy the vector store ID through the actions menu", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByTestId("vector-store-actions-vs-newer")); + await user.click(await screen.findByTestId("vector-store-action-copy")); + expect(await window.navigator.clipboard.readText()).toBe("vs-newer"); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx index 074af873601..2f8508dc7c6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx @@ -1,215 +1,57 @@ -import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - getSortedRowModel, - SortingState, - useReactTable, -} from "@tanstack/react-table"; -import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; -import { Tooltip } from "antd"; -import React from "react"; -import { DateCell, IdCell } from "@/components/shared/table_cells"; -import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; -import { getProviderLogoAndName } from "@/components/provider_info_helpers"; +"use client"; + +import { SortingState } from "@tanstack/react-table"; +import { Inbox } from "lucide-react"; +import React, { useMemo, useState } from "react"; + +import { DataTable } from "@/components/shared/DataTable"; import { VectorStore } from "@/components/vector_store_management/types"; +import { getVectorStoreTableColumns } from "./VectorStoreTableColumns"; + interface VectorStoreTableProps { data: VectorStore[]; onView: (vectorStoreId: string) => void; onEdit: (vectorStoreId: string) => void; onDelete: (vectorStoreId: string) => void; + isLoading?: boolean; } -const VectorStoreTable: React.FC = ({ data, onView, onEdit, onDelete }) => { - const [sorting, setSorting] = React.useState([{ id: "created_at", desc: true }]); - - const columns: ColumnDef[] = [ - { - header: "Vector Store ID", - accessorKey: "vector_store_id", - cell: ({ row }) => , - }, - { - header: "Name", - accessorKey: "vector_store_name", - cell: ({ row }) => { - const vectorStore = row.original; - return ( - - {vectorStore.vector_store_name || "-"} - - ); - }, - }, - { - header: "Description", - accessorKey: "vector_store_description", - cell: ({ row }) => { - const vectorStore = row.original; - return ( - - {vectorStore.vector_store_description || "-"} - - ); - }, - }, - { - header: "Files", - accessorKey: "vector_store_metadata", - cell: ({ row }) => { - const vectorStore = row.original; - const ingestedFiles = vectorStore.vector_store_metadata?.ingested_files || []; - - if (ingestedFiles.length === 0) { - return -; - } - - const filenames = ingestedFiles.map((file) => file.filename || file.file_url || "Unknown").join(", "); - - const displayText = - ingestedFiles.length === 1 - ? ingestedFiles[0].filename || ingestedFiles[0].file_url || "1 file" - : `${ingestedFiles.length} files`; - - return ( - - {displayText} - - ); - }, - }, - { - header: "Provider", - accessorKey: "custom_llm_provider", - cell: ({ row }) => { - const vectorStore = row.original; - const { displayName, logo } = getProviderLogoAndName(vectorStore.custom_llm_provider); - return ( -
- {logo && {displayName}} - {displayName} -
- ); - }, - }, - { - header: "Created At", - accessorKey: "created_at", - sortingFn: "datetime", - cell: ({ row }) => , - }, - { - header: "Updated At", - accessorKey: "updated_at", - sortingFn: "datetime", - cell: ({ row }) => , - }, - { - id: "actions", - header: "", - cell: ({ row }) => { - const vectorStore = row.original; - return ( -
- onEdit(vectorStore.vector_store_id)} - /> - onDelete(vectorStore.vector_store_id)} - /> -
- ); - }, - }, - ]; - - const table = useReactTable({ - data, - columns, - state: { - sorting, - }, - onSortingChange: setSorting, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - enableSorting: true, - }); +const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }]; +function EmptyState() { return ( -
-
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - -
-
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
- {header.id !== "actions" && ( -
- {header.column.getIsSorted() ? ( - { - asc: , - desc: , - }[header.column.getIsSorted() as string] - ) : ( - - )} -
- )} -
-
- ))} -
- ))} -
- - {table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - -
-

No vector stores found

-
-
-
- )} -
-
+
+
+ +
+
No vector stores
+
+ Connect a vector store to enable retrieval-augmented generation.
); +} + +const VectorStoreTable: React.FC = ({ data, onView, onEdit, onDelete, isLoading = false }) => { + const [sorting, setSorting] = useState(DEFAULT_SORTING); + + const columns = useMemo(() => getVectorStoreTableColumns({ onView, onEdit, onDelete }), [onView, onEdit, onDelete]); + + return ( + vectorStore.vector_store_id || String(index)} + sortingMode="client" + sorting={sorting} + onSortingChange={setSorting} + isLoading={isLoading} + loadingMessage="Loading vector stores…" + noDataMessage={} + size="compact" + /> + ); }; export default VectorStoreTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTableColumns.tsx new file mode 100644 index 00000000000..cf162578177 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTableColumns.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; + +import { DataTableSortHeader } from "@/components/shared/DataTable"; +import { CellTooltip, DateCell, IdentityCell } from "@/components/shared/table_cells"; +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; +import { buttonVariants } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { VectorStore } from "@/components/vector_store_management/types"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +function VectorStoreProviderCell({ provider }: { provider: string }) { + const { displayName, logo } = getProviderLogoAndName(provider); + return ( +
+ {logo ? ( + { + (event.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : null} + {displayName} +
+ ); +} + +function VectorStoreFilesCell({ vectorStore }: { vectorStore: VectorStore }) { + const ingestedFiles = vectorStore.vector_store_metadata?.ingested_files || []; + if (ingestedFiles.length === 0) { + return -; + } + + const filenames = ingestedFiles.map((file) => file.filename || file.file_url || "Unknown").join(", "); + const displayText = + ingestedFiles.length === 1 + ? ingestedFiles[0].filename || ingestedFiles[0].file_url || "1 file" + : `${ingestedFiles.length} files`; + + return ( + {displayText}} + /> + ); +} + +interface VectorStoreRowActionsProps { + vectorStore: VectorStore; + onEdit: (vectorStoreId: string) => void; + onDelete: (vectorStoreId: string) => void; +} + +function VectorStoreRowActions({ vectorStore, onEdit, onDelete }: VectorStoreRowActionsProps) { + return ( + + + + + + onEdit(vectorStore.vector_store_id)}> + + Edit + + void copyToClipboard(vectorStore.vector_store_id, "Vector store ID copied")} + > + + Copy vector store ID + + + onDelete(vectorStore.vector_store_id)} + > + + Delete + + + + ); +} + +interface VectorStoreTableColumnsDeps { + onView: (vectorStoreId: string) => void; + onEdit: (vectorStoreId: string) => void; + onDelete: (vectorStoreId: string) => void; +} + +export const getVectorStoreTableColumns = ({ + onView, + onEdit, + onDelete, +}: VectorStoreTableColumnsDeps): ColumnDef[] => [ + { + id: "vector_store_id", + accessorKey: "vector_store_id", + meta: { title: "Vector Store ID" }, + header: ({ column }) => , + size: 220, + enableSorting: true, + cell: ({ row }) => ( + onView(row.original.vector_store_id)} + /> + ), + }, + { + id: "vector_store_name", + accessorKey: "vector_store_name", + meta: { title: "Name" }, + header: ({ column }) => , + size: 200, + enableSorting: true, + cell: ({ row }) => { + const name = row.original.vector_store_name; + return ( + + {name || "-"} + + ); + }, + }, + { + id: "vector_store_description", + accessorKey: "vector_store_description", + meta: { title: "Description" }, + header: "Description", + size: 280, + enableSorting: false, + cell: ({ row }) => { + const description = row.original.vector_store_description; + return ( + + {description || "-"} + + ); + }, + }, + { + id: "files", + meta: { title: "Files" }, + header: "Files", + size: 160, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "provider", + accessorKey: "custom_llm_provider", + meta: { title: "Provider" }, + header: "Provider", + size: 160, + enableSorting: false, + cell: ({ row }) => , + }, + { + id: "created_at", + accessorKey: "created_at", + sortingFn: "datetime", + meta: { title: "Created At" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "updated_at", + accessorKey: "updated_at", + sortingFn: "datetime", + meta: { title: "Updated At" }, + header: ({ column }) => , + size: 150, + enableSorting: true, + cell: ({ row }) => , + }, + { + id: "actions", + meta: { className: "text-right", headerClassName: "text-right" }, + header: () => Actions, + size: 64, + enableSorting: false, + enableHiding: false, + cell: ({ row }) => ( +
+ +
+ ), + }, +]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx new file mode 100644 index 00000000000..2931372f384 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { vectorStoreListCall } from "@/components/networking"; + +import VectorStoreManagement from "./index"; + +vi.mock("@/components/networking", () => ({ + vectorStoreListCall: vi.fn(), + vectorStoreDeleteCall: vi.fn(), + credentialListCall: vi.fn(), +})); + +vi.mock("./VectorStoreTable", () => ({ + __esModule: true, + default: ({ isLoading }: { isLoading?: boolean }) => ( +
{isLoading ? "table-loading" : "table-loaded"}
+ ), +})); + +vi.mock("./VectorStoreForm", () => ({ __esModule: true, default: () => null })); +vi.mock("./vector_store_info", () => ({ __esModule: true, default: () => null })); +vi.mock("./CreateVectorStore", () => ({ __esModule: true, default: () => null })); +vi.mock("./TestVectorStoreTab", () => ({ __esModule: true, default: () => null })); + +const mockVectorStoreListCall = vi.mocked(vectorStoreListCall); + +describe("VectorStoreManagement loading state", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should resolve the loading state when accessToken is null instead of showing the skeleton forever", async () => { + render(); + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockVectorStoreListCall).not.toHaveBeenCalled(); + }); + + it("should show the loading state until the vector store fetch settles", async () => { + let resolveFetch: (value: { data: never[] }) => void = () => {}; + mockVectorStoreListCall.mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + render(); + expect(screen.getByText("table-loading")).toBeInTheDocument(); + + resolveFetch({ data: [] }); + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx index 565e645f7b5..5d3f81f0275 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx @@ -36,6 +36,7 @@ interface VectorStoreProps { const VectorStoreManagement: React.FC = ({ accessToken, userID, userRole }) => { const [vectorStores, setVectorStores] = useState([]); + const [isLoadingVectorStores, setIsLoadingVectorStores] = useState(true); const [isCreateModalVisible, setIsCreateModalVisible] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [vectorStoreToDelete, setVectorStoreToDelete] = useState(null); @@ -46,13 +47,18 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID const [isDeleting, setIsDeleting] = useState(false); const fetchVectorStores = async () => { - if (!accessToken) return; + if (!accessToken) { + setIsLoadingVectorStores(false); + return; + } try { const response = await vectorStoreListCall(accessToken); setVectorStores(response.data || []); } catch (error) { console.error("Error fetching vector stores:", error); NotificationsManager.fromBackend("Error fetching vector stores: " + error); + } finally { + setIsLoadingVectorStores(false); } }; @@ -181,6 +187,7 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID