diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 54aeac56..577ea195 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -7,7 +7,7 @@ import IgnoreBadge from "./IgnoreBadge"; import SkeletonRows from "../shared/SkeletonRows"; import type { FilterChipGroupDef } from "../shared/filterTypes"; import FilterToolbar from "../shared/FilterToolbar"; -import ChevronIcon from "../shared/ChevronIcon"; +import RepoGroupHeader from "../shared/RepoGroupHeader"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; @@ -277,7 +277,7 @@ export default function ActionsTab(props: ActionsTabProps) { sortWorkflowsByStatus(repoGroup.workflows) ); - const collapsedSummary = createMemo(() => { + const workflowCounts = createMemo(() => { const wfs = repoGroup.workflows; const total = wfs.length; let passed = 0; @@ -294,42 +294,41 @@ export default function ActionsTab(props: ActionsTabProps) { return (
- {/* Repo header */} -
- - - -
+ 0 && (workflowCounts().failed > 0 || workflowCounts().running > 0)}> + {", "} + + 0}> + {workflowCounts().failed} failed + + 0 && workflowCounts().running > 0}> + {", "} + + 0}> + {workflowCounts().running} running + + + + } + /> {(peek) => (
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index bef78a88..60013914 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -10,9 +10,9 @@ import { scopeFilterGroup, type FilterChipGroupDef } from "../shared/filterTypes import FilterToolbar from "../shared/FilterToolbar"; import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; -import ChevronIcon from "../shared/ChevronIcon"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; -import { deriveInvolvementRoles, formatStarCount } from "../../lib/format"; +import { deriveInvolvementRoles } from "../../lib/format"; +import RepoGroupHeader from "../shared/RepoGroupHeader"; import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import RepoLockControls from "../shared/RepoLockControls"; @@ -351,44 +351,42 @@ export default function IssuesTab(props: IssuesTabProps) { return (
-
- - - -
+ } + trailing={ + <> + + + + } + collapsedSummary={ + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} + + {([role, count]) => ( + + {role} ×{count} + + )} + + + } + />
diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 105721f1..094b7f1e 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { config, type TrackedUser } from "../../stores/config"; import { viewState, ignoreItem, unignoreItem, setTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, type PullRequestFilterField } from "../../stores/view"; import type { PullRequest, RepoRef } from "../../services/api"; -import { deriveInvolvementRoles, prSizeCategory, formatStarCount } from "../../lib/format"; +import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format"; import { isSafeGitHubUrl } from "../../lib/url"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import ItemRow from "./ItemRow"; @@ -16,7 +16,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"; +import RepoGroupHeader from "../shared/RepoGroupHeader"; import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; @@ -458,86 +458,84 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { return (
-
- - - -
+ )} + + + } + /> {(peek) => (
diff --git a/src/app/components/shared/RepoGroupHeader.tsx b/src/app/components/shared/RepoGroupHeader.tsx new file mode 100644 index 00000000..330278ce --- /dev/null +++ b/src/app/components/shared/RepoGroupHeader.tsx @@ -0,0 +1,39 @@ +import { Show, type JSX } from "solid-js"; +import ChevronIcon from "./ChevronIcon"; +import { formatStarCount } from "../../lib/format"; + +interface RepoGroupHeaderProps { + repoFullName: string; + starCount?: number | null; + isExpanded: boolean; + isHighlighted?: boolean; + onToggle: () => void; + collapsedSummary?: JSX.Element; + trailing?: JSX.Element; + badges?: JSX.Element; +} + +export default function RepoGroupHeader(props: RepoGroupHeaderProps) { + return ( +
+ + {props.trailing} +
+ ); +} diff --git a/src/app/index.css b/src/app/index.css index 1823df6b..09a9d74b 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -51,7 +51,11 @@ } @utility animate-reorder-highlight { - animation: reorder-highlight 1.5s ease-out forwards; + animation: reorder-highlight 1.5s ease-out; +} + +@utility repo-header-text { + color: light-dark(oklch(0.45 0.15 250), oklch(0.70 0.15 250)); } /* ── Notification animations ──────────────────────────────────────────────── */ diff --git a/tests/components/dashboard/ActionsTab.test.tsx b/tests/components/dashboard/ActionsTab.test.tsx index a0a036ef..88768042 100644 --- a/tests/components/dashboard/ActionsTab.test.tsx +++ b/tests/components/dashboard/ActionsTab.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; +import userEvent from "@testing-library/user-event"; import { makeWorkflowRun } from "../../helpers/index"; // ── localStorage mock ───────────────────────────────────────────────────────── @@ -104,3 +105,44 @@ describe("ActionsTab — empty-repo state preservation", () => { expect(viewState.lockedRepos).toEqual([]); }); }); + +// ── ActionsTab — RepoGroupHeader integration ────────────────────────────────── + +describe("ActionsTab — RepoGroupHeader rendering", () => { + it("renders the repo name in the group header when workflow runs are present", () => { + const runs = [makeWorkflowRun({ repoFullName: "owner/my-repo" })]; + render(() => ( + + )); + // The header toggle button has aria-expanded, distinguishing it from pin/unpin buttons + const headerBtn = screen.getAllByRole("button", { name: /owner\/my-repo/i }) + .find(btn => btn.hasAttribute("aria-expanded")); + expect(headerBtn).toBeTruthy(); + }); + + it("toggles repo group expanded state when header button is clicked", async () => { + const user = userEvent.setup(); + const runs = [makeWorkflowRun({ repoFullName: "owner/repo" })]; + + render(() => ( + + )); + + const headerBtn = screen.getAllByRole("button", { name: /owner\/repo/i }) + .find(btn => btn.hasAttribute("aria-expanded"))!; + + // Initially collapsed + expect(headerBtn.getAttribute("aria-expanded")).toBe("false"); + expect(viewState.expandedRepos.actions["owner/repo"]).toBeFalsy(); + + // Click to expand + await user.click(headerBtn); + expect(headerBtn.getAttribute("aria-expanded")).toBe("true"); + expect(viewState.expandedRepos.actions["owner/repo"]).toBe(true); + + // Click again to collapse + await user.click(headerBtn); + expect(headerBtn.getAttribute("aria-expanded")).toBe("false"); + expect(viewState.expandedRepos.actions["owner/repo"]).toBeFalsy(); + }); +}); diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index da4724b0..c4d168e8 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -511,7 +511,7 @@ describe("IssuesTab — star count in repo headers", () => { /> )); - screen.getByText("★ 1.2k"); + screen.getByLabelText("1234 stars"); }); it("does not show star display when starCount is undefined", () => { diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 1a7d8df8..373e94c3 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -461,7 +461,7 @@ describe("PullRequestsTab — star count in repo headers", () => { /> )); - screen.getByText("★ 1.2k"); + screen.getByLabelText("1234 stars"); }); it("does not show star display when starCount is undefined", () => { diff --git a/tests/components/shared/RepoGroupHeader.test.tsx b/tests/components/shared/RepoGroupHeader.test.tsx new file mode 100644 index 00000000..a8d110dc --- /dev/null +++ b/tests/components/shared/RepoGroupHeader.test.tsx @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, fireEvent } from "@solidjs/testing-library"; +import RepoGroupHeader from "../../../src/app/components/shared/RepoGroupHeader"; + +describe("RepoGroupHeader", () => { + const defaultProps = { + repoFullName: "owner/repo", + isExpanded: true, + onToggle: vi.fn(), + }; + + it("renders repo name", () => { + const { getByText } = render(() => ); + expect(getByText("owner/repo")).toBeTruthy(); + }); + + it("renders star count when provided", () => { + const { getByLabelText } = render(() => ( + + )); + expect(getByLabelText("1234 stars")).toBeTruthy(); + }); + + it("hides star count when null", () => { + const { queryByLabelText } = render(() => ( + + )); + expect(queryByLabelText(/stars/)).toBeNull(); + }); + + it("hides star count when undefined", () => { + const { queryByLabelText } = render(() => ( + + )); + expect(queryByLabelText(/stars/)).toBeNull(); + }); + + it("hides star count when zero", () => { + const { queryByLabelText } = render(() => ( + + )); + expect(queryByLabelText(/stars/)).toBeNull(); + }); + + it("renders chevron rotated when collapsed", () => { + const { container } = render(() => ( + + )); + const svg = container.querySelector("svg"); + expect(svg?.getAttribute("class")).toContain("-rotate-90"); + }); + + it("renders chevron not rotated when expanded", () => { + const { container } = render(() => ( + + )); + const svg = container.querySelector("svg"); + expect(svg?.getAttribute("class")).not.toContain("-rotate-90"); + }); + + it("calls onToggle on button click", () => { + const onToggle = vi.fn(); + const { getByRole } = render(() => ( + + )); + fireEvent.click(getByRole("button", { name: /owner\/repo/ })); + expect(onToggle).toHaveBeenCalledTimes(1); + expect(onToggle).toHaveBeenCalledWith(); + }); + + it("renders collapsedSummary when collapsed", () => { + const { getByText } = render(() => ( + collapsed content} /> + )); + expect(getByText("collapsed content")).toBeTruthy(); + }); + + it("hides collapsedSummary when expanded", () => { + const { queryByText } = render(() => ( + collapsed content} /> + )); + expect(queryByText("collapsed content")).toBeNull(); + }); + + it("renders trailing slot", () => { + const { getByText } = render(() => ( + trailing} /> + )); + expect(getByText("trailing")).toBeTruthy(); + }); + + it("renders badges slot", () => { + const { getByText } = render(() => ( + badge} /> + )); + expect(getByText("badge")).toBeTruthy(); + }); + + it("applies animate-reorder-highlight when isHighlighted", () => { + const { container } = render(() => ( + + )); + const outer = container.firstElementChild as HTMLElement; + expect(outer.className).toContain("animate-reorder-highlight"); + }); + + it("does not apply animate-reorder-highlight when not highlighted", () => { + const { container } = render(() => ( + + )); + const outer = container.firstElementChild as HTMLElement; + expect(outer.className).not.toContain("animate-reorder-highlight"); + }); + + it("sets aria-expanded correctly", () => { + const { getByRole } = render(() => ( + + )); + expect(getByRole("button", { name: /owner\/repo/ }).getAttribute("aria-expanded")).toBe("true"); + }); + + it("sets aria-expanded=false when collapsed", () => { + const { getByRole } = render(() => ( + + )); + expect(getByRole("button", { name: /owner\/repo/ }).getAttribute("aria-expanded")).toBe("false"); + }); + + it("applies repo-header-text class to the button", () => { + const { getByRole } = render(() => ); + expect(getByRole("button", { name: /owner\/repo/ }).className).toContain("repo-header-text"); + }); +});