From dad3a846f54512f16d04d6c41ffb7e8ab53b7238 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 29 Mar 2026 12:07:15 -0400 Subject: [PATCH 01/11] feat(store): adds per-tab expandedRepos with helpers --- src/app/stores/view.ts | 62 +++++++++++++++++++++++++++++ tests/helpers/index.tsx | 1 + tests/stores/view.test.ts | 84 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index c0b47d67..3f779104 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -69,6 +69,15 @@ export const ViewStateSchema = z.object({ actions: { conclusion: "all", event: "all" }, }), showPrRuns: z.boolean().default(false), + expandedRepos: z.object({ + issues: z.record(z.string(), z.boolean()).default({}), + pullRequests: z.record(z.string(), z.boolean()).default({}), + actions: z.record(z.string(), z.boolean()).default({}), + }).default({ + issues: {}, + pullRequests: {}, + actions: {}, + }), }); export type ViewState = z.infer; @@ -192,6 +201,59 @@ export function resetAllTabFilters( ); } +export function toggleExpandedRepo( + tab: keyof ViewState["expandedRepos"], + repoFullName: string +): void { + setViewState( + produce((draft) => { + if (draft.expandedRepos[tab][repoFullName]) { + delete draft.expandedRepos[tab][repoFullName]; + } else { + draft.expandedRepos[tab][repoFullName] = true; + } + }) + ); +} + +export function setAllExpanded( + tab: keyof ViewState["expandedRepos"], + repoFullNames: string[], + expanded: boolean +): void { + setViewState( + produce((draft) => { + if (expanded) { + for (const name of repoFullNames) { + draft.expandedRepos[tab][name] = true; + } + } else { + for (const name of repoFullNames) { + delete draft.expandedRepos[tab][name]; + } + } + }) + ); +} + +export function pruneExpandedRepos( + tab: keyof ViewState["expandedRepos"], + activeRepoNames: string[] +): void { + if (Object.keys(viewState.expandedRepos[tab]).length === 0) return; + const activeSet = new Set(activeRepoNames); + setViewState( + produce((draft) => { + const keys = Object.keys(draft.expandedRepos[tab]); + for (const key of keys) { + if (!activeSet.has(key)) { + delete draft.expandedRepos[tab][key]; + } + } + }) + ); +} + export function initViewPersistence(): void { let debounceTimer: ReturnType | undefined; createEffect(() => { diff --git a/tests/helpers/index.tsx b/tests/helpers/index.tsx index 48ef311e..569825c6 100644 --- a/tests/helpers/index.tsx +++ b/tests/helpers/index.tsx @@ -114,5 +114,6 @@ export function resetViewStore(): void { actions: { conclusion: "all", event: "all" }, }, showPrRuns: false, + expandedRepos: { issues: {}, pullRequests: {}, actions: {} }, }); } diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index 6a47dc54..fcc96501 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -9,6 +9,9 @@ import { setGlobalFilter, initViewPersistence, ViewStateSchema, + toggleExpandedRepo, + setAllExpanded, + pruneExpandedRepos, } from "../../src/app/stores/view"; import type { IgnoredItem } from "../../src/app/stores/view"; @@ -49,6 +52,7 @@ function resetViewState() { sortPreferences: {}, ignoredItems: [], globalFilter: { org: null, repo: null }, + expandedRepos: { issues: {}, pullRequests: {}, actions: {} }, }); } @@ -204,4 +208,84 @@ describe("ViewStateSchema", () => { const result = ViewStateSchema.safeParse({ lastActiveTab: "invalid-tab-name" }); expect(result.success).toBe(false); }); + + it("missing expandedRepos field parses to defaults", () => { + const result = ViewStateSchema.parse({ lastActiveTab: "actions" }); + expect(result.expandedRepos).toEqual({ issues: {}, pullRequests: {}, actions: {} }); + }); +}); + +describe("expandedRepos helpers", () => { + it("toggleExpandedRepo sets key to true when absent", () => { + toggleExpandedRepo("issues", "owner/repo"); + expect(viewState.expandedRepos.issues["owner/repo"]).toBe(true); + }); + + it("toggleExpandedRepo deletes key when already true (sparse record)", () => { + toggleExpandedRepo("issues", "owner/repo"); + expect(viewState.expandedRepos.issues["owner/repo"]).toBe(true); + toggleExpandedRepo("issues", "owner/repo"); + expect("owner/repo" in viewState.expandedRepos.issues).toBe(false); + }); + + it("toggleExpandedRepo works independently per tab", () => { + toggleExpandedRepo("issues", "owner/repo"); + toggleExpandedRepo("pullRequests", "owner/repo"); + expect(viewState.expandedRepos.issues["owner/repo"]).toBe(true); + expect(viewState.expandedRepos.pullRequests["owner/repo"]).toBe(true); + expect("owner/repo" in viewState.expandedRepos.actions).toBe(false); + }); + + it("setAllExpanded sets multiple repos to true", () => { + setAllExpanded("issues", ["owner/a", "owner/b", "owner/c"], true); + expect(viewState.expandedRepos.issues["owner/a"]).toBe(true); + expect(viewState.expandedRepos.issues["owner/b"]).toBe(true); + expect(viewState.expandedRepos.issues["owner/c"]).toBe(true); + }); + + it("setAllExpanded with expanded=false deletes all keys (sparse record)", () => { + setAllExpanded("issues", ["owner/a", "owner/b"], true); + setAllExpanded("issues", ["owner/a", "owner/b"], false); + expect("owner/a" in viewState.expandedRepos.issues).toBe(false); + expect("owner/b" in viewState.expandedRepos.issues).toBe(false); + }); + + it("pruneExpandedRepos removes stale keys and keeps active ones", () => { + setAllExpanded("actions", ["owner/active", "owner/stale"], true); + pruneExpandedRepos("actions", ["owner/active"]); + expect(viewState.expandedRepos.actions["owner/active"]).toBe(true); + expect("owner/stale" in viewState.expandedRepos.actions).toBe(false); + }); + + it("pruneExpandedRepos short-circuits when no stale keys exist", () => { + setAllExpanded("pullRequests", ["owner/a"], true); + // Spy on setViewState indirectly: verify state is unchanged and no error thrown + const before = JSON.stringify(viewState.expandedRepos.pullRequests); + pruneExpandedRepos("pullRequests", ["owner/a"]); + expect(JSON.stringify(viewState.expandedRepos.pullRequests)).toBe(before); + expect(viewState.expandedRepos.pullRequests["owner/a"]).toBe(true); + }); + + it("localStorage round-trip: expandedRepos persists and restores via schema", async () => { + vi.useFakeTimers(); + let dispose!: () => void; + createRoot((d) => { + dispose = d; + initViewPersistence(); + toggleExpandedRepo("issues", "myorg/myrepo"); + setAllExpanded("actions", ["myorg/ci"], true); + }); + + await Promise.resolve(); + vi.advanceTimersByTime(200); + + const raw = localStorageMock.getItem(VIEW_KEY); + expect(raw).not.toBeNull(); + const restored = ViewStateSchema.parse(JSON.parse(raw!)); + expect(restored.expandedRepos.issues["myorg/myrepo"]).toBe(true); + expect(restored.expandedRepos.actions["myorg/ci"]).toBe(true); + expect(restored.expandedRepos.pullRequests).toEqual({}); + dispose(); + vi.useRealTimers(); + }); }); From 49410afd92185c45a46e77b328957304a0a456b1 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 29 Mar 2026 12:07:45 -0400 Subject: [PATCH 02/11] feat(ui): adds ExpandCollapseButtons shared component --- .../shared/ExpandCollapseButtons.tsx | 53 +++++++++++++++++++ .../components/ExpandCollapseButtons.test.tsx | 25 +++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/app/components/shared/ExpandCollapseButtons.tsx create mode 100644 tests/components/ExpandCollapseButtons.test.tsx diff --git a/src/app/components/shared/ExpandCollapseButtons.tsx b/src/app/components/shared/ExpandCollapseButtons.tsx new file mode 100644 index 00000000..7c6752f8 --- /dev/null +++ b/src/app/components/shared/ExpandCollapseButtons.tsx @@ -0,0 +1,53 @@ +export interface ExpandCollapseButtonsProps { + onExpandAll: () => void; + onCollapseAll: () => void; +} + +export default function ExpandCollapseButtons(props: ExpandCollapseButtonsProps) { + return ( +
+ + +
+ ); +} diff --git a/tests/components/ExpandCollapseButtons.test.tsx b/tests/components/ExpandCollapseButtons.test.tsx new file mode 100644 index 00000000..ea4951cb --- /dev/null +++ b/tests/components/ExpandCollapseButtons.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import ExpandCollapseButtons from "../../src/app/components/shared/ExpandCollapseButtons"; + +describe("ExpandCollapseButtons", () => { + it("renders both buttons with correct aria-labels", () => { + render(() => {}} onCollapseAll={() => {}} />); + expect(screen.getByRole("button", { name: "Expand all" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Collapse all" })).toBeTruthy(); + }); + + it("calls onExpandAll when expand button is clicked", () => { + const onExpandAll = vi.fn(); + render(() => {}} />); + fireEvent.click(screen.getByRole("button", { name: "Expand all" })); + expect(onExpandAll).toHaveBeenCalledTimes(1); + }); + + it("calls onCollapseAll when collapse button is clicked", () => { + const onCollapseAll = vi.fn(); + render(() => {}} onCollapseAll={onCollapseAll} />); + fireEvent.click(screen.getByRole("button", { name: "Collapse all" })); + expect(onCollapseAll).toHaveBeenCalledTimes(1); + }); +}); From fa754cf4397452376ac0e76695796fd6a9845114 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 29 Mar 2026 12:08:45 -0400 Subject: [PATCH 03/11] feat(dashboard): integrates persisted expand/collapse state --- src/app/components/dashboard/ActionsTab.tsx | 30 ++- src/app/components/dashboard/IssuesTab.tsx | 31 ++- .../components/dashboard/PullRequestsTab.tsx | 31 ++- tests/components/ActionsTab.test.tsx | 107 ++++++++++ tests/components/DashboardPage.test.tsx | 26 ++- tests/components/IssuesTab.test.tsx | 161 ++++++++++----- tests/components/PullRequestsTab.test.tsx | 190 +++++++++++++----- 7 files changed, 420 insertions(+), 156 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index e1072a6f..5c68039e 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -1,14 +1,15 @@ -import { createMemo, For, Show } from "solid-js"; +import { createEffect, createMemo, For, Show } from "solid-js"; import { createStore } from "solid-js/store"; import type { WorkflowRun } from "../../services/api"; import { config } from "../../stores/config"; -import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type ActionsFilterField } from "../../stores/view"; +import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type ActionsFilterField } from "../../stores/view"; import WorkflowSummaryCard from "./WorkflowSummaryCard"; import IgnoreBadge from "./IgnoreBadge"; import SkeletonRows from "../shared/SkeletonRows"; import FilterChips from "../shared/FilterChips"; import type { FilterChipGroupDef } from "../shared/FilterChips"; import ChevronIcon from "../shared/ChevronIcon"; +import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; interface ActionsTabProps { workflowRuns: WorkflowRun[]; @@ -116,17 +117,22 @@ const actionsFilterGroups: FilterChipGroupDef[] = [ ]; export default function ActionsTab(props: ActionsTabProps) { - const [expandedRepos, setExpandedRepos] = createStore>({}); const [expandedWorkflows, setExpandedWorkflows] = createStore>({}); - function toggleRepo(repoFullName: string) { - setExpandedRepos(repoFullName, (v) => !v); - } - function toggleWorkflow(key: string) { setExpandedWorkflows(key, (v) => !v); } + const activeRepoNames = createMemo(() => + [...new Set(props.workflowRuns.map((r) => r.repoFullName))] + ); + + createEffect(() => { + const names = activeRepoNames(); + if (names.length === 0) return; + pruneExpandedRepos("actions", names); + }); + function handleIgnore(run: WorkflowRun) { ignoreItem({ id: String(run.id), @@ -180,7 +186,7 @@ export default function ActionsTab(props: ActionsTabProps) { return (
{/* Toolbar */} -
+
diff --git a/tests/components/ItemRow.test.tsx b/tests/components/ItemRow.test.tsx index 7e762987..877ccf6f 100644 --- a/tests/components/ItemRow.test.tsx +++ b/tests/components/ItemRow.test.tsx @@ -54,23 +54,33 @@ describe("ItemRow", () => { screen.getByTestId("child-slot"); }); + it("children slot sits above overlay link (relative z-10)", () => { + const { container } = render(() => ( + + extra content + + )); + const childWrapper = container.querySelector("[data-testid='child-slot']")!.parentElement!; + expect(childWrapper.className).toContain("relative"); + expect(childWrapper.className).toContain("z-10"); + }); + it("does not render children slot when not provided", () => { render(() => ); expect(screen.queryByTestId("child-slot")).toBeNull(); }); - it("title is a real link with correct href and target", () => { + it("renders overlay link with correct href, target, rel, and aria-label", () => { render(() => ); - const link = screen.getByText("Fix a bug").closest("a")!; + const link = screen.getByRole("link", { name: /octocat\/Hello-World #42: Fix a bug/ }); expect(link.getAttribute("href")).toBe(defaultProps.url); expect(link.getAttribute("target")).toBe("_blank"); expect(link.getAttribute("rel")).toBe("noopener noreferrer"); }); - it("title link has no href for non-GitHub URLs", () => { + it("does not render overlay link for non-GitHub URLs", () => { render(() => ); - const link = screen.getByText("Fix a bug").closest("a")!; - expect(link.getAttribute("href")).toBeNull(); + expect(screen.queryByRole("link")).toBeNull(); }); it("calls onIgnore when ignore button is clicked", async () => { @@ -84,15 +94,11 @@ describe("ItemRow", () => { expect(onIgnore).toHaveBeenCalledOnce(); }); - it("ignore button does not navigate (sits above stretched link)", async () => { - const user = userEvent.setup(); - const onIgnore = vi.fn(); - render(() => ); - + it("ignore button has relative z-10 to sit above overlay link", () => { + render(() => ); const ignoreBtn = screen.getByLabelText(/Ignore #42/i); - await user.click(ignoreBtn); - - expect(onIgnore).toHaveBeenCalledOnce(); + expect(ignoreBtn.className).toContain("relative"); + expect(ignoreBtn.className).toContain("z-10"); }); it("applies compact padding in compact density", () => { From ccfd9a065b36440bc8c8e942d8e2b629d8f1c2ec Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 29 Mar 2026 19:55:12 -0400 Subject: [PATCH 09/11] fix(a11y): adds focus-visible ring to ItemRow overlay link --- src/app/components/dashboard/ItemRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/dashboard/ItemRow.tsx b/src/app/components/dashboard/ItemRow.tsx index 95903114..9f9e9b94 100644 --- a/src/app/components/dashboard/ItemRow.tsx +++ b/src/app/components/dashboard/ItemRow.tsx @@ -35,7 +35,7 @@ export default function ItemRow(props: ItemRowProps) { href={url()} target="_blank" rel="noopener noreferrer" - class="absolute inset-0" + class="absolute inset-0 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset rounded" aria-label={`${props.repo} #${props.number}: ${props.title}`} /> )} From 5b3b5fc31ff17f1726827444a0279c8446238885 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 29 Mar 2026 20:04:13 -0400 Subject: [PATCH 10/11] fix(test): deduplicates reset helpers and stabilizes timing test - resetViewStore delegates to resetViewState (single source of truth) - increases pooled concurrency test delay from 5ms to 20ms (fixes flake) --- tests/helpers/index.tsx | 16 ++-------------- tests/services/api-optimization.test.ts | 2 +- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/tests/helpers/index.tsx b/tests/helpers/index.tsx index 569825c6..4155266f 100644 --- a/tests/helpers/index.tsx +++ b/tests/helpers/index.tsx @@ -1,6 +1,6 @@ import { render } from "@solidjs/testing-library"; import { MemoryRouter, createMemoryHistory } from "@solidjs/router"; -import { updateViewState } from "../../src/app/stores/view"; +import { resetViewState } from "../../src/app/stores/view"; import type { Issue, PullRequest, WorkflowRun, ApiError } from "../../src/app/services/api"; import type { JSX } from "solid-js"; @@ -103,17 +103,5 @@ export function renderWithRouter( } export function resetViewStore(): void { - updateViewState({ - lastActiveTab: "issues", - sortPreferences: {}, - ignoredItems: [], - globalFilter: { org: null, repo: null }, - tabFilters: { - issues: { role: "all", comments: "all" }, - pullRequests: { role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all" }, - actions: { conclusion: "all", event: "all" }, - }, - showPrRuns: false, - expandedRepos: { issues: {}, pullRequests: {}, actions: {} }, - }); + resetViewState(); } diff --git a/tests/services/api-optimization.test.ts b/tests/services/api-optimization.test.ts index 471efe58..29fee11a 100644 --- a/tests/services/api-optimization.test.ts +++ b/tests/services/api-optimization.test.ts @@ -591,7 +591,7 @@ describe("workflow run concurrency", () => { it("processes repos faster with pooled concurrency than sequential chunks", async () => { const repos = makeRepos(30); - const DELAY_MS = 5; + const DELAY_MS = 20; const makeTimedOctokit = () => ({ request: vi.fn(async () => { From e8a2b608fb0818f54c678b266591984e07f2ecb8 Mon Sep 17 00:00:00 2001 From: testvalue Date: Sun, 29 Mar 2026 20:13:06 -0400 Subject: [PATCH 11/11] fix(test): updates hot poll tests for collapsed-by-default repos - expands repo group before checking StatusDot in hot poll tests - uses collapsed summary assertion for initial state verification --- tests/components/DashboardPage.test.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx index 894e0e54..08a94ff9 100644 --- a/tests/components/DashboardPage.test.tsx +++ b/tests/components/DashboardPage.test.tsx @@ -386,11 +386,11 @@ describe("DashboardPage — onHotData integration", () => { expect(capturedOnHotData).not.toBeNull(); }); - // Verify initial state shows pending + // Verify initial state shows pending (collapsed summary shows "1 PR" with pending count) const user = userEvent.setup(); await user.click(screen.getByText("Pull Requests")); await waitFor(() => { - expect(screen.getByLabelText("Checks in progress")).toBeTruthy(); + screen.getByText("1 PR"); }); // Simulate hot poll returning a status update (generation=0 matches default mock) @@ -402,7 +402,8 @@ describe("DashboardPage — onHotData integration", () => { }]]); capturedOnHotData!(prUpdates, new Map(), 0); - // The StatusDot should update from "Checks in progress" to "All checks passed" + // Expand the repo to verify the StatusDot updated + await user.click(screen.getByText("owner/repo")); await waitFor(() => { expect(screen.getByLabelText("All checks passed")).toBeTruthy(); }); @@ -427,6 +428,12 @@ describe("DashboardPage — onHotData integration", () => { const user = userEvent.setup(); await user.click(screen.getByText("Pull Requests")); + await waitFor(() => { + screen.getByText("1 PR"); + }); + + // Expand repo to see StatusDot + await user.click(screen.getByText("owner/repo")); await waitFor(() => { expect(screen.getByLabelText("Checks in progress")).toBeTruthy(); });