From 9012a8470cb0813792c09534ba9f4c4ddcbfe520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timot=C3=A9=C3=A9?= Date: Wed, 27 May 2026 13:22:39 +0100 Subject: [PATCH] feat: add label filtering and sorting to IssueBacklog (#461) --- frontend/src/components/IssueBacklog.test.tsx | 182 ++++++++++++++++++ frontend/src/components/IssueBacklog.tsx | 180 +++++++++++++++-- 2 files changed, 345 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/IssueBacklog.test.tsx diff --git a/frontend/src/components/IssueBacklog.test.tsx b/frontend/src/components/IssueBacklog.test.tsx new file mode 100644 index 0000000..f4f591c --- /dev/null +++ b/frontend/src/components/IssueBacklog.test.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { describe, it, expect, afterEach } from "vitest"; +import { IssueBacklog } from "./IssueBacklog"; +import { OpenIssue } from "../types/stream"; + +const MOCK_ISSUES: OpenIssue[] = [ + { + id: "1", + title: "Fix login bug", + summary: "Users cannot log in with email", + complexity: "Trivial", + points: 100, + labels: ["bug", "auth"], + }, + { + id: "2", + title: "Add dark mode", + summary: "Support system dark mode preference", + complexity: "Medium", + points: 150, + labels: ["enhancement", "ui"], + }, + { + id: "3", + title: "Refactor DB layer", + summary: "Extract DB calls into a service", + complexity: "High", + points: 200, + labels: ["refactor", "backend"], + }, + { + id: "4", + title: "Fix typo in README", + summary: "Small typo on line 42", + complexity: "Trivial", + points: 100, + labels: ["bug", "docs"], + }, +]; + +afterEach(() => cleanup()); + +describe("IssueBacklog — label filtering", () => { + it("renders all issues when no label filter is active", () => { + render(); + expect(screen.getAllByRole("article")).toHaveLength(4); + }); + + it("filter by a single label reduces visible issues", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "bug" })); + + const articles = screen.getAllByRole("article"); + // issues 1 and 4 have the "bug" label + expect(articles).toHaveLength(2); + expect(screen.getByText("Fix login bug")).toBeInTheDocument(); + expect(screen.getByText("Fix typo in README")).toBeInTheDocument(); + expect(screen.queryByText("Add dark mode")).not.toBeInTheDocument(); + expect(screen.queryByText("Refactor DB layer")).not.toBeInTheDocument(); + }); + + it("OR logic: selecting two labels shows issues matching either", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "bug" })); + fireEvent.click(screen.getByRole("button", { name: "ui" })); + + const articles = screen.getAllByRole("article"); + // bug → issues 1,4 | ui → issue 2 + expect(articles).toHaveLength(3); + }); + + it("toggling an active label chip removes it from the filter", () => { + render(); + + const bugChip = screen.getByRole("button", { name: "bug" }); + fireEvent.click(bugChip); // activate + expect(screen.getAllByRole("article")).toHaveLength(2); + + fireEvent.click(bugChip); // deactivate + expect(screen.getAllByRole("article")).toHaveLength(4); + }); + + it("shows empty state message when no issues match the filter", () => { + render(); + + // "docs" label only on issue 4; then also filter "backend" — but let's + // use a label that exists on no issue by passing a custom set + const noMatchIssues: OpenIssue[] = [ + { id: "x", title: "X", summary: "s", complexity: "Trivial", points: 100, labels: ["alpha"] }, + ]; + cleanup(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "alpha" })); + // deactivate to get 0 results — use an issue list where filter yields nothing + // Easier: render with empty issues and no labels + cleanup(); + render(); + expect(screen.getByText(/No issues match/i)).toBeInTheDocument(); + }); + + it("'Clear filters' button resets label selection and shows all issues", () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: "bug" })); + expect(screen.getAllByRole("article")).toHaveLength(2); + + fireEvent.click(screen.getByRole("button", { name: /clear filters/i })); + expect(screen.getAllByRole("article")).toHaveLength(4); + }); + + it("'Clear filters' button is not visible when no filters are active", () => { + render(); + expect(screen.queryByRole("button", { name: /clear filters/i })).not.toBeInTheDocument(); + }); + + it("label chips have aria-pressed reflecting active state", () => { + render(); + + const bugChip = screen.getByRole("button", { name: "bug" }); + expect(bugChip).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(bugChip); + expect(bugChip).toHaveAttribute("aria-pressed", "true"); + }); +}); + +describe("IssueBacklog — sorting", () => { + it("sorts by complexity Trivial → High", () => { + render(); + + const select = screen.getByLabelText(/sort by/i); + fireEvent.change(select, { target: { value: "complexity-asc" } }); + + const articles = screen.getAllByRole("article"); + // Trivial issues first (ids 1,4), then Medium (2), then High (3) + expect(articles[0]).toHaveTextContent("Fix login bug"); + expect(articles[articles.length - 1]).toHaveTextContent("Refactor DB layer"); + }); + + it("sorts by points low → high", () => { + render(); + + fireEvent.change(screen.getByLabelText(/sort by/i), { target: { value: "points-asc" } }); + + const articles = screen.getAllByRole("article"); + // 100, 100, 150, 200 + expect(articles[articles.length - 1]).toHaveTextContent("Refactor DB layer"); + }); + + it("sorts by points high → low", () => { + render(); + + fireEvent.change(screen.getByLabelText(/sort by/i), { target: { value: "points-desc" } }); + + const articles = screen.getAllByRole("article"); + expect(articles[0]).toHaveTextContent("Refactor DB layer"); + }); + + it("'Clear filters' also resets sort to default", () => { + render(); + + const select = screen.getByLabelText(/sort by/i) as HTMLSelectElement; + fireEvent.change(select, { target: { value: "points-desc" } }); + expect(select.value).toBe("points-desc"); + + fireEvent.click(screen.getByRole("button", { name: /clear filters/i })); + expect(select.value).toBe("none"); + }); +}); + +describe("IssueBacklog — loading state", () => { + it("renders skeleton items when loading is true", () => { + render(); + expect(screen.queryByRole("article")).not.toBeInTheDocument(); + // skeletons are divs, not articles — just verify no issue content + expect(screen.queryByText(/No issues match/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/IssueBacklog.tsx b/frontend/src/components/IssueBacklog.tsx index f0fa1c6..3c96c49 100644 --- a/frontend/src/components/IssueBacklog.tsx +++ b/frontend/src/components/IssueBacklog.tsx @@ -1,18 +1,71 @@ +import { useState, useMemo } from "react"; import { OpenIssue } from "../types/stream"; +type SortKey = "none" | "complexity-asc" | "points-asc" | "points-desc"; + +const COMPLEXITY_ORDER: Record = { + Trivial: 0, + Medium: 1, + High: 2, +}; + interface IssueBacklogProps { issues: OpenIssue[]; loading?: boolean; } export function IssueBacklog({ issues, loading }: IssueBacklogProps) { + const [activeLabels, setActiveLabels] = useState([]); + const [sortKey, setSortKey] = useState("none"); + + const allLabels = useMemo(() => { + const set = new Set(); + issues.forEach((issue) => issue.labels.forEach((l) => set.add(l))); + return Array.from(set).sort(); + }, [issues]); + + const toggleLabel = (label: string) => { + setActiveLabels((prev) => + prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label] + ); + }; + + const clearFilters = () => { + setActiveLabels([]); + setSortKey("none"); + }; + + const hasActiveFilters = activeLabels.length > 0 || sortKey !== "none"; + + const visibleIssues = useMemo(() => { + let result = issues; + + if (activeLabels.length > 0) { + result = result.filter((issue) => + activeLabels.some((label) => issue.labels.includes(label)) + ); + } + + if (sortKey === "complexity-asc") { + result = [...result].sort( + (a, b) => COMPLEXITY_ORDER[a.complexity] - COMPLEXITY_ORDER[b.complexity] + ); + } else if (sortKey === "points-asc") { + result = [...result].sort((a, b) => a.points - b.points); + } else if (sortKey === "points-desc") { + result = [...result].sort((a, b) => b.points - a.points); + } + + return result; + }, [issues, activeLabels, sortKey]); + if (loading) { return (

Maintainer Backlog

{[1, 2, 3].map((i) => ( -
+
))}
@@ -23,23 +76,116 @@ export function IssueBacklog({ issues, loading }: IssueBacklogProps) {

Maintainer Backlog

Open these as GitHub issues after publishing the repository.

-
- {issues.map((issue) => ( -
-

{issue.title}

-

{issue.summary}

-

- Complexity: {issue.complexity} | Points: {issue.points} -

-
- {issue.labels.map((label) => ( - + + {/* Controls */} +
+ {/* Label filter chips */} + {allLabels.length > 0 && ( +
+ {allLabels.map((label) => { + const active = activeLabels.includes(label); + return ( +
-
- ))} + + ); + })} +
+ )} + + {/* Sort select */} +
+ + +
+ + {/* Clear filters */} + {hasActiveFilters && ( + + )} +
+ + {/* Results count when filtering */} + {activeLabels.length > 0 && ( +

+ Showing {visibleIssues.length} of {issues.length} issues +

+ )} + + {/* Issue list */} +
+ {visibleIssues.length === 0 ? ( +

No issues match the selected filters.

+ ) : ( + visibleIssues.map((issue) => ( +
+

{issue.title}

+

{issue.summary}

+

+ Complexity: {issue.complexity} | Points: {issue.points} +

+
+ {issue.labels.map((label) => ( + + {label} + + ))} +
+
+ )) + )}
);