Merge pull request #33343 from BerriAI/litellm_/migrate-simple-tables-ac3786

refactor(ui): migrate vector stores, prompts, and skills tables onto shared DataTable
This commit is contained in:
yuneng-jiang 2026-07-16 07:29:59 -07:00 • committed by GitHub
commit 69a491e168
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1251 additions and 1112 deletions

View file

@ -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

View file

@ -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(<PromptTable {...defaultProps} />);
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(<PromptTable {...defaultProps} promptsList={[]} />);
expect(screen.getByText("No prompts yet")).toBeInTheDocument();
});
it("should sort by created date descending by default", () => {
render(<PromptTable {...defaultProps} />);
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(<PromptTable {...defaultProps} />);
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(<PromptTable {...defaultProps} />);
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(<PromptTable {...defaultProps} />);
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(<PromptTable {...defaultProps} />);
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(<PromptTable {...defaultProps} isAdmin={false} />);
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();
});
});

View file

@ -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 (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No prompts yet</div>
<div className="text-sm text-muted-foreground">Add a prompt to start managing reusable templates.</div>
</div>
);
}
const PromptTable: React.FC<PromptTableProps> = ({
promptsList,
isLoading,
onPromptClick,
onDeleteClick,
accessToken,
isAdmin,
}) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const [modelHubData, setModelHubData] = useState<Map<string, ModelGroupInfo>>(new Map());
useEffect(() => {
const fetchModelHubData = async () => {
if (!accessToken) return;
try {
const response = await modelHubCall(accessToken);
if (response?.data) {
const modelMap = new Map<string, ModelGroupInfo>();
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 (
<DataTable
data={promptsList}
columns={columns}
getRowId={(prompt, index) => prompt.prompt_id || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading prompts…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};
export default PromptTable;

View file

@ -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<string, StatusTone> = {
production: "error",
staging: "warning",
development: "success",
};
function PromptModelCell({ prompt, modelHubData }: { prompt: PromptSpec; modelHubData: Map<string, ModelGroupInfo> }) {
const model = extractModel(prompt);
if (!model) {
return <span className="text-sm text-muted-foreground">-</span>;
}
const provider = getProviderFromModelHub(model, modelHubData);
const { logo } = provider ? getProviderLogoAndName(provider) : { logo: "" };
return (
<CellTooltip
content={model}
trigger={
<div className="flex items-center gap-2">
{logo ? (
<img
src={logo}
alt=""
className="size-4 shrink-0"
onError={(event) => {
(event.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">
{provider?.charAt(0) || "-"}
</span>
)}
<span className="max-w-40 truncate text-sm">{model}</span>
</div>
}
/>
);
}
interface PromptRowActionsProps {
prompt: PromptSpec;
isAdmin: boolean;
onDeleteClick?: (id: string, name: string) => void;
}
function PromptRowActions({ prompt, isAdmin, onDeleteClick }: PromptRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open prompt actions"
data-testid={`prompt-actions-${prompt.prompt_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
data-testid="prompt-action-copy"
onClick={() => void copyToClipboard(prompt.prompt_id, "Prompt ID copied")}
>
<Copy />
Copy prompt ID
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
data-testid="prompt-action-delete"
onClick={() => onDeleteClick?.(prompt.prompt_id, prompt.prompt_id || "Unknown Prompt")}
>
<Trash2 />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface PromptTableColumnsDeps {
modelHubData: Map<string, ModelGroupInfo>;
isAdmin: boolean;
onPromptClick?: (id: string) => void;
onDeleteClick?: (id: string, name: string) => void;
}
export const getPromptTableColumns = ({
modelHubData,
isAdmin,
onPromptClick,
onDeleteClick,
}: PromptTableColumnsDeps): ColumnDef<PromptSpec>[] => [
{
id: "prompt_id",
accessorKey: "prompt_id",
meta: { title: "Prompt ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Prompt ID" />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
<IdentityCell
title={row.original.prompt_id}
titleClassName="font-mono text-xs font-normal"
className="max-w-60"
onClick={onPromptClick ? () => onPromptClick(row.original.prompt_id) : undefined}
/>
),
},
{
id: "model",
meta: { title: "Model" },
header: "Model",
size: 200,
enableSorting: false,
cell: ({ row }) => <PromptModelCell prompt={row.original} modelHubData={modelHubData} />,
},
{
id: "created_at",
accessorKey: "created_at",
sortingFn: "datetime",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
size: 160,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
{
id: "updated_at",
accessorKey: "updated_at",
sortingFn: "datetime",
meta: { title: "Updated At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
size: 160,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
},
{
id: "environment",
accessorKey: "environment",
meta: { title: "Environment", skeleton: "badge" },
header: "Environment",
size: 130,
enableSorting: false,
cell: ({ row }) => {
const environment = row.original.environment || "development";
return <StatusBadge tone={ENVIRONMENT_TONE[environment] ?? "neutral"} label={environment} />;
},
},
{
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 (
<span className="block max-w-60 truncate text-sm text-muted-foreground" title={createdBy}>
{createdBy || "-"}
</span>
);
},
},
{
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 (
<span className="block max-w-40 truncate text-sm" title={promptType}>
{promptType}
</span>
);
},
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<PromptRowActions prompt={row.original} isAdmin={isAdmin} onDeleteClick={onDeleteClick} />
</div>
),
},
];

View file

@ -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 }) => (
<div data-testid="prompt-table">{isLoading ? "table-loading" : "table-loaded"}</div>
),
}));
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(<PromptsPanel accessToken={null} />);
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(<PromptsPanel accessToken="sk-test" userRole="Admin" />);
expect(screen.getByText("table-loading")).toBeInTheDocument();
resolveFetch({ prompts: [] });
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockGetPromptsList).toHaveBeenCalledWith("sk-test", undefined);
});
});

View file

@ -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<PromptsProps> = ({ accessToken, userRole }) => {
const [promptsList, setPromptsList] = useState<PromptSpec[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [selectedEnvironment, setSelectedEnvironment] = useState<string | undefined>(undefined);
const [selectedPromptId, setSelectedPromptId] = useState<string | null>(null);
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
@ -32,6 +32,7 @@ const PromptsPanel: React.FC<PromptsProps> = ({ accessToken, userRole }) => {
const fetchPrompts = async () => {
if (!accessToken) {
setIsLoading(false);
return;
}

View file

@ -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<PromptTableProps> = ({
promptsList,
isLoading,
onPromptClick,
onDeleteClick,
accessToken,
isAdmin,
}) => {
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
const [modelHubData, setModelHubData] = useState<Map<string, ModelGroupInfo>>(new Map());
useEffect(() => {
const fetchModelHubData = async () => {
if (!accessToken) return;
try {
const response = await modelHubCall(accessToken);
if (response?.data) {
const modelMap = new Map<string, ModelGroupInfo>();
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<PromptSpec>[] = [
{
header: "Prompt ID",
accessorKey: "prompt_id",
cell: (info: any) => <IdCell value={info.getValue()} onClick={onPromptClick} copyable />,
},
{
header: "Model",
accessorKey: "model",
cell: ({ row }) => {
const prompt = row.original;
const model = extractModel(prompt);
if (!model) {
return <span className="text-xs text-gray-400">-</span>;
}
const provider = getProviderFromModelHub(model, modelHubData);
const { logo } = getProviderLogoAndName(provider || "");
return (
<Tooltip title={model}>
<div className="flex items-center space-x-2">
{/* Provider Icon */}
<div className="shrink-0">
{provider && logo ? (
<img
src={logo}
alt={`${provider} logo`}
className="w-4 h-4"
onError={(e) => {
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);
}
}}
/>
) : (
<div className="w-4 h-4 rounded-full bg-gray-200 flex items-center justify-center text-xs">-</div>
)}
</div>
{/* Model Name */}
<span className="max-w-[15ch] truncate block">{model}</span>
</div>
</Tooltip>
);
},
},
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
{
header: "Updated At",
accessorKey: "updated_at",
cell: ({ row }) => <DateCell value={row.original.updated_at} />,
},
{
header: "Environment",
accessorKey: "environment",
cell: ({ row }) => {
const prompt = row.original;
const env = prompt.environment || "development";
const colorMap: Record<string, string> = {
production: "text-red-600 bg-red-50",
staging: "text-yellow-600 bg-yellow-50",
development: "text-green-600 bg-green-50",
};
return (
<span className={`text-xs px-2 py-0.5 rounded-sm ${colorMap[env] || "text-gray-600 bg-gray-50"}`}>{env}</span>
);
},
},
{
header: "Created By",
accessorKey: "created_by",
cell: ({ row }) => {
const prompt = row.original;
return <span className="text-xs text-gray-600">{prompt.created_by || "-"}</span>;
},
},
{
header: "Type",
accessorKey: "prompt_info.prompt_type",
cell: ({ row }) => {
const prompt = row.original;
return (
<Tooltip title={prompt.prompt_info.prompt_type}>
<span className="text-xs">{prompt.prompt_info.prompt_type}</span>
</Tooltip>
);
},
},
...(isAdmin
? [
{
header: "Actions",
id: "actions",
enableSorting: false,
cell: ({ row }: any) => {
const prompt = row.original;
const promptName = prompt.prompt_id || "Unknown Prompt";
return (
<div className="flex items-center gap-1">
<Tooltip title="Delete prompt">
<Button
size="xs"
variant="light"
color="red"
onClick={(e) => {
e.stopPropagation();
onDeleteClick?.(prompt.prompt_id, promptName);
}}
icon={TrashIcon}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
/>
</Tooltip>
</div>
);
},
},
]
: []),
];
const table = useReactTable({
data: promptsList,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className="py-1 h-8"
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</div>
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading...</p>
</div>
</TableCell>
</TableRow>
) : promptsList.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No prompts found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
export default PromptTable;

View file

@ -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;

View file

@ -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 }) => (
<div data-testid="plugin-table">{isLoading ? "table-loading" : "table-loaded"}</div>
),
}));
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(<ClaudeCodePluginsPanel accessToken={null} />);
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(<ClaudeCodePluginsPanel accessToken="sk-test" userRole="Admin" />);
expect(screen.getByText("table-loading")).toBeInTheDocument();
resolveFetch({ plugins: [], count: 0 });
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockGetClaudeCodePluginsList).toHaveBeenCalledWith("sk-test", false);
});
});

View file

@ -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<ClaudeCodePluginsPanelProps> = ({ accessToken, userRole }) => {
const [pluginsList, setPluginsList] = useState<Plugin[]>([]);
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<ClaudeCodePluginsPanelProps> = ({ 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<ClaudeCodePluginsPanelProps> = ({ accessT
pluginsList={pluginsList}
isLoading={isLoading}
onDeleteClick={handleDeleteClick}
accessToken={accessToken}
isAdmin={isAdmin}
onPluginClick={(id) => {
const skill = pluginsList.find((p) => p.id === id);

View file

@ -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(<PluginTable {...defaultProps} />);
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(<PluginTable {...defaultProps} pluginsList={[]} />);
expect(screen.getByText("No skills found")).toBeInTheDocument();
});
it("should sort by created date descending by default", () => {
render(<PluginTable {...defaultProps} />);
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(<PluginTable {...defaultProps} />);
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(<PluginTable {...defaultProps} />);
await user.click(screen.getByText("A skill for testing"));
expect(mockOnPluginClick).not.toHaveBeenCalled();
});
it("should badge the category and fall back to Uncategorized", () => {
render(<PluginTable {...defaultProps} />);
expect(screen.getByText("development")).toBeInTheDocument();
expect(screen.getByText("Uncategorized")).toBeInTheDocument();
});
it("should show whether the skill is public", () => {
render(<PluginTable {...defaultProps} />);
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(<PluginTable {...defaultProps} />);
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(<PluginTable {...defaultProps} />);
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(<PluginTable {...defaultProps} isAdmin={false} />);
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();
});
});

View file

@ -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 (
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No skills found</div>
<div className="text-sm text-muted-foreground">Add one to get started.</div>
</div>
);
}
const PluginTable: React.FC<PluginTableProps> = ({ pluginsList, isLoading, onDeleteClick, isAdmin, onPluginClick }) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(
() => getPluginTableColumns({ isAdmin, onPluginClick, onDeleteClick }),
[isAdmin, onPluginClick, onDeleteClick],
);
return (
<DataTable
data={pluginsList}
columns={columns}
getRowId={(plugin, index) => plugin.id || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading skills…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};
export default PluginTable;

View file

@ -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<ReturnType<typeof getCategoryBadgeColor>, 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 (
<Badge
variant="outline"
className={cn("whitespace-nowrap font-normal", CATEGORY_BADGE_CLASS[getCategoryBadgeColor(category)])}
>
{category || "Uncategorized"}
</Badge>
);
}
interface PluginRowActionsProps {
plugin: Plugin;
isAdmin: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
}
function PluginRowActions({ plugin, isAdmin, onDeleteClick }: PluginRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open skill actions"
data-testid={`plugin-actions-${plugin.name}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
data-testid="plugin-action-copy"
onClick={() => void copyToClipboard(plugin.id, "Skill ID copied")}
>
<Copy />
Copy skill ID
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
data-testid="plugin-action-delete"
onClick={() => onDeleteClick(plugin.name, plugin.name)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface PluginTableColumnsDeps {
isAdmin: boolean;
onPluginClick: (pluginId: string) => void;
onDeleteClick: (pluginName: string, displayName: string) => void;
}
export const getPluginTableColumns = ({
isAdmin,
onPluginClick,
onDeleteClick,
}: PluginTableColumnsDeps): ColumnDef<Plugin>[] => [
{
id: "name",
accessorKey: "name",
meta: { title: "Skill Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Skill Name" />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
<IdentityCell
title={row.original.name}
titleClassName="font-mono text-xs font-normal"
className="max-w-60"
onClick={() => onPluginClick(row.original.id)}
/>
),
},
{
id: "version",
accessorKey: "version",
meta: { title: "Version" },
header: "Version",
size: 100,
enableSorting: false,
cell: ({ row }) => <span className="text-sm text-muted-foreground">{row.original.version || "N/A"}</span>,
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
size: 300,
enableSorting: false,
cell: ({ row }) => {
const description = row.original.description;
return (
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={description}>
{description || "No description"}
</span>
);
},
},
{
id: "category",
accessorKey: "category",
meta: { title: "Category", skeleton: "badge" },
header: "Category",
size: 150,
enableSorting: false,
cell: ({ row }) => <PluginCategoryBadge category={row.original.category} />,
},
{
id: "enabled",
accessorKey: "enabled",
meta: { title: "Public", skeleton: "badge" },
header: "Public",
size: 100,
enableSorting: false,
cell: ({ row }) => (
<StatusBadge tone={row.original.enabled ? "success" : "neutral"} label={row.original.enabled ? "Yes" : "No"} />
),
},
{
id: "created_at",
accessorKey: "created_at",
sortingFn: "datetime",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
size: 160,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<PluginRowActions plugin={row.original} isAdmin={isAdmin} onDeleteClick={onDeleteClick} />
</div>
),
},
];

View file

@ -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<PluginTableProps> = ({
pluginsList,
isLoading,
onDeleteClick,
accessToken,
isAdmin,
onPluginClick,
}) => {
const [sorting, setSorting] = useState<SortingState>([{ id: "created_at", desc: true }]);
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
};
const columns: ColumnDef<Plugin>[] = [
{
header: "Skill Name",
accessorKey: "name",
cell: ({ row }) => {
const plugin = row.original;
return (
<div className="flex items-center gap-2">
<IdCell value={plugin.name} onClick={() => onPluginClick(plugin.id)} />
<Tooltip title="Copy Plugin ID">
<CopyOutlined
onClick={(e) => {
e.stopPropagation();
copyToClipboard(plugin.id);
}}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
);
},
},
{
header: "Version",
accessorKey: "version",
cell: ({ row }) => {
const version = row.original.version || "N/A";
return <span className="text-xs text-gray-600">{version}</span>;
},
},
{
header: "Description",
accessorKey: "description",
cell: ({ row }) => {
const description = row.original.description || "No description";
return (
<Tooltip title={description}>
<span className="text-xs text-gray-600 block max-w-[300px] truncate">{description}</span>
</Tooltip>
);
},
},
{
header: "Category",
accessorKey: "category",
cell: ({ row }) => {
const category = row.original.category;
if (!category) {
return (
<Badge color="gray" className="text-xs font-normal" size="xs">
Uncategorized
</Badge>
);
}
const badgeColor = getCategoryBadgeColor(category);
return (
<Badge color={badgeColor} className="text-xs font-normal" size="xs">
{category}
</Badge>
);
},
},
{
header: "Public",
accessorKey: "enabled",
cell: ({ row }) => {
const plugin = row.original;
return <StatusBadge tone={plugin.enabled ? "success" : "neutral"} label={plugin.enabled ? "Yes" : "No"} />;
},
},
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => <DateCell value={row.original.created_at} />,
},
...(isAdmin
? [
{
header: "Actions",
id: "actions",
enableSorting: false,
cell: ({ row }: any) => {
const plugin = row.original;
return (
<div className="flex items-center gap-1">
<Tooltip title="Delete skill">
<Button
size="xs"
variant="light"
color="red"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(plugin.name, plugin.name);
}}
icon={TrashIcon}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
/>
</Tooltip>
</div>
);
},
},
]
: []),
];
const table = useReactTable({
data: pluginsList,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${
header.id === "actions" ? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" : ""
}`}
onClick={header.column.getCanSort() ? header.column.getToggleSortingHandler() : undefined}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</div>
{header.column.getCanSort() && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading...</p>
</div>
</TableCell>
</TableRow>
) : pluginsList && pluginsList.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="h-8 cursor-pointer hover:bg-gray-50"
onClick={() => onPluginClick(row.original.id)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No skills found. Add one to get started.</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
export default PluginTable;

View file

@ -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 (
<button
data-testid={`action-button-${props.variant.toLowerCase()}`}
data-tooltip={props.tooltipText}
onClick={props.onClick}
disabled={props.disabled}
>
{props.variant}
</button>
);
getProviderLogoAndName: (provider: string) => {
const providerMap: Record<string, { displayName: string; logo: string }> = {
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) => <table {...props}>{children}</table>,
TableHead: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
TableHeaderCell: ({ children, ...props }: any) => <th {...props}>{children}</th>,
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
}));
// Mock antd Tooltip
vi.mock("antd", () => ({
Tooltip: ({ children, title }: any) => (
<div data-testid="tooltip" data-title={title}>
{children}
</div>
),
}));
// Mock Heroicons
vi.mock("@heroicons/react/outline", () => ({
ChevronDownIcon: (props: any) => <div data-testid="chevron-down" {...props} />,
ChevronUpIcon: (props: any) => <div data-testid="chevron-up" {...props} />,
SwitchVerticalIcon: (props: any) => <div data-testid="switch-vertical" {...props} />,
}));
// 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(<VectorStoreTable {...defaultProps} {...props} />);
};
describe("VectorStoreTable", () => {
beforeEach(() => {
vi.clearAllMocks();
// Setup default mock returns for getProviderLogoAndName
mockGetProviderLogoAndName.mockImplementation((provider: string) => {
const providerMap: Record<string, { displayName: string; logo: string }> = {
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(<VectorStoreTable {...defaultProps} />);
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(<VectorStoreTable {...defaultProps} data={[]} />);
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(<VectorStoreTable {...defaultProps} />);
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(<VectorStoreTable {...defaultProps} />);
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(<VectorStoreTable {...defaultProps} />);
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(<VectorStoreTable {...defaultProps} />);
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(<VectorStoreTable {...defaultProps} />);
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(<VectorStoreTable {...defaultProps} />);
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(<VectorStoreTable {...defaultProps} />);
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");
});
});

View file

@ -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<VectorStoreTableProps> = ({ data, onView, onEdit, onDelete }) => {
const [sorting, setSorting] = React.useState<SortingState>([{ id: "created_at", desc: true }]);
const columns: ColumnDef<VectorStore>[] = [
{
header: "Vector Store ID",
accessorKey: "vector_store_id",
cell: ({ row }) => <IdCell value={row.original.vector_store_id} onClick={onView} />,
},
{
header: "Name",
accessorKey: "vector_store_name",
cell: ({ row }) => {
const vectorStore = row.original;
return (
<Tooltip title={vectorStore.vector_store_name}>
<span className="text-xs">{vectorStore.vector_store_name || "-"}</span>
</Tooltip>
);
},
},
{
header: "Description",
accessorKey: "vector_store_description",
cell: ({ row }) => {
const vectorStore = row.original;
return (
<Tooltip title={vectorStore.vector_store_description}>
<span className="text-xs">{vectorStore.vector_store_description || "-"}</span>
</Tooltip>
);
},
},
{
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 <span className="text-xs text-gray-400">-</span>;
}
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 (
<Tooltip title={filenames}>
<span className="text-xs text-blue-600">{displayText}</span>
</Tooltip>
);
},
},
{
header: "Provider",
accessorKey: "custom_llm_provider",
cell: ({ row }) => {
const vectorStore = row.original;
const { displayName, logo } = getProviderLogoAndName(vectorStore.custom_llm_provider);
return (
<div className="flex items-center space-x-2">
{logo && <img src={logo} alt={displayName} className="h-4 w-4" />}
<span className="text-xs">{displayName}</span>
</div>
);
},
},
{
header: "Created At",
accessorKey: "created_at",
sortingFn: "datetime",
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
header: "Updated At",
accessorKey: "updated_at",
sortingFn: "datetime",
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const vectorStore = row.original;
return (
<div className="flex space-x-2">
<TableIconActionButton
variant="Edit"
tooltipText="Edit vector store"
onClick={() => onEdit(vectorStore.vector_store_id)}
/>
<TableIconActionButton
variant="Delete"
tooltipText="Delete vector store"
onClick={() => onDelete(vectorStore.vector_store_id)}
/>
</div>
);
},
},
];
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 (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${
header.id === "actions" ? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" : ""
}`}
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</div>
{header.id !== "actions" && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No vector stores found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<div className="flex flex-col items-center gap-1 py-6">
<div className="mb-1 flex size-10 items-center justify-center rounded-lg bg-muted">
<Inbox className="size-5 text-muted-foreground" />
</div>
<div className="text-sm font-medium text-foreground">No vector stores</div>
<div className="text-sm text-muted-foreground">
Connect a vector store to enable retrieval-augmented generation.
</div>
</div>
);
}
const VectorStoreTable: React.FC<VectorStoreTableProps> = ({ data, onView, onEdit, onDelete, isLoading = false }) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const columns = useMemo(() => getVectorStoreTableColumns({ onView, onEdit, onDelete }), [onView, onEdit, onDelete]);
return (
<DataTable
data={data}
columns={columns}
getRowId={(vectorStore, index) => vectorStore.vector_store_id || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading vector stores…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};
export default VectorStoreTable;

View file

@ -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 (
<div className="flex items-center gap-2">
{logo ? (
<img
src={logo}
alt=""
className="size-4 shrink-0"
onError={(event) => {
(event.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : null}
<span className="truncate text-sm">{displayName}</span>
</div>
);
}
function VectorStoreFilesCell({ vectorStore }: { vectorStore: VectorStore }) {
const ingestedFiles = vectorStore.vector_store_metadata?.ingested_files || [];
if (ingestedFiles.length === 0) {
return <span className="text-sm text-muted-foreground">-</span>;
}
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 (
<CellTooltip
content={filenames}
trigger={<span className="block max-w-60 truncate text-sm text-primary">{displayText}</span>}
/>
);
}
interface VectorStoreRowActionsProps {
vectorStore: VectorStore;
onEdit: (vectorStoreId: string) => void;
onDelete: (vectorStoreId: string) => void;
}
function VectorStoreRowActions({ vectorStore, onEdit, onDelete }: VectorStoreRowActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open vector store actions"
data-testid={`vector-store-actions-${vectorStore.vector_store_id}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem data-testid="vector-store-action-edit" onClick={() => onEdit(vectorStore.vector_store_id)}>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
data-testid="vector-store-action-copy"
onClick={() => void copyToClipboard(vectorStore.vector_store_id, "Vector store ID copied")}
>
<Copy />
Copy vector store ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
data-testid="vector-store-action-delete"
onClick={() => onDelete(vectorStore.vector_store_id)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface VectorStoreTableColumnsDeps {
onView: (vectorStoreId: string) => void;
onEdit: (vectorStoreId: string) => void;
onDelete: (vectorStoreId: string) => void;
}
export const getVectorStoreTableColumns = ({
onView,
onEdit,
onDelete,
}: VectorStoreTableColumnsDeps): ColumnDef<VectorStore>[] => [
{
id: "vector_store_id",
accessorKey: "vector_store_id",
meta: { title: "Vector Store ID" },
header: ({ column }) => <DataTableSortHeader column={column} title="Vector Store ID" />,
size: 220,
enableSorting: true,
cell: ({ row }) => (
<IdentityCell
title={row.original.vector_store_id}
titleClassName="font-mono text-xs font-normal"
className="max-w-60"
onClick={() => onView(row.original.vector_store_id)}
/>
),
},
{
id: "vector_store_name",
accessorKey: "vector_store_name",
meta: { title: "Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
size: 200,
enableSorting: true,
cell: ({ row }) => {
const name = row.original.vector_store_name;
return (
<span className="block max-w-60 truncate text-sm font-medium" title={name ?? undefined}>
{name || "-"}
</span>
);
},
},
{
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 (
<span className="block max-w-72 truncate text-sm text-muted-foreground" title={description ?? undefined}>
{description || "-"}
</span>
);
},
},
{
id: "files",
meta: { title: "Files" },
header: "Files",
size: 160,
enableSorting: false,
cell: ({ row }) => <VectorStoreFilesCell vectorStore={row.original} />,
},
{
id: "provider",
accessorKey: "custom_llm_provider",
meta: { title: "Provider" },
header: "Provider",
size: 160,
enableSorting: false,
cell: ({ row }) => <VectorStoreProviderCell provider={row.original.custom_llm_provider} />,
},
{
id: "created_at",
accessorKey: "created_at",
sortingFn: "datetime",
meta: { title: "Created At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
size: 150,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "updated_at",
accessorKey: "updated_at",
sortingFn: "datetime",
meta: { title: "Updated At" },
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
size: 150,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<VectorStoreRowActions vectorStore={row.original} onEdit={onEdit} onDelete={onDelete} />
</div>
),
},
];

View file

@ -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 }) => (
<div data-testid="vector-store-table">{isLoading ? "table-loading" : "table-loaded"}</div>
),
}));
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(<VectorStoreManagement accessToken={null} userID={null} userRole={null} />);
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(<VectorStoreManagement accessToken="sk-test" userID="user-1" userRole="Admin" />);
expect(screen.getByText("table-loading")).toBeInTheDocument();
resolveFetch({ data: [] });
expect(await screen.findByText("table-loaded")).toBeInTheDocument();
expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test");
});
});

View file

@ -36,6 +36,7 @@ interface VectorStoreProps {
const VectorStoreManagement: React.FC<VectorStoreProps> = ({ accessToken, userID, userRole }) => {
const [vectorStores, setVectorStores] = useState<VectorStore[]>([]);
const [isLoadingVectorStores, setIsLoadingVectorStores] = useState(true);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [vectorStoreToDelete, setVectorStoreToDelete] = useState<string | null>(null);
@ -46,13 +47,18 @@ const VectorStoreManagement: React.FC<VectorStoreProps> = ({ 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<VectorStoreProps> = ({ accessToken, userID
<Col numColSpan={1}>
<VectorStoreTable
data={vectorStores}
isLoading={isLoadingVectorStores}
onView={handleView}
onEdit={handleEdit}
onDelete={handleDelete}