Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Counts are computed across all repos regardless of any org or repo filter you ha

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.

Use the **Expand all** / **Collapse all** buttons in the toolbar to expand or collapse all groups at once.
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.

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.

Expand Down
10 changes: 5 additions & 5 deletions src/app/components/dashboard/ActionsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createEffect, createMemo, For, Show } from "solid-js";
import { createStore } from "solid-js/store";
import type { WorkflowRun } from "../../services/api";
import { viewState, setViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, ActionsFiltersSchema } from "../../stores/view";
import { viewState, setViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, isRepoExpanded, pruneExpandedRepos, pruneLockedRepos, ActionsFiltersSchema } from "../../stores/view";
import { createTabFilterHandlers, mergeActiveFilters } from "../../lib/tabFilters";
import { isRunVisible } from "../../lib/filters";
import WorkflowSummaryCard from "./WorkflowSummaryCard";
Expand Down Expand Up @@ -140,7 +140,7 @@ export default function ActionsTab(props: ActionsTabProps) {
const { flashingIds: flashingRunIds, peekUpdates } = createFlashDetection({
getItems: () => props.workflowRuns,
getHotIds: () => props.hotPollingRunIds,
getExpandedRepos: () => viewState.expandedRepos[tabKey()] ?? {},
isRepoExpanded: (repo) => isRepoExpanded(tabKey(), repo),
trackKey: (run) => `${run.status}|${run.conclusion}`,
itemLabel: (run) => run.name,
itemStatus: (run) => run.conclusion ?? run.status,
Expand Down Expand Up @@ -236,8 +236,8 @@ export default function ActionsTab(props: ActionsTabProps) {
</div>
<div class="shrink-0 flex items-center gap-2 py-0.5">
<ExpandCollapseButtons
onExpandAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), true)}
onCollapseAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), false)}
onExpandAll={() => setAllExpanded(tabKey(), true)}
onCollapseAll={() => setAllExpanded(tabKey(), false)}
/>
<IgnoreBadge
items={ignoredWorkflowRuns()}
Expand All @@ -256,7 +256,7 @@ export default function ActionsTab(props: ActionsTabProps) {
<For each={repoGroups()}>
{(repoGroup) => {
const isEmpty = () => repoGroup.workflows.length === 0;
const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[tabKey()] ?? {})[repoGroup.repoFullName];
const isExpanded = () => !isEmpty() && isRepoExpanded(tabKey(), repoGroup.repoFullName);

const sortedWorkflows = createMemo(() =>
sortWorkflowsByStatus(repoGroup.workflows)
Expand Down
1 change: 1 addition & 0 deletions src/app/components/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,7 @@ export default function DashboardPage() {
const keys = new Set([
...Object.keys(viewState.customTabFilters),
...Object.keys(viewState.expandedRepos).filter((k) => !isBuiltinTab(k)),
...Object.keys(viewState.expandDefault).filter((k) => !isBuiltinTab(k)),
...Object.keys(viewState.lockedRepos).filter((k) => !isBuiltinTab(k)),
]);
return [...keys].filter((id) => !activeIds.has(id));
Expand Down
8 changes: 4 additions & 4 deletions src/app/components/dashboard/IssuesTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { config, type TrackedUser } from "../../stores/config";
import { viewState, updateViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, IssueFiltersSchema } from "../../stores/view";
import { viewState, updateViewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, isRepoExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, IssueFiltersSchema } from "../../stores/view";
import { createTabFilterHandlers, mergeActiveFilters } from "../../lib/tabFilters";
import type { Issue, RepoRef } from "../../services/api";
import { isIssueVisible } from "../../lib/filters";
Expand Down Expand Up @@ -294,8 +294,8 @@ export default function IssuesTab(props: IssuesTabProps) {
</div>
<div class="shrink-0 flex items-center gap-2 py-0.5">
<ExpandCollapseButtons
onExpandAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), true)}
onCollapseAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), false)}
onExpandAll={() => setAllExpanded(tabKey(), true)}
onCollapseAll={() => setAllExpanded(tabKey(), false)}
/>
<IgnoreBadge
items={ignoredIssues()}
Expand All @@ -315,7 +315,7 @@ export default function IssuesTab(props: IssuesTabProps) {
<For each={pageGroups()}>
{(repoGroup) => {
const isEmpty = () => repoGroup.items.length === 0;
const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[tabKey()] ?? {})[repoGroup.repoFullName];
const isExpanded = () => !isEmpty() && isRepoExpanded(tabKey(), repoGroup.repoFullName);

const roleSummary = createMemo(() => {
const counts: Record<string, number> = {};
Expand Down
24 changes: 5 additions & 19 deletions src/app/components/dashboard/JiraAssignedTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, For, Show, on, onCleanup } from "solid-js";
import type { JiraIssue } from "../../../shared/jira-types";
import { viewState, setTabFilter, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded, setJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE, JIRA_CUSTOM_SORT_FIELD } from "../../stores/view";
import { viewState, setTabFilter, JiraFiltersSchema, trackItem, untrackJiraItem, setAllExpanded, toggleExpandedRepo, isRepoExpanded, setJiraCustomOrder, JIRA_CUSTOM_ORDER_SCOPE, JIRA_CUSTOM_SORT_FIELD } from "../../stores/view";
import { config } from "../../stores/config";
import JiraFieldValue from "./JiraFieldValue";
import { jiraStatusCategoryClass, stripParenthetical } from "../../lib/format";
Expand Down Expand Up @@ -86,10 +86,7 @@ const STATUS_SDLC_ORDER: Record<string, number> = Object.assign(Object.create(nu
"Stalled / Blocked": 8, "Blocked/On Hold": 8, "QA Blocked": 8,
});

let _jiraExpandInitialized = false;

export function _resetJiraTabState() {
_jiraExpandInitialized = false;
itemRefs.clear();
}

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

const projectKeys = createMemo(() => repoGroups().map((g) => g.repoFullName));

createEffect(() => {
const max = pageCount() - 1;
if (page() > max) setPage(max);
});

createEffect(() => {
const keys = projectKeys();
if (keys.length === 0 || _jiraExpandInitialized) return;
const expanded = viewState.expandedRepos[TAB_KEY];
if (expanded && Object.keys(expanded).length > 0) return;
_jiraExpandInitialized = true;
setAllExpanded(TAB_KEY, keys, true);
});

// Reordering is only meaningful — and safe — against the canonical, unfiltered
// "assigned" scope: filtered() must exclude nothing so filteredSorted()'s key list
// is the complete set, matching what Task 4's prune gate guards against.
Expand Down Expand Up @@ -698,8 +684,8 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
/>
<Show when={!isCustomMode()}>
<ExpandCollapseButtons
onExpandAll={() => setAllExpanded(TAB_KEY, projectKeys(), true)}
onCollapseAll={() => setAllExpanded(TAB_KEY, projectKeys(), false)}
onExpandAll={() => setAllExpanded(TAB_KEY, true)}
onCollapseAll={() => setAllExpanded(TAB_KEY, false)}
/>
</Show>
</div>
Expand All @@ -720,13 +706,13 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
<For each={pageGroups()}>
{(group) => {
const isEmpty = () => group.items.length === 0;
const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[TAB_KEY] ?? {})[group.repoFullName];
const isExpanded = () => !isEmpty() && isRepoExpanded(TAB_KEY, group.repoFullName);

return (
<div>
<div class="group/repo-header flex items-center bg-info/5 border-y border-base-300 hover:bg-info/10 transition-colors">
<button
onClick={() => setAllExpanded(TAB_KEY, [group.repoFullName], !isExpanded())}
onClick={() => toggleExpandedRepo(TAB_KEY, group.repoFullName)}
aria-expanded={isExpanded()}
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"
>
Expand Down
10 changes: 5 additions & 5 deletions src/app/components/dashboard/PullRequestsTab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { config, type TrackedUser } from "../../stores/config";
import { viewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, PullRequestFiltersSchema } from "../../stores/view";
import { viewState, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, isRepoExpanded, pruneExpandedRepos, pruneLockedRepos, trackItem, untrackItem, PullRequestFiltersSchema } from "../../stores/view";
import { createTabFilterHandlers, mergeActiveFilters } from "../../lib/tabFilters";
import { isPrVisible } from "../../lib/filters";
import type { PullRequest, RepoRef } from "../../services/api";
Expand Down Expand Up @@ -295,7 +295,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
const { flashingIds: flashingPRIds, peekUpdates } = createFlashDetection({
getItems: () => props.pullRequests,
getHotIds: () => props.hotPollingPRIds,
getExpandedRepos: () => viewState.expandedRepos[tabKey()] ?? {},
isRepoExpanded: (repo) => isRepoExpanded(tabKey(), repo),
trackKey: (pr) => `${pr.checkStatus}|${pr.reviewDecision}`,
itemLabel: (pr) => `#${pr.number} ${pr.title}`,
itemStatus: (pr) => pr.checkStatus ?? pr.reviewDecision ?? "updated",
Expand Down Expand Up @@ -355,8 +355,8 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
</div>
<div class="shrink-0 flex items-center gap-2 py-0.5">
<ExpandCollapseButtons
onExpandAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), true)}
onCollapseAll={() => setAllExpanded(tabKey(), repoGroups().map((g) => g.repoFullName), false)}
onExpandAll={() => setAllExpanded(tabKey(), true)}
onCollapseAll={() => setAllExpanded(tabKey(), false)}
/>
<IgnoreBadge
items={ignoredPullRequests()}
Expand All @@ -376,7 +376,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
<For each={pageGroups()}>
{(repoGroup) => {
const isEmpty = () => repoGroup.items.length === 0;
const isExpanded = () => !isEmpty() && !!(viewState.expandedRepos[tabKey()] ?? {})[repoGroup.repoFullName];
const isExpanded = () => !isEmpty() && isRepoExpanded(tabKey(), repoGroup.repoFullName);

const summaryMeta = createMemo(() => {
const checks = { success: 0, failure: 0, pending: 0, conflict: 0 };
Expand Down
5 changes: 2 additions & 3 deletions src/app/lib/flashDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface PeekUpdate {
export function createFlashDetection<T extends { id: number; repoFullName: string }>(opts: {
getItems: Accessor<T[]>;
getHotIds: Accessor<ReadonlySet<number> | undefined>;
getExpandedRepos: Accessor<Record<string, boolean>>;
isRepoExpanded: (repoFullName: string) => boolean;
trackKey: (item: T) => string;
itemLabel: (item: T) => string;
itemStatus: (item: T) => string;
Expand Down Expand Up @@ -62,10 +62,9 @@ export function createFlashDetection<T extends { id: number; repoFullName: strin
const peeks = new Map<string, PeekUpdate>();
const peekCounts = new Map<string, number>();
const peekFirstLabels = new Map<string, string>();
const expandedRepos = opts.getExpandedRepos();
for (const item of items) {
if (changed.has(item.id)) {
if (!expandedRepos[item.repoFullName]) {
if (!opts.isRepoExpanded(item.repoFullName)) {
const count = (peekCounts.get(item.repoFullName) ?? 0) + 1;
peekCounts.set(item.repoFullName, count);
if (count === 1) {
Expand Down
57 changes: 39 additions & 18 deletions src/app/stores/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ export const ViewStateSchema = z.object({
actions: {},
jiraAssigned: {},
}),
// Per-tab default expand state. A repo with no entry in `expandedRepos[tab]` follows
// this default (a tab with no entry here defaults to collapsed). Expand All / Collapse
// All set this default so repos that appear later inherit it; a manual per-repo toggle
// records an exception in `expandedRepos[tab]`. Jira project groups default to expanded.
expandDefault: z.record(z.string(), z.boolean()).default({ jiraAssigned: true }),
lockedRepos: z.record(z.string(), z.array(z.string().max(200)).max(LOCKED_REPOS_CAP)).default({ issues: [], pullRequests: [], actions: [], jiraAssigned: [] }),
trackedItems: z.array(TrackedItemSchema).max(TRACKED_ITEMS_CAP).default([]),
dependencyExpandedGroups: z.array(z.string()).default(["mergeable"]),
Expand Down Expand Up @@ -206,6 +211,11 @@ export function resetViewState(): void {
delete draft.expandedRepos[key];
}
}
for (const key of Object.keys(draft.expandDefault)) {
if (!(REPO_STATE_TAB_IDS as readonly string[]).includes(key)) {
delete draft.expandDefault[key];
}
}
for (const key of Object.keys(draft.customTabFilters)) {
delete draft.customTabFilters[key];
}
Expand All @@ -230,6 +240,7 @@ export function resetViewState(): void {
hideDepDashboard: true,
customTabFilters: {},
expandedRepos: { issues: {}, pullRequests: {}, actions: {}, jiraAssigned: {} },
expandDefault: { jiraAssigned: true },
lockedRepos: { issues: [], pullRequests: [], actions: [], jiraAssigned: [] },
trackedItems: [],
dependencyExpandedGroups: ["mergeable"],
Expand Down Expand Up @@ -370,39 +381,48 @@ export function setDependencyExpandedGroups(groups: string[]): void {
);
}

// Effective expand state for a single repo: its per-repo exception if one exists,
// otherwise the tab's default (a tab with no default is collapsed). The per-repo key
// is read unconditionally so SolidJS tracks it — a `hasOwnProperty` guard would skip
// the tracked read and leave callers stale when an exception is added or removed.
export function isRepoExpanded(tab: string, repoFullName: string): boolean {
const override = viewState.expandedRepos[tab]?.[repoFullName];
if (override !== undefined) return override;
return viewState.expandDefault[tab] ?? false;
}

export function toggleExpandedRepo(
tab: string,
repoFullName: string
): void {
setViewState(
produce((draft) => {
const def = draft.expandDefault[tab] ?? false;
if (!draft.expandedRepos[tab]) draft.expandedRepos[tab] = {};
if (draft.expandedRepos[tab][repoFullName]) {
delete draft.expandedRepos[tab][repoFullName];
const overrides = draft.expandedRepos[tab];
const current = Object.prototype.hasOwnProperty.call(overrides, repoFullName)
? overrides[repoFullName]
: def;
const next = !current;
if (next === def) {
// Back in line with the tab default — drop the exception so this repo follows
// the default again (and any future Expand/Collapse All).
delete overrides[repoFullName];
} else {
draft.expandedRepos[tab][repoFullName] = true;
overrides[repoFullName] = next;
}
})
);
}

export function setAllExpanded(
tab: string,
repoFullNames: string[],
expanded: boolean
): void {
// Expand All / Collapse All. Sets the tab-wide default so repos that appear later
// inherit it, and clears every per-repo exception so all current repos (including any
// manually toggled the other way) snap to the new default.
export function setAllExpanded(tab: string, expanded: boolean): void {
setViewState(
produce((draft) => {
if (!draft.expandedRepos[tab]) draft.expandedRepos[tab] = {};
if (expanded) {
for (const name of repoFullNames) {
draft.expandedRepos[tab][name] = true;
}
} else {
for (const name of repoFullNames) {
delete draft.expandedRepos[tab][name];
}
}
draft.expandDefault[tab] = expanded;
draft.expandedRepos[tab] = {};
})
);
}
Expand Down Expand Up @@ -448,6 +468,7 @@ export function removeCustomTabState(tabId: string): void {
produce((draft) => {
delete draft.customTabFilters[tabId];
delete draft.expandedRepos[tabId];
delete draft.expandDefault[tabId];
delete draft.lockedRepos[tabId];
})
);
Expand Down
Loading