From f67caac8d4963c4709df5add30e978e5dffd06fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnold=20G=C3=A1lovics?= Date: Thu, 1 Oct 2026 05:16:08 +0200 Subject: [PATCH] feat(ui): filter tags by name and description on the Tag Management page (#42949) --- .../_components/TagTable.test.tsx | 73 +++++++++++++++++++ .../tag-management/_components/TagTable.tsx | 42 ++++++++++- .../_components/tagTableColumns.tsx | 2 + 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.test.tsx index 75b78a99128..0fdbe255c16 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await user.type(screen.getByRole("textbox", { name: "Filter by description" }), "invoice"); + expect(screen.getByText("Prod-Billing")).toBeInTheDocument(); + expect(screen.queryByText("bare")).not.toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx index fb4793ab340..d56695a9056 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx @@ -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 ( +
+
+ +
+
No matching tags
+
Try a different tag name or description.
+
+ ); +} + +function TagFilters({ table }: { table: Table }) { + return ( +
+ table.getColumn("name")?.setFilterValue(event.target.value)} + /> + table.getColumn("description")?.setFilterValue(event.target.value)} + /> +
+ ); +} + const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag, isLoading = false }) => { const [sorting, setSorting] = useState(DEFAULT_SORTING); @@ -48,7 +82,9 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag onSortingChange={setSorting} isLoading={isLoading} loadingMessage="Loading tags…" - noDataMessage={} + filterMode="client" + toolbar={(table) => } + noDataMessage={data.length === 0 ? : } size="compact" /> ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx index 1c44ae272c6..1cb56e90038 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/tagTableColumns.tsx @@ -119,6 +119,7 @@ export const getTagTableColumns = ({ onSelectTag, onEdit, onDelete }: TagTableCo header: ({ column }) => , size: 260, enableSorting: true, + filterFn: "includesString", cell: ({ row }) => , }, { @@ -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 (