refactor(ui): migrate tags table onto shared DataTable

This commit is contained in:
Yuneng Jiang 2026-07-14 18:05:39 -07:00
parent b2202cb1aa
commit 07deb66218
No known key found for this signature in database
5 changed files with 265 additions and 240 deletions

View file

@ -1161,11 +1161,6 @@
"count": 1
}
},
"src/app/(dashboard)/tag-management/_components/TagTable.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/app/(dashboard)/tag-management/_components/components/CreateTagModal.tsx": {
"no-restricted-imports": {
"count": 1

View file

@ -1,9 +1,12 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { formatCellDate } from "@/components/shared/table_cells";
import TagTable from "./TagTable";
import { Tag } from "@/components/tag_management/types";
import TagTable from "./TagTable";
describe("TagTable", () => {
const mockOnEdit = vi.fn();
const mockOnDelete = vi.fn();
@ -41,28 +44,28 @@ describe("TagTable", () => {
vi.clearAllMocks();
});
it("should render", () => {
it("should render every column header", () => {
render(<TagTable {...defaultProps} />);
expect(screen.getByText("Tag Name")).toBeInTheDocument();
expect(screen.getByText("Description")).toBeInTheDocument();
expect(screen.getByText("Allowed Models")).toBeInTheDocument();
expect(screen.getByText("Created")).toBeInTheDocument();
expect(screen.getByText("Actions")).toBeInTheDocument();
for (const header of ["Tag Name", "Description", "Allowed Models", "Created"]) {
expect(screen.getByText(header)).toBeInTheDocument();
}
});
it("should display no tags found message when data is empty", () => {
it("should display the empty state when data is empty", () => {
render(<TagTable {...defaultProps} />);
expect(screen.getByText("No tags found")).toBeInTheDocument();
expect(screen.getByText("No tags yet")).toBeInTheDocument();
});
it("should display tag name", () => {
it("should display tag name and description", () => {
render(<TagTable {...defaultProps} data={[mockTag]} />);
expect(screen.getByText("test-tag")).toBeInTheDocument();
expect(screen.getByText("Test description")).toBeInTheDocument();
});
it("should display tag description", () => {
it("should display model names from model_info", () => {
render(<TagTable {...defaultProps} data={[mockTag]} />);
expect(screen.getByText("Test description")).toBeInTheDocument();
expect(screen.getByText("GPT-4")).toBeInTheDocument();
expect(screen.getByText("Claude-3")).toBeInTheDocument();
});
it("should display All Models badge when models array is empty", () => {
@ -80,30 +83,56 @@ describe("TagTable", () => {
expect(screen.getByText(formattedDate)).toBeInTheDocument();
});
it("should call onSelectTag when tag name is clicked", () => {
it("should sort by created date descending by default", () => {
const olderTag: Tag = { ...mockTag, name: "older-tag", created_at: "2023-01-01T00:00:00Z" };
const newerTag: Tag = { ...mockTag, name: "newer-tag", created_at: "2025-01-01T00:00:00Z" };
render(<TagTable {...defaultProps} data={[olderTag, newerTag]} />);
const rows = screen.getAllByRole("row").slice(1);
expect(within(rows[0]).getByText("newer-tag")).toBeInTheDocument();
expect(within(rows[1]).getByText("older-tag")).toBeInTheDocument();
});
it("should call onSelectTag when tag name is clicked", async () => {
const user = userEvent.setup();
render(<TagTable {...defaultProps} data={[mockTag]} />);
fireEvent.click(screen.getByRole("button", { name: "test-tag" }));
await user.click(screen.getByRole("button", { name: "test-tag" }));
expect(mockOnSelectTag).toHaveBeenCalledWith("test-tag");
});
it("should render tag name as non-clickable for dynamic spend tags", () => {
render(<TagTable {...defaultProps} data={[mockDynamicSpendTag]} />);
expect(screen.queryByRole("button", { name: "dynamic-spend-tag" })).not.toBeInTheDocument();
fireEvent.click(screen.getByText("dynamic-spend-tag"));
expect(screen.getByText("dynamic-spend-tag")).toBeInTheDocument();
expect(mockOnSelectTag).not.toHaveBeenCalled();
});
it("should disable edit icon for dynamic spend tags", () => {
render(<TagTable {...defaultProps} data={[mockDynamicSpendTag]} />);
const editIcon = screen.getByLabelText("Edit tag (disabled)");
expect(editIcon).toBeInTheDocument();
expect(editIcon).toHaveClass("cursor-not-allowed");
it("should edit a tag through the actions menu", async () => {
const user = userEvent.setup();
render(<TagTable {...defaultProps} data={[mockTag]} />);
await user.click(screen.getByTestId("tag-actions-test-tag"));
await user.click(await screen.findByTestId("tag-action-edit"));
expect(mockOnEdit).toHaveBeenCalledWith(mockTag);
});
it("should disable delete icon for dynamic spend tags", () => {
it("should delete a tag through the actions menu", async () => {
const user = userEvent.setup();
render(<TagTable {...defaultProps} data={[mockTag]} />);
await user.click(screen.getByTestId("tag-actions-test-tag"));
await user.click(await screen.findByTestId("tag-action-delete"));
expect(mockOnDelete).toHaveBeenCalledWith("test-tag");
});
it("should disable edit and delete for dynamic spend tags", async () => {
const user = userEvent.setup();
render(<TagTable {...defaultProps} data={[mockDynamicSpendTag]} />);
const deleteIcon = screen.getByLabelText("Delete tag (disabled)");
expect(deleteIcon).toBeInTheDocument();
expect(deleteIcon).toHaveClass("cursor-not-allowed");
await user.click(screen.getByTestId("tag-actions-dynamic-spend-tag"));
const editItem = await screen.findByTestId("tag-action-edit");
const deleteItem = await screen.findByTestId("tag-action-delete");
expect(editItem).toHaveAttribute("data-disabled");
expect(deleteItem).toHaveAttribute("data-disabled");
expect(mockOnEdit).not.toHaveBeenCalled();
expect(mockOnDelete).not.toHaveBeenCalled();
});
});

View file

@ -1,227 +1,54 @@
import { ChevronDownIcon, ChevronUpIcon, PencilAltIcon, SwitchVerticalIcon, TrashIcon } from "@heroicons/react/outline";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react";
import { Tooltip } from "antd";
import React from "react";
import { DateCell, IdCell } from "@/components/shared/table_cells";
"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 { Tag } from "@/components/tag_management/types";
import { getTagTableColumns } from "./tagTableColumns";
interface TagTableProps {
data: Tag[];
onEdit: (tag: Tag) => void;
onDelete: (tagName: string) => void;
onSelectTag: (tagName: string) => void;
isLoading?: boolean;
}
const DYNAMIC_SPEND_TAG_DESCRIPTION =
"This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.";
const DEFAULT_SORTING: SortingState = [{ id: "created_at", desc: true }];
const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag }) => {
const [sorting, setSorting] = React.useState<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 tags yet</div>
<div className="text-sm text-muted-foreground">Create a tag to start routing and restricting model usage.</div>
</div>
);
}
const columns: ColumnDef<Tag>[] = [
{
header: "Tag Name",
accessorKey: "name",
cell: ({ row }) => {
const tag = row.original;
const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
return (
<div className="overflow-hidden">
<IdCell
value={tag.name}
truncate={false}
onClick={onSelectTag}
disabled={isDynamicSpendTag}
tooltip={
isDynamicSpendTag ? "You cannot view the information of a dynamically generated spend tag" : tag.name
}
/>
</div>
);
},
},
{
header: "Description",
accessorKey: "description",
cell: ({ row }) => {
const tag = row.original;
return (
<Tooltip title={tag.description}>
<span className="text-xs">{tag.description || "-"}</span>
</Tooltip>
);
},
},
{
header: "Allowed Models",
accessorKey: "models",
cell: ({ row }) => {
const tag = row.original;
return (
<div style={{ display: "flex", flexDirection: "column" }}>
{tag?.models?.length === 0 ? (
<Badge size="xs" className="mb-1" color="red">
All Models
</Badge>
) : (
tag?.models?.map((modelId) => (
<Badge key={modelId} size="xs" className="mb-1" color="blue">
<Tooltip title={`ID: ${modelId}`}>
<Text>{tag.model_info?.[modelId] || modelId}</Text>
</Tooltip>
</Badge>
))
)}
</div>
);
},
},
{
header: "Created",
accessorKey: "created_at",
sortingFn: "datetime",
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => {
const tag = row.original;
const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
return (
<div className="flex space-x-2">
{isDynamicSpendTag ? (
<Tooltip title="Dynamically generated spend tags cannot be edited">
<Icon
icon={PencilAltIcon}
size="sm"
className="opacity-50 cursor-not-allowed"
aria-label="Edit tag (disabled)"
/>
</Tooltip>
) : (
<Tooltip title="Edit tag">
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => onEdit(tag)}
className="cursor-pointer hover:text-blue-500"
/>
</Tooltip>
)}
{isDynamicSpendTag ? (
<Tooltip title="Dynamically generated spend tags cannot be deleted">
<Icon
icon={TrashIcon}
size="sm"
className="opacity-50 cursor-not-allowed"
aria-label="Delete tag (disabled)"
/>
</Tooltip>
) : (
<Tooltip title="Delete tag">
<Icon
icon={TrashIcon}
size="sm"
onClick={() => onDelete(tag.name)}
className="cursor-pointer hover:text-red-500"
/>
</Tooltip>
)}
</div>
);
},
},
];
const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag, isLoading = false }) => {
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
const table = useReactTable({
data,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
const columns = useMemo(() => getTagTableColumns({ onSelectTag, onEdit, onDelete }), [onSelectTag, onEdit, onDelete]);
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${
header.id === "actions" ? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]" : ""
}`}
onClick={header.column.getToggleSortingHandler()}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</div>
{header.id !== "actions" && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No tags found</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTable
data={data}
columns={columns}
getRowId={(tag, index) => tag.name || String(index)}
sortingMode="client"
sorting={sorting}
onSortingChange={setSorting}
isLoading={isLoading}
loadingMessage="Loading tags…"
noDataMessage={<EmptyState />}
size="compact"
/>
);
};

View file

@ -27,6 +27,7 @@ interface TagProps {
const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) => {
const [tags, setTags] = useState<Tag[]>([]);
const [isLoadingTags, setIsLoadingTags] = useState(true);
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
const [editTag, setEditTag] = useState<boolean>(false);
@ -43,6 +44,8 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
} catch (error) {
console.error("Error fetching tags:", error);
NotificationsManager.fromBackend("Error fetching tags: " + error);
} finally {
setIsLoadingTags(false);
}
};
@ -163,6 +166,7 @@ const TagManagement: React.FC<TagProps> = ({ accessToken, userID, userRole }) =>
<Col numColSpan={1}>
<TagTable
data={tags}
isLoading={isLoadingTags}
onEdit={(tag) => {
setSelectedTagId(tag.name);
setEditTag(true);

View file

@ -0,0 +1,170 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { DataTableSortHeader } from "@/components/shared/DataTable";
import { CellTooltip, DateCell, IdentityCell } from "@/components/shared/table_cells";
import { Tag } from "@/components/tag_management/types";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/cva.config";
export const DYNAMIC_SPEND_TAG_DESCRIPTION =
"This is just a spend tag that was passed dynamically in a request. It does not control any LLM models.";
const isDynamicSpendTag = (tag: Tag) => tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION;
function TagNameCell({ tag, onSelectTag }: { tag: Tag; onSelectTag: (tagName: string) => void }) {
if (isDynamicSpendTag(tag)) {
return (
<CellTooltip
content="You cannot view the information of a dynamically generated spend tag"
trigger={<span className="block truncate font-mono text-xs">{tag.name}</span>}
/>
);
}
return (
<IdentityCell
title={tag.name}
titleClassName="font-mono text-xs font-normal"
onClick={() => onSelectTag(tag.name)}
/>
);
}
function TagModelsCell({ tag }: { tag: Tag }) {
const models = tag.models ?? [];
if (models.length === 0) {
return <Badge variant="secondary">All Models</Badge>;
}
return (
<div className="flex flex-wrap items-center gap-1">
{models.map((modelId) => (
<CellTooltip
key={modelId}
content={`ID: ${modelId}`}
trigger={
<Badge variant="outline" className="cursor-default">
{tag.model_info?.[modelId] || modelId}
</Badge>
}
/>
))}
</div>
);
}
interface TagRowActionsProps {
tag: Tag;
onEdit: (tag: Tag) => void;
onDelete: (tagName: string) => void;
}
function TagRowActions({ tag, onEdit, onDelete }: TagRowActionsProps) {
const isDynamic = isDynamicSpendTag(tag);
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open tag actions"
data-testid={`tag-actions-${tag.name}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }), "text-muted-foreground")}
>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
disabled={isDynamic}
data-testid="tag-action-edit"
title={isDynamic ? "Dynamically generated spend tags cannot be edited" : undefined}
onClick={() => onEdit(tag)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={isDynamic}
data-testid="tag-action-delete"
title={isDynamic ? "Dynamically generated spend tags cannot be deleted" : undefined}
onClick={() => onDelete(tag.name)}
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
interface TagTableColumnsDeps {
onSelectTag: (tagName: string) => void;
onEdit: (tag: Tag) => void;
onDelete: (tagName: string) => void;
}
export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableColumnsDeps): ColumnDef<Tag>[] => [
{
id: "name",
accessorKey: "name",
meta: { title: "Tag Name" },
header: ({ column }) => <DataTableSortHeader column={column} title="Tag Name" />,
size: 260,
enableSorting: true,
cell: ({ row }) => <TagNameCell tag={row.original} onSelectTag={onSelectTag} />,
},
{
id: "description",
accessorKey: "description",
meta: { title: "Description" },
header: "Description",
size: 300,
enableSorting: false,
cell: ({ row }) => {
const description = row.original.description;
return (
<span className="block truncate text-sm text-muted-foreground" title={description}>
{description || "-"}
</span>
);
},
},
{
id: "models",
meta: { title: "Allowed Models", skeleton: "chips" },
header: "Allowed Models",
size: 240,
enableSorting: false,
cell: ({ row }) => <TagModelsCell tag={row.original} />,
},
{
id: "created_at",
accessorKey: "created_at",
sortingFn: "datetime",
meta: { title: "Created" },
header: ({ column }) => <DataTableSortHeader column={column} title="Created" />,
size: 150,
enableSorting: true,
cell: ({ row }) => <DateCell value={row.original.created_at} precision="date" />,
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },
header: () => <span className="sr-only">Actions</span>,
size: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<div className="flex justify-end">
<TagRowActions tag={row.original} onEdit={onEdit} onDelete={onDelete} />
</div>
),
},
];