Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 0 additions & 21 deletions ui/litellm-dashboard/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -1098,14 +1098,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
Expand Down Expand Up @@ -1153,14 +1145,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
Expand Down Expand Up @@ -1320,11 +1304,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
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading