Skip to content

Commit 7d090a7

Browse files
committed
feat(dashboard): expand/collapse sets a per-tab default for new repos
Expand All / Collapse All now set a persistent per-tab default so repos that appear later inherit it, rather than only affecting the repos visible at click time. A manual per-repo expand/collapse is stored as an exception to that default and is cleared when Expand/Collapse All resets the tab. Jira project groups keep their expanded-by-default behavior via the new schema default (replacing the old one-shot auto-expand effect). USER_GUIDE updated.
1 parent a777b74 commit 7d090a7

17 files changed

Lines changed: 295 additions & 214 deletions

docs/USER_GUIDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ Counts are computed across all repos regardless of any org or repo filter you ha
136136

137137
Items are grouped by repository. Each repo group has a header row showing the repo name, item count, and a summary of statuses (check results, review decisions, role counts). Click a repo header to expand or collapse that group.
138138

139-
Use the **Expand all** / **Collapse all** buttons in the toolbar to expand or collapse all groups at once.
139+
Use the **Expand all** / **Collapse all** buttons in the toolbar to set the default for that tab. The default applies to every group, including repos that appear later — so **Expand all** keeps newly-surfaced repos expanded without needing another click. Clicking an individual repo header overrides the default for just that repo and is remembered as an exception, until the next **Expand all** / **Collapse all** resets every group to the new default. GitHub tabs start collapsed by default; Jira project groups start expanded.
140140

