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..6b72203c 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} --strictPort`, + url: `http://localhost:${port}`, timeout: 120_000, reuseExistingServer: !process.env.CI, }, diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 06f073ad..62be2dea 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"; @@ -8,25 +9,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[]; @@ -122,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) { @@ -255,13 +218,14 @@ export default function ActionsTab(props: ActionsTabProps) { {(repoGroup) => { const isRepoCollapsed = () => - collapsedRepos().has(repoGroup.repoFullName); + collapsedRepos[repoGroup.repoFullName]; return (
{/* 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..d91eee0c 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 { createEffect, 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"; @@ -15,6 +16,8 @@ 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"; +import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/grouping"; export interface PullRequestsTabProps { pullRequests: PullRequest[]; @@ -103,6 +106,11 @@ const prFilterGroups: FilterChipGroupDef[] = [ export default function PullRequestsTab(props: PullRequestsTabProps) { const [page, setPage] = createSignal(0); + const [collapsedRepos, setCollapsedRepos] = createStore>({}); + + function toggleRepo(repoFullName: string) { + setCollapsedRepos(repoFullName, (v) => !v); + } const sortPref = createMemo(() => { const pref = viewState.sortPreferences["pullRequests"]; @@ -190,19 +198,19 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { return { items, meta }; }); - const filteredSorted = () => filteredSortedWithMeta().items; - const prMeta = () => filteredSortedWithMeta().meta; - - const pageSize = createMemo(() => config.itemsPerPage); + const filteredSorted = createMemo(() => filteredSortedWithMeta().items); + const prMeta = createMemo(() => filteredSortedWithMeta().meta); - const pageCount = createMemo(() => - Math.max(1, Math.ceil(filteredSorted().length / pageSize())) + const repoGroups = createMemo(() => groupByRepo(filteredSorted())); + const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); + const pageCount = createMemo(() => pageLayout().pageCount); + const pageGroups = createMemo(() => + slicePageGroups(repoGroups(), pageLayout().boundaries, pageCount(), page()) ); - const pagedItems = createMemo(() => { - const p = Math.min(page(), pageCount() - 1); - const start = p * pageSize(); - return filteredSorted().slice(start, start + pageSize()); + createEffect(() => { + const max = pageCount() - 1; + if (page() > max) setPage(max); }); function handleSort(field: SortField) { @@ -270,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); + }} />
@@ -284,7 +301,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { {/* PR rows */} 0}> 0} + when={pageGroups().length > 0} fallback={
} > -
- - {(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 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/src/app/lib/grouping.ts b/src/app/lib/grouping.ts new file mode 100644 index 00000000..6b5ef39d --- /dev/null +++ b/src/app/lib/grouping.ts @@ -0,0 +1,50 @@ +export interface RepoGroup { + 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/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 32624cb7..c8ea15b1 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -1,13 +1,16 @@ 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 IssuesTab from "../../src/app/components/dashboard/IssuesTab"; -import type { ApiError } from "../../src/app/services/api"; +import type { Issue, 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", () => { @@ -80,7 +83,7 @@ describe("IssuesTab", () => { 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(() => ); @@ -116,19 +119,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", () => { @@ -184,4 +184,153 @@ 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"); + }); + + 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("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("resets page when data shrinks below current page", async () => { + const user = userEvent.setup(); + updateConfig({ itemsPerPage: 10 }); + const repoAIssues = Array.from({ length: 6 }, (_, i) => + makeIssue({ id: 100 + i, title: `Repo A issue ${i}`, repoFullName: "org/repo-a" }) + ); + const repoBIssues = Array.from({ length: 6 }, (_, i) => + makeIssue({ id: 200 + i, title: `Repo B issue ${i}`, repoFullName: "org/repo-b" }) + ); + const [issues, setIssues] = createSignal([...repoAIssues, ...repoBIssues]); + 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 + setIssues(repoAIssues); + expect(screen.queryByLabelText("Next page")).toBeNull(); + screen.getByText("org/repo-a"); + screen.getByText("Repo A issue 0"); + }); + + 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/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..d118f44b 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -1,13 +1,16 @@ 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"; beforeEach(() => { resetViewStore(); + resetConfig(); }); describe("PullRequestsTab", () => { @@ -79,7 +82,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(() => ); @@ -206,7 +209,6 @@ describe("PullRequestsTab", () => { render(() => ); screen.getByText("My PR"); expect(screen.queryByText("Other PR")).toBeNull(); - viewStore.resetTabFilter("pullRequests", "role"); }); it("filters by reviewDecision tab filter", () => { @@ -263,4 +265,120 @@ 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"); + }); + + 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(); + }); + + 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)