Skip to content

Commit 5a54903

Browse files
authored
feat: adds expand/collapse persistence and toolbar buttons (#31)
* feat(store): adds per-tab expandedRepos with helpers * feat(ui): adds ExpandCollapseButtons shared component * feat(dashboard): integrates persisted expand/collapse state * fix(store): adds untrack and proper short-circuit to pruning * test: fixes userEvent consistency and adds multi-page test * fix(store): fixes resetViewState nested key leak - fixes Zod+SolidJS interaction where .default({}) reuses refs - removes wrapper lambdas in ExpandCollapseButtons - adds pruning, empty-data, round-trip, and filter tests - adds resetViewState test proving expandedRepos cleared - renames view.test.ts helper delegating to resetViewState * feat(ui): makes item rows actual links with stretched anchor * fix(ui): uses overlay link pattern for ItemRow - replaces stretched pseudo-element with separate overlay <a> - fixes truncate clipping ::after (overflow:hidden conflict) - conditionally renders link only for valid GitHub URLs - adds aria-label with repo, number, and title context * fix(a11y): adds focus-visible ring to ItemRow overlay link * 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) * 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
1 parent bdcd6db commit 5a54903

File tree

15 files changed

+942
-243
lines changed

15 files changed

+942
-243
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/ItemRow.tsx

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { For, JSX, Show } from "solid-js";
2-
import { openGitHubUrl } from "../../lib/url";
2+
import { isSafeGitHubUrl } from "../../lib/url";
33
import { relativeTime, labelTextColor, formatCount } from "../../lib/format";
44

55
export interface ItemRowProps {
@@ -19,29 +19,28 @@ export interface ItemRowProps {
1919

2020
export default function ItemRow(props: ItemRowProps) {
2121
const isCompact = () => props.density === "compact";
22-
23-
function handleRowClick(e: MouseEvent) {
24-
// Only open if click was not on the ignore button
25-
if ((e.target as HTMLElement).closest("[data-ignore-btn]")) return;
26-
openGitHubUrl(props.url);
27-
}
22+
const safeUrl = () => isSafeGitHubUrl(props.url) ? props.url : undefined;
2823

2924
return (
3025
<div
31-
role="row"
32-
tabIndex={0}
33-
onClick={handleRowClick}
34-
onKeyDown={(e) => {
35-
if (e.key === "Enter" || e.key === " ") {
36-
e.preventDefault();
37-
openGitHubUrl(props.url);
38-
}
39-
}}
40-
class={`group relative flex items-start gap-3 cursor-pointer
26+
class={`group relative flex items-start gap-3
4127
hover:bg-base-200
42-
transition-colors focus:outline-none focus:bg-base-200 focus-visible:ring-2 focus-visible:ring-primary
28+
transition-colors
4329
${isCompact() ? "px-4 py-2" : "px-4 py-3"}`}
4430
>
31+
{/* Overlay link — covers entire row; interactive children use relative z-10 */}
32+
<Show when={safeUrl()}>
33+
{(url) => (
34+
<a
35+
href={url()}
36+
target="_blank"
37+
rel="noopener noreferrer"
38+
class="absolute inset-0 focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset rounded"
39+
aria-label={`${props.repo} #${props.number}: ${props.title}`}
40+
/>
41+
)}
42+
</Show>
43+
4544
{/* Repo badge */}
4645
<Show when={!props.hideRepo}>
4746
<span
@@ -86,9 +85,9 @@ export default function ItemRow(props: ItemRowProps) {
8685
</div>
8786
</Show>
8887

89-
{/* Additional children slot */}
88+
{/* Additional children slot — z-10 to sit above stretched link */}
9089
<Show when={props.children !== undefined}>
91-
<div class={isCompact() ? "mt-0.5" : "mt-1"}>{props.children}</div>
90+
<div class={`relative z-10 ${isCompact() ? "mt-0.5" : "mt-1"}`}>{props.children}</div>
9291
</Show>
9392
</div>
9493

@@ -119,11 +118,8 @@ export default function ItemRow(props: ItemRowProps) {
119118
{/* Ignore button — visible on hover */}
120119
<button
121120
data-ignore-btn
122-
onClick={(e) => {
123-
e.stopPropagation();
124-
props.onIgnore();
125-
}}
126-
class={`shrink-0 self-center rounded p-1
121+
onClick={() => props.onIgnore()}
122+
class={`relative z-10 shrink-0 self-center rounded p-1
127123
text-base-content/30
128124
hover:text-error
129125
opacity-0 group-hover:opacity-100 focus:opacity-100

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
>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export interface ExpandCollapseButtonsProps {
2+
onExpandAll: () => void;
3+
onCollapseAll: () => void;
4+
}
5+
6+
export default function ExpandCollapseButtons(props: ExpandCollapseButtonsProps) {
7+
return (
8+
<div class="flex items-center gap-1">
9+
<button
10+
class="btn btn-ghost btn-xs"
11+
title="Expand all"
12+
aria-label="Expand all"
13+
onClick={props.onExpandAll}
14+
>
15+
<svg
16+
xmlns="http://www.w3.org/2000/svg"
17+
fill="none"
18+
viewBox="0 0 24 24"
19+
stroke-width={1.5}
20+
stroke="currentColor"
21+
class="h-4 w-4"
22+
>
23+
<path
24+
stroke-linecap="round"
25+
stroke-linejoin="round"
26+
d="m4.5 5.25 7.5 7.5 7.5-7.5m-15 6 7.5 7.5 7.5-7.5"
27+
/>
28+
</svg>
29+
</button>
30+
<button
31+
class="btn btn-ghost btn-xs"
32+
title="Collapse all"
33+
aria-label="Collapse all"
34+
onClick={props.onCollapseAll}
35+
>
36+
<svg
37+
xmlns="http://www.w3.org/2000/svg"
38+
fill="none"
39+
viewBox="0 0 24 24"
40+
stroke-width={1.5}
41+
stroke="currentColor"
42+
class="h-4 w-4"
43+
>
44+
<path
45+
stroke-linecap="round"
46+
stroke-linejoin="round"
47+
d="m4.5 18.75 7.5-7.5 7.5 7.5m-15-6 7.5-7.5 7.5 7.5"
48+
/>
49+
</svg>
50+
</button>
51+
</div>
52+
);
53+
}

0 commit comments

Comments
 (0)