141141
When a group is collapsed, a brief preview of any status change detected by the hot poll appears under the header for a few seconds before fading.
142142

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createEffect, createMemo, For, Show } from "solid-js";
22
import { createStore } from "solid-js/store";
33
import type { WorkflowRun } from "../../services/api";
4-
import { viewState, setViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, ActionsFiltersSchema } from "../../stores/view";
4+
import { viewState, setViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, isRepoExpanded, pruneExpandedRepos, pruneLockedRepos, ActionsFiltersSchema } from "../../stores/view";
55
import { createTabFilterHandlers, mergeActiveFilters } from "../../lib/tabFilters";
66
import { isRunVisible } from "../../lib/filters";
77
import WorkflowSummaryCard from "./WorkflowSummaryCard";
@@ -140,7 +140,7 @@ export default function ActionsTab(props: ActionsTabProps) {
140140
const { flashingIds: flashingRunIds, peekUpdates } = createFlashDetection({
141141
getItems: () => props.workflowRuns,
142142
getHotIds: () => props.hotPollingRunIds,
143-
getExpandedRepos: () => viewState.expandedRepos[tabKey()] ?? {},
143+
isRepoExpanded: (repo) => isRepoExpanded(tabKey(), repo),
144144
trackKey: (run) => `${run.status}|${run.conclusion}`,
145145
itemLabel: (run) => run.name,
146146
itemStatus: (run) => run.conclusion ?? run.status,
@@ -236,8 +236,8 @@ export default function ActionsTab(props: ActionsTabProps) {
236236
</div>
237237
<div class="shrink-0 flex items-center gap-2 py-0.5">
238238
<ExpandCollapseButtons
239-
onExpandAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), true)}
240-
onCollapseAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), false)}
239+
onExpandAll={() => setAllExpanded(tabKey(), true)}
240+
onCollapseAll={() => setAllExpanded(tabKey(), false)}
241241
/>
242242
<IgnoreBadge
243243
items={ignoredWorkflowRuns()}
@@ -256,7 +256,7 @@ export default function ActionsTab(props: ActionsTabProps) {
256256
<For each={repoGroups()}>
257257
{(repoGroup) => {
258258
const isEmpty = () => repoGroup.workflows.length === 0;
259-
const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[tabKey()] ?? {})[repoGroup.repoFullName];
259+
const isExpanded = () => !isEmpty() && isRepoExpanded(tabKey(), repoGroup.repoFullName);
260260

261261
const sortedWorkflows = createMemo(() =>
262262
sortWorkflowsByStatus(repoGroup.workflows)

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,7 @@ export default function DashboardPage() {
11141114
const keys = new Set([
11151115
...Object.keys(viewState.customTabFilters),
11161116
...Object.keys(viewState.expandedRepos).filter((k) => !isBuiltinTab(k)),
1117+
...Object.keys(viewState.expandDefault).filter((k) => !isBuiltinTab(k)),
11171118
...Object.keys(viewState.lockedRepos).filter((k) => !isBuiltinTab(k)),
11181119
]);
11191120
return [...keys].filter((id) => !activeIds.has(id));

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
22
import { config, type TrackedUser } from "../../stores/config";
3-
import { viewState, updateViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, IssueFiltersSchema } from "../../stores/view";
3+
import { viewState, updateViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, isRepoExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, IssueFiltersSchema } from "../../stores/view";
44
import { createTabFilterHandlers, mergeActiveFilters } from "../../lib/tabFilters";
55
import type { Issue, RepoRef } from "../../services/api";
66
import { isIssueVisible } from "../../lib/filters";
@@ -294,8 +294,8 @@ export default function IssuesTab(props: IssuesTabProps) {
294294
</div>
295295
<div class="shrink-0 flex items-center gap-2 py-0.5">
296296
<ExpandCollapseButtons
297-
onExpandAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), true)}
298-
onCollapseAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), false)}
297+
onExpandAll={() => setAllExpanded(tabKey(), true)}
298+
onCollapseAll={() => setAllExpanded(tabKey(), false)}
299299
/>
300300
<IgnoreBadge
301301
items={ignoredIssues()}
@@ -315,7 +315,7 @@ export default function IssuesTab(props: IssuesTabProps) {
315315
<For each={pageGroups()}>
316316
{(repoGroup) => {
317317
const isEmpty = () => repoGroup.items.length === 0;
318-
const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[tabKey()] ?? {})[repoGroup.repoFullName];
318+
const isExpanded = () => !isEmpty() && isRepoExpanded(tabKey(), repoGroup.repoFullName);
319319

320320
const roleSummary = createMemo(() => {
321321
const counts: Record<string, number> = {};

src/app/components/dashboard/JiraAssignedTab.tsx

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createEffect, createMemo, createSignal, For, Show, on, onCleanup } from "solid-js";
22
import type { JiraIssue } from "../../../shared/jira-types";
3-
import { viewState, setTabFilter, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded, setJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE, JIRA_CUSTOM_SORT_FIELD } from "../../stores/view";
3+
import { viewState, setTabFilter, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded, toggleExpandedRepo, isRepoExpanded, setJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE, JIRA_CUSTOM_SORT_FIELD } from "../../stores/view";
44
import { config } from "../../stores/config";
55
import JiraFieldValue from "./JiraFieldValue";
66
import { jiraStatusCategoryClass, stripParenthetical } from "../../lib/format";
@@ -86,10 +86,7 @@ const STATUS_SDLC_ORDER: Record<string, number> = Object.assign(Object.create(nu
8686
"Stalled / Blocked": 8, "Blocked/On Hold": 8, "QA Blocked": 8,
8787
});
8888

89-
let _jiraExpandInitialized = false;
90-
9189
export function _resetJiraTabState() {
92-
_jiraExpandInitialized = false;
9390
itemRefs.clear();
9491
}
9592

@@ -350,22 +347,11 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
350347
slicePageGroups(repoGroups(), pageLayout().boundaries, pageLayout().pageCount, page())
351348
);
352349

353-
const projectKeys = createMemo(() => repoGroups().map((g) => g.repoFullName));
354-
355350
createEffect(() => {
356351
const max = pageCount() - 1;
357352
if (page() > max) setPage(max);
358353
});
359354

360-
createEffect(() => {
361-
const keys = projectKeys();
362-
if (keys.length === 0 || _jiraExpandInitialized) return;
363-
const expanded = viewState.expandedRepos[TAB_KEY];
364-
if (expanded && Object.keys(expanded).length > 0) return;
365-
_jiraExpandInitialized = true;
366-
setAllExpanded(TAB_KEY, keys, true);
367-
});
368-
369355
// Reordering is only meaningful — and safe — against the canonical, unfiltered
370356
// "assigned" scope: filtered() must exclude nothing so filteredSorted()'s key list
371357
// is the complete set, matching what Task 4's prune gate guards against.
@@ -694,8 +680,8 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
694680
/>
695681
<Show when={!isCustomMode()}>
696682
<ExpandCollapseButtons
697-
onExpandAll={() => setAllExpanded(TAB_KEY, projectKeys(), true)}
698-
onCollapseAll={() => setAllExpanded(TAB_KEY, projectKeys(), false)}
683+
onExpandAll={() => setAllExpanded(TAB_KEY, true)}
684+
onCollapseAll={() => setAllExpanded(TAB_KEY, false)}
699685
/>
700686
</Show>
701687
</div>
@@ -716,13 +702,13 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
716702
<For each={pageGroups()}>
717703
{(group) => {
718704
const isEmpty = () => group.items.length === 0;
719-
const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[TAB_KEY] ?? {})[group.repoFullName];
705+
const isExpanded = () => !isEmpty() && isRepoExpanded(TAB_KEY, group.repoFullName);
720706

721707
return (
722708
<div>
723709
<div class="group/repo-header flex items-center bg-info/5 border-y border-base-300 hover:bg-info/10 transition-colors">
724710
<button
725-
onClick={() => setAllExpanded(TAB_KEY, [group.repoFullName], !isExpanded())}
711+
onClick={() => toggleExpandedRepo(TAB_KEY, group.repoFullName)}
726712
aria-expanded={isExpanded()}
727713
class="flex-1 flex items-center gap-2 px-4 py-2.5 compact:py-1.5 text-left text-base compact:text-sm font-bold"
728714
>

src/app/components/dashboard/PullRequestsTab.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
22
import { config, type TrackedUser } from "../../stores/config";
3-
import { viewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, PullRequestFiltersSchema } from "../../stores/view";
3+
import { viewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, isRepoExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, PullRequestFiltersSchema } from "../../stores/view";
44
import { createTabFilterHandlers, mergeActiveFilters } from "../../lib/tabFilters";
55
import { isPrVisible } from "../../lib/filters";
66
import type { PullRequest, RepoRef } from "../../services/api";
@@ -295,7 +295,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
295295
const { flashingIds: flashingPRIds, peekUpdates } = createFlashDetection({
296296
getItems: () => props.pullRequests,
297297
getHotIds: () => props.hotPollingPRIds,
298-
getExpandedRepos: () => viewState.expandedRepos[tabKey()] ?? {},
298+
isRepoExpanded: (repo) => isRepoExpanded(tabKey(), repo),
299299
trackKey: (pr) => `${pr.checkStatus}|${pr.reviewDecision}`,
300300
itemLabel: (pr) => `#${pr.number} ${pr.title}`,
301301
itemStatus: (pr) => pr.checkStatus ?? pr.reviewDecision ?? "updated",
@@ -355,8 +355,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
355355
</div>
356356
<div class="shrink-0 flex items-center gap-2 py-0.5">
357357
<ExpandCollapseButtons
358-
onExpandAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), true)}
359-
onCollapseAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), false)}
358+
onExpandAll={() => setAllExpanded(tabKey(), true)}
359+
onCollapseAll={() => setAllExpanded(tabKey(), false)}
360360
/>
361361
<IgnoreBadge
362362
items={ignoredPullRequests()}
@@ -376,7 +376,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
376376
<For each={pageGroups()}>
377377
{(repoGroup) => {
378378
const isEmpty = () => repoGroup.items.length === 0;
379-
const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[tabKey()] ?? {})[repoGroup.repoFullName];
379+
const isExpanded = () => !isEmpty() && isRepoExpanded(tabKey(), repoGroup.repoFullName);
380380

381381
const summaryMeta = createMemo(() => {
382382
const checks = { success: 0, failure: 0, pending: 0, conflict: 0 };

src/app/lib/flashDetection.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface PeekUpdate {
88
export function createFlashDetection<T extends { id: number; repoFullName: string }>(opts: {
99
getItems: Accessor<T[]>;
1010
getHotIds: Accessor<ReadonlySet<number> | undefined>;
11-
getExpandedRepos: Accessor<Record<string, boolean>>;
11+
isRepoExpanded: (repoFullName: string) => boolean;
1212
trackKey: (item: T) => string;
1313
itemLabel: (item: T) => string;
1414
itemStatus: (item: T) => string;
@@ -62,10 +62,9 @@ export function createFlashDetection<T extends { id: number; repoFullName: strin
6262
const peeks = new Map<string, PeekUpdate>();
6363
const peekCounts = new Map<string, number>();
6464
const peekFirstLabels = new Map<string, string>();
65-
const expandedRepos = opts.getExpandedRepos();
6665
for (const item of items) {
6766
if (changed.has(item.id)) {
68-
if (!expandedRepos[item.repoFullName]) {
67+
if (!opts.isRepoExpanded(item.repoFullName)) {
6968
const count = (peekCounts.get(item.repoFullName) ?? 0) + 1;
7069
peekCounts.set(item.repoFullName, count);
7170
if (count === 1) {

src/app/stores/view.ts

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ export const ViewStateSchema = z.object({
128128
actions: {},
129129
jiraAssigned: {},
130130
}),
131+
// Per-tab default expand state. A repo with no entry in `expandedRepos[tab]` follows
132+
// this default (a tab with no entry here defaults to collapsed). Expand All / Collapse
133+
// All set this default so repos that appear later inherit it; a manual per-repo toggle
134+
// records an exception in `expandedRepos[tab]`. Jira project groups default to expanded.
135+
expandDefault: z.record(z.string(), z.boolean()).default({ jiraAssigned: true }),
131136
lockedRepos: z.record(z.string(), z.array(z.string().max(200)).max(LOCKED_REPOS_CAP)).default({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }),
132137
trackedItems: z.array(TrackedItemSchema).max(TRACKED_ITEMS_CAP).default([]),
133138
dependencyExpandedGroups: z.array(z.string()).default(["mergeable"]),
@@ -206,6 +211,11 @@ export function resetViewState(): void {
206211
delete draft.expandedRepos[key];
207212
}
208213
}
214+
for (const key of Object.keys(draft.expandDefault)) {
215+
if (!(REPO_STATE_TAB_IDS as readonly string[]).includes(key)) {
216+
delete draft.expandDefault[key];
217+
}
218+
}
209219
for (const key of Object.keys(draft.customTabFilters)) {
210220
delete draft.customTabFilters[key];
211221
}
@@ -230,6 +240,7 @@ export function resetViewState(): void {
230240
hideDepDashboard: true,
231241
customTabFilters: {},
232242
expandedRepos: { issues: {}, pullRequests: {}, actions: {}, jiraAssigned: {} },
243+
expandDefault: { jiraAssigned: true },
233244
lockedRepos: { issues: [], pullRequests: [], actions: [], jiraAssigned: [] },
234245
trackedItems: [],
235246
dependencyExpandedGroups: ["mergeable"],
@@ -370,39 +381,48 @@ export function setDependencyExpandedGroups(groups: string[]): void {
370381
);
371382
}
372383

384+
// Effective expand state for a single repo: its per-repo exception if one exists,
385+
// otherwise the tab's default (a tab with no default is collapsed). The per-repo key
386+
// is read unconditionally so SolidJS tracks it — a `hasOwnProperty` guard would skip
387+
// the tracked read and leave callers stale when an exception is added or removed.
388+
export function isRepoExpanded(tab: string, repoFullName: string): boolean {
389+
const override = viewState.expandedRepos[tab]?.[repoFullName];
390+
if (override !== undefined) return override;
391+
return viewState.expandDefault[tab] ?? false;
392+
}
393+
373394
export function toggleExpandedRepo(
374395
tab: string,
375396
repoFullName: string
376397
): void {
377398
setViewState(
378399
produce((draft) => {
400+
const def = draft.expandDefault[tab] ?? false;
379401
if (!draft.expandedRepos[tab]) draft.expandedRepos[tab] = {};
380-
if (draft.expandedRepos[tab][repoFullName]) {
381-
delete draft.expandedRepos[tab][repoFullName];
402+
const overrides = draft.expandedRepos[tab];
403+
const current = Object.prototype.hasOwnProperty.call(overrides, repoFullName)
404+
? overrides[repoFullName]
405+
: def;
406+
const next = !current;
407+
if (next === def) {
408+
// Back in line with the tab default — drop the exception so this repo follows
409+
// the default again (and any future Expand/Collapse All).
410+
delete overrides[repoFullName];
382411
} else {
383-
draft.expandedRepos[tab][repoFullName] = true;
412+
overrides[repoFullName] = next;
384413
}
385414
})
386415
);
387416
}
388417

389-
export function setAllExpanded(
390-
tab: string,
391-
repoFullNames: string[],
392-
expanded: boolean
393-
): void {
418+
// Expand All / Collapse All. Sets the tab-wide default so repos that appear later
419+
// inherit it, and clears every per-repo exception so all current repos (including any
420+
// manually toggled the other way) snap to the new default.
421+
export function setAllExpanded(tab: string, expanded: boolean): void {
394422
setViewState(
395423
produce((draft) => {
396-
if (!draft.expandedRepos[tab]) draft.expandedRepos[tab] = {};
397-
if (expanded) {
398-
for (const name of repoFullNames) {
399-
draft.expandedRepos[tab][name] = true;
400-
}
401-
} else {
402-
for (const name of repoFullNames) {
403-
delete draft.expandedRepos[tab][name];
404-
}
405-
}
424+
draft.expandDefault[tab] = expanded;
425+
draft.expandedRepos[tab] = {};
406426
})
407427
);
408428
}
@@ -448,6 +468,7 @@ export function removeCustomTabState(tabId: string): void {
448468
produce((draft) => {
449469
delete draft.customTabFilters[tabId];
450470
delete draft.expandedRepos[tabId];
471+
delete draft.expandDefault[tabId];
451472
delete draft.lockedRepos[tabId];
452473
})
453474
);

0 commit comments

Comments
 (0)