mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Merge pull request #33357 from BerriAI/litellm_/gallant-carson-880e37
refactor(ui): migrate policies, deleted keys, deleted teams, budgets, and search tools tables onto shared DataTable
This commit is contained in:
parent
ff06119aa9
commit
dce1beadca
27 changed files with 1485 additions and 1409 deletions
|
|
@ -950,19 +950,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/policy_table.test.tsx": {
|
||||
"react/display-name": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/policy_table.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 2
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/policies/_components/policy_test_panel.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
@ -1448,22 +1435,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx": {
|
||||
"no-nested-ternary": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/EntityUsageExport/ExportSummary.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import BudgetTable from "./BudgetTable";
|
||||
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
|
||||
const makeBudget = (overrides: Partial<budgetItem> = {}): budgetItem => ({
|
||||
budget_id: "budget-1",
|
||||
max_budget: 100,
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 10,
|
||||
updated_at: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
budgets: [makeBudget()],
|
||||
isLoading: false,
|
||||
canModify: true,
|
||||
onEditClick: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
};
|
||||
|
||||
describe("BudgetTable", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should display budget information", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} />);
|
||||
expect(screen.getByText("budget-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("$100.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("1000")).toBeInTheDocument();
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show n/a for missing rate limits and Unlimited for a missing max budget", () => {
|
||||
renderWithProviders(
|
||||
<BudgetTable {...defaultProps} budgets={[makeBudget({ max_budget: null, tpm_limit: null, rpm_limit: null })]} />,
|
||||
);
|
||||
expect(screen.getAllByText("n/a")).toHaveLength(2);
|
||||
expect(screen.getByText("Unlimited")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort budgets by updated_at descending", () => {
|
||||
const budgets = [
|
||||
makeBudget({ budget_id: "budget-old", updated_at: "2024-01-01T00:00:00Z" }),
|
||||
makeBudget({ budget_id: "budget-new", updated_at: "2024-06-01T00:00:00Z" }),
|
||||
];
|
||||
renderWithProviders(<BudgetTable {...defaultProps} budgets={budgets} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("budget-new")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("budget-old")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onEditClick from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} />);
|
||||
await user.click(screen.getByTestId("budget-actions-budget-1"));
|
||||
await user.click(await screen.findByTestId("budget-action-edit"));
|
||||
expect(defaultProps.onEditClick).toHaveBeenCalledWith(defaultProps.budgets[0]);
|
||||
});
|
||||
|
||||
it("should call onDeleteClick from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<BudgetTable {...defaultProps} />);
|
||||
await user.click(screen.getByTestId("budget-actions-budget-1"));
|
||||
await user.click(await screen.findByTestId("budget-action-delete"));
|
||||
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith(defaultProps.budgets[0]);
|
||||
});
|
||||
|
||||
it("should not render the actions menu when the user cannot modify budgets", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} canModify={false} />);
|
||||
expect(screen.queryByTestId("budget-actions-budget-1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show skeleton rows when loading", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} budgets={[]} isLoading />);
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show the empty state when there are no budgets", () => {
|
||||
renderWithProviders(<BudgetTable {...defaultProps} budgets={[]} />);
|
||||
expect(screen.getByText("No budgets yet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { Inbox } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
|
||||
import { getBudgetTableColumns } from "./BudgetTableColumns";
|
||||
|
||||
interface BudgetTableProps {
|
||||
budgets: budgetItem[];
|
||||
isLoading: boolean;
|
||||
canModify: boolean;
|
||||
onEditClick: (budget: budgetItem) => void;
|
||||
onDeleteClick: (budget: budgetItem) => void;
|
||||
}
|
||||
|
||||
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 budgets yet</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Create a budget to set spend, TPM and RPM limits for customers.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BudgetTable: React.FC<BudgetTableProps> = ({ budgets, isLoading, canModify, onEditClick, onDeleteClick }) => {
|
||||
const rows = useMemo(
|
||||
() => [...budgets].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()),
|
||||
[budgets],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => getBudgetTableColumns({ canModify, onEditClick, onDeleteClick }),
|
||||
[canModify, onEditClick, onDeleteClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
getRowId={(budget, index) => budget.budget_id || String(index)}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading budgets…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BudgetTable;
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
function RateLimitCell({ value }: { value: number | null }) {
|
||||
if (value == null) {
|
||||
return <span className="text-muted-foreground">n/a</span>;
|
||||
}
|
||||
return <span className="tabular-nums">{value}</span>;
|
||||
}
|
||||
|
||||
interface BudgetRowActionsProps {
|
||||
budget: budgetItem;
|
||||
onEditClick: (budget: budgetItem) => void;
|
||||
onDeleteClick: (budget: budgetItem) => void;
|
||||
}
|
||||
|
||||
function BudgetRowActions({ budget, onEditClick, onDeleteClick }: BudgetRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open budget actions"
|
||||
data-testid={`budget-actions-${budget.budget_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="budget-action-edit" onClick={() => onEditClick(budget)}>
|
||||
<Pencil />
|
||||
Edit budget
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="budget-action-delete"
|
||||
onClick={() => onDeleteClick(budget)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete budget
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface BudgetTableColumnsDeps {
|
||||
canModify: boolean;
|
||||
onEditClick: (budget: budgetItem) => void;
|
||||
onDeleteClick: (budget: budgetItem) => void;
|
||||
}
|
||||
|
||||
export const getBudgetTableColumns = ({
|
||||
canModify,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}: BudgetTableColumnsDeps): ColumnDef<budgetItem>[] => [
|
||||
{
|
||||
id: "budget_id",
|
||||
accessorKey: "budget_id",
|
||||
meta: { title: "Budget ID" },
|
||||
header: "Budget ID",
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.budget_id} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
meta: { title: "Max Budget", numeric: true },
|
||||
header: "Max Budget",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={2} showZero emptyText="Unlimited" />,
|
||||
},
|
||||
{
|
||||
id: "tpm_limit",
|
||||
accessorKey: "tpm_limit",
|
||||
meta: { title: "TPM", numeric: true },
|
||||
header: "TPM",
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <RateLimitCell value={row.original.tpm_limit} />,
|
||||
},
|
||||
{
|
||||
id: "rpm_limit",
|
||||
accessorKey: "rpm_limit",
|
||||
meta: { title: "RPM", numeric: true },
|
||||
header: "RPM",
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <RateLimitCell value={row.original.rpm_limit} />,
|
||||
},
|
||||
...(canModify
|
||||
? [
|
||||
{
|
||||
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">
|
||||
<BudgetRowActions budget={row.original} onEditClick={onEditClick} onDeleteClick={onDeleteClick} />
|
||||
</div>
|
||||
),
|
||||
} satisfies ColumnDef<budgetItem>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { fireEvent, render, waitFor, screen } from "@testing-library/react";
|
||||
import { act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import BudgetPanel from "./budget_panel";
|
||||
|
|
@ -57,7 +58,8 @@ describe("Budget Panel", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should open delete modal when clicking delete icon", async () => {
|
||||
it("should open delete modal from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
|
|
@ -77,11 +79,8 @@ describe("Budget Panel", () => {
|
|||
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByTestId("delete-budget-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(deleteButton);
|
||||
});
|
||||
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
|
||||
await user.click(await screen.findByTestId("budget-action-delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
|
||||
|
|
@ -89,6 +88,7 @@ describe("Budget Panel", () => {
|
|||
});
|
||||
|
||||
it("should successfully delete a budget", async () => {
|
||||
const user = userEvent.setup();
|
||||
const deleteMutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [
|
||||
|
|
@ -113,17 +113,13 @@ describe("Budget Panel", () => {
|
|||
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open delete modal
|
||||
const deleteButton = screen.getByTestId("delete-budget-button");
|
||||
act(() => {
|
||||
fireEvent.click(deleteButton);
|
||||
});
|
||||
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
|
||||
await user.click(await screen.findByTestId("budget-action-delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Confirm delete
|
||||
const confirmButton = screen.getByRole("button", { name: /delete/i });
|
||||
act(() => {
|
||||
fireEvent.click(confirmButton);
|
||||
|
|
@ -148,6 +144,7 @@ describe("Budget Panel", () => {
|
|||
});
|
||||
|
||||
it("should handle delete error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const deleteMutateAsync = vi.fn().mockRejectedValue(new Error("Delete failed"));
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [
|
||||
|
|
@ -172,17 +169,13 @@ describe("Budget Panel", () => {
|
|||
expect(screen.getByText("budget-to-delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open delete modal
|
||||
const deleteButton = screen.getByTestId("delete-budget-button");
|
||||
act(() => {
|
||||
fireEvent.click(deleteButton);
|
||||
});
|
||||
await user.click(screen.getByTestId("budget-actions-budget-to-delete"));
|
||||
await user.click(await screen.findByTestId("budget-action-delete"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Delete Budget?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Confirm delete
|
||||
const confirmButton = screen.getByRole("button", { name: /delete/i });
|
||||
act(() => {
|
||||
fireEvent.click(confirmButton);
|
||||
|
|
@ -193,7 +186,8 @@ describe("Budget Panel", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should open edit modal when clicking edit icon", async () => {
|
||||
it("should open edit modal from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(useBudgets).mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
|
|
@ -213,11 +207,8 @@ describe("Budget Panel", () => {
|
|||
expect(screen.getByText("budget-to-edit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const editButton = screen.getByTestId("edit-budget-button");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(editButton);
|
||||
});
|
||||
await user.click(screen.getByTestId("budget-actions-budget-to-edit"));
|
||||
await user.click(await screen.findByTestId("budget-action-edit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Edit Budget")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -3,30 +3,14 @@
|
|||
*
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Tab,
|
||||
TabGroup,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
} from "@tremor/react";
|
||||
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text } from "@tremor/react";
|
||||
import React, { useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import BudgetModal from "./budget_modal";
|
||||
import BudgetTable from "./BudgetTable";
|
||||
import EditBudgetModal from "./edit_budget_modal";
|
||||
import { CREATE_END_USER_CURL_COMMAND, CHAT_COMPLETIONS_CURL_COMMAND, OPENAI_SDK_PYTHON_CODE } from "./constants";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
|
@ -46,7 +30,7 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
// Admin Viewer follows the read-parity rule: see budgets, no writes.
|
||||
const canModify = isProxyAdminRole(userRole ?? "");
|
||||
|
||||
const { data: budgetList = [] } = useBudgets();
|
||||
const { data: budgetList = [], isLoading } = useBudgets();
|
||||
const deleteBudget = useDeleteBudget();
|
||||
|
||||
const handleEditCall = async (budget: budgetItem) => {
|
||||
|
|
@ -109,51 +93,14 @@ const BudgetPanel: React.FC<BudgetSettingsPageProps> = ({ accessToken }) => {
|
|||
existingBudget={selectedBudget}
|
||||
/>
|
||||
)}
|
||||
<Card>
|
||||
<Text>Create a budget to assign to customers.</Text>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Budget ID</TableHeaderCell>
|
||||
<TableHeaderCell>Max Budget</TableHeaderCell>
|
||||
<TableHeaderCell>TPM</TableHeaderCell>
|
||||
<TableHeaderCell>RPM</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{budgetList
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
.map((value: budgetItem) => (
|
||||
<TableRow key={value.budget_id}>
|
||||
<TableCell>{value.budget_id}</TableCell>
|
||||
<TableCell>
|
||||
<MoneyCell value={value.max_budget} decimals={2} showZero emptyText="Unlimited" />
|
||||
</TableCell>
|
||||
<TableCell>{value.tpm_limit ? value.tpm_limit : "n/a"}</TableCell>
|
||||
<TableCell>{value.rpm_limit ? value.rpm_limit : "n/a"}</TableCell>
|
||||
{canModify && (
|
||||
<>
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit budget"
|
||||
onClick={() => handleEditCall(value)}
|
||||
dataTestId="edit-budget-button"
|
||||
/>
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete budget"
|
||||
onClick={() => handleDeleteClick(value)}
|
||||
dataTestId="delete-budget-button"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
<Text className="mb-4">Create a budget to assign to customers.</Text>
|
||||
<BudgetTable
|
||||
budgets={budgetList}
|
||||
isLoading={isLoading}
|
||||
canModify={canModify}
|
||||
onEditClick={handleEditCall}
|
||||
onDeleteClick={handleDeleteClick}
|
||||
/>
|
||||
<DeleteResourceModal
|
||||
isOpen={isDeleteModalVisible}
|
||||
title="Delete Budget?"
|
||||
|
|
|
|||
|
|
@ -118,10 +118,10 @@ export const useDeletedKeys = (
|
|||
page: number,
|
||||
pageSize: number,
|
||||
options: KeyListCallOptions = {},
|
||||
): UseQueryResult<KeysResponse> => {
|
||||
): UseQueryResult<DeletedKeysResponse> => {
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
return useQuery<KeysResponse>({
|
||||
return useQuery<DeletedKeysResponse>({
|
||||
queryKey: deletedKeyKeys.list({ page, limit: pageSize, ...options }),
|
||||
queryFn: async () => await keyListCall(accessToken!, page, pageSize, { ...options, status: "deleted" }),
|
||||
enabled: Boolean(accessToken),
|
||||
|
|
|
|||
|
|
@ -1,47 +1,11 @@
|
|||
import React from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import PolicyTable from "./policy_table";
|
||||
import PolicyTable from "./PolicyTable";
|
||||
import { Policy } from "@/components/policies/types";
|
||||
|
||||
vi.mock("@heroicons/react/outline", () => ({
|
||||
TrashIcon: function TrashIcon() {
|
||||
return null;
|
||||
},
|
||||
PencilIcon: function PencilIcon() {
|
||||
return null;
|
||||
},
|
||||
SwitchVerticalIcon: function SwitchVerticalIcon() {
|
||||
return null;
|
||||
},
|
||||
ChevronUpIcon: function ChevronUpIcon() {
|
||||
return null;
|
||||
},
|
||||
ChevronDownIcon: function ChevronDownIcon() {
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tremor/react")>();
|
||||
return {
|
||||
...actual,
|
||||
Button: React.forwardRef<HTMLButtonElement, any>(({ children, ...props }, ref) =>
|
||||
React.createElement("button", { ...props, ref }, children),
|
||||
),
|
||||
Icon: ({ icon: IconComp, onClick, className }: any) =>
|
||||
React.createElement(
|
||||
"button",
|
||||
{ type: "button", onClick, className },
|
||||
IconComp?.displayName ?? IconComp?.name ?? "icon",
|
||||
),
|
||||
Tooltip: ({ children }: { children?: React.ReactNode }) => React.createElement(React.Fragment, null, children),
|
||||
Badge: ({ children }: { children?: React.ReactNode }) => React.createElement("span", null, children),
|
||||
};
|
||||
});
|
||||
|
||||
const makePolicy = (overrides: Partial<Policy> = {}): Policy => ({
|
||||
policy_id: "policy-id-1",
|
||||
policy_name: "test-policy",
|
||||
|
|
@ -71,20 +35,21 @@ describe("PolicyTable", () => {
|
|||
renderWithProviders(<PolicyTable {...defaultProps} />);
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("Guardrails (Add)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a loading message when isLoading is true", () => {
|
||||
it("should show skeleton rows when isLoading is true", () => {
|
||||
renderWithProviders(<PolicyTable {...defaultProps} isLoading />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show 'No policies found' when there are no policies", () => {
|
||||
it("should show the empty state when there are no policies", () => {
|
||||
renderWithProviders(<PolicyTable {...defaultProps} />);
|
||||
expect(screen.getByText(/no policies found/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("No policies found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a button with the policy name for each grouped policy", () => {
|
||||
it("should render a clickable name cell for each grouped policy", () => {
|
||||
const policies = [
|
||||
makePolicy({ policy_name: "alpha-policy", policy_id: "id-1" }),
|
||||
makePolicy({ policy_name: "beta-policy", policy_id: "id-2" }),
|
||||
|
|
@ -94,7 +59,18 @@ describe("PolicyTable", () => {
|
|||
expect(screen.getByRole("button", { name: "beta-policy" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onViewClick with the policy_id when the policy name button is clicked", async () => {
|
||||
it("should sort rows by policy name ascending by default", () => {
|
||||
const policies = [
|
||||
makePolicy({ policy_name: "zeta-policy", policy_id: "id-z" }),
|
||||
makePolicy({ policy_name: "alpha-policy", policy_id: "id-a" }),
|
||||
];
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={policies} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("alpha-policy")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("zeta-policy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onViewClick with the policy_id when the policy name is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const policy = makePolicy({ policy_name: "my-policy", policy_id: "view-id-1" });
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} />);
|
||||
|
|
@ -102,36 +78,46 @@ describe("PolicyTable", () => {
|
|||
expect(defaultProps.onViewClick).toHaveBeenCalledWith("view-id-1");
|
||||
});
|
||||
|
||||
it("should call onDeleteClick with policy_id and policy_name when the delete icon is clicked", async () => {
|
||||
it("should call onDeleteClick with policy_id and policy_name from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const policy = makePolicy({ policy_name: "del-policy", policy_id: "del-id-1" });
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} />);
|
||||
await user.click(screen.getByRole("button", { name: /TrashIcon/i }));
|
||||
await user.click(screen.getByTestId("policy-actions-del-id-1"));
|
||||
await user.click(await screen.findByTestId("policy-action-delete"));
|
||||
expect(defaultProps.onDeleteClick).toHaveBeenCalledWith("del-id-1", "del-policy");
|
||||
});
|
||||
|
||||
it("should call onEditClick with the policy when the edit icon is clicked", async () => {
|
||||
it("should call onEditClick with the policy from the actions menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
const policy = makePolicy({ policy_name: "edit-policy", policy_id: "edit-id-1" });
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} />);
|
||||
await user.click(screen.getByRole("button", { name: /PencilIcon/i }));
|
||||
await user.click(screen.getByTestId("policy-actions-edit-id-1"));
|
||||
await user.click(await screen.findByTestId("policy-action-edit"));
|
||||
expect(defaultProps.onEditClick).toHaveBeenCalledWith(policy);
|
||||
});
|
||||
|
||||
it("should not show admin action icons for non-admins", () => {
|
||||
it("should not show the actions menu for non-admins", () => {
|
||||
const policy = makePolicy();
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} isAdmin={false} />);
|
||||
expect(screen.queryByRole("button", { name: /TrashIcon/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /PencilIcon/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(`policy-actions-${policy.policy_id}`)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a version badge when multiple versions of the same policy name exist", () => {
|
||||
const policies = [
|
||||
makePolicy({ policy_name: "versioned", policy_id: "v1", version_status: "published", version_number: 1 }),
|
||||
makePolicy({ policy_name: "versioned", policy_id: "v2", version_status: "production", version_number: 2 }),
|
||||
];
|
||||
const publishedVersion: Partial<Policy> = {
|
||||
policy_name: "versioned",
|
||||
policy_id: "v1",
|
||||
version_status: "published",
|
||||
version_number: 1,
|
||||
};
|
||||
const productionVersion: Partial<Policy> = {
|
||||
policy_name: "versioned",
|
||||
policy_id: "v2",
|
||||
version_status: "production",
|
||||
version_number: 2,
|
||||
};
|
||||
const policies = [makePolicy(publishedVersion), makePolicy(productionVersion)];
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={policies} />);
|
||||
expect(screen.getByText(/2 version/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("2 versions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should group policies with the same name into a single row", () => {
|
||||
|
|
@ -140,10 +126,10 @@ describe("PolicyTable", () => {
|
|||
makePolicy({ policy_name: "shared", policy_id: "s2", version_status: "production" }),
|
||||
];
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={policies} />);
|
||||
expect(screen.getAllByRole("button", { name: "shared" })).toHaveLength(1);
|
||||
expect(screen.getAllByText("shared")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should show an overflow tag when more than 2 guardrails_add exist", () => {
|
||||
it("should show an overflow badge when more than 2 guardrails_add exist", () => {
|
||||
const policy = makePolicy({ guardrails_add: ["g1", "g2", "g3", "g4"] });
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={[policy]} />);
|
||||
expect(screen.getByText("+2")).toBeInTheDocument();
|
||||
|
|
@ -156,7 +142,7 @@ describe("PolicyTable", () => {
|
|||
makePolicy({ policy_name: "grouped", policy_id: "prod-id", version_status: "production" }),
|
||||
];
|
||||
renderWithProviders(<PolicyTable {...defaultProps} policies={policies} />);
|
||||
await user.click(screen.getByRole("button", { name: "grouped" }));
|
||||
await user.click(screen.getByRole("button", { name: /grouped/ }));
|
||||
expect(defaultProps.onViewClick).toHaveBeenCalledWith("prod-id");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"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 { Policy } from "@/components/policies/types";
|
||||
|
||||
import { getPolicyTableColumns, PolicyRow } from "./PolicyTableColumns";
|
||||
|
||||
/** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */
|
||||
function groupPoliciesByName(policies: Policy[]): PolicyRow[] {
|
||||
const names = Array.from(new Set(policies.map((policy) => policy.policy_name || "(unnamed)")));
|
||||
return names.map((policyName) => {
|
||||
const versions = policies.filter((policy) => (policy.policy_name || "(unnamed)") === policyName);
|
||||
const primary =
|
||||
versions.find((version) => version.version_status === "production") ??
|
||||
[...versions].sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0))[0];
|
||||
return { policy_name: policyName, primaryPolicy: primary, versionCount: versions.length };
|
||||
});
|
||||
}
|
||||
|
||||
interface PolicyTableProps {
|
||||
policies: Policy[];
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (policyId: string, policyName: string) => void;
|
||||
onEditClick: (policy: Policy) => void;
|
||||
onViewClick: (policyId: string) => void;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "policy_name", desc: false }];
|
||||
|
||||
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 policies found</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Create a policy to bundle guardrails and apply them across teams.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PolicyTable: React.FC<PolicyTableProps> = ({
|
||||
policies,
|
||||
isLoading,
|
||||
onDeleteClick,
|
||||
onEditClick,
|
||||
onViewClick,
|
||||
isAdmin = false,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const rows = useMemo(() => groupPoliciesByName(policies), [policies]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { isAdmin, onViewClick, onEditClick, onDeleteClick };
|
||||
return getPolicyTableColumns(deps);
|
||||
}, [isAdmin, onViewClick, onEditClick, onDeleteClick]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.policy_name}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading policies…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolicyTable;
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Policy } from "@/components/policies/types";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
export interface PolicyRow {
|
||||
policy_name: string;
|
||||
primaryPolicy: Policy;
|
||||
versionCount: number;
|
||||
}
|
||||
|
||||
function GuardrailChips({ guardrails, tone }: { guardrails: string[]; tone: "success" | "error" }) {
|
||||
if (guardrails.length === 0) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{guardrails.slice(0, 2).map((guardrail) => (
|
||||
<StatusBadge key={guardrail} tone={tone} label={guardrail} />
|
||||
))}
|
||||
{guardrails.length > 2 && (
|
||||
<StatusBadge tone="neutral" label={`+${guardrails.length - 2}`} tooltip={guardrails.slice(2).join(", ")} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PolicyRowActionsProps {
|
||||
policy: Policy;
|
||||
onEditClick: (policy: Policy) => void;
|
||||
onDeleteClick: (policyId: string, policyName: string) => void;
|
||||
}
|
||||
|
||||
function PolicyRowActions({ policy, onEditClick, onDeleteClick }: PolicyRowActionsProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open policy actions"
|
||||
data-testid={`policy-actions-${policy.policy_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="policy-action-edit" onClick={() => onEditClick(policy)}>
|
||||
<Pencil />
|
||||
Edit policy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
data-testid="policy-action-delete"
|
||||
onClick={() => onDeleteClick(policy.policy_id, policy.policy_name || "Unnamed Policy")}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete policy
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface PolicyTableColumnsDeps {
|
||||
isAdmin: boolean;
|
||||
onViewClick: (policyId: string) => void;
|
||||
onEditClick: (policy: Policy) => void;
|
||||
onDeleteClick: (policyId: string, policyName: string) => void;
|
||||
}
|
||||
|
||||
export const getPolicyTableColumns = ({
|
||||
isAdmin,
|
||||
onViewClick,
|
||||
onEditClick,
|
||||
onDeleteClick,
|
||||
}: PolicyTableColumnsDeps): ColumnDef<PolicyRow>[] => [
|
||||
{
|
||||
id: "policy_name",
|
||||
accessorKey: "policy_name",
|
||||
meta: { title: "Name", skeleton: "twoLine" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
size: 220,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<IdentityCell
|
||||
title={row.original.policy_name}
|
||||
titleClassName="max-w-60"
|
||||
badge={
|
||||
row.original.versionCount > 1 ? (
|
||||
<StatusBadge tone="neutral" label={`${row.original.versionCount} versions`} />
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => onViewClick(row.original.primaryPolicy.policy_id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "description",
|
||||
accessorFn: (row) => row.primaryPolicy.description ?? "",
|
||||
meta: { title: "Description" },
|
||||
header: "Description",
|
||||
size: 220,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const description = row.original.primaryPolicy.description;
|
||||
if (!description) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-60 truncate text-muted-foreground" title={description}>
|
||||
{description}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "inherit",
|
||||
accessorFn: (row) => row.primaryPolicy.inherit ?? "",
|
||||
meta: { title: "Inherits From", skeleton: "badge" },
|
||||
header: "Inherits From",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const inherit = row.original.primaryPolicy.inherit;
|
||||
if (!inherit) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return <StatusBadge tone="info" label={inherit} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "guardrails_add",
|
||||
meta: { title: "Guardrails (Add)", skeleton: "chips" },
|
||||
header: "Guardrails (Add)",
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <GuardrailChips guardrails={row.original.primaryPolicy.guardrails_add ?? []} tone="success" />,
|
||||
},
|
||||
{
|
||||
id: "guardrails_remove",
|
||||
meta: { title: "Guardrails (Remove)", skeleton: "chips" },
|
||||
header: "Guardrails (Remove)",
|
||||
size: 180,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <GuardrailChips guardrails={row.original.primaryPolicy.guardrails_remove ?? []} tone="error" />,
|
||||
},
|
||||
{
|
||||
id: "model_condition",
|
||||
meta: { title: "Model Condition" },
|
||||
header: "Model Condition",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const model = row.original.primaryPolicy.condition?.model;
|
||||
if (!model) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<code className="block max-w-40 truncate rounded-sm bg-muted px-1 py-0.5 font-mono text-xs" title={model}>
|
||||
{model}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.primaryPolicy.created_at ?? "",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
size: 150,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.primaryPolicy.created_at} />,
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
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">
|
||||
<PolicyRowActions
|
||||
policy={row.original.primaryPolicy}
|
||||
onEditClick={onEditClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
} satisfies ColumnDef<PolicyRow>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
|
@ -5,7 +5,7 @@ import { Alert } from "antd";
|
|||
import MessageManager from "@/components/molecules/message_manager";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { isAdminRole } from "@/utils/roles";
|
||||
import PolicyTable from "./policy_table";
|
||||
import PolicyTable from "./PolicyTable";
|
||||
import PolicyInfoView from "./policy_info";
|
||||
import AddPolicyForm from "./add_policy_form";
|
||||
import { FlowBuilderPage } from "./pipeline_flow_builder";
|
||||
|
|
|
|||
|
|
@ -1,326 +0,0 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon, Button, Badge } from "@tremor/react";
|
||||
import { TrashIcon, PencilIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline";
|
||||
import { Tooltip, Tag } from "antd";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { DateCell } from "@/components/shared/table_cells";
|
||||
import { Policy } from "@/components/policies/types";
|
||||
|
||||
/** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */
|
||||
interface PolicyRow {
|
||||
policy_name: string;
|
||||
primaryPolicy: Policy;
|
||||
versionCount: number;
|
||||
}
|
||||
|
||||
function groupPoliciesByName(policies: Policy[]): PolicyRow[] {
|
||||
const byName = new Map<string, Policy[]>();
|
||||
for (const p of policies) {
|
||||
const name = p.policy_name || "(unnamed)";
|
||||
if (!byName.has(name)) byName.set(name, []);
|
||||
byName.get(name)!.push(p);
|
||||
}
|
||||
const rows: PolicyRow[] = [];
|
||||
for (const [policyName, versions] of byName) {
|
||||
// Prefer production, then highest version_number
|
||||
const primary =
|
||||
versions.find((v) => v.version_status === "production") ??
|
||||
[...versions].sort((a, b) => (b.version_number ?? 0) - (a.version_number ?? 0))[0] ??
|
||||
versions[0];
|
||||
rows.push({ policy_name: policyName, primaryPolicy: primary, versionCount: versions.length });
|
||||
}
|
||||
return rows.sort((a, b) => a.policy_name.localeCompare(b.policy_name));
|
||||
}
|
||||
|
||||
interface PolicyTableProps {
|
||||
policies: Policy[];
|
||||
isLoading: boolean;
|
||||
onDeleteClick: (policyId: string, policyName: string) => void;
|
||||
onEditClick: (policy: Policy) => void;
|
||||
onViewClick: (policyId: string) => void;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
const PolicyTable: React.FC<PolicyTableProps> = ({
|
||||
policies,
|
||||
isLoading,
|
||||
onDeleteClick,
|
||||
onEditClick,
|
||||
onViewClick,
|
||||
isAdmin = false,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "policy_name", desc: false }]);
|
||||
|
||||
const rows = useMemo(() => groupPoliciesByName(policies), [policies]);
|
||||
|
||||
const columns: ColumnDef<PolicyRow>[] = [
|
||||
{
|
||||
header: "Name",
|
||||
accessorKey: "policy_name",
|
||||
cell: ({ row }) => {
|
||||
const { primaryPolicy, versionCount } = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
title={`${primaryPolicy.policy_name || "-"}${versionCount > 1 ? ` (${versionCount} versions)` : ""}`}
|
||||
>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-medium text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left"
|
||||
onClick={() => primaryPolicy.policy_id && onViewClick(primaryPolicy.policy_id)}
|
||||
>
|
||||
{primaryPolicy.policy_name || "-"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{versionCount > 1 && (
|
||||
<Badge color="gray" size="xs">
|
||||
{versionCount} version{versionCount !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Description",
|
||||
accessorFn: (row) => row.primaryPolicy.description ?? "",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original.primaryPolicy;
|
||||
return (
|
||||
<Tooltip title={policy.description}>
|
||||
<span className="text-xs truncate max-w-[200px] block">{policy.description || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Inherits From",
|
||||
accessorFn: (row) => row.primaryPolicy.inherit ?? "",
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original.primaryPolicy;
|
||||
return policy.inherit ? (
|
||||
<Badge color="blue" size="xs">
|
||||
{policy.inherit}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">-</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Guardrails (Add)",
|
||||
accessorFn: (row) => (row.primaryPolicy.guardrails_add ?? []).join(", "),
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original.primaryPolicy;
|
||||
const guardrails = policy.guardrails_add || [];
|
||||
if (guardrails.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{guardrails.slice(0, 2).map((g, i) => (
|
||||
<Tag key={i} color="green" className="text-xs">
|
||||
{g}
|
||||
</Tag>
|
||||
))}
|
||||
{guardrails.length > 2 && (
|
||||
<Tooltip title={guardrails.slice(2).join(", ")}>
|
||||
<Tag className="text-xs">+{guardrails.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Guardrails (Remove)",
|
||||
accessorFn: (row) => (row.primaryPolicy.guardrails_remove ?? []).join(", "),
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original.primaryPolicy;
|
||||
const guardrails = policy.guardrails_remove || [];
|
||||
if (guardrails.length === 0) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{guardrails.slice(0, 2).map((g, i) => (
|
||||
<Tag key={i} color="red" className="text-xs">
|
||||
{g}
|
||||
</Tag>
|
||||
))}
|
||||
{guardrails.length > 2 && (
|
||||
<Tooltip title={guardrails.slice(2).join(", ")}>
|
||||
<Tag className="text-xs">+{guardrails.length - 2}</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Model Condition",
|
||||
accessorFn: (row) => {
|
||||
const m = row.primaryPolicy.condition?.model;
|
||||
return typeof m === "string" ? m : JSON.stringify(m ?? "");
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const policy = row.original.primaryPolicy;
|
||||
const modelCondition = policy.condition?.model;
|
||||
if (!modelCondition) {
|
||||
return <span className="text-xs text-gray-400">-</span>;
|
||||
}
|
||||
return (
|
||||
<Tooltip title={typeof modelCondition === "string" ? modelCondition : JSON.stringify(modelCondition)}>
|
||||
<code className="text-xs bg-gray-100 px-1 py-0.5 rounded-sm">
|
||||
{typeof modelCondition === "string"
|
||||
? modelCondition.length > 20
|
||||
? modelCondition.slice(0, 20) + "..."
|
||||
: modelCondition
|
||||
: "Multiple"}
|
||||
</code>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Created At",
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.primaryPolicy.created_at ?? "",
|
||||
cell: ({ row }) => <DateCell value={row.original.primaryPolicy.created_at} />,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const { primaryPolicy } = row.original;
|
||||
const policy = primaryPolicy;
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Tooltip title="Edit policy">
|
||||
<Icon
|
||||
icon={PencilIcon}
|
||||
size="sm"
|
||||
onClick={() => onEditClick(policy)}
|
||||
className="cursor-pointer hover:text-blue-500"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete policy">
|
||||
<Icon
|
||||
icon={TrashIcon}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
policy.policy_id && onDeleteClick(policy.policy_id, policy.policy_name || "Unnamed Policy")
|
||||
}
|
||||
className="cursor-pointer hover:text-red-500"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: rows,
|
||||
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.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>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.original.policy_name} 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 policies found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolicyTable;
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { Tag } from "antd";
|
||||
import { ColumnsType } from "antd/es/table";
|
||||
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
|
||||
import { DateCell, IdCell } from "@/components/shared/table_cells";
|
||||
import { SearchTool } from "./types";
|
||||
|
||||
export const searchToolColumns = (
|
||||
onView: (searchToolId: string) => void,
|
||||
onEdit: (searchToolId: string) => void,
|
||||
onDelete: (searchToolId: string) => void,
|
||||
availableProviders: Array<{ provider_name: string; ui_friendly_name: string }>,
|
||||
): ColumnsType<SearchTool> => [
|
||||
{
|
||||
title: "Search Tool ID",
|
||||
dataIndex: "search_tool_id",
|
||||
key: "search_tool_id",
|
||||
render: (_, tool) => {
|
||||
const isFromConfig = tool.is_from_config;
|
||||
|
||||
if (isFromConfig) {
|
||||
return <span className="text-xs">-</span>;
|
||||
}
|
||||
|
||||
return <IdCell value={tool.search_tool_id} onClick={onView} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Name",
|
||||
dataIndex: "search_tool_name",
|
||||
key: "search_tool_name",
|
||||
render: (name: string) => <span className="font-medium">{name}</span>,
|
||||
},
|
||||
{
|
||||
title: "Provider",
|
||||
key: "provider",
|
||||
render: (_, tool) => {
|
||||
const provider = tool.litellm_params.search_provider;
|
||||
const providerInfo = availableProviders.find((p) => p.provider_name === provider);
|
||||
const displayName = providerInfo?.ui_friendly_name || provider;
|
||||
|
||||
return <span className="text-sm">{displayName}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Created At",
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
render: (_, tool) => {
|
||||
return <DateCell value={tool.created_at} precision="date" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Updated At",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
render: (_, tool) => {
|
||||
return <DateCell value={tool.updated_at} precision="date" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Source",
|
||||
key: "source",
|
||||
render: (_, tool) => {
|
||||
const isFromConfig = tool.is_from_config ?? false;
|
||||
|
||||
return <Tag color={isFromConfig ? "default" : "blue"}>{isFromConfig ? "Config" : "DB"}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
key: "actions",
|
||||
render: (_, tool) => {
|
||||
const toolId = tool.search_tool_id;
|
||||
const isFromConfig = tool.is_from_config ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TableIconActionButton
|
||||
variant="Edit"
|
||||
tooltipText="Edit search tool"
|
||||
disabled={isFromConfig}
|
||||
disabledTooltipText="Config search tool cannot be edited on the dashboard. Please edit it from the config file."
|
||||
onClick={() => {
|
||||
if (toolId && !isFromConfig) {
|
||||
onEdit(toolId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TableIconActionButton
|
||||
variant="Delete"
|
||||
tooltipText="Delete search tool"
|
||||
disabled={isFromConfig}
|
||||
disabledTooltipText="Config search tool cannot be deleted on the dashboard. Please delete it from the config file."
|
||||
onClick={() => {
|
||||
if (toolId && !isFromConfig) {
|
||||
onDelete(toolId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "@/../tests/test-utils";
|
||||
import SearchToolTable from "./SearchToolTable";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
const makeSearchTool = (overrides: Partial<SearchTool> = {}): SearchTool => ({
|
||||
search_tool_id: "tool-1",
|
||||
search_tool_name: "Perplexity Search",
|
||||
litellm_params: {
|
||||
search_provider: "perplexity",
|
||||
},
|
||||
created_at: "2024-01-15T10:30:00Z",
|
||||
updated_at: "2024-01-16T10:30:00Z",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const availableProviders: AvailableSearchProvider[] = [
|
||||
{ provider_name: "perplexity", ui_friendly_name: "Perplexity AI" },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
searchTools: [makeSearchTool()],
|
||||
isLoading: false,
|
||||
availableProviders,
|
||||
onView: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
describe("SearchToolTable", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should display search tool information with the friendly provider name", () => {
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} />);
|
||||
expect(screen.getByText("Perplexity Search")).toBeInTheDocument();
|
||||
expect(screen.getByText("tool-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Perplexity AI")).toBeInTheDocument();
|
||||
expect(screen.getByText("DB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onView when the search tool ID is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} />);
|
||||
await user.click(screen.getByRole("button", { name: /tool-1/ }));
|
||||
expect(defaultProps.onView).toHaveBeenCalledWith("tool-1");
|
||||
});
|
||||
|
||||
it("should call onEdit and onDelete from the actions menu for a DB tool", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByTestId("search-tool-actions-tool-1"));
|
||||
await user.click(await screen.findByTestId("search-tool-action-edit"));
|
||||
expect(defaultProps.onEdit).toHaveBeenCalledWith("tool-1");
|
||||
|
||||
await user.click(screen.getByTestId("search-tool-actions-tool-1"));
|
||||
await user.click(await screen.findByTestId("search-tool-action-delete"));
|
||||
expect(defaultProps.onDelete).toHaveBeenCalledWith("tool-1");
|
||||
});
|
||||
|
||||
it("should show a dash instead of a clickable ID for config tools", () => {
|
||||
const configTool = makeSearchTool({ search_tool_id: "config-tool", is_from_config: true });
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={[configTool]} />);
|
||||
expect(screen.queryByRole("button", { name: /config-tool/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable Edit and Delete for config tools and suppress their callbacks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const configTool = makeSearchTool({ search_tool_id: "config-tool", is_from_config: true });
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={[configTool]} />);
|
||||
|
||||
await user.click(screen.getByTestId("search-tool-actions-config-tool"));
|
||||
|
||||
const editItem = await screen.findByTestId("search-tool-action-edit");
|
||||
const deleteItem = await screen.findByTestId("search-tool-action-delete");
|
||||
expect(editItem).toHaveAttribute("data-disabled");
|
||||
expect(deleteItem).toHaveAttribute("data-disabled");
|
||||
|
||||
await user.click(editItem);
|
||||
await user.click(deleteItem);
|
||||
expect(defaultProps.onEdit).not.toHaveBeenCalled();
|
||||
expect(defaultProps.onDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should sort tools by created_at descending by default", () => {
|
||||
const tools = [
|
||||
makeSearchTool({
|
||||
search_tool_id: "tool-old",
|
||||
search_tool_name: "older-tool",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
}),
|
||||
makeSearchTool({
|
||||
search_tool_id: "tool-new",
|
||||
search_tool_name: "newer-tool",
|
||||
created_at: "2024-06-01T00:00:00Z",
|
||||
}),
|
||||
];
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={tools} />);
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("newer-tool")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("older-tool")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show skeleton rows when loading", () => {
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={[]} isLoading />);
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show the empty state when there are no search tools", () => {
|
||||
renderWithProviders(<SearchToolTable {...defaultProps} searchTools={[]} />);
|
||||
expect(screen.getByText("No search tools configured")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"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 { getSearchToolTableColumns, searchToolKey } from "./SearchToolTableColumns";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
interface SearchToolTableProps {
|
||||
searchTools: SearchTool[];
|
||||
isLoading: boolean;
|
||||
availableProviders: AvailableSearchProvider[];
|
||||
onView: (searchToolId: string) => void;
|
||||
onEdit: (searchToolId: string) => void;
|
||||
onDelete: (searchToolId: 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 search tools configured</div>
|
||||
<div className="text-sm text-muted-foreground">Add a search tool to enable web search for your models.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SearchToolTable: React.FC<SearchToolTableProps> = ({
|
||||
searchTools,
|
||||
isLoading,
|
||||
availableProviders,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const deps = { availableProviders, onView, onEdit, onDelete };
|
||||
return getSearchToolTableColumns(deps);
|
||||
}, [availableProviders, onView, onEdit, onDelete]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={searchTools}
|
||||
columns={columns}
|
||||
getRowId={(tool, index) => searchToolKey(tool) || String(index)}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading search tools…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchToolTable;
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
const CONFIG_EDIT_HINT = "Config search tools cannot be edited on the dashboard. Please edit the config file.";
|
||||
const CONFIG_DELETE_HINT = "Config search tools cannot be deleted on the dashboard. Please edit the config file.";
|
||||
|
||||
export const searchToolKey = (tool: SearchTool): string => tool.search_tool_id || tool.search_tool_name;
|
||||
|
||||
interface SearchToolRowActionsProps {
|
||||
tool: SearchTool;
|
||||
onEdit: (searchToolId: string) => void;
|
||||
onDelete: (searchToolId: string) => void;
|
||||
}
|
||||
|
||||
function SearchToolRowActions({ tool, onEdit, onDelete }: SearchToolRowActionsProps) {
|
||||
const isFromConfig = tool.is_from_config ?? false;
|
||||
const toolId = tool.search_tool_id;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open search tool actions"
|
||||
data-testid={`search-tool-actions-${searchToolKey(tool)}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem
|
||||
disabled={isFromConfig || !toolId}
|
||||
data-testid="search-tool-action-edit"
|
||||
title={isFromConfig ? CONFIG_EDIT_HINT : undefined}
|
||||
onClick={() => toolId && onEdit(toolId)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit search tool
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
disabled={isFromConfig || !toolId}
|
||||
data-testid="search-tool-action-delete"
|
||||
title={isFromConfig ? CONFIG_DELETE_HINT : undefined}
|
||||
onClick={() => toolId && onDelete(toolId)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete search tool
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
interface SearchToolTableColumnsDeps {
|
||||
availableProviders: AvailableSearchProvider[];
|
||||
onView: (searchToolId: string) => void;
|
||||
onEdit: (searchToolId: string) => void;
|
||||
onDelete: (searchToolId: string) => void;
|
||||
}
|
||||
|
||||
export const getSearchToolTableColumns = ({
|
||||
availableProviders,
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: SearchToolTableColumnsDeps): ColumnDef<SearchTool>[] => [
|
||||
{
|
||||
id: "search_tool_id",
|
||||
accessorKey: "search_tool_id",
|
||||
meta: { title: "Search Tool ID" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Search Tool ID" />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => {
|
||||
const tool = row.original;
|
||||
const toolId = tool.search_tool_id;
|
||||
if (tool.is_from_config || !toolId) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<IdentityCell title={toolId} titleClassName="font-mono text-xs font-normal" onClick={() => onView(toolId)} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "search_tool_name",
|
||||
accessorKey: "search_tool_name",
|
||||
meta: { title: "Name" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Name" />,
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => (
|
||||
<span className="block max-w-60 truncate text-sm font-medium" title={row.original.search_tool_name}>
|
||||
{row.original.search_tool_name || "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "provider",
|
||||
meta: { title: "Provider" },
|
||||
header: "Provider",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const provider = row.original.litellm_params.search_provider;
|
||||
const providerInfo = availableProviders.find((candidate) => candidate.provider_name === provider);
|
||||
return <span className="text-sm">{providerInfo?.ui_friendly_name || provider}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
meta: { title: "Updated At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Updated At" />,
|
||||
size: 130,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.updated_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
meta: { title: "Source", skeleton: "badge" },
|
||||
header: "Source",
|
||||
size: 100,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const isFromConfig = row.original.is_from_config ?? false;
|
||||
return <StatusBadge tone={isFromConfig ? "neutral" : "info"} label={isFromConfig ? "Config" : "DB"} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
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">
|
||||
<SearchToolRowActions tool={row.original} onEdit={onEdit} onDelete={onDelete} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
@ -1,8 +1,7 @@
|
|||
import { isAdminRole } from "@/utils/roles";
|
||||
import { LoadingOutlined } from "@ant-design/icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button, Text, Title } from "@tremor/react";
|
||||
import { Form, Input, Modal, Select, Spin, Table } from "antd";
|
||||
import { Form, Input, Modal, Select } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
|
@ -13,7 +12,7 @@ import {
|
|||
updateSearchTool,
|
||||
} from "@/components/networking";
|
||||
import CreateSearchTool from "./CreateSearchTools";
|
||||
import { searchToolColumns } from "./SearchToolColumn";
|
||||
import SearchToolTable from "./SearchToolTable";
|
||||
import { SearchToolView } from "./SearchToolView";
|
||||
import { AvailableSearchProvider, SearchTool } from "./types";
|
||||
|
||||
|
|
@ -58,34 +57,29 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
const [isEditModalVisible, setEditModalVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const columns = React.useMemo(
|
||||
() =>
|
||||
searchToolColumns(
|
||||
(toolId: string) => {
|
||||
setSelectedToolId(toolId);
|
||||
setEditTool(false);
|
||||
},
|
||||
(toolId: string) => {
|
||||
const tool = searchTools?.find((t) => t.search_tool_id === toolId);
|
||||
if (tool) {
|
||||
form.setFieldsValue({
|
||||
search_tool_name: tool.search_tool_name,
|
||||
search_provider: tool.litellm_params.search_provider,
|
||||
api_key: tool.litellm_params.api_key,
|
||||
api_base: tool.litellm_params.api_base,
|
||||
timeout: tool.litellm_params.timeout,
|
||||
max_retries: tool.litellm_params.max_retries,
|
||||
description: tool.search_tool_info?.description,
|
||||
});
|
||||
setSelectedToolId(toolId);
|
||||
setEditModalVisible(true);
|
||||
}
|
||||
},
|
||||
handleDelete,
|
||||
availableProviders,
|
||||
),
|
||||
[availableProviders, searchTools, form],
|
||||
);
|
||||
const handleView = (toolId: string) => {
|
||||
setSelectedToolId(toolId);
|
||||
setEditTool(false);
|
||||
};
|
||||
|
||||
const handleEditOpen = (toolId: string) => {
|
||||
const tool = searchTools?.find((t) => t.search_tool_id === toolId);
|
||||
if (!tool) {
|
||||
return;
|
||||
}
|
||||
const editFormValues = {
|
||||
search_tool_name: tool.search_tool_name,
|
||||
search_provider: tool.litellm_params.search_provider,
|
||||
api_key: tool.litellm_params.api_key,
|
||||
api_base: tool.litellm_params.api_base,
|
||||
timeout: tool.litellm_params.timeout,
|
||||
max_retries: tool.litellm_params.max_retries,
|
||||
description: tool.search_tool_info?.description,
|
||||
};
|
||||
form.setFieldsValue(editFormValues);
|
||||
setSelectedToolId(toolId);
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
function handleDelete(toolId: string) {
|
||||
setToolToDelete(toolId);
|
||||
|
|
@ -220,19 +214,14 @@ const SearchTools: React.FC<SearchToolsProps> = ({ accessToken, userRole, userID
|
|||
/>
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
<Spin spinning={isLoadingTools} indicator={<LoadingOutlined spin />} size="large">
|
||||
<Table
|
||||
bordered
|
||||
dataSource={searchTools || []}
|
||||
columns={columns}
|
||||
rowKey={(record) => record.search_tool_id || record.search_tool_name}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: "No search tools configured",
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Spin>
|
||||
<SearchToolTable
|
||||
searchTools={searchTools || []}
|
||||
isLoading={isLoadingTools}
|
||||
availableProviders={availableProviders}
|
||||
onView={handleView}
|
||||
onEdit={handleEditOpen}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi, it, expect, beforeEach, MockedFunction } from "vitest";
|
||||
import { renderWithProviders } from "../../../tests/test-utils";
|
||||
import DeletedKeysPage from "./DeletedKeysPage";
|
||||
|
|
@ -13,6 +14,9 @@ const mockUseDeletedKeys = useDeletedKeys as MockedFunction<typeof useDeletedKey
|
|||
const mockDeletedKey: DeletedKeyResponse = {
|
||||
token: "sk-1234567890abcdef",
|
||||
token_id: "key-1",
|
||||
key_type: "llm_api",
|
||||
project_id: null,
|
||||
last_active: null,
|
||||
key_name: "test-key",
|
||||
key_alias: "Test Key Alias",
|
||||
spend: 5.5,
|
||||
|
|
@ -78,9 +82,8 @@ beforeEach(() => {
|
|||
current_page: 1,
|
||||
total_pages: 1,
|
||||
},
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
} as any);
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useDeletedKeys>);
|
||||
});
|
||||
|
||||
it("should render DeletedKeysPage component", () => {
|
||||
|
|
@ -89,14 +92,32 @@ it("should render DeletedKeysPage component", () => {
|
|||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle loading state", () => {
|
||||
it("should show skeleton rows while the initial load is pending", () => {
|
||||
mockUseDeletedKeys.mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
isFetching: false,
|
||||
} as any);
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useDeletedKeys>);
|
||||
|
||||
renderWithProviders(<DeletedKeysPage />);
|
||||
|
||||
expect(screen.getByText("🚅 Loading keys...")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should request the next page from the hook when the pagination next button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseDeletedKeys.mockReturnValue({
|
||||
data: {
|
||||
keys: [mockDeletedKey],
|
||||
total_count: 120,
|
||||
current_page: 1,
|
||||
total_pages: 3,
|
||||
},
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useDeletedKeys>);
|
||||
|
||||
renderWithProviders(<DeletedKeysPage />);
|
||||
|
||||
expect(mockUseDeletedKeys).toHaveBeenLastCalledWith(1, 50);
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
expect(mockUseDeletedKeys).toHaveBeenLastCalledWith(2, 50);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { PaginationState } from "@tanstack/react-table";
|
||||
import { Alert } from "antd";
|
||||
import { useDeletedKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
|
@ -7,10 +8,9 @@ import { DeletedKeysTable } from "./DeletedKeysTable/DeletedKeysTable";
|
|||
|
||||
export default function DeletedKeysPage() {
|
||||
const { premiumUser } = useAuthorized();
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize] = useState(50);
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 50 });
|
||||
|
||||
const { data: keysData, isPending: isLoading, isFetching } = useDeletedKeys(pageIndex + 1, pageSize);
|
||||
const { data: keysData, isLoading } = useDeletedKeys(pagination.pageIndex + 1, pagination.pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -27,10 +27,8 @@ export default function DeletedKeysPage() {
|
|||
keys={keysData?.keys || []}
|
||||
totalCount={keysData?.total_count || 0}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPageIndex}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,101 +1,88 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { vi, it, expect, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "../../../../tests/test-utils";
|
||||
import { DeletedKeysTable } from "./DeletedKeysTable";
|
||||
import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
|
||||
const mockDeletedKey: DeletedKeyResponse = {
|
||||
token: "sk-1234567890abcdef",
|
||||
token_id: "key-1",
|
||||
key_name: "test-key",
|
||||
key_alias: "Test Key Alias",
|
||||
spend: 5.5,
|
||||
max_budget: 100,
|
||||
expires: "2024-12-31T23:59:59Z",
|
||||
models: ["gpt-3.5-turbo"],
|
||||
aliases: {},
|
||||
config: {},
|
||||
user_id: "user-1",
|
||||
team_id: "team-1",
|
||||
max_parallel_requests: 10,
|
||||
metadata: {},
|
||||
tpm_limit: 1000,
|
||||
rpm_limit: 100,
|
||||
duration: "30d",
|
||||
budget_duration: "1m",
|
||||
budget_reset_at: "2024-12-01T00:00:00Z",
|
||||
allowed_cache_controls: [],
|
||||
allowed_routes: [],
|
||||
permissions: {},
|
||||
model_spend: {},
|
||||
model_max_budget: {},
|
||||
soft_budget_cooldown: false,
|
||||
blocked: false,
|
||||
litellm_budget_table: {},
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-11-01T10:00:00Z",
|
||||
updated_at: "2024-11-15T10:00:00Z",
|
||||
team_spend: 5.5,
|
||||
team_alias: "Test Team",
|
||||
team_tpm_limit: 5000,
|
||||
team_rpm_limit: 500,
|
||||
team_max_budget: 500,
|
||||
team_models: ["gpt-3.5-turbo"],
|
||||
team_blocked: false,
|
||||
soft_budget: 50,
|
||||
team_model_aliases: {},
|
||||
team_member_spend: 0,
|
||||
team_metadata: {},
|
||||
end_user_id: "end-user-1",
|
||||
end_user_tpm_limit: 100,
|
||||
end_user_rpm_limit: 10,
|
||||
end_user_max_budget: 10,
|
||||
last_refreshed_at: Date.now(),
|
||||
api_key: "sk-1234567890abcdef",
|
||||
user_role: "user",
|
||||
rpm_limit_per_model: {},
|
||||
tpm_limit_per_model: {},
|
||||
user_tpm_limit: 1000,
|
||||
user_rpm_limit: 100,
|
||||
user_email: "user@example.com",
|
||||
deleted_at: "2024-11-15T10:00:00Z",
|
||||
deleted_by: "user-1",
|
||||
const makeDeletedKey = (overrides: Partial<DeletedKeyResponse> = {}): DeletedKeyResponse =>
|
||||
({
|
||||
token: "sk-1234567890abcdef",
|
||||
token_id: "key-1",
|
||||
key_name: "test-key",
|
||||
key_alias: "Test Key Alias",
|
||||
spend: 5.5,
|
||||
max_budget: 100,
|
||||
models: ["gpt-3.5-turbo"],
|
||||
user_id: "user-1",
|
||||
team_id: "team-1",
|
||||
organization_id: "org-1",
|
||||
created_at: "2024-11-01T10:00:00Z",
|
||||
updated_at: "2024-11-15T10:00:00Z",
|
||||
created_by: "creator-1",
|
||||
team_alias: "Test Team",
|
||||
user_email: "user@example.com",
|
||||
deleted_at: "2024-11-15T10:00:00Z",
|
||||
deleted_by: "user-1",
|
||||
...overrides,
|
||||
}) as DeletedKeyResponse;
|
||||
|
||||
const defaultProps = {
|
||||
keys: [makeDeletedKey()],
|
||||
totalCount: 1,
|
||||
isLoading: false,
|
||||
pagination: { pageIndex: 0, pageSize: 50 },
|
||||
onPaginationChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render DeletedKeysTable component", () => {
|
||||
renderWithProviders(
|
||||
<DeletedKeysTable
|
||||
keys={[mockDeletedKey]}
|
||||
totalCount={1}
|
||||
isLoading={false}
|
||||
isFetching={false}
|
||||
pageIndex={0}
|
||||
pageSize={50}
|
||||
onPageChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display key information correctly", () => {
|
||||
renderWithProviders(
|
||||
<DeletedKeysTable
|
||||
keys={[mockDeletedKey]}
|
||||
totalCount={1}
|
||||
isLoading={false}
|
||||
isFetching={false}
|
||||
pageIndex={0}
|
||||
pageSize={50}
|
||||
onPageChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
it("should display key information", () => {
|
||||
renderWithProviders(<DeletedKeysTable {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Key Alias")).toBeInTheDocument();
|
||||
expect(screen.getByText("sk-1234567890abcdef")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 - 1 of 1 results")).toBeInTheDocument();
|
||||
expect(screen.getByText("user@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show the total count in the pagination footer", () => {
|
||||
renderWithProviders(<DeletedKeysTable {...defaultProps} totalCount={120} />);
|
||||
|
||||
expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-50 of 120");
|
||||
expect(screen.getByTestId("pagination-page")).toHaveTextContent("Page 1 of 3");
|
||||
});
|
||||
|
||||
it("should propagate pagination changes when the next page button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<DeletedKeysTable {...defaultProps} totalCount={120} />);
|
||||
|
||||
await user.click(screen.getByTestId("pagination-next"));
|
||||
|
||||
expect(defaultProps.onPaginationChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should sort the current page by deleted_at descending by default", () => {
|
||||
const keys = [
|
||||
makeDeletedKey({ token: "sk-older", key_alias: "older-key", deleted_at: "2024-01-01T10:00:00Z" }),
|
||||
makeDeletedKey({ token: "sk-newer", key_alias: "newer-key", deleted_at: "2024-06-01T10:00:00Z" }),
|
||||
];
|
||||
renderWithProviders(<DeletedKeysTable {...defaultProps} keys={keys} totalCount={2} />);
|
||||
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("newer-key")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("older-key")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show skeleton rows when loading", () => {
|
||||
renderWithProviders(<DeletedKeysTable {...defaultProps} keys={[]} isLoading />);
|
||||
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show the empty state when there are no deleted keys", () => {
|
||||
renderWithProviders(<DeletedKeysTable {...defaultProps} keys={[]} totalCount={0} />);
|
||||
|
||||
expect(screen.getByText("No deleted keys found")).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,364 +1,63 @@
|
|||
"use client";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline";
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
PaginationState,
|
||||
SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
import { KeyResponse } from "../../key_team_helpers/key_list";
|
||||
|
||||
import { OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
|
||||
import { getDeletedKeysTableColumns } from "./DeletedKeysTableColumns";
|
||||
|
||||
interface DeletedKeysTableProps {
|
||||
keys: KeyResponse[];
|
||||
keys: DeletedKeyResponse[];
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
onPageChange: (pageIndex: number) => void;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
}
|
||||
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "deleted_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 deleted keys found</div>
|
||||
<div className="text-sm text-muted-foreground">Keys deleted from this proxy will show up here.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeletedKeysTable({
|
||||
keys,
|
||||
totalCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
onPageChange,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
}: DeletedKeysTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "deleted_at",
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const [tablePagination, setTablePagination] = useState<PaginationState>({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
// Sync pagination state when prop changes
|
||||
React.useEffect(() => {
|
||||
setTablePagination({ pageIndex, pageSize });
|
||||
}, [pageIndex, pageSize]);
|
||||
|
||||
const columns: ColumnDef<KeyResponse>[] = [
|
||||
{
|
||||
id: "token",
|
||||
accessorKey: "token",
|
||||
header: "Key ID",
|
||||
size: 150,
|
||||
maxSize: 250,
|
||||
cell: (info) => <IdCell value={info.getValue() as string} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
accessorKey: "key_alias",
|
||||
header: "Key Alias",
|
||||
size: 150,
|
||||
maxSize: 200,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className="font-mono text-xs truncate block max-w-[200px]">{value ?? "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
header: "Team Alias",
|
||||
size: 120,
|
||||
maxSize: 180,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return <span className="truncate block max-w-[180px]">{value || "-"}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
header: "Spend (USD)",
|
||||
size: 100,
|
||||
maxSize: 140,
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
maxSize: 150,
|
||||
cell: (info) => (
|
||||
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorKey: "user_email",
|
||||
header: "User Email",
|
||||
size: 160,
|
||||
maxSize: 250,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span className="font-mono text-xs truncate block max-w-[250px]">{value ?? "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
header: "User ID",
|
||||
size: 120,
|
||||
maxSize: 200,
|
||||
cell: (info) => <IdCell value={info.getValue() as string | null} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
header: "Created At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
header: "Created By",
|
||||
size: 120,
|
||||
maxSize: 180,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).created_by as string | null | undefined;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[180px]">{value || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
header: "Deleted At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => (
|
||||
<DateCell value={(info.row.original as any).deleted_at as string | null | undefined} precision="date" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "deleted_by",
|
||||
accessorKey: "deleted_by",
|
||||
header: "Deleted By",
|
||||
size: 120,
|
||||
maxSize: 180,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).deleted_by as string | null | undefined;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[180px]">{value || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: keys,
|
||||
columns,
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
state: {
|
||||
sorting,
|
||||
pagination: tablePagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: (updater) => {
|
||||
const newPagination = typeof updater === "function" ? updater(tablePagination) : updater;
|
||||
setTablePagination(newPagination);
|
||||
onPageChange(newPagination.pageIndex);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
enableSorting: true,
|
||||
manualSorting: false,
|
||||
manualPagination: true,
|
||||
pageCount: Math.ceil(totalCount / pageSize),
|
||||
});
|
||||
|
||||
const { pageIndex: currentPageIndex } = table.getState().pagination;
|
||||
const start = currentPageIndex * pageSize + 1;
|
||||
const end = Math.min((currentPageIndex + 1) * pageSize, totalCount);
|
||||
const rangeLabel = `${start} - ${end}`;
|
||||
const columns = useMemo(() => getDeletedKeysTableColumns(), []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<div className="border-b py-4 flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between w-full mb-4">
|
||||
{isLoading || isFetching ? (
|
||||
<span className="inline-flex text-sm text-gray-700">Loading...</span>
|
||||
) : (
|
||||
<span className="inline-flex text-sm text-gray-700">
|
||||
Showing {rangeLabel} of {totalCount} results
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
{isLoading || isFetching ? (
|
||||
<span className="text-sm text-gray-700">Loading...</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {currentPageIndex + 1} of {table.getPageCount()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={isLoading || isFetching || !table.getCanPreviousPage()}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={isLoading || isFetching || !table.getCanNextPage()}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[75vh] overflow-auto">
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
data-header-id={header.id}
|
||||
className={`py-1 h-8 relative hover:bg-gray-50`}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
maxWidth: header.column.columnDef.maxSize,
|
||||
position: "relative",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer) {
|
||||
(resizer as HTMLElement).style.opacity = "0.5";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer && !header.column.getIsResizing()) {
|
||||
(resizer as HTMLElement).style.opacity = "0";
|
||||
}
|
||||
}}
|
||||
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
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
width: "5px",
|
||||
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
|
||||
cursor: "col-resize",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
opacity: header.column.getIsResizing() ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading || isFetching ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading keys...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : keys.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
maxWidth: cell.column.columnDef.maxSize,
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
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 deleted keys found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
data={keys}
|
||||
columns={columns}
|
||||
getRowId={(key, index) => key.token || String(index)}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
paginationMode="server"
|
||||
pagination={pagination}
|
||||
onPaginationChange={onPaginationChange}
|
||||
rowCount={totalCount}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading deleted keys…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { DeletedKeyResponse } from "@/app/(dashboard)/hooks/keys/useKeys";
|
||||
|
||||
function TruncatedTextCell({ value }: { value: string | null | undefined }) {
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-60 truncate" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const getDeletedKeysTableColumns = (): ColumnDef<DeletedKeyResponse>[] => [
|
||||
{
|
||||
id: "token",
|
||||
accessorKey: "token",
|
||||
meta: { title: "Key ID" },
|
||||
header: "Key ID",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.token} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
accessorKey: "key_alias",
|
||||
meta: { title: "Key Alias" },
|
||||
header: "Key Alias",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.key_alias;
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-60 truncate font-mono text-xs" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
meta: { title: "Team Alias" },
|
||||
header: "Team Alias",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TruncatedTextCell value={row.original.team_alias} />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
meta: { title: "Budget (USD)", numeric: true },
|
||||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={0} emptyText="Unlimited" showZero />,
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorKey: "user_email",
|
||||
meta: { title: "User Email" },
|
||||
header: "User Email",
|
||||
size: 160,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TruncatedTextCell value={row.original.user_email} />,
|
||||
},
|
||||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
meta: { title: "User ID" },
|
||||
header: "User ID",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.user_id} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created At" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
meta: { title: "Created By" },
|
||||
header: "Created By",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TruncatedTextCell value={row.original.created_by} />,
|
||||
},
|
||||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
meta: { title: "Deleted At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Deleted At" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.deleted_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "deleted_by",
|
||||
accessorKey: "deleted_by",
|
||||
meta: { title: "Deleted By" },
|
||||
header: "Deleted By",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <TruncatedTextCell value={row.original.deleted_by} />,
|
||||
},
|
||||
];
|
||||
|
|
@ -32,9 +32,8 @@ beforeEach(() => {
|
|||
|
||||
mockUseDeletedTeams.mockReturnValue({
|
||||
data: [mockDeletedTeam],
|
||||
isPending: false,
|
||||
isFetching: false,
|
||||
} as any);
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useDeletedTeams>);
|
||||
});
|
||||
|
||||
it("should render DeletedTeamsPage component", () => {
|
||||
|
|
@ -43,14 +42,13 @@ it("should render DeletedTeamsPage component", () => {
|
|||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle loading state", () => {
|
||||
it("should show skeleton rows while the initial load is pending", () => {
|
||||
mockUseDeletedTeams.mockReturnValue({
|
||||
data: undefined,
|
||||
isPending: true,
|
||||
isFetching: false,
|
||||
} as any);
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useDeletedTeams>);
|
||||
|
||||
renderWithProviders(<DeletedTeamsPage />);
|
||||
|
||||
expect(screen.getByText("🚅 Loading teams...")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { DeletedTeamsTable } from "./DeletedTeamsTable/DeletedTeamsTable";
|
|||
|
||||
export default function DeletedTeamsPage() {
|
||||
const { premiumUser } = useAuthorized();
|
||||
const { data: teamsData, isPending: isLoading, isFetching } = useDeletedTeams(1, 100);
|
||||
const { data: teamsData, isLoading } = useDeletedTeams(1, 100);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -19,7 +19,7 @@ export default function DeletedTeamsPage() {
|
|||
description="Deleted team auditing is graduating from beta into our Enterprise audit & compliance suite."
|
||||
/>
|
||||
)}
|
||||
<DeletedTeamsTable teams={teamsData || []} isLoading={isLoading} isFetching={isFetching} />
|
||||
<DeletedTeamsTable teams={teamsData || []} isLoading={isLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { screen } from "@testing-library/react";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import { vi, it, expect, beforeEach } from "vitest";
|
||||
import { renderWithProviders } from "../../../../tests/test-utils";
|
||||
import { DeletedTeamsTable } from "./DeletedTeamsTable";
|
||||
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
const mockDeletedTeam: DeletedTeam = {
|
||||
const makeDeletedTeam = (overrides: Partial<DeletedTeam> = {}): DeletedTeam => ({
|
||||
team_id: "team-1",
|
||||
team_alias: "Test Team",
|
||||
models: ["gpt-3.5-turbo", "gpt-4"],
|
||||
|
|
@ -19,22 +19,41 @@ const mockDeletedTeam: DeletedTeam = {
|
|||
deleted_at: "2024-11-15T10:00:00Z",
|
||||
deleted_by: "user-1",
|
||||
spend: 100.5,
|
||||
};
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render DeletedTeamsTable component", () => {
|
||||
renderWithProviders(<DeletedTeamsTable teams={[mockDeletedTeam]} isLoading={false} isFetching={false} />);
|
||||
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display team information correctly", () => {
|
||||
renderWithProviders(<DeletedTeamsTable teams={[mockDeletedTeam]} isLoading={false} isFetching={false} />);
|
||||
it("should display team information", () => {
|
||||
renderWithProviders(<DeletedTeamsTable teams={[makeDeletedTeam()]} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText("Test Team")).toBeInTheDocument();
|
||||
expect(screen.getByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Showing 1 team")).toBeInTheDocument();
|
||||
expect(screen.getByText("org-1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should sort teams by deleted_at descending by default", () => {
|
||||
const teams = [
|
||||
makeDeletedTeam({ team_id: "team-old", team_alias: "older-team", deleted_at: "2024-01-01T10:00:00Z" }),
|
||||
makeDeletedTeam({ team_id: "team-new", team_alias: "newer-team", deleted_at: "2024-06-01T10:00:00Z" }),
|
||||
];
|
||||
renderWithProviders(<DeletedTeamsTable teams={teams} isLoading={false} />);
|
||||
|
||||
const rows = screen.getAllByRole("row").slice(1);
|
||||
expect(within(rows[0]).getByText("newer-team")).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("older-team")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show skeleton rows when loading", () => {
|
||||
renderWithProviders(<DeletedTeamsTable teams={[]} isLoading />);
|
||||
|
||||
expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should show the empty state when there are no deleted teams", () => {
|
||||
renderWithProviders(<DeletedTeamsTable teams={[]} isLoading={false} />);
|
||||
|
||||
expect(screen.getByText("No deleted teams found")).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,299 +1,50 @@
|
|||
"use client";
|
||||
import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
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, Badge, Text } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
|
||||
import { getDeletedTeamsTableColumns } from "./DeletedTeamsTableColumns";
|
||||
|
||||
interface DeletedTeamsTableProps {
|
||||
teams: DeletedTeam[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeamsTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "deleted_at",
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const columns: ColumnDef<DeletedTeam>[] = [
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
header: "Team Name",
|
||||
size: 150,
|
||||
maxSize: 200,
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[200px]">{value || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
accessorKey: "team_id",
|
||||
header: "Team ID",
|
||||
size: 150,
|
||||
maxSize: 250,
|
||||
cell: (info) => <IdCell value={info.getValue() as string} />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
header: "Created",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
header: "Spend (USD)",
|
||||
size: 100,
|
||||
maxSize: 140,
|
||||
cell: (info) => <MoneyCell value={info.getValue() as number | undefined} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
maxSize: 150,
|
||||
cell: (info) => (
|
||||
<MoneyCell value={info.getValue() as number | null} decimals={0} emptyText="Unlimited" showZero />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorKey: "models",
|
||||
header: "Models",
|
||||
size: 200,
|
||||
maxSize: 300,
|
||||
cell: (info) => {
|
||||
const models = info.getValue() as string[];
|
||||
if (!Array.isArray(models) || models.length === 0) {
|
||||
return (
|
||||
<Badge size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 max-w-[300px]">
|
||||
{models.slice(0, 3).map((model: string, index: number) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index} size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index} size={"xs"} color="blue">
|
||||
<Text>
|
||||
{model.length > 30 ? `${getModelDisplayName(model).slice(0, 30)}...` : getModelDisplayName(model)}
|
||||
</Text>
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
{models.length > 3 && (
|
||||
<Badge size={"xs"} color="gray">
|
||||
<Text>
|
||||
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "organization_id",
|
||||
accessorKey: "organization_id",
|
||||
header: "Organization",
|
||||
size: 150,
|
||||
maxSize: 200,
|
||||
cell: (info) => <IdCell value={info.getValue() as string} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
header: "Deleted At",
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
cell: (info) => <DateCell value={info.getValue() as string | null | undefined} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "deleted_by",
|
||||
accessorKey: "deleted_by",
|
||||
header: "Deleted By",
|
||||
size: 120,
|
||||
maxSize: 180,
|
||||
cell: (info) => {
|
||||
const value = (info.row.original as any).deleted_by as string | null | undefined;
|
||||
return (
|
||||
<Tooltip title={value || undefined}>
|
||||
<span className="truncate block max-w-[180px]">{value || "-"}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: teams,
|
||||
columns,
|
||||
columnResizeMode: "onChange",
|
||||
columnResizeDirection: "ltr",
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableSorting: true,
|
||||
manualSorting: false,
|
||||
});
|
||||
const DEFAULT_SORTING: SortingState = [{ id: "deleted_at", desc: true }];
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<div className="border-b py-4 flex-1 overflow-hidden">
|
||||
<div className="flex items-center justify-between w-full mb-4">
|
||||
{isLoading || isFetching ? (
|
||||
<span className="inline-flex text-sm text-gray-700">Loading...</span>
|
||||
) : (
|
||||
<span className="inline-flex text-sm text-gray-700">
|
||||
Showing {teams.length} {teams.length === 1 ? "team" : "teams"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[75vh] overflow-auto">
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1" style={{ width: table.getCenterTotalSize() }}>
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
data-header-id={header.id}
|
||||
className={`py-1 h-8 relative hover:bg-gray-50`}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
maxWidth: header.column.columnDef.maxSize,
|
||||
position: "relative",
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer) {
|
||||
(resizer as HTMLElement).style.opacity = "0.5";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
const resizer = document.querySelector(`[data-header-id="${header.id}"] .resizer`);
|
||||
if (resizer && !header.column.getIsResizing()) {
|
||||
(resizer as HTMLElement).style.opacity = "0";
|
||||
}
|
||||
}}
|
||||
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
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={`resizer ${table.options.columnResizeDirection} ${header.column.getIsResizing() ? "isResizing" : ""}`}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
width: "5px",
|
||||
background: header.column.getIsResizing() ? "#3b82f6" : "transparent",
|
||||
cursor: "col-resize",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
opacity: header.column.getIsResizing() ? 1 : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading || isFetching ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading teams...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : teams.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
maxWidth: cell.column.columnDef.maxSize,
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
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 deleted teams found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 deleted teams found</div>
|
||||
<div className="text-sm text-muted-foreground">Teams deleted from this proxy will show up here.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeletedTeamsTable({ teams, isLoading }: DeletedTeamsTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
const columns = useMemo(() => getDeletedTeamsTableColumns(), []);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={teams}
|
||||
columns={columns}
|
||||
getRowId={(team, index) => team.team_id || String(index)}
|
||||
sortingMode="client"
|
||||
sorting={sorting}
|
||||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading deleted teams…"
|
||||
noDataMessage={<EmptyState />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { DateCell, IdCell, ModelsCell, MoneyCell } from "@/components/shared/table_cells";
|
||||
import { DeletedTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
|
||||
export const getDeletedTeamsTableColumns = (): ColumnDef<DeletedTeam>[] => [
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_alias",
|
||||
meta: { title: "Team Name" },
|
||||
header: "Team Name",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.team_alias;
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-60 truncate font-medium" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
accessorKey: "team_id",
|
||||
meta: { title: "Team ID" },
|
||||
header: "Team ID",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.team_id} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
meta: { title: "Created" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
meta: { title: "Spend (USD)", numeric: true },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Spend (USD)" />,
|
||||
size: 100,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
meta: { title: "Budget (USD)", numeric: true },
|
||||
header: "Budget (USD)",
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <MoneyCell value={row.original.max_budget} decimals={0} emptyText="Unlimited" showZero />,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorKey: "models",
|
||||
meta: { title: "Models", skeleton: "chips" },
|
||||
header: "Models",
|
||||
size: 200,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <ModelsCell models={row.original.models} />,
|
||||
},
|
||||
{
|
||||
id: "organization_id",
|
||||
accessorKey: "organization_id",
|
||||
meta: { title: "Organization" },
|
||||
header: "Organization",
|
||||
size: 150,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => <IdCell value={row.original.organization_id} variant="plain" />,
|
||||
},
|
||||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
meta: { title: "Deleted At" },
|
||||
header: ({ column }) => <DataTableSortHeader column={column} title="Deleted At" />,
|
||||
size: 120,
|
||||
enableSorting: true,
|
||||
cell: ({ row }) => <DateCell value={row.original.deleted_at} precision="date" />,
|
||||
},
|
||||
{
|
||||
id: "deleted_by",
|
||||
accessorKey: "deleted_by",
|
||||
meta: { title: "Deleted By" },
|
||||
header: "Deleted By",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const value = row.original.deleted_by;
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground">-</span>;
|
||||
}
|
||||
return (
|
||||
<span className="block max-w-60 truncate" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Reference in a new issue