feat(ui): filter tags by name and description on the Tag Management page (#42949)

This commit is contained in:
Arnold Gálovics 2026-10-01 05:16:08 +02:00 • committed by GitHub
parent ae60fd1b2f
commit f67caac8d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 114 additions and 3 deletions

View file

@ -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();
});
});
});

View file

@ -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"
/>
);

View file

@ -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 (