Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);

Expand All @@ -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"
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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} />,
},
{
Expand All @@ -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 (
Expand Down
Loading