mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
feat(ui): filter tags by name and description on the Tag Management page (#42949)
This commit is contained in:
parent
ae60fd1b2f
commit
f67caac8d4
3 changed files with 114 additions and 3 deletions
|
|
@ -150,4 +150,77 @@ describe("TagTable", () => {
|
|||
expect(mockOnEdit).not.toHaveBeenCalled();
|
||||
expect(mockOnDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("filters", () => {
|
||||
const prodTag: Tag = { ...mockTag, name: "Prod-Billing", description: "Handles Invoices" };
|
||||
const devTag: Tag = { ...mockTag, name: "dev-billing", description: "Sandbox usage" };
|
||||
const prodOnlyTag: Tag = { ...mockTag, name: "prod-search", description: "Search traffic" };
|
||||
const data = [prodTag, devTag, prodOnlyTag];
|
||||
|
||||
it("should narrow rows by tag name containing the text, ignoring case", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagTable {...defaultProps} data={data} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Filter by tag name" }), "PROD");
|
||||
expect(screen.getByText("Prod-Billing")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod-search")).toBeInTheDocument();
|
||||
expect(screen.queryByText("dev-billing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should match the name anywhere in the string, not only as a prefix", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagTable {...defaultProps} data={data} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Filter by tag name" }), "billing");
|
||||
expect(screen.getByText("Prod-Billing")).toBeInTheDocument();
|
||||
expect(screen.getByText("dev-billing")).toBeInTheDocument();
|
||||
expect(screen.queryByText("prod-search")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should narrow rows by description containing the text", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagTable {...defaultProps} data={data} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Filter by description" }), "invoice");
|
||||
expect(screen.getByText("Prod-Billing")).toBeInTheDocument();
|
||||
expect(screen.queryByText("dev-billing")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("prod-search")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should require both filters to match when both are set", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagTable {...defaultProps} data={data} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Filter by tag name" }), "billing");
|
||||
await user.type(screen.getByRole("textbox", { name: "Filter by description" }), "sandbox");
|
||||
expect(screen.getByText("dev-billing")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Prod-Billing")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("prod-search")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should restore every row when the filters are cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagTable {...defaultProps} data={data} />);
|
||||
const nameFilter = screen.getByRole("textbox", { name: "Filter by tag name" });
|
||||
await user.type(nameFilter, "dev");
|
||||
expect(screen.queryByText("Prod-Billing")).not.toBeInTheDocument();
|
||||
await user.clear(nameFilter);
|
||||
expect(screen.getByText("Prod-Billing")).toBeInTheDocument();
|
||||
expect(screen.getByText("dev-billing")).toBeInTheDocument();
|
||||
expect(screen.getByText("prod-search")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show a no-matching message rather than the empty state when nothing matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<TagTable {...defaultProps} data={data} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Filter by tag name" }), "zzz");
|
||||
expect(screen.getByText("No matching tags")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No tags yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not fail on tags without a description", async () => {
|
||||
const user = userEvent.setup();
|
||||
const noDescription: Tag = { ...mockTag, name: "bare", description: undefined };
|
||||
render(<TagTable {...defaultProps} data={[noDescription, prodTag]} />);
|
||||
await user.type(screen.getByRole("textbox", { name: "Filter by description" }), "invoice");
|
||||
expect(screen.getByText("Prod-Billing")).toBeInTheDocument();
|
||||
expect(screen.queryByText("bare")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { SortingState } from "@tanstack/react-table";
|
||||
import { Inbox } from "lucide-react";
|
||||
import { SortingState, Table } from "@tanstack/react-table";
|
||||
import { Inbox, SearchX } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { Tag } from "@/components/tag_management/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { getTagTableColumns } from "./tagTableColumns";
|
||||
|
||||
|
|
@ -31,6 +32,39 @@ function EmptyState() {
|
|||
);
|
||||
}
|
||||
|
||||
function NoMatchingTags() {
|
||||
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">
|
||||
<SearchX className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">No matching tags</div>
|
||||
<div className="text-sm text-muted-foreground">Try a different tag name or description.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TagFilters({ table }: { table: Table<Tag> }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
aria-label="Filter by tag name"
|
||||
placeholder="Filter by tag name…"
|
||||
className="h-8 w-56"
|
||||
value={(table.getColumn("name")?.getFilterValue() as string | undefined) ?? ""}
|
||||
onChange={(event) => table.getColumn("name")?.setFilterValue(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Filter by description"
|
||||
placeholder="Filter by description…"
|
||||
className="h-8 w-56"
|
||||
value={(table.getColumn("description")?.getFilterValue() as string | undefined) ?? ""}
|
||||
onChange={(event) => table.getColumn("description")?.setFilterValue(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag, isLoading = false }) => {
|
||||
const [sorting, setSorting] = useState<SortingState>(DEFAULT_SORTING);
|
||||
|
||||
|
|
@ -48,7 +82,9 @@ const TagTable: React.FC<TagTableProps> = ({ data, onEdit, onDelete, onSelectTag
|
|||
onSortingChange={setSorting}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading tags…"
|
||||
noDataMessage={<EmptyState />}
|
||||
filterMode="client"
|
||||
toolbar={(table) => <TagFilters table={table} />}
|
||||
noDataMessage={data.length === 0 ? <EmptyState /> : <NoMatchingTags />}
|
||||
size="compact"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableCo
|
|||
header: ({ column }) => <DataTableSortHeader column={column} title="Tag Name" />,
|
||||
size: 260,
|
||||
enableSorting: true,
|
||||
filterFn: "includesString",
|
||||
cell: ({ row }) => <TagNameCell tag={row.original} onSelectTag={onSelectTag} />,
|
||||
},
|
||||
{
|
||||
|
|
@ -128,6 +129,7 @@ export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableCo
|
|||
header: "Description",
|
||||
size: 300,
|
||||
enableSorting: false,
|
||||
filterFn: "includesString",
|
||||
cell: ({ row }) => {
|
||||
const description = row.original.description;
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue