Skip to content

Commit b325045

Browse files
committed
feat(dashboard): integrates persisted expand/collapse state
1 parent fc6033b commit b325045

File tree

7 files changed

+420
-156
lines changed

7 files changed

+420
-156
lines changed

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1-
import { createMemo, For, Show } from "solid-js";
1+
import { createEffect, createMemo, For, Show } from "solid-js";
22
import { createStore } from "solid-js/store";
33
import type { WorkflowRun } from "../../services/api";
44
import { config } from "../../stores/config";
5-
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type ActionsFilterField } from "../../stores/view";
5+
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type ActionsFilterField } from "../../stores/view";
66
import WorkflowSummaryCard from "./WorkflowSummaryCard";
77
import IgnoreBadge from "./IgnoreBadge";
88
import SkeletonRows from "../shared/SkeletonRows";
99
import FilterChips from "../shared/FilterChips";
1010
import type { FilterChipGroupDef } from "../shared/FilterChips";
1111
import ChevronIcon from "../shared/ChevronIcon";
12+
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
1213

1314
interface ActionsTabProps {
1415
workflowRuns: WorkflowRun[];
@@ -116,17 +117,22 @@ const actionsFilterGroups: FilterChipGroupDef[] = [
116117
];
117118

118119
export default function ActionsTab(props: ActionsTabProps) {
119-
const [expandedRepos, setExpandedRepos] = createStore<Record<string, boolean>>({});
120120
const [expandedWorkflows, setExpandedWorkflows] = createStore<Record<string, boolean>>({});
121121

122-
function toggleRepo(repoFullName: string) {
123-
setExpandedRepos(repoFullName, (v) => !v);
124-
}
125-
126122
function toggleWorkflow(key: string) {
127123
setExpandedWorkflows(key, (v) => !v);
128124
}
129125

126+
const activeRepoNames = createMemo(() =>
127+
[...new Set(props.workflowRuns.map((r) => r.repoFullName))]
128+
);
129+
130+
createEffect(() => {
131+
const names = activeRepoNames();
132+
if (names.length === 0) return;
133+
pruneExpandedRepos("actions", names);
134+
});
135+
130136
function handleIgnore(run: WorkflowRun) {
131137
ignoreItem({
132138
id: String(run.id),
@@ -180,7 +186,7 @@ export default function ActionsTab(props: ActionsTabProps) {
180186
return (
181187
<div class="divide-y divide-base-300">
182188
{/* Toolbar */}
183-
<div class="flex flex-wrap items-center gap-3 px-4 py-2 bg-base-100">
189+
<div class="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
184190
<label class="flex items-center gap-1.5 text-sm text-base-content/70 cursor-pointer select-none">
185191
<input
186192
type="checkbox"
@@ -198,6 +204,10 @@ export default function ActionsTab(props: ActionsTabProps) {
198204
onResetAll={() => resetAllTabFilters("actions")}
199205
/>
200206
<div class="flex-1" />
207+
<ExpandCollapseButtons
208+
onExpandAll={() => setAllExpanded("actions", repoGroups().map((g) => g.repoFullName), true)}
209+
onCollapseAll={() => setAllExpanded("actions", repoGroups().map((g) => g.repoFullName), false)}
210+
/>
201211
<IgnoreBadge
202212
items={viewState.ignoredItems.filter((i) => i.type === "workflowRun")}
203213
onUnignore={unignoreItem}
@@ -224,7 +234,7 @@ export default function ActionsTab(props: ActionsTabProps) {
224234
<Show when={repoGroups().length > 0}>
225235
<For each={repoGroups()}>
226236
{(repoGroup) => {
227-
const isExpanded = () => !!expandedRepos[repoGroup.repoFullName];
237+
const isExpanded = () => !!viewState.expandedRepos.actions[repoGroup.repoFullName];
228238

229239
const sortedWorkflows = createMemo(() =>
230240
sortWorkflowsByStatus(repoGroup.workflows)
@@ -249,7 +259,7 @@ export default function ActionsTab(props: ActionsTabProps) {
249259
<div class="bg-base-100">
250260
{/* Repo header */}
251261
<button
252-
onClick={() => toggleRepo(repoGroup.repoFullName)}
262+
onClick={() => toggleExpandedRepo("actions", repoGroup.repoFullName)}
253263
aria-expanded={isExpanded()}
254264
class="w-full flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors"
255265
>

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
2-
import { createStore } from "solid-js/store";
32
import { config } from "../../stores/config";
4-
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type IssueFilterField } from "../../stores/view";
3+
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type IssueFilterField } from "../../stores/view";
54
import type { Issue } from "../../services/api";
65
import ItemRow from "./ItemRow";
76
import IgnoreBadge from "./IgnoreBadge";
@@ -13,6 +12,7 @@ import type { FilterChipGroupDef } from "../shared/FilterChips";
1312
import RoleBadge from "../shared/RoleBadge";
1413
import SkeletonRows from "../shared/SkeletonRows";
1514
import ChevronIcon from "../shared/ChevronIcon";
15+
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
1616
import { deriveInvolvementRoles } from "../../lib/format";
1717
import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/grouping";
1818

@@ -54,11 +54,6 @@ const sortOptions: SortOption[] = [
5454

5555
export default function IssuesTab(props: IssuesTabProps) {
5656
const [page, setPage] = createSignal(0);
57-
const [expandedRepos, setExpandedRepos] = createStore<Record<string, boolean>>({});
58-
59-
function toggleRepo(repoFullName: string) {
60-
setExpandedRepos(repoFullName, (v) => !v);
61-
}
6257

6358
const sortPref = createMemo(() => {
6459
const pref = viewState.sortPreferences["issues"];
@@ -141,14 +136,14 @@ export default function IssuesTab(props: IssuesTabProps) {
141136
if (page() > max) setPage(max);
142137
});
143138

144-
// Auto-expand first group on initial mount
145-
let hasAutoExpanded = false;
139+
const activeRepoNames = createMemo(() =>
140+
[...new Set(props.issues.map((i) => i.repoFullName))]
141+
);
142+
146143
createEffect(() => {
147-
const groups = pageGroups();
148-
if (!hasAutoExpanded && groups.length > 0) {
149-
hasAutoExpanded = true;
150-
setExpandedRepos(groups[0].repoFullName, true);
151-
}
144+
const names = activeRepoNames();
145+
if (names.length === 0) return;
146+
pruneExpandedRepos("issues", names);
152147
});
153148

154149
function handleSort(field: string, direction: "asc" | "desc") {
@@ -193,6 +188,10 @@ export default function IssuesTab(props: IssuesTabProps) {
193188
}}
194189
/>
195190
<div class="flex-1" />
191+
<ExpandCollapseButtons
192+
onExpandAll={() => setAllExpanded("issues", repoGroups().map((g) => g.repoFullName), true)}
193+
onCollapseAll={() => setAllExpanded("issues", repoGroups().map((g) => g.repoFullName), false)}
194+
/>
196195
<IgnoreBadge
197196
items={viewState.ignoredItems.filter((i) => i.type === "issue")}
198197
onUnignore={unignoreItem}
@@ -234,7 +233,7 @@ export default function IssuesTab(props: IssuesTabProps) {
234233
<div class="divide-y divide-base-300">
235234
<For each={pageGroups()}>
236235
{(repoGroup) => {
237-
const isExpanded = () => !!expandedRepos[repoGroup.repoFullName];
236+
const isExpanded = () => !!viewState.expandedRepos.issues[repoGroup.repoFullName];
238237

239238
const roleSummary = createMemo(() => {
240239
const counts: Record<string, number> = {};
@@ -252,7 +251,7 @@ export default function IssuesTab(props: IssuesTabProps) {
252251
return (
253252
<div class="bg-base-100">
254253
<button
255-
onClick={() => toggleRepo(repoGroup.repoFullName)}
254+
onClick={() => toggleExpandedRepo("issues", repoGroup.repoFullName)}
256255
aria-expanded={isExpanded()}
257256
class="w-full flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors"
258257
>

src/app/components/dashboard/PullRequestsTab.tsx

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
2-
import { createStore } from "solid-js/store";
32
import { config } from "../../stores/config";
4-
import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, type PullRequestFilterField } from "../../stores/view";
3+
import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, type PullRequestFilterField } from "../../stores/view";
54
import type { PullRequest } from "../../services/api";
65
import { deriveInvolvementRoles, prSizeCategory } from "../../lib/format";
6+
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
77
import ItemRow from "./ItemRow";
88
import StatusDot from "../shared/StatusDot";
99
import IgnoreBadge from "./IgnoreBadge";
@@ -119,11 +119,6 @@ const sortOptions: SortOption[] = [
119119

120120
export default function PullRequestsTab(props: PullRequestsTabProps) {
121121
const [page, setPage] = createSignal(0);
122-
const [expandedRepos, setExpandedRepos] = createStore<Record<string, boolean>>({});
123-
124-
function toggleRepo(repoFullName: string) {
125-
setExpandedRepos(repoFullName, (v) => !v);
126-
}
127122

128123
const sortPref = createMemo(() => {
129124
const pref = viewState.sortPreferences["pullRequests"];
@@ -230,14 +225,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
230225
if (page() > max) setPage(max);
231226
});
232227

233-
// Auto-expand first group on initial load
234-
let hasAutoExpanded = false;
228+
const activeRepoNames = createMemo(() =>
229+
[...new Set(props.pullRequests.map((pr) => pr.repoFullName))]
230+
);
231+
235232
createEffect(() => {
236-
const groups = pageGroups();
237-
if (!hasAutoExpanded && groups.length > 0) {
238-
hasAutoExpanded = true;
239-
setExpandedRepos(groups[0].repoFullName, true);
240-
}
233+
const names = activeRepoNames();
234+
if (names.length === 0) return;
235+
pruneExpandedRepos("pullRequests", names);
241236
});
242237

243238
function handleSort(field: string, direction: "asc" | "desc") {
@@ -282,6 +277,10 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
282277
}}
283278
/>
284279
<div class="flex-1" />
280+
<ExpandCollapseButtons
281+
onExpandAll={() => setAllExpanded("pullRequests", repoGroups().map((g) => g.repoFullName), true)}
282+
onCollapseAll={() => setAllExpanded("pullRequests", repoGroups().map((g) => g.repoFullName), false)}
283+
/>
285284
<IgnoreBadge
286285
items={viewState.ignoredItems.filter((i) => i.type === "pullRequest")}
287286
onUnignore={unignoreItem}
@@ -323,7 +322,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
323322
<div class="divide-y divide-base-300">
324323
<For each={pageGroups()}>
325324
{(repoGroup) => {
326-
const isExpanded = () => !!expandedRepos[repoGroup.repoFullName];
325+
const isExpanded = () => !!viewState.expandedRepos.pullRequests[repoGroup.repoFullName];
327326

328327
const summaryMeta = createMemo(() => {
329328
const checks = { success: 0, failure: 0, pending: 0, conflict: 0 };
@@ -354,7 +353,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
354353
return (
355354
<div class="bg-base-100">
356355
<button
357-
onClick={() => toggleRepo(repoGroup.repoFullName)}
356+
onClick={() => toggleExpandedRepo("pullRequests", repoGroup.repoFullName)}
358357
aria-expanded={isExpanded()}
359358
class="w-full flex items-center gap-2 px-4 py-2.5 text-left text-sm font-semibold text-base-content bg-base-200/60 border-y border-base-300 hover:bg-base-200 transition-colors"
360359
>

tests/components/ActionsTab.test.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { render, screen } from "@solidjs/testing-library";
33
import userEvent from "@testing-library/user-event";
44
import ActionsTab from "../../src/app/components/dashboard/ActionsTab";
55
import * as viewStore from "../../src/app/stores/view";
6+
import { viewState } from "../../src/app/stores/view";
67
import { makeWorkflowRun, resetViewStore } from "../helpers/index";
78

89
beforeEach(() => {
@@ -295,4 +296,110 @@ describe("ActionsTab", () => {
295296
screen.getByRole("checkbox");
296297
screen.getByText("Show PR runs");
297298
});
299+
300+
it("Expand all button expands all repo groups", async () => {
301+
const user = userEvent.setup();
302+
const runs = [
303+
makeWorkflowRun({ repoFullName: "owner/repo-a", workflowId: 1, name: "CI-A", displayTitle: "run-a" }),
304+
makeWorkflowRun({ repoFullName: "owner/repo-b", workflowId: 2, name: "CI-B", displayTitle: "run-b" }),
305+
];
306+
render(() => <ActionsTab workflowRuns={runs} />);
307+
308+
// Both groups collapsed by default
309+
expect(screen.queryByText("run-a")).toBeNull();
310+
expect(screen.queryByText("run-b")).toBeNull();
311+
312+
await user.click(screen.getByRole("button", { name: /Expand all/i }));
313+
314+
// After expand all, workflow names visible (repos expanded)
315+
expect(screen.getAllByText("CI-A").length).toBeGreaterThanOrEqual(1);
316+
expect(screen.getAllByText("CI-B").length).toBeGreaterThanOrEqual(1);
317+
});
318+
319+
it("Collapse all button collapses all repo groups", async () => {
320+
const user = userEvent.setup();
321+
const runs = [
322+
makeWorkflowRun({ repoFullName: "owner/repo-a", workflowId: 1, name: "CI-A" }),
323+
makeWorkflowRun({ repoFullName: "owner/repo-b", workflowId: 2, name: "CI-B" }),
324+
];
325+
render(() => <ActionsTab workflowRuns={runs} />);
326+
327+
// Expand all first
328+
await user.click(screen.getByRole("button", { name: /Expand all/i }));
329+
expect(screen.getAllByText("CI-A").length).toBeGreaterThanOrEqual(1);
330+
331+
// Collapse all
332+
await user.click(screen.getByRole("button", { name: /Collapse all/i }));
333+
334+
// Repo groups collapsed — workflow names hidden
335+
expect(screen.queryByText("CI-A")).toBeNull();
336+
expect(screen.queryByText("CI-B")).toBeNull();
337+
});
338+
339+
it("workflow card expansion is independent of repo-level expand/collapse all", async () => {
340+
const user = userEvent.setup();
341+
const runs = [
342+
makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "CI", displayTitle: "my-unique-run" }),
343+
];
344+
render(() => <ActionsTab workflowRuns={runs} />);
345+
346+
// Expand repo
347+
await user.click(screen.getByRole("button", { name: /Expand all/i }));
348+
// Expand workflow card
349+
const cards = screen.getAllByText("CI");
350+
await user.click(cards[0]);
351+
// Run row now visible
352+
screen.getByText("my-unique-run");
353+
354+
// Collapse all repos, then expand again
355+
await user.click(screen.getByRole("button", { name: /Collapse all/i }));
356+
await user.click(screen.getByRole("button", { name: /Expand all/i }));
357+
358+
// Workflow card expansion is local state — it resets when repo collapses/re-renders
359+
// The key assertion: repo expand/collapse does not affect workflow-level state in viewState
360+
// (workflow state is local, not persisted)
361+
expect(viewState.expandedRepos.actions["owner/repo"]).toBe(true);
362+
});
363+
364+
it("expanded repo state persists in viewState", async () => {
365+
const user = userEvent.setup();
366+
const runs = [
367+
makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "CI" }),
368+
];
369+
render(() => <ActionsTab workflowRuns={runs} />);
370+
371+
// Initially not expanded
372+
expect(viewState.expandedRepos.actions["owner/repo"]).toBeFalsy();
373+
374+
// Click repo header to expand
375+
await user.click(screen.getByText("owner/repo"));
376+
377+
// viewState updated
378+
expect(viewState.expandedRepos.actions["owner/repo"]).toBe(true);
379+
380+
// Click again to collapse
381+
await user.click(screen.getByText("owner/repo"));
382+
expect(viewState.expandedRepos.actions["owner/repo"]).toBeFalsy();
383+
});
384+
385+
it("expanded state survives component unmount and remount", async () => {
386+
const user = userEvent.setup();
387+
const runs = [
388+
makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "CI", displayTitle: "unique-title" }),
389+
];
390+
391+
// First render — expand repo
392+
const { unmount } = render(() => <ActionsTab workflowRuns={runs} />);
393+
await user.click(screen.getByText("owner/repo"));
394+
expect(viewState.expandedRepos.actions["owner/repo"]).toBe(true);
395+
396+
// Unmount
397+
unmount();
398+
399+
// Re-render — viewState persists so repo should still be expanded
400+
render(() => <ActionsTab workflowRuns={runs} />);
401+
// Workflow name visible means the repo group is expanded
402+
expect(screen.getAllByText("CI").length).toBeGreaterThanOrEqual(1);
403+
expect(viewState.expandedRepos.actions["owner/repo"]).toBe(true);
404+
});
298405
});

0 commit comments

Comments
 (0)