mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
commit
69a491e168
19 changed files with 1251 additions and 1112 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue