From 26af98acdc6749d98e741b8874d2feb4201b2fab Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 13:15:06 -0400 Subject: [PATCH 01/10] feat(ui): adds repo-based collapsible grouping to Issues and PRs tabs --- src/app/components/dashboard/ActionsTab.tsx | 21 +-- src/app/components/dashboard/IssuesTab.tsx | 127 ++++++++++---- src/app/components/dashboard/ItemRow.tsx | 19 ++- .../components/dashboard/PullRequestsTab.tsx | 158 +++++++++++++----- src/app/components/shared/ChevronIcon.tsx | 18 ++ tests/components/IssuesTab.test.tsx | 39 +++++ tests/components/ItemRow.test.tsx | 11 ++ tests/components/PullRequestsTab.test.tsx | 39 +++++ 8 files changed, 334 insertions(+), 98 deletions(-) create mode 100644 src/app/components/shared/ChevronIcon.tsx diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 06f073ad..3896b161 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -8,25 +8,7 @@ import ErrorBannerList from "../shared/ErrorBannerList"; import SkeletonRows from "../shared/SkeletonRows"; import FilterChips from "../shared/FilterChips"; import type { FilterChipGroupDef } from "../shared/FilterChips"; - -function ChevronIcon(props: { size: "sm" | "md"; rotated: boolean }) { - const sizeClass = () => (props.size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"); - return ( - - ); -} +import ChevronIcon from "../shared/ChevronIcon"; interface ActionsTabProps { workflowRuns: WorkflowRun[]; @@ -262,6 +244,7 @@ export default function ActionsTab(props: ActionsTabProps) { {/* Repo header */} + +
+ + {(issue) => ( +
+ handleIgnore(issue)} + density={config.viewDensity} + commentCount={issue.comments} + > + + +
+ )} +
+
+
)} diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 395b9a7e..fd19b830 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -14,6 +14,7 @@ export interface ItemRowProps { onIgnore: () => void; density: "compact" | "comfortable"; commentCount?: number; + hideRepo?: boolean; } export default function ItemRow(props: ItemRowProps) { @@ -43,14 +44,16 @@ export default function ItemRow(props: ItemRowProps) { ${isCompact() ? "px-4 py-2" : "px-4 py-3"}`} > {/* Repo badge */} - - {props.repo} - + + + {props.repo} + + {/* Main content */}
diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 22d97700..468d1f08 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -15,6 +15,7 @@ import ReviewBadge from "../shared/ReviewBadge"; import SizeBadge from "../shared/SizeBadge"; import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; +import ChevronIcon from "../shared/ChevronIcon"; export interface PullRequestsTabProps { pullRequests: PullRequest[]; @@ -101,8 +102,66 @@ const prFilterGroups: FilterChipGroupDef[] = [ }, ]; +interface PrRepoGroup { + repoFullName: string; + items: PullRequest[]; +} + +function groupByRepo(items: PullRequest[]): PrRepoGroup[] { + const groups: PrRepoGroup[] = []; + const map = new Map(); + for (const item of items) { + let group = map.get(item.repoFullName); + if (!group) { + group = { repoFullName: item.repoFullName, items: [] }; + map.set(item.repoFullName, group); + groups.push(group); + } + group.items.push(item); + } + return groups; +} + +function paginateGroups( + groups: PrRepoGroup[], + page: number, + approxPageSize: number, +): { pageGroups: PrRepoGroup[]; pageCount: number } { + if (groups.length === 0) return { pageGroups: [], pageCount: 1 }; + + const pageBoundaries: number[] = [0]; + let currentPageItems = 0; + for (let i = 0; i < groups.length; i++) { + if (currentPageItems > 0 && currentPageItems + groups[i].items.length > approxPageSize) { + pageBoundaries.push(i); + currentPageItems = 0; + } + currentPageItems += groups[i].items.length; + } + + const pageCount = Math.max(1, pageBoundaries.length); + const clampedPage = Math.max(0, Math.min(page, pageCount - 1)); + const start = pageBoundaries[clampedPage]; + const end = clampedPage + 1 < pageBoundaries.length ? pageBoundaries[clampedPage + 1] : groups.length; + + return { pageGroups: groups.slice(start, end), pageCount }; +} + export default function PullRequestsTab(props: PullRequestsTabProps) { const [page, setPage] = createSignal(0); + const [collapsedRepos, setCollapsedRepos] = createSignal>(new Set()); + + function toggleRepo(repoFullName: string) { + setCollapsedRepos((prev) => { + const next = new Set(prev); + if (next.has(repoFullName)) { + next.delete(repoFullName); + } else { + next.add(repoFullName); + } + return next; + }); + } const sortPref = createMemo(() => { const pref = viewState.sortPreferences["pullRequests"]; @@ -195,15 +254,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const pageSize = createMemo(() => config.itemsPerPage); - const pageCount = createMemo(() => - Math.max(1, Math.ceil(filteredSorted().length / pageSize())) - ); - - const pagedItems = createMemo(() => { - const p = Math.min(page(), pageCount() - 1); - const start = p * pageSize(); - return filteredSorted().slice(start, start + pageSize()); - }); + const repoGroups = createMemo(() => groupByRepo(filteredSorted())); + const paginatedResult = createMemo(() => paginateGroups(repoGroups(), page(), pageSize())); + const pageCount = () => paginatedResult().pageCount; function handleSort(field: SortField) { const current = sortPref(); @@ -284,7 +337,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { {/* PR rows */} 0}> 0} + when={paginatedResult().pageGroups.length > 0} fallback={
} > -
- - {(pr) => ( -
- handleIgnore(pr)} - density={config.viewDensity} +
+ + {(repoGroup) => ( +
+ + +
+ + {(pr) => ( +
+ handleIgnore(pr)} + density={config.viewDensity} + > +
+ + + + + + + Draft + + + 0}> + + Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")} + {pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`} + {pr.totalReviewCount > pr.reviewerLogins.length && ` (${pr.totalReviewCount} total)`} + + +
+
+
+ )} +
- +
)}
diff --git a/src/app/components/shared/ChevronIcon.tsx b/src/app/components/shared/ChevronIcon.tsx new file mode 100644 index 00000000..67b5b754 --- /dev/null +++ b/src/app/components/shared/ChevronIcon.tsx @@ -0,0 +1,18 @@ +export default function ChevronIcon(props: { size: "sm" | "md"; rotated: boolean }) { + const sizeClass = () => (props.size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"); + return ( + + ); +} diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 32624cb7..29a65584 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -184,4 +184,43 @@ describe("IssuesTab", () => { render(() => ); screen.getByLabelText("Sort by Comments"); }); + + it("groups issues by repo with collapsible headers", () => { + const issues = [ + makeIssue({ id: 1, title: "Issue in repo A", repoFullName: "org/repo-a" }), + makeIssue({ id: 2, title: "Issue in repo B", repoFullName: "org/repo-b" }), + makeIssue({ id: 3, title: "Another in repo A", repoFullName: "org/repo-a" }), + ]; + render(() => ); + screen.getByText("org/repo-a"); + screen.getByText("org/repo-b"); + screen.getByText("Issue in repo A"); + screen.getByText("Another in repo A"); + screen.getByText("Issue in repo B"); + }); + + it("collapses a repo group when header is clicked", async () => { + const user = userEvent.setup(); + const issues = [ + makeIssue({ id: 1, title: "Visible issue", repoFullName: "org/repo-a" }), + makeIssue({ id: 2, title: "Other repo issue", repoFullName: "org/repo-b" }), + ]; + render(() => ); + screen.getByText("Visible issue"); + + const repoHeader = screen.getByText("org/repo-a"); + await user.click(repoHeader); + + expect(screen.queryByText("Visible issue")).toBeNull(); + screen.getByText("Other repo issue"); + }); + + it("sets aria-expanded on repo group headers", () => { + const issues = [ + makeIssue({ id: 1, title: "Test issue", repoFullName: "org/repo-a" }), + ]; + render(() => ); + const header = screen.getByText("org/repo-a").closest("button")!; + expect(header.getAttribute("aria-expanded")).toBe("true"); + }); }); diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index e70c229f..f51bcf68 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -120,4 +120,15 @@ describe("ItemRow", () => { render(() => ); expect(screen.queryByText("bug")).toBeNull(); }); + + it("hides repo badge when hideRepo is true", () => { + render(() => ); + expect(screen.queryByText("octocat/Hello-World")).toBeNull(); + screen.getByText("Fix a bug"); + }); + + it("shows repo badge when hideRepo is false", () => { + render(() => ); + screen.getByText("octocat/Hello-World"); + }); }); diff --git a/tests/components/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index 4e1e869c..383a51b3 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -263,4 +263,43 @@ describe("PullRequestsTab", () => { screen.getByText("Small PR"); expect(screen.queryByText("Large PR")).toBeNull(); }); + + it("groups PRs by repo with collapsible headers", () => { + const prs = [ + makePullRequest({ id: 1, title: "PR in repo A", repoFullName: "org/repo-a" }), + makePullRequest({ id: 2, title: "PR in repo B", repoFullName: "org/repo-b" }), + makePullRequest({ id: 3, title: "Another in repo A", repoFullName: "org/repo-a" }), + ]; + render(() => ); + screen.getByText("org/repo-a"); + screen.getByText("org/repo-b"); + screen.getByText("PR in repo A"); + screen.getByText("Another in repo A"); + screen.getByText("PR in repo B"); + }); + + it("collapses a repo group when header is clicked", async () => { + const user = userEvent.setup(); + const prs = [ + makePullRequest({ id: 1, title: "Visible PR", repoFullName: "org/repo-a" }), + makePullRequest({ id: 2, title: "Other repo PR", repoFullName: "org/repo-b" }), + ]; + render(() => ); + screen.getByText("Visible PR"); + + const repoHeader = screen.getByText("org/repo-a"); + await user.click(repoHeader); + + expect(screen.queryByText("Visible PR")).toBeNull(); + screen.getByText("Other repo PR"); + }); + + it("sets aria-expanded on repo group headers", () => { + const prs = [ + makePullRequest({ id: 1, title: "Test PR", repoFullName: "org/repo-a" }), + ]; + render(() => ); + const header = screen.getByText("org/repo-a").closest("button")!; + expect(header.getAttribute("aria-expanded")).toBe("true"); + }); }); From 0f82bcdc9c1002ed70fa0c6bd5193a6c72fea4c6 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 14:31:15 -0400 Subject: [PATCH 02/10] perf(ui): replaces collapsedRepos createSignal with createStore Migrates all 3 tabs (ActionsTab, IssuesTab, PullRequestsTab) from createSignal> to createStore> for per-repo fine-grained reactivity. Toggling one repo group no longer re-evaluates aria-expanded, ChevronIcon rotation, and Show conditions for all other groups. Adds 6 new tests: aria-expanded toggle (collapse+re-expand for both tabs), multi-page pagination, and oversized single-group behavior. --- src/app/components/dashboard/ActionsTab.tsx | 35 +++--------- src/app/components/dashboard/IssuesTab.tsx | 19 ++----- .../components/dashboard/PullRequestsTab.tsx | 19 ++----- tests/components/IssuesTab.test.tsx | 57 +++++++++++++++++++ tests/components/PullRequestsTab.test.tsx | 54 ++++++++++++++++++ 5 files changed, 131 insertions(+), 53 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 3896b161..c8f5129c 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -1,4 +1,5 @@ -import { createMemo, createSignal, For, Show } from "solid-js"; +import { createMemo, For, Show } from "solid-js"; +import { createStore } from "solid-js/store"; import type { WorkflowRun, ApiError } from "../../services/api"; import { config } from "../../stores/config"; import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type ActionsFilterField } from "../../stores/view"; @@ -104,35 +105,15 @@ const actionsFilterGroups: FilterChipGroupDef[] = [ ]; export default function ActionsTab(props: ActionsTabProps) { - const [collapsedRepos, setCollapsedRepos] = createSignal>( - new Set() - ); - const [collapsedWorkflows, setCollapsedWorkflows] = createSignal>( - new Set() - ); + const [collapsedRepos, setCollapsedRepos] = createStore>({}); + const [collapsedWorkflows, setCollapsedWorkflows] = createStore>({}); function toggleRepo(repoFullName: string) { - setCollapsedRepos((prev) => { - const next = new Set(prev); - if (next.has(repoFullName)) { - next.delete(repoFullName); - } else { - next.add(repoFullName); - } - return next; - }); + setCollapsedRepos(repoFullName, (v) => !v); } function toggleWorkflow(key: string) { - setCollapsedWorkflows((prev) => { - const next = new Set(prev); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); + setCollapsedWorkflows(key, (v) => !v); } function handleIgnore(run: WorkflowRun) { @@ -237,7 +218,7 @@ export default function ActionsTab(props: ActionsTabProps) { {(repoGroup) => { const isRepoCollapsed = () => - collapsedRepos().has(repoGroup.repoFullName); + collapsedRepos[repoGroup.repoFullName]; return (
@@ -257,7 +238,7 @@ export default function ActionsTab(props: ActionsTabProps) { {(wfGroup) => { const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; const isWfCollapsed = () => - collapsedWorkflows().has(wfKey); + collapsedWorkflows[wfKey]; return (
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 95132a4a..607abb25 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -1,4 +1,5 @@ import { createMemo, createSignal, For, Show } from "solid-js"; +import { createStore } from "solid-js/store"; import { config } from "../../stores/config"; import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type IssueFilterField } from "../../stores/view"; import type { Issue, ApiError } from "../../services/api"; @@ -89,18 +90,10 @@ function paginateGroups( export default function IssuesTab(props: IssuesTabProps) { const [page, setPage] = createSignal(0); - const [collapsedRepos, setCollapsedRepos] = createSignal>(new Set()); + const [collapsedRepos, setCollapsedRepos] = createStore>({}); function toggleRepo(repoFullName: string) { - setCollapsedRepos((prev) => { - const next = new Set(prev); - if (next.has(repoFullName)) { - next.delete(repoFullName); - } else { - next.add(repoFullName); - } - return next; - }); + setCollapsedRepos(repoFullName, (v) => !v); } const sortPref = createMemo(() => { @@ -295,13 +288,13 @@ export default function IssuesTab(props: IssuesTabProps) {
- +
{(issue) => ( diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 468d1f08..2afd86ad 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -1,4 +1,5 @@ import { createMemo, createSignal, For, Show } from "solid-js"; +import { createStore } from "solid-js/store"; import { config } from "../../stores/config"; import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, type PullRequestFilterField } from "../../stores/view"; import type { PullRequest, ApiError } from "../../services/api"; @@ -149,18 +150,10 @@ function paginateGroups( export default function PullRequestsTab(props: PullRequestsTabProps) { const [page, setPage] = createSignal(0); - const [collapsedRepos, setCollapsedRepos] = createSignal>(new Set()); + const [collapsedRepos, setCollapsedRepos] = createStore>({}); function toggleRepo(repoFullName: string) { - setCollapsedRepos((prev) => { - const next = new Set(prev); - if (next.has(repoFullName)) { - next.delete(repoFullName); - } else { - next.add(repoFullName); - } - return next; - }); + setCollapsedRepos(repoFullName, (v) => !v); } const sortPref = createMemo(() => { @@ -367,13 +360,13 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
- +
{(pr) => ( diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 29a65584..9cc76607 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -5,9 +5,11 @@ import IssuesTab from "../../src/app/components/dashboard/IssuesTab"; import type { ApiError } from "../../src/app/services/api"; import { makeIssue, resetViewStore } from "../helpers/index"; import * as viewStore from "../../src/app/stores/view"; +import { updateConfig, resetConfig } from "../../src/app/stores/config"; beforeEach(() => { resetViewStore(); + resetConfig(); }); describe("IssuesTab", () => { @@ -223,4 +225,59 @@ describe("IssuesTab", () => { const header = screen.getByText("org/repo-a").closest("button")!; expect(header.getAttribute("aria-expanded")).toBe("true"); }); + + it("toggles aria-expanded to false on collapse and back to true on re-expand", async () => { + const user = userEvent.setup(); + const issues = [ + makeIssue({ id: 1, title: "Toggle issue", repoFullName: "org/repo-a" }), + ]; + render(() => ); + const header = screen.getByText("org/repo-a").closest("button")!; + + expect(header.getAttribute("aria-expanded")).toBe("true"); + await user.click(header); + expect(header.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByText("Toggle issue")).toBeNull(); + + await user.click(header); + expect(header.getAttribute("aria-expanded")).toBe("true"); + screen.getByText("Toggle issue"); + }); + + it("paginates repo groups across pages", async () => { + const user = userEvent.setup(); + updateConfig({ itemsPerPage: 10 }); + const issues = [ + ...Array.from({ length: 6 }, (_, i) => + makeIssue({ id: 100 + i, title: `Repo A issue ${i}`, repoFullName: "org/repo-a" }) + ), + ...Array.from({ length: 6 }, (_, i) => + makeIssue({ id: 200 + i, title: `Repo B issue ${i}`, repoFullName: "org/repo-b" }) + ), + ]; + render(() => ); + // Page 1: repo-a (6 items), Page 2: repo-b (6 items) — 12 total > pageSize 10 + screen.getByText("org/repo-a"); + screen.getByText(/Page 1 of 2/); + expect(screen.queryByText("org/repo-b")).toBeNull(); + + const nextBtn = screen.getByLabelText("Next page"); + await user.click(nextBtn); + screen.getByText("org/repo-b"); + screen.getByText(/Page 2 of 2/); + }); + + it("keeps a large single-repo group on one page without splitting", () => { + updateConfig({ itemsPerPage: 10 }); + const issues = Array.from({ length: 15 }, (_, i) => + makeIssue({ id: 300 + i, title: `Big repo issue ${i}`, repoFullName: "org/big-repo" }) + ); + render(() => ); + // All 15 items in one group — whole-groups-only pagination keeps them together + screen.getByText("org/big-repo"); + screen.getByText("Big repo issue 0"); + screen.getByText("Big repo issue 14"); + // No pagination controls (single page with oversized group) + expect(screen.queryByLabelText("Next page")).toBeNull(); + }); }); diff --git a/tests/components/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index 383a51b3..34cc74de 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -5,9 +5,11 @@ import PullRequestsTab from "../../src/app/components/dashboard/PullRequestsTab" import type { ApiError } from "../../src/app/services/api"; import * as viewStore from "../../src/app/stores/view"; import { makePullRequest, resetViewStore } from "../helpers/index"; +import { updateConfig, resetConfig } from "../../src/app/stores/config"; beforeEach(() => { resetViewStore(); + resetConfig(); }); describe("PullRequestsTab", () => { @@ -302,4 +304,56 @@ describe("PullRequestsTab", () => { const header = screen.getByText("org/repo-a").closest("button")!; expect(header.getAttribute("aria-expanded")).toBe("true"); }); + + it("toggles aria-expanded to false on collapse and back to true on re-expand", async () => { + const user = userEvent.setup(); + const prs = [ + makePullRequest({ id: 1, title: "Toggle PR", repoFullName: "org/repo-a" }), + ]; + render(() => ); + const header = screen.getByText("org/repo-a").closest("button")!; + + expect(header.getAttribute("aria-expanded")).toBe("true"); + await user.click(header); + expect(header.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByText("Toggle PR")).toBeNull(); + + await user.click(header); + expect(header.getAttribute("aria-expanded")).toBe("true"); + screen.getByText("Toggle PR"); + }); + + it("paginates repo groups across pages", async () => { + const user = userEvent.setup(); + updateConfig({ itemsPerPage: 10 }); + const prs = [ + ...Array.from({ length: 6 }, (_, i) => + makePullRequest({ id: 100 + i, title: `Repo A PR ${i}`, repoFullName: "org/repo-a" }) + ), + ...Array.from({ length: 6 }, (_, i) => + makePullRequest({ id: 200 + i, title: `Repo B PR ${i}`, repoFullName: "org/repo-b" }) + ), + ]; + render(() => ); + screen.getByText("org/repo-a"); + screen.getByText(/Page 1 of 2/); + expect(screen.queryByText("org/repo-b")).toBeNull(); + + const nextBtn = screen.getByLabelText("Next page"); + await user.click(nextBtn); + screen.getByText("org/repo-b"); + screen.getByText(/Page 2 of 2/); + }); + + it("keeps a large single-repo group on one page without splitting", () => { + updateConfig({ itemsPerPage: 10 }); + const prs = Array.from({ length: 15 }, (_, i) => + makePullRequest({ id: 300 + i, title: `Big repo PR ${i}`, repoFullName: "org/big-repo" }) + ); + render(() => ); + screen.getByText("org/big-repo"); + screen.getByText("Big repo PR 0"); + screen.getByText("Big repo PR 14"); + expect(screen.queryByLabelText("Next page")).toBeNull(); + }); }); From 52144bd6aaf4a5f5f77905660fc0b95a3e50fc0d Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 14:35:39 -0400 Subject: [PATCH 03/10] test(ui): adds collapse-filter interaction test for IssuesTab --- tests/components/IssuesTab.test.tsx | 30 +++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 9cc76607..fc7d7498 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -267,6 +267,36 @@ describe("IssuesTab", () => { screen.getByText(/Page 2 of 2/); }); + it("preserves collapse state across filter changes", async () => { + const user = userEvent.setup(); + const issues = [ + makeIssue({ id: 1, title: "Alice issue", repoFullName: "org/repo-a", userLogin: "alice" }), + makeIssue({ id: 2, title: "Bob issue", repoFullName: "org/repo-b", userLogin: "bob" }), + ]; + render(() => ); + + // Collapse repo-a + const repoHeader = screen.getByText("org/repo-a").closest("button")!; + await user.click(repoHeader); + expect(screen.queryByText("Alice issue")).toBeNull(); + expect(repoHeader.getAttribute("aria-expanded")).toBe("false"); + + // Apply role filter that keeps only alice's issue (repo-a) + viewStore.setTabFilter("issues", "role", "author"); + // repo-a still visible (collapsed), repo-b filtered out + screen.getByText("org/repo-a"); + expect(screen.queryByText("org/repo-b")).toBeNull(); + // Items still hidden because collapse state persists + expect(screen.queryByText("Alice issue")).toBeNull(); + + // Clear filter — repo-b reappears, repo-a stays collapsed + viewStore.resetTabFilter("issues", "role"); + screen.getByText("org/repo-a"); + screen.getByText("org/repo-b"); + expect(screen.queryByText("Alice issue")).toBeNull(); + screen.getByText("Bob issue"); + }); + it("keeps a large single-repo group on one page without splitting", () => { updateConfig({ itemsPerPage: 10 }); const issues = Array.from({ length: 15 }, (_, i) => From 7f97f65b9ab6e89c9c5605dcbf58f94a13f53f8f Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 14:38:49 -0400 Subject: [PATCH 04/10] fix: wraps filteredSorted/meta accessors in createMemo, fixes test gaps --- src/app/components/dashboard/IssuesTab.tsx | 4 ++-- src/app/components/dashboard/PullRequestsTab.tsx | 4 ++-- src/app/components/shared/ChevronIcon.tsx | 4 +++- tests/components/IssuesTab.test.tsx | 13 +++++-------- tests/components/PullRequestsTab.test.tsx | 3 +-- 5 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 607abb25..33bd70cb 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -162,8 +162,8 @@ export default function IssuesTab(props: IssuesTabProps) { return { items, meta }; }); - const filteredSorted = () => filteredSortedWithMeta().items; - const issueMeta = () => filteredSortedWithMeta().meta; + const filteredSorted = createMemo(() => filteredSortedWithMeta().items); + const issueMeta = createMemo(() => filteredSortedWithMeta().meta); const pageSize = createMemo(() => config.itemsPerPage); diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 2afd86ad..dbecac13 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -242,8 +242,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { return { items, meta }; }); - const filteredSorted = () => filteredSortedWithMeta().items; - const prMeta = () => filteredSortedWithMeta().meta; + const filteredSorted = createMemo(() => filteredSortedWithMeta().items); + const prMeta = createMemo(() => filteredSortedWithMeta().meta); const pageSize = createMemo(() => config.itemsPerPage); diff --git a/src/app/components/shared/ChevronIcon.tsx b/src/app/components/shared/ChevronIcon.tsx index 67b5b754..2c3a998e 100644 --- a/src/app/components/shared/ChevronIcon.tsx +++ b/src/app/components/shared/ChevronIcon.tsx @@ -1,5 +1,7 @@ +import { createMemo } from "solid-js"; + export default function ChevronIcon(props: { size: "sm" | "md"; rotated: boolean }) { - const sizeClass = () => (props.size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"); + const sizeClass = createMemo(() => (props.size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5")); return ( { it("filters by globalFilter.org", () => { const issues = [ makeIssue({ number: 1, title: "In org", repoFullName: "myorg/repo-a" }), - makeIssue({ number: 2, title: "Outside org", repoFullName: "otherorge/repo-b" }), + makeIssue({ number: 2, title: "Outside org", repoFullName: "otherorg/repo-b" }), ]; viewStore.setGlobalFilter("myorg", null); render(() => ); @@ -118,19 +118,16 @@ describe("IssuesTab", () => { it("toggles sort direction on second click of same column", async () => { const user = userEvent.setup(); - const setSortSpy = vi.spyOn(viewStore, "setSortPreference"); const issues = [makeIssue({ title: "Issue A" })]; render(() => ); const titleHeader = screen.getByLabelText(/Sort by Title/i); - // First click: sets desc + // First click: title was not active, so sets desc await user.click(titleHeader); - // Simulate sort pref being updated to title/desc (spy already called) - // Second click should toggle to asc + expect(viewStore.viewState.sortPreferences["issues"]).toEqual({ field: "title", direction: "desc" }); + // Second click on same column: toggles to asc await user.click(titleHeader); - - expect(setSortSpy).toHaveBeenCalledTimes(2); - setSortSpy.mockRestore(); + expect(viewStore.viewState.sortPreferences["issues"]).toEqual({ field: "title", direction: "asc" }); }); it("does not show pagination when there is only one page", () => { diff --git a/tests/components/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index 34cc74de..1be0b834 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -81,7 +81,7 @@ describe("PullRequestsTab", () => { it("filters by globalFilter.org", () => { const prs = [ makePullRequest({ number: 1, title: "In org", repoFullName: "myorg/repo-a" }), - makePullRequest({ number: 2, title: "Outside org", repoFullName: "otherorge/repo-b" }), + makePullRequest({ number: 2, title: "Outside org", repoFullName: "otherorg/repo-b" }), ]; viewStore.setGlobalFilter("myorg", null); render(() => ); @@ -208,7 +208,6 @@ describe("PullRequestsTab", () => { render(() => ); screen.getByText("My PR"); expect(screen.queryByText("Other PR")).toBeNull(); - viewStore.resetTabFilter("pullRequests", "role"); }); it("filters by reviewDecision tab filter", () => { From f98ac92055d0ba44585fa023314fcff781110aa9 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 14:41:29 -0400 Subject: [PATCH 05/10] perf(ui): splits paginateGroups into pageLayout and pageGroups memos --- src/app/components/dashboard/IssuesTab.tsx | 39 ++++++++++++------- .../components/dashboard/PullRequestsTab.tsx | 39 ++++++++++++------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 33bd70cb..7c83a727 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -63,29 +63,35 @@ function groupByRepo(items: Issue[]): IssueRepoGroup[] { return groups; } -function paginateGroups( +function computePageLayout( groups: IssueRepoGroup[], - page: number, approxPageSize: number, -): { pageGroups: IssueRepoGroup[]; pageCount: number } { - if (groups.length === 0) return { pageGroups: [], pageCount: 1 }; +): { boundaries: number[]; pageCount: number } { + if (groups.length === 0) return { boundaries: [0], pageCount: 1 }; - const pageBoundaries: number[] = [0]; + const boundaries: number[] = [0]; let currentPageItems = 0; for (let i = 0; i < groups.length; i++) { if (currentPageItems > 0 && currentPageItems + groups[i].items.length > approxPageSize) { - pageBoundaries.push(i); + boundaries.push(i); currentPageItems = 0; } currentPageItems += groups[i].items.length; } - const pageCount = Math.max(1, pageBoundaries.length); - const clampedPage = Math.max(0, Math.min(page, pageCount - 1)); - const start = pageBoundaries[clampedPage]; - const end = clampedPage + 1 < pageBoundaries.length ? pageBoundaries[clampedPage + 1] : groups.length; + return { boundaries, pageCount: Math.max(1, boundaries.length) }; +} - return { pageGroups: groups.slice(start, end), pageCount }; +function slicePageGroups( + groups: IssueRepoGroup[], + boundaries: number[], + pageCount: number, + page: number, +): IssueRepoGroup[] { + const clampedPage = Math.max(0, Math.min(page, pageCount - 1)); + const start = boundaries[clampedPage]; + const end = clampedPage + 1 < boundaries.length ? boundaries[clampedPage + 1] : groups.length; + return groups.slice(start, end); } export default function IssuesTab(props: IssuesTabProps) { @@ -168,8 +174,11 @@ export default function IssuesTab(props: IssuesTabProps) { const pageSize = createMemo(() => config.itemsPerPage); const repoGroups = createMemo(() => groupByRepo(filteredSorted())); - const paginatedResult = createMemo(() => paginateGroups(repoGroups(), page(), pageSize())); - const pageCount = () => paginatedResult().pageCount; + const pageLayout = createMemo(() => computePageLayout(repoGroups(), pageSize())); + const pageCount = () => pageLayout().pageCount; + const pageGroups = createMemo(() => + slicePageGroups(repoGroups(), pageLayout().boundaries, pageLayout().pageCount, page()) + ); function handleSort(field: SortField) { const current = sortPref(); @@ -258,7 +267,7 @@ export default function IssuesTab(props: IssuesTabProps) { {/* Issue rows */} 0}> 0} + when={pageGroups().length > 0} fallback={
- + {(repoGroup) => (
- -
- - {(issue) => ( -
- handleIgnore(issue)} - density={config.viewDensity} - commentCount={issue.comments} - > - - -
- )} -
-
-
-
- )} + {(repoGroup) => { + const isRepoCollapsed = () => collapsedRepos[repoGroup.repoFullName]; + return ( +
+ + +
+ + {(issue) => ( +
+ handleIgnore(issue)} + density={config.viewDensity} + commentCount={issue.comments} + > + + +
+ )} +
+
+
+
+ ); + }}
diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 94c318d4..d91eee0c 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -17,6 +17,7 @@ import SizeBadge from "../shared/SizeBadge"; import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; import ChevronIcon from "../shared/ChevronIcon"; +import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/grouping"; export interface PullRequestsTabProps { pullRequests: PullRequest[]; @@ -103,57 +104,6 @@ const prFilterGroups: FilterChipGroupDef[] = [ }, ]; -interface PrRepoGroup { - repoFullName: string; - items: PullRequest[]; -} - -function groupByRepo(items: PullRequest[]): PrRepoGroup[] { - const groups: PrRepoGroup[] = []; - const map = new Map(); - for (const item of items) { - let group = map.get(item.repoFullName); - if (!group) { - group = { repoFullName: item.repoFullName, items: [] }; - map.set(item.repoFullName, group); - groups.push(group); - } - group.items.push(item); - } - return groups; -} - -function computePageLayout( - groups: PrRepoGroup[], - approxPageSize: number, -): { boundaries: number[]; pageCount: number } { - if (groups.length === 0) return { boundaries: [0], pageCount: 1 }; - - const boundaries: number[] = [0]; - let currentPageItems = 0; - for (let i = 0; i < groups.length; i++) { - if (currentPageItems > 0 && currentPageItems + groups[i].items.length > approxPageSize) { - boundaries.push(i); - currentPageItems = 0; - } - currentPageItems += groups[i].items.length; - } - - return { boundaries, pageCount: Math.max(1, boundaries.length) }; -} - -function slicePageGroups( - groups: PrRepoGroup[], - boundaries: number[], - pageCount: number, - page: number, -): PrRepoGroup[] { - const clampedPage = Math.max(0, Math.min(page, pageCount - 1)); - const start = boundaries[clampedPage]; - const end = clampedPage + 1 < boundaries.length ? boundaries[clampedPage + 1] : groups.length; - return groups.slice(start, end); -} - export default function PullRequestsTab(props: PullRequestsTabProps) { const [page, setPage] = createSignal(0); const [collapsedRepos, setCollapsedRepos] = createStore>({}); @@ -251,13 +201,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const filteredSorted = createMemo(() => filteredSortedWithMeta().items); const prMeta = createMemo(() => filteredSortedWithMeta().meta); - const pageSize = createMemo(() => config.itemsPerPage); - const repoGroups = createMemo(() => groupByRepo(filteredSorted())); - const pageLayout = createMemo(() => computePageLayout(repoGroups(), pageSize())); - const pageCount = () => pageLayout().pageCount; + const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); + const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => - slicePageGroups(repoGroups(), pageLayout().boundaries, pageLayout().pageCount, page()) + slicePageGroups(repoGroups(), pageLayout().boundaries, pageCount(), page()) ); createEffect(() => { @@ -330,9 +278,18 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { { setTabFilter("pullRequests", field as PullRequestFilterField, value); setPage(0); }} - onReset={(field) => { resetTabFilter("pullRequests", field as PullRequestFilterField); setPage(0); }} - onResetAll={() => { resetAllTabFilters("pullRequests"); setPage(0); }} + onChange={(field, value) => { + setTabFilter("pullRequests", field as PullRequestFilterField, value); + setPage(0); + }} + onReset={(field) => { + resetTabFilter("pullRequests", field as PullRequestFilterField); + setPage(0); + }} + onResetAll={() => { + resetAllTabFilters("pullRequests"); + setPage(0); + }} />
@@ -370,60 +327,63 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { >
- {(repoGroup) => ( -
- - -
- - {(pr) => ( -
- handleIgnore(pr)} - density={config.viewDensity} - > -
- - - - - - - Draft - - - 0}> - - Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")} - {pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`} - {pr.totalReviewCount > pr.reviewerLogins.length && ` (${pr.totalReviewCount} total)`} - - -
-
-
- )} -
-
-
-
- )} + {(repoGroup) => { + const isRepoCollapsed = () => collapsedRepos[repoGroup.repoFullName]; + return ( +
+ + +
+ + {(pr) => ( +
+ handleIgnore(pr)} + density={config.viewDensity} + > +
+ + + + + + + Draft + + + 0}> + + Reviewers: {pr.reviewerLogins.slice(0, 5).join(", ")} + {pr.reviewerLogins.length > 5 && ` +${pr.reviewerLogins.length - 5} more`} + {pr.totalReviewCount > pr.reviewerLogins.length && ` (${pr.totalReviewCount} total)`} + + +
+
+
+ )} +
+
+
+
+ ); + }}
diff --git a/src/app/components/shared/ChevronIcon.tsx b/src/app/components/shared/ChevronIcon.tsx index 2c3a998e..67b5b754 100644 --- a/src/app/components/shared/ChevronIcon.tsx +++ b/src/app/components/shared/ChevronIcon.tsx @@ -1,7 +1,5 @@ -import { createMemo } from "solid-js"; - export default function ChevronIcon(props: { size: "sm" | "md"; rotated: boolean }) { - const sizeClass = createMemo(() => (props.size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5")); + const sizeClass = () => (props.size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5"); return ( { + repoFullName: string; + items: T[]; +} + +export function groupByRepo(items: T[]): RepoGroup[] { + const groups: RepoGroup[] = []; + const map = new Map>(); + for (const item of items) { + let group = map.get(item.repoFullName); + if (!group) { + group = { repoFullName: item.repoFullName, items: [] }; + map.set(item.repoFullName, group); + groups.push(group); + } + group.items.push(item); + } + return groups; +} + +export function computePageLayout( + groups: RepoGroup[], + approxPageSize: number, +): { boundaries: number[]; pageCount: number } { + if (groups.length === 0) return { boundaries: [0], pageCount: 1 }; + + const boundaries: number[] = [0]; + let currentPageItems = 0; + for (let i = 0; i < groups.length; i++) { + if (currentPageItems > 0 && currentPageItems + groups[i].items.length > approxPageSize) { + boundaries.push(i); + currentPageItems = 0; + } + currentPageItems += groups[i].items.length; + } + + return { boundaries, pageCount: Math.max(1, boundaries.length) }; +} + +export function slicePageGroups( + groups: RepoGroup[], + boundaries: number[], + pageCount: number, + page: number, +): RepoGroup[] { + const clampedPage = Math.max(0, Math.min(page, pageCount - 1)); + const start = boundaries[clampedPage]; + const end = clampedPage + 1 < boundaries.length ? boundaries[clampedPage + 1] : groups.length; + return groups.slice(start, end); +} diff --git a/tests/components/ActionsTab.test.tsx b/tests/components/ActionsTab.test.tsx index 11308e44..0b1f9dc0 100644 --- a/tests/components/ActionsTab.test.tsx +++ b/tests/components/ActionsTab.test.tsx @@ -192,4 +192,62 @@ describe("ActionsTab", () => { screen.getByText("push-run"); expect(screen.queryByText("schedule-run")).toBeNull(); }); + + it("sets aria-expanded on repo group header", () => { + const runs = [ + makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "CI" }), + ]; + render(() => ); + const repoHeader = screen.getByText("owner/repo").closest("button")!; + expect(repoHeader.getAttribute("aria-expanded")).toBe("true"); + }); + + it("toggles repo aria-expanded on click", async () => { + const user = userEvent.setup(); + const runs = [ + makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "CI" }), + ]; + render(() => ); + const repoHeader = screen.getByText("owner/repo").closest("button")!; + expect(repoHeader.getAttribute("aria-expanded")).toBe("true"); + + await user.click(repoHeader); + expect(repoHeader.getAttribute("aria-expanded")).toBe("false"); + + await user.click(repoHeader); + expect(repoHeader.getAttribute("aria-expanded")).toBe("true"); + }); + + it("sets aria-expanded on workflow group header", () => { + const runs = [ + makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "MyWorkflow" }), + ]; + render(() => ); + const buttons = screen.getAllByRole("button"); + const wfHeader = buttons.find( + (b) => b.textContent?.includes("MyWorkflow") && !b.textContent?.includes("owner/repo") + )!; + expect(wfHeader.getAttribute("aria-expanded")).toBe("true"); + }); + + it("toggles workflow aria-expanded on click", async () => { + const user = userEvent.setup(); + const runs = [ + makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "MyWorkflow", displayTitle: "wf-run" }), + ]; + render(() => ); + const buttons = screen.getAllByRole("button"); + const wfHeader = buttons.find( + (b) => b.textContent?.includes("MyWorkflow") && !b.textContent?.includes("owner/repo") + )!; + expect(wfHeader.getAttribute("aria-expanded")).toBe("true"); + + await user.click(wfHeader); + expect(wfHeader.getAttribute("aria-expanded")).toBe("false"); + expect(screen.queryByText("wf-run")).toBeNull(); + + await user.click(wfHeader); + expect(wfHeader.getAttribute("aria-expanded")).toBe("true"); + screen.getByText("wf-run"); + }); }); diff --git a/tests/components/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index 1be0b834..d118f44b 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; +import { createSignal } from "solid-js"; import PullRequestsTab from "../../src/app/components/dashboard/PullRequestsTab"; -import type { ApiError } from "../../src/app/services/api"; +import type { PullRequest, ApiError } from "../../src/app/services/api"; import * as viewStore from "../../src/app/stores/view"; import { makePullRequest, resetViewStore } from "../helpers/index"; import { updateConfig, resetConfig } from "../../src/app/stores/config"; @@ -355,4 +356,29 @@ describe("PullRequestsTab", () => { screen.getByText("Big repo PR 14"); expect(screen.queryByLabelText("Next page")).toBeNull(); }); + + it("resets page when data shrinks below current page", async () => { + const user = userEvent.setup(); + updateConfig({ itemsPerPage: 10 }); + const repoAPrs = Array.from({ length: 6 }, (_, i) => + makePullRequest({ id: 100 + i, title: `Repo A PR ${i}`, repoFullName: "org/repo-a" }) + ); + const repoBPrs = Array.from({ length: 6 }, (_, i) => + makePullRequest({ id: 200 + i, title: `Repo B PR ${i}`, repoFullName: "org/repo-b" }) + ); + const [prs, setPrs] = createSignal([...repoAPrs, ...repoBPrs]); + render(() => ); + + // Navigate to page 2 + screen.getByText(/Page 1 of 2/); + await user.click(screen.getByLabelText("Next page")); + screen.getByText(/Page 2 of 2/); + screen.getByText("org/repo-b"); + + // Shrink data to fit on 1 page — page should reset + setPrs(repoAPrs); + expect(screen.queryByLabelText("Next page")).toBeNull(); + screen.getByText("org/repo-a"); + screen.getByText("Repo A PR 0"); + }); }); diff --git a/tests/lib/grouping.test.ts b/tests/lib/grouping.test.ts new file mode 100644 index 00000000..e9a7ce46 --- /dev/null +++ b/tests/lib/grouping.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { groupByRepo, computePageLayout, slicePageGroups, type RepoGroup } from "../../src/app/lib/grouping"; + +interface Item { + repoFullName: string; + id: number; +} + +function makeItem(repo: string, id: number): Item { + return { repoFullName: repo, id }; +} + +function makeGroup(repo: string, count: number): RepoGroup { + return { + repoFullName: repo, + items: Array.from({ length: count }, (_, i) => makeItem(repo, i)), + }; +} + +describe("groupByRepo", () => { + it("groups items by repoFullName preserving insertion order", () => { + const items = [ + makeItem("org/a", 1), + makeItem("org/b", 2), + makeItem("org/a", 3), + ]; + const groups = groupByRepo(items); + expect(groups).toHaveLength(2); + expect(groups[0].repoFullName).toBe("org/a"); + expect(groups[0].items).toHaveLength(2); + expect(groups[1].repoFullName).toBe("org/b"); + expect(groups[1].items).toHaveLength(1); + }); + + it("returns empty array for empty input", () => { + expect(groupByRepo([])).toEqual([]); + }); + + it("returns single group when all items share a repo", () => { + const items = [makeItem("org/repo", 1), makeItem("org/repo", 2)]; + const groups = groupByRepo(items); + expect(groups).toHaveLength(1); + expect(groups[0].items).toHaveLength(2); + }); +}); + +describe("computePageLayout", () => { + it("returns single page for empty groups", () => { + const result = computePageLayout([], 10); + expect(result).toEqual({ boundaries: [0], pageCount: 1 }); + }); + + it("keeps all groups on one page when total items <= pageSize", () => { + const groups = [makeGroup("org/a", 3), makeGroup("org/b", 4)]; + const result = computePageLayout(groups, 10); + expect(result).toEqual({ boundaries: [0], pageCount: 1 }); + }); + + it("splits groups across pages when total exceeds pageSize", () => { + const groups = [makeGroup("org/a", 6), makeGroup("org/b", 6)]; + const result = computePageLayout(groups, 10); + expect(result).toEqual({ boundaries: [0, 1], pageCount: 2 }); + }); + + it("does not split a single oversized group", () => { + const groups = [makeGroup("org/big", 20)]; + const result = computePageLayout(groups, 10); + expect(result).toEqual({ boundaries: [0], pageCount: 1 }); + }); + + it("keeps groups exactly at pageSize on one page", () => { + const groups = [makeGroup("org/a", 5), makeGroup("org/b", 5)]; + const result = computePageLayout(groups, 10); + expect(result).toEqual({ boundaries: [0], pageCount: 1 }); + }); + + it("splits when adding next group exceeds pageSize", () => { + const groups = [makeGroup("org/a", 5), makeGroup("org/b", 6)]; + const result = computePageLayout(groups, 10); + expect(result).toEqual({ boundaries: [0, 1], pageCount: 2 }); + }); + + it("handles three pages", () => { + const groups = [ + makeGroup("org/a", 6), + makeGroup("org/b", 6), + makeGroup("org/c", 6), + ]; + const result = computePageLayout(groups, 10); + expect(result).toEqual({ boundaries: [0, 1, 2], pageCount: 3 }); + }); + + it("packs multiple small groups onto one page", () => { + const groups = [ + makeGroup("org/a", 2), + makeGroup("org/b", 3), + makeGroup("org/c", 4), + ]; + const result = computePageLayout(groups, 10); + expect(result).toEqual({ boundaries: [0], pageCount: 1 }); + }); + + it("splits when small groups accumulate past pageSize", () => { + const groups = [ + makeGroup("org/a", 4), + makeGroup("org/b", 4), + makeGroup("org/c", 4), + ]; + const result = computePageLayout(groups, 10); + expect(result).toEqual({ boundaries: [0, 2], pageCount: 2 }); + }); +}); + +describe("slicePageGroups", () => { + const groups = [ + makeGroup("org/a", 6), + makeGroup("org/b", 6), + makeGroup("org/c", 6), + ]; + const boundaries = [0, 1, 2]; + const pageCount = 3; + + it("returns first page groups", () => { + const result = slicePageGroups(groups, boundaries, pageCount, 0); + expect(result).toHaveLength(1); + expect(result[0].repoFullName).toBe("org/a"); + }); + + it("returns middle page groups", () => { + const result = slicePageGroups(groups, boundaries, pageCount, 1); + expect(result).toHaveLength(1); + expect(result[0].repoFullName).toBe("org/b"); + }); + + it("returns last page groups", () => { + const result = slicePageGroups(groups, boundaries, pageCount, 2); + expect(result).toHaveLength(1); + expect(result[0].repoFullName).toBe("org/c"); + }); + + it("clamps page below zero", () => { + const result = slicePageGroups(groups, boundaries, pageCount, -1); + expect(result).toHaveLength(1); + expect(result[0].repoFullName).toBe("org/a"); + }); + + it("clamps page above max", () => { + const result = slicePageGroups(groups, boundaries, pageCount, 99); + expect(result).toHaveLength(1); + expect(result[0].repoFullName).toBe("org/c"); + }); + + it("returns all groups when single page", () => { + const singleBoundaries = [0]; + const result = slicePageGroups(groups, singleBoundaries, 1, 0); + expect(result).toHaveLength(3); + }); + + it("returns multiple groups packed on one page", () => { + // boundaries [0, 2] means page 0 has groups 0-1, page 1 has group 2 + const result = slicePageGroups(groups, [0, 2], 2, 0); + expect(result).toHaveLength(2); + expect(result[0].repoFullName).toBe("org/a"); + expect(result[1].repoFullName).toBe("org/b"); + }); +}); diff --git a/tests/services/api.test.ts b/tests/services/api.test.ts index c7c22d27..c98f79c4 100644 --- a/tests/services/api.test.ts +++ b/tests/services/api.test.ts @@ -715,6 +715,41 @@ describe("fetchWorkflowRuns", () => { }); }); + it("sorts workflows by most recent activity descending", async () => { + const octokit = makeOctokitForRuns(); + + const { workflowRuns } = await fetchWorkflowRuns( + octokit as unknown as ReturnType, + [testRepo], + 5, + 10 + ); + + // CI workflow (id 1001) has latestAt 2024-01-15T10:05 (from run 9002) + // Deploy workflow (id 1002) has latestAt 2024-01-15T09:25 (from run 9004) + // CI should appear first (more recent) + const firstCiIndex = workflowRuns.findIndex((r) => r.workflowId === 1001); + const firstDeployIndex = workflowRuns.findIndex((r) => r.workflowId === 1002); + expect(firstCiIndex).toBeLessThan(firstDeployIndex); + }); + + it("sorts runs within a workflow by created_at descending", async () => { + const octokit = makeOctokitForRuns(); + + const { workflowRuns } = await fetchWorkflowRuns( + octokit as unknown as ReturnType, + [testRepo], + 5, + 10 + ); + + // CI runs: 9002 (10:00), 9001 (09:00), 9003 (Jan 14 15:00) — descending by created_at + const ciRuns = workflowRuns.filter((r) => r.workflowId === 1001); + expect(ciRuns[0].id).toBe(9002); + expect(ciRuns[1].id).toBe(9001); + expect(ciRuns[2].id).toBe(9003); + }); + it("throws when octokit is null", async () => { await expect( fetchWorkflowRuns(null, [testRepo], 5, 3) From 22ccbda49f76e4c76652cc3f92b6fc398eea23ef Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 16:47:13 -0400 Subject: [PATCH 09/10] fix(test): allocates dynamic port for E2E to avoid collisions --- package.json | 2 +- playwright.config.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index c1c49548..63e86d69 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "test:watch": "vitest --config vitest.config.ts", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit", - "test:e2e": "playwright test" + "test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test" }, "dependencies": { "@octokit/core": "^7.0.6", diff --git a/playwright.config.ts b/playwright.config.ts index a46aeebd..7fb9ab36 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,10 +1,12 @@ import { defineConfig, devices } from "@playwright/test"; +const port = Number(process.env.E2E_PORT) || 5173; + export default defineConfig({ testDir: "./e2e", reporter: [["html", { open: "never" }]], use: { - baseURL: "http://localhost:5173", + baseURL: `http://localhost:${port}`, trace: "on-first-retry", }, projects: [ @@ -14,8 +16,8 @@ export default defineConfig({ }, ], webServer: { - command: "pnpm dev", - port: 5173, + command: `pnpm exec vite dev --port ${port}`, + url: `http://localhost:${port}`, timeout: 120_000, reuseExistingServer: !process.env.CI, }, From 0bb2d68b18e722b50e1a1efff71cbef3ab10e9e4 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 24 Mar 2026 16:56:09 -0400 Subject: [PATCH 10/10] fix(test): adds --strictPort to prevent silent port rebinding --- playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright.config.ts b/playwright.config.ts index 7fb9ab36..6b72203c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,7 +16,7 @@ export default defineConfig({ }, ], webServer: { - command: `pnpm exec vite dev --port ${port}`, + command: `pnpm exec vite dev --port ${port} --strictPort`, url: `http://localhost:${port}`, timeout: 120_000, reuseExistingServer: !process.env.CI,