diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 390ea186..97162711 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -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.
diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx
index 8fcb616c..87d96642 100644
--- a/src/app/components/dashboard/ActionsTab.tsx
+++ b/src/app/components/dashboard/ActionsTab.tsx
@@ -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";
@@ -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,
@@ -236,8 +236,8 @@ export default function ActionsTab(props: ActionsTabProps) {
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)}
/>
{(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)
diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx
index 6c35dad9..ef209c1e 100644
--- a/src/app/components/dashboard/DashboardPage.tsx
+++ b/src/app/components/dashboard/DashboardPage.tsx
@@ -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));
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx
index dee194ee..5eb2626e 100644
--- a/src/app/components/dashboard/IssuesTab.tsx
+++ b/src/app/components/dashboard/IssuesTab.tsx
@@ -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";
@@ -294,8 +294,8 @@ export default function IssuesTab(props: IssuesTabProps) {
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)}
/>
{(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 = {};
diff --git a/src/app/components/dashboard/JiraAssignedTab.tsx b/src/app/components/dashboard/JiraAssignedTab.tsx
index 793a6b29..1537a09b 100644
--- a/src/app/components/dashboard/JiraAssignedTab.tsx
+++ b/src/app/components/dashboard/JiraAssignedTab.tsx
@@ -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";
@@ -86,10 +86,7 @@ const STATUS_SDLC_ORDER: Record = 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();
}
@@ -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.
@@ -698,8 +684,8 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
/>
setAllExpanded(TAB_KEY, projectKeys(), true)}
- onCollapseAll={() => setAllExpanded(TAB_KEY, projectKeys(), false)}
+ onExpandAll={() => setAllExpanded(TAB_KEY, true)}
+ onCollapseAll={() => setAllExpanded(TAB_KEY, false)}
/>
@@ -720,13 +706,13 @@ export default function JiraAssignedTab(props: JiraAssignedTabProps) {
{(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 (
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)}
/>
{(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 };
diff --git a/src/app/lib/flashDetection.ts b/src/app/lib/flashDetection.ts
index cd227ee8..22ecf6a5 100644
--- a/src/app/lib/flashDetection.ts
+++ b/src/app/lib/flashDetection.ts
@@ -8,7 +8,7 @@ export interface PeekUpdate {
export function createFlashDetection(opts: {
getItems: Accessor;
getHotIds: Accessor | undefined>;
- getExpandedRepos: Accessor>;
+ isRepoExpanded: (repoFullName: string) => boolean;
trackKey: (item: T) => string;
itemLabel: (item: T) => string;
itemStatus: (item: T) => string;
@@ -62,10 +62,9 @@ export function createFlashDetection();
const peekCounts = new Map();
const peekFirstLabels = new Map();
- 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) {
diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts
index ed7cdb67..5bd4df79 100644
--- a/src/app/stores/view.ts
+++ b/src/app/stores/view.ts
@@ -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"]),
@@ -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];
}
@@ -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"],
@@ -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] = {};
})
);
}
@@ -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];
})
);
diff --git a/tests/components/ActionsTab.test.tsx b/tests/components/ActionsTab.test.tsx
index 9854957c..669d992e 100644
--- a/tests/components/ActionsTab.test.tsx
+++ b/tests/components/ActionsTab.test.tsx
@@ -360,7 +360,7 @@ describe("ActionsTab", () => {
// Workflow card expansion is local component state (not persisted in viewState)
// It survives collapse/expand within the same mount because expandedWorkflows
// is at component scope, but would reset on full component remount
- expect(viewState.expandedRepos.actions["owner/repo"]).toBe(true);
+ expect(viewStore.isRepoExpanded("actions", "owner/repo")).toBe(true);
// Run row still visible — local store persists within same component instance
screen.getByText("my-unique-run");
});
@@ -370,12 +370,12 @@ describe("ActionsTab", () => {
makeWorkflowRun({ repoFullName: "owner/repo-a", workflowId: 1, name: "CI-A" }),
makeWorkflowRun({ repoFullName: "owner/repo-b", workflowId: 2, name: "CI-B" }),
]);
- viewStore.setAllExpanded("actions", ["owner/repo-a", "owner/repo-b"], true);
+ viewStore.setAllExpanded("actions", true);
render(() => );
// Remove repo-b from data — pruning effect should fire
setRuns([makeWorkflowRun({ repoFullName: "owner/repo-a", workflowId: 1, name: "CI-A" })]);
- expect(viewState.expandedRepos.actions["owner/repo-a"]).toBe(true);
+ expect(viewStore.isRepoExpanded("actions", "owner/repo-a")).toBe(true);
expect("owner/repo-b" in viewState.expandedRepos.actions).toBe(false);
});
@@ -383,17 +383,39 @@ describe("ActionsTab", () => {
const [runs, setRuns] = createSignal([
makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "CI" }),
]);
- viewStore.setAllExpanded("actions", ["owner/repo"], true);
+ viewStore.setAllExpanded("actions", true);
render(() => );
setRuns([]);
- expect(viewState.expandedRepos.actions["owner/repo"]).toBe(true);
+ expect(viewStore.isRepoExpanded("actions", "owner/repo")).toBe(true);
// Data returns — UI should use preserved expanded state
setRuns([makeWorkflowRun({ repoFullName: "owner/repo", workflowId: 1, name: "CI" })]);
expect(screen.getAllByText("CI").length).toBeGreaterThanOrEqual(1);
});
+ it("expand all keeps a repo that appears in a later data update expanded", async () => {
+ const user = userEvent.setup();
+ const [runs, setRuns] = createSignal([
+ makeWorkflowRun({ repoFullName: "owner/repo-a", workflowId: 1, name: "CI-A" }),
+ ]);
+ render(() => );
+
+ // Expand all — sets the tab default to expanded
+ await user.click(screen.getByRole("button", { name: /Expand all/i }));
+ expect(screen.getAllByText("CI-A").length).toBeGreaterThanOrEqual(1);
+
+ // A brand-new repo arrives later (never present when Expand all was clicked)
+ setRuns([
+ makeWorkflowRun({ repoFullName: "owner/repo-a", workflowId: 1, name: "CI-A" }),
+ makeWorkflowRun({ repoFullName: "owner/repo-b", workflowId: 2, name: "CI-B" }),
+ ]);
+
+ // It inherits the expanded default with no further interaction
+ screen.getByText("owner/repo-b");
+ expect(screen.getAllByText("CI-B").length).toBeGreaterThanOrEqual(1);
+ });
+
it("expanded repo state persists in viewState", async () => {
const user = userEvent.setup();
const runs = [
@@ -442,7 +464,7 @@ describe("ActionsTab", () => {
makeWorkflowRun({ id: 10, repoFullName: "org/repo", workflowId: 1, name: "CI", status: "in_progress", conclusion: null }),
makeWorkflowRun({ id: 20, repoFullName: "org/repo", workflowId: 1, name: "CI", status: "completed", conclusion: "success" }),
];
- setAllExpanded("actions", ["org/repo"], true);
+ setAllExpanded("actions", true);
const { container } = render(() => (
));
@@ -461,7 +483,7 @@ describe("ActionsTab", () => {
const runs = [
makeWorkflowRun({ id: 1, repoFullName: "org/repo", workflowId: 1, name: "CI", status: "in_progress", conclusion: null }),
];
- setAllExpanded("actions", ["org/repo"], true);
+ setAllExpanded("actions", true);
const { container } = render(() => );
await user.click(screen.getByText("CI"));
const runRows = container.querySelectorAll("[class*='flex items-center gap-3']");
diff --git a/tests/components/DashboardPage.test.tsx b/tests/components/DashboardPage.test.tsx
index 0c931b44..6b83a602 100644
--- a/tests/components/DashboardPage.test.tsx
+++ b/tests/components/DashboardPage.test.tsx
@@ -1484,7 +1484,7 @@ describe("DashboardPage — runtime redirect when active custom tab is deleted",
// ── Orphaned view state cleanup ──────────────────────────────────────────────
describe("DashboardPage — orphaned view state cleanup", () => {
- it("removes customTabFilters and expandedRepos keys when a custom tab is deleted", async () => {
+ it("removes customTabFilters, expandedRepos, and expandDefault keys when a custom tab is deleted", async () => {
configStore.addCustomTab({
id: "orphan01",
name: "Orphan Tab",
@@ -1495,12 +1495,14 @@ describe("DashboardPage — orphaned view state cleanup", () => {
exclusive: false,
});
viewStore.setCustomTabFilter("orphan01", "role", "author");
+ viewStore.setAllExpanded("orphan01", true);
viewStore.toggleExpandedRepo("orphan01", "myorg/repo");
render(() => );
await waitFor(() => {
expect(viewStore.viewState.customTabFilters["orphan01"]).toBeDefined();
+ expect(viewStore.viewState.expandDefault["orphan01"]).toBe(true);
});
configStore.removeCustomTab("orphan01");
@@ -1508,16 +1510,34 @@ describe("DashboardPage — orphaned view state cleanup", () => {
await waitFor(() => {
expect(viewStore.viewState.customTabFilters["orphan01"]).toBeUndefined();
expect(viewStore.viewState.expandedRepos["orphan01"]).toBeUndefined();
+ expect(viewStore.viewState.expandDefault["orphan01"]).toBeUndefined();
});
});
- it("prunes stale customTabFilters entries at mount time for unknown tab IDs", async () => {
+ it("prunes stale customTabFilters and expandDefault entries at mount time for unknown tab IDs", async () => {
viewStore.setCustomTabFilter("ghost-tab", "role", "assignee");
+ viewStore.setAllExpanded("ghost-tab", true);
+ expect(viewStore.viewState.expandDefault["ghost-tab"]).toBe(true);
render(() => );
await waitFor(() => {
expect(viewStore.viewState.customTabFilters["ghost-tab"]).toBeUndefined();
+ expect(viewStore.viewState.expandDefault["ghost-tab"]).toBeUndefined();
+ });
+ });
+
+ it("prunes a stale tab whose only footprint is an expandDefault entry at mount time", async () => {
+ // A divergent persisted blob can carry an expandDefault entry for a tab with no
+ // matching expandedRepos/customTabFilters key — the cleanup must still discover it
+ // via the expandDefault key set alone.
+ viewStore.updateViewState({ expandDefault: { jiraAssigned: true, "ghost-default": true } });
+ expect(viewStore.viewState.expandDefault["ghost-default"]).toBe(true);
+
+ render(() => );
+
+ await waitFor(() => {
+ expect(viewStore.viewState.expandDefault["ghost-default"]).toBeUndefined();
});
});
});
diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx
index f68373b1..e72807f6 100644
--- a/tests/components/IssuesTab.test.tsx
+++ b/tests/components/IssuesTab.test.tsx
@@ -20,7 +20,7 @@ describe("IssuesTab", () => {
makeIssue({ number: 1, title: "First issue" }),
makeIssue({ number: 2, title: "Second issue" }),
];
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("First issue");
screen.getByText("Second issue");
@@ -59,7 +59,7 @@ describe("IssuesTab", () => {
makeIssue({ number: 2, title: "In other repo", repoFullName: "owner/other" }),
];
viewStore.setGlobalFilter(null, "owner/target");
- setAllExpanded("issues", ["owner/target"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("In target repo");
expect(screen.queryByText("In other repo")).toBeNull();
@@ -71,7 +71,7 @@ describe("IssuesTab", () => {
makeIssue({ number: 2, title: "Outside org", repoFullName: "otherorg/repo-b" }),
];
viewStore.setGlobalFilter("myorg", null);
- setAllExpanded("issues", ["myorg/repo-a"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("In org");
expect(screen.queryByText("Outside org")).toBeNull();
@@ -82,7 +82,7 @@ describe("IssuesTab", () => {
makeIssue({ id: 1, title: "Older issue", updatedAt: "2024-01-10T00:00:00Z" }),
makeIssue({ id: 2, title: "Newer issue", updatedAt: "2024-01-20T00:00:00Z" }),
];
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
const allText = screen.getAllByRole("listitem");
const texts = allText.map((el) => el.textContent ?? "");
@@ -123,7 +123,7 @@ describe("IssuesTab", () => {
makeIssue({ id: 2, title: "Other Issue", userLogin: "bob", assigneeLogins: [] }),
];
viewStore.setTabFilter("issues", "role", "author");
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("My Issue");
expect(screen.queryByText("Other Issue")).toBeNull();
@@ -135,7 +135,7 @@ describe("IssuesTab", () => {
makeIssue({ id: 2, title: "Silent Issue", comments: 0 }),
];
viewStore.setTabFilter("issues", "comments", "has");
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("Discussed Issue");
expect(screen.queryByText("Silent Issue")).toBeNull();
@@ -147,7 +147,7 @@ describe("IssuesTab", () => {
makeIssue({ id: 2, title: "Silent Issue", comments: 0 }),
];
viewStore.setTabFilter("issues", "comments", "none");
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("Silent Issue");
expect(screen.queryByText("Discussed Issue")).toBeNull();
@@ -159,7 +159,7 @@ describe("IssuesTab", () => {
makeIssue({ id: 2, title: "Issue in repo B", repoFullName: "org/repo-b" }),
makeIssue({ id: 3, title: "Another in repo A", repoFullName: "org/repo-a" }),
];
- setAllExpanded("issues", ["org/repo-a"], true);
+ viewStore.toggleExpandedRepo("issues", "org/repo-a");
render(() => );
// Both repo headers visible
screen.getByText("org/repo-a");
@@ -362,13 +362,13 @@ describe("IssuesTab", () => {
expect(screen.queryByText("Bob issue")).toBeNull();
});
- it("collapse all with active filter preserves hidden repos' expanded state", async () => {
+ it("collapse all also collapses repos hidden by an active filter", async () => {
const user = userEvent.setup();
const issues = [
makeIssue({ id: 1, title: "Alice issue", repoFullName: "org/repo-a", userLogin: "alice" }),
makeIssue({ id: 2, title: "Bob issue", repoFullName: "org/repo-b", userLogin: "bob" }),
];
- setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("Alice issue");
screen.getByText("Bob issue");
@@ -378,14 +378,15 @@ describe("IssuesTab", () => {
screen.getByText("Alice issue");
expect(screen.queryByText("org/repo-b")).toBeNull();
- // Collapse all — only affects visible (filtered) repos
+ // Collapse all — sets the tab default to collapsed and clears every exception
await user.click(screen.getByLabelText("Collapse all repos"));
expect(screen.queryByText("Alice issue")).toBeNull();
- // Remove filter — repo-b should still be expanded (was hidden during collapse-all)
+ // Remove filter — repo-b is collapsed too: the new default applies to every repo,
+ // even ones hidden when Collapse all ran.
viewStore.setTabFilter("issues", "role", "all");
- screen.getByText("Bob issue");
- // repo-a was collapsed by collapse-all
+ screen.getByText("org/repo-b");
+ expect(screen.queryByText("Bob issue")).toBeNull();
expect(screen.queryByText("Alice issue")).toBeNull();
});
@@ -398,7 +399,7 @@ describe("IssuesTab", () => {
const repoBIssues = Array.from({ length: 6 }, (_, i) =>
makeIssue({ id: 200 + i, title: `Repo B issue ${i}`, repoFullName: "org/repo-b" })
);
- setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true);
+ setAllExpanded("issues", true);
const [issues, setIssues] = createSignal([...repoAIssues, ...repoBIssues]);
render(() => );
@@ -420,7 +421,7 @@ describe("IssuesTab", () => {
const issues = Array.from({ length: 15 }, (_, i) =>
makeIssue({ id: 300 + i, title: `Big repo issue ${i}`, repoFullName: "org/big-repo" })
);
- setAllExpanded("issues", ["org/big-repo"], true);
+ setAllExpanded("issues", true);
render(() => );
// All 15 items in one group — whole-groups-only pagination keeps them together
screen.getByText("org/big-repo");
@@ -452,7 +453,7 @@ describe("IssuesTab", () => {
makeIssue({ id: 1, title: "Issue A", repoFullName: "org/repo-a" }),
makeIssue({ id: 2, title: "Issue B", repoFullName: "org/repo-b" }),
];
- setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("Issue A");
screen.getByText("Issue B");
@@ -494,7 +495,7 @@ describe("IssuesTab", () => {
makeIssue({ id: 1, title: "Repo A issue", repoFullName: "org/repo-a" }),
makeIssue({ id: 2, title: "Repo B issue", repoFullName: "org/repo-b" }),
]);
- setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true);
+ setAllExpanded("issues", true);
render(() => );
// Both repos expanded
screen.getByText("Repo A issue");
@@ -502,7 +503,7 @@ describe("IssuesTab", () => {
// Remove repo-b from data — pruning effect should fire
setIssues([makeIssue({ id: 1, title: "Repo A issue", repoFullName: "org/repo-a" })]);
- expect(viewState.expandedRepos.issues["org/repo-a"]).toBe(true);
+ expect(viewStore.isRepoExpanded("issues", "org/repo-a")).toBe(true);
expect("org/repo-b" in viewState.expandedRepos.issues).toBe(false);
});
@@ -511,26 +512,26 @@ describe("IssuesTab", () => {
makeIssue({ id: 1, title: "Repo A issue", repoFullName: "org/repo-a" }),
makeIssue({ id: 2, title: "Repo B issue", repoFullName: "org/repo-b" }),
]);
- setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true);
+ setAllExpanded("issues", true);
render(() => );
// Remove repo-a (first), keep repo-b (second)
setIssues([makeIssue({ id: 2, title: "Repo B issue", repoFullName: "org/repo-b" })]);
expect("org/repo-a" in viewState.expandedRepos.issues).toBe(false);
- expect(viewState.expandedRepos.issues["org/repo-b"]).toBe(true);
+ expect(viewStore.isRepoExpanded("issues", "org/repo-b")).toBe(true);
});
it("preserves expanded keys when data becomes empty and restores UI on re-population", () => {
const [issues, setIssues] = createSignal([
makeIssue({ id: 1, title: "Issue A", repoFullName: "org/repo-a" }),
]);
- setAllExpanded("issues", ["org/repo-a"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("Issue A");
// Data becomes empty (e.g. loading state) — expanded state should be preserved
setIssues([]);
- expect(viewState.expandedRepos.issues["org/repo-a"]).toBe(true);
+ expect(viewStore.isRepoExpanded("issues", "org/repo-a")).toBe(true);
// Data returns — UI should use preserved expanded state
setIssues([makeIssue({ id: 1, title: "Issue A", repoFullName: "org/repo-a" })]);
@@ -563,9 +564,31 @@ describe("IssuesTab", () => {
screen.getByText("Repo B issue 0");
});
+ it("expand all keeps a repo that appears in a later data update expanded", async () => {
+ const user = userEvent.setup();
+ const [issues, setIssues] = createSignal([
+ makeIssue({ id: 1, title: "Repo A issue", repoFullName: "org/repo-a" }),
+ ]);
+ render(() => );
+
+ // Expand all — sets the tab default to expanded
+ await user.click(screen.getByLabelText("Expand all repos"));
+ screen.getByText("Repo A issue");
+
+ // A brand-new repo arrives later (never present when Expand all was clicked)
+ setIssues([
+ makeIssue({ id: 1, title: "Repo A issue", repoFullName: "org/repo-a" }),
+ makeIssue({ id: 2, title: "Repo B issue", repoFullName: "org/repo-b" }),
+ ]);
+
+ // It inherits the expanded default with no further interaction
+ screen.getByText("org/repo-b");
+ screen.getByText("Repo B issue");
+ });
+
it("renders repo header link to GitHub issues", () => {
const issues = [makeIssue({ id: 1 })];
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
const link = screen.getByLabelText("Open owner/repo issues on GitHub");
expect(link.getAttribute("href")).toBe("https://github.com/owner/repo/issues");
@@ -581,7 +604,7 @@ describe("IssuesTab — hideDepDashboard + dependencies.enabled interaction", ()
const issues = [
makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo", userLogin: "me" }),
];
- setAllExpanded("my-custom-tab", ["org/repo"], true);
+ setAllExpanded("my-custom-tab", true);
render(() => );
screen.getByText("Dependency Dashboard");
});
@@ -595,7 +618,7 @@ describe("IssuesTab — hideDepDashboard + dependencies.enabled", () => {
makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }),
makeIssue({ id: 2, title: "Regular issue", repoFullName: "org/repo" }),
];
- setAllExpanded("issues", ["org/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
expect(screen.queryByText("Dependency Dashboard")).toBeNull();
screen.getByText("Regular issue");
@@ -608,7 +631,7 @@ describe("IssuesTab — hideDepDashboard + dependencies.enabled", () => {
makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }),
makeIssue({ id: 2, title: "Regular issue", repoFullName: "org/repo" }),
];
- setAllExpanded("issues", ["org/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("Dependency Dashboard");
screen.getByText("Regular issue");
@@ -620,7 +643,7 @@ describe("IssuesTab — hideDepDashboard + dependencies.enabled", () => {
const issues = [
makeIssue({ id: 1, title: "Dependency Dashboard", repoFullName: "org/repo" }),
];
- setAllExpanded("issues", ["org/repo"], true);
+ setAllExpanded("issues", true);
render(() => );
screen.getByText("Dependency Dashboard");
});
diff --git a/tests/components/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx
index b92c2415..67b2017d 100644
--- a/tests/components/PullRequestsTab.test.tsx
+++ b/tests/components/PullRequestsTab.test.tsx
@@ -20,7 +20,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 1, number: 1, title: "First PR", repoFullName: "org/repo-a" }),
makePullRequest({ id: 2, number: 2, title: "Second PR", repoFullName: "org/repo-a" }),
];
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("First PR");
screen.getByText("Second PR");
@@ -58,7 +58,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, number: 2, title: "In other repo", repoFullName: "owner/other" }),
];
viewStore.setGlobalFilter(null, "owner/target");
- setAllExpanded("pullRequests", ["owner/target"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("In target repo");
expect(screen.queryByText("In other repo")).toBeNull();
@@ -70,7 +70,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, number: 2, title: "Outside org", repoFullName: "otherorg/repo-b" }),
];
viewStore.setGlobalFilter("myorg", null);
- setAllExpanded("pullRequests", ["myorg/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("In org");
expect(screen.queryByText("Outside org")).toBeNull();
@@ -81,7 +81,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 1, title: "Older PR", updatedAt: "2024-01-10T00:00:00Z", repoFullName: "org/repo-a" }),
makePullRequest({ id: 2, title: "Newer PR", updatedAt: "2024-01-20T00:00:00Z", repoFullName: "org/repo-a" }),
];
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
const items = screen.getAllByRole("listitem");
const texts = items.map((el) => el.textContent ?? "");
@@ -107,14 +107,14 @@ describe("PullRequestsTab", () => {
const prs = [
makePullRequest({ id: 1, title: "PR with status", checkStatus: "success", repoFullName: "org/repo-a" }),
];
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByLabelText("All checks passed");
});
it("shows Draft badge for draft PRs when expanded", () => {
const pr = makePullRequest({ id: 1, title: "Draft PR", draft: true, repoFullName: "org/repo-a" });
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
// "Draft" appears as a PR badge
const draftEls = screen.getAllByText("Draft");
@@ -134,7 +134,7 @@ describe("PullRequestsTab", () => {
it("shows Author role badge when userLogin matches PR author", () => {
const pr = makePullRequest({ id: 1, title: "My PR", userLogin: "alice", reviewerLogins: [], assigneeLogins: [], repoFullName: "org/repo-a" });
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
// "Author" appears as a role badge
const authorEls = screen.getAllByText("Author");
@@ -144,7 +144,7 @@ describe("PullRequestsTab", () => {
it("shows Reviewer role badge when userLogin is a reviewer", () => {
const pr = makePullRequest({ id: 1, title: "Review PR", userLogin: "bob", reviewerLogins: ["alice"], assigneeLogins: [], repoFullName: "org/repo-a" });
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
// "Reviewer" appears as a role badge
const reviewerEls = screen.getAllByText("Reviewer");
@@ -154,7 +154,7 @@ describe("PullRequestsTab", () => {
it("shows ReviewBadge for approved PRs when expanded", () => {
const pr = makePullRequest({ id: 1, title: "Approved PR", reviewDecision: "APPROVED", repoFullName: "org/repo-a" });
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
// "Approved" appears as a review badge
const approvedEls = screen.getAllByText("Approved");
@@ -164,7 +164,7 @@ describe("PullRequestsTab", () => {
it("shows SizeBadge for each PR when expanded", () => {
const pr = makePullRequest({ id: 1, title: "Big PR", additions: 300, deletions: 100, repoFullName: "org/repo-a" });
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
// prSizeCategory(300, 100) = 400 total -> L
const lEls = screen.getAllByText("L");
@@ -178,7 +178,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, title: "Other PR", userLogin: "bob", reviewerLogins: [], assigneeLogins: [], repoFullName: "org/repo-a" }),
];
viewStore.setTabFilter("pullRequests", "role", "author");
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("My PR");
expect(screen.queryByText("Other PR")).toBeNull();
@@ -190,7 +190,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, title: "Pending PR", reviewDecision: null, repoFullName: "org/repo-a" }),
];
viewStore.setTabFilter("pullRequests", "reviewDecision", "APPROVED");
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("Approved PR");
expect(screen.queryByText("Pending PR")).toBeNull();
@@ -202,7 +202,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, title: "Ready PR", draft: false, repoFullName: "org/repo-a" }),
];
viewStore.setTabFilter("pullRequests", "draft", "draft");
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("Draft PR");
expect(screen.queryByText("Ready PR")).toBeNull();
@@ -214,7 +214,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, title: "Failing PR", checkStatus: "failure", repoFullName: "org/repo-a" }),
];
viewStore.setTabFilter("pullRequests", "checkStatus", "success");
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("Passing PR");
expect(screen.queryByText("Failing PR")).toBeNull();
@@ -226,7 +226,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, title: "Has CI PR", checkStatus: "success", repoFullName: "org/repo-a" }),
];
viewStore.setTabFilter("pullRequests", "checkStatus", "none");
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("No CI PR");
expect(screen.queryByText("Has CI PR")).toBeNull();
@@ -238,7 +238,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, title: "Large PR", additions: 600, deletions: 200, repoFullName: "org/repo-a" }),
];
viewStore.setTabFilter("pullRequests", "sizeCategory", "XS");
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("Small PR");
expect(screen.queryByText("Large PR")).toBeNull();
@@ -250,7 +250,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, title: "Medium PR", additions: 30, deletions: 20, repoFullName: "org/repo-a" }),
];
viewStore.setTabFilter("pullRequests", "sizeCategory", "XXL");
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("Huge PR");
expect(screen.queryByText("Medium PR")).toBeNull();
@@ -262,7 +262,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 2, title: "PR in repo B", repoFullName: "org/repo-b" }),
makePullRequest({ id: 3, title: "Another in repo A", repoFullName: "org/repo-a" }),
];
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ viewStore.toggleExpandedRepo("pullRequests", "org/repo-a");
render(() => );
screen.getByText("org/repo-a");
screen.getByText("org/repo-b");
@@ -451,7 +451,7 @@ describe("PullRequestsTab", () => {
const prs = Array.from({ length: 15 }, (_, i) =>
makePullRequest({ id: 300 + i, title: `Big repo PR ${i}`, repoFullName: "org/big-repo" })
);
- setAllExpanded("pullRequests", ["org/big-repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("org/big-repo");
screen.getByText("Big repo PR 0");
@@ -506,7 +506,7 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 1, title: "PR in repo A", repoFullName: "org/repo-a" }),
makePullRequest({ id: 2, title: "PR in repo B", repoFullName: "org/repo-b" }),
];
- setAllExpanded("pullRequests", ["org/repo-a", "org/repo-b"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
// Both start expanded
screen.getByText("PR in repo A");
@@ -560,14 +560,14 @@ describe("PullRequestsTab", () => {
makePullRequest({ id: 1, title: "Repo A PR", repoFullName: "org/repo-a" }),
makePullRequest({ id: 2, title: "Repo B PR", repoFullName: "org/repo-b" }),
]);
- setAllExpanded("pullRequests", ["org/repo-a", "org/repo-b"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("Repo A PR");
screen.getByText("Repo B PR");
// Remove repo-b from data — pruning effect should fire
setPrs([makePullRequest({ id: 1, title: "Repo A PR", repoFullName: "org/repo-a" })]);
- expect(viewStore.viewState.expandedRepos.pullRequests["org/repo-a"]).toBe(true);
+ expect(viewStore.isRepoExpanded("pullRequests", "org/repo-a")).toBe(true);
expect("org/repo-b" in viewStore.viewState.expandedRepos.pullRequests).toBe(false);
});
@@ -575,12 +575,12 @@ describe("PullRequestsTab", () => {
const [prs, setPrs] = createSignal([
makePullRequest({ id: 1, title: "PR A", repoFullName: "org/repo-a" }),
]);
- setAllExpanded("pullRequests", ["org/repo-a"], true);
+ setAllExpanded("pullRequests", true);
render(() => );
screen.getByText("PR A");
setPrs([]);
- expect(viewStore.viewState.expandedRepos.pullRequests["org/repo-a"]).toBe(true);
+ expect(viewStore.isRepoExpanded("pullRequests", "org/repo-a")).toBe(true);
// Data returns — UI should use preserved expanded state
setPrs([makePullRequest({ id: 1, title: "PR A", repoFullName: "org/repo-a" })]);
@@ -613,12 +613,34 @@ describe("PullRequestsTab", () => {
screen.getByText("Repo B PR 0");
});
+ it("expand all keeps a repo that appears in a later data update expanded", async () => {
+ const user = userEvent.setup();
+ const [prs, setPrs] = createSignal([
+ makePullRequest({ id: 1, title: "Repo A PR", repoFullName: "org/repo-a" }),
+ ]);
+ render(() => );
+
+ // Expand all — sets the tab default to expanded
+ await user.click(screen.getByLabelText("Expand all repos"));
+ screen.getByText("Repo A PR");
+
+ // A brand-new repo arrives later (never present when Expand all was clicked)
+ setPrs([
+ makePullRequest({ id: 1, title: "Repo A PR", repoFullName: "org/repo-a" }),
+ makePullRequest({ id: 2, title: "Repo B PR", repoFullName: "org/repo-b" }),
+ ]);
+
+ // It inherits the expanded default with no further interaction
+ screen.getByText("org/repo-b");
+ screen.getByText("Repo B PR");
+ });
+
it("applies shimmer class to rows whose IDs are in hotPollingPRIds", () => {
const prs = [
makePullRequest({ id: 42, number: 42, title: "Hot PR", repoFullName: "org/repo" }),
makePullRequest({ id: 99, number: 99, title: "Cold PR", repoFullName: "org/repo" }),
];
- setAllExpanded("pullRequests", ["org/repo"], true);
+ setAllExpanded("pullRequests", true);
const { container } = render(() => (
));
@@ -634,7 +656,7 @@ describe("PullRequestsTab", () => {
const prs = [
makePullRequest({ id: 1, number: 1, title: "Normal PR", repoFullName: "org/repo" }),
];
- setAllExpanded("pullRequests", ["org/repo"], true);
+ setAllExpanded("pullRequests", true);
const { container } = render(() => (
));
diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx
index 550bc484..7d72af7e 100644
--- a/tests/components/dashboard/IssuesTab.test.tsx
+++ b/tests/components/dashboard/IssuesTab.test.tsx
@@ -89,7 +89,7 @@ describe("IssuesTab — user filter logic", () => {
makeIssue({ id: 2, title: "Tracked issue", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }),
];
setTabFilter("issues", "scope", "all");
- setAllExpanded("issues", ["owner/repo-a", "owner/repo-b"], true);
+ setAllExpanded("issues", true);
render(() => (
{
setTabFilter("issues", "scope", "all");
setTabFilter("issues", "user", "tracked1");
- setAllExpanded("issues", ["owner/repo-a", "owner/repo-b"], true);
+ setAllExpanded("issues", true);
render(() => (
{
// Filter to "me" — legacy items without surfacedBy should show as belonging to the main user
setTabFilter("issues", "user", "me");
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
{
];
setTabFilter("issues", "user", "tracked1");
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
{
// Set filter to a user that no longer exists in allUsers
setTabFilter("issues", "user", "removed-user");
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
{
];
setTabFilter("issues", "scope", "all");
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
{
makeIssue({ id: 1, title: "My issue", repoFullName: "owner/repo", surfacedBy: ["me"] }),
];
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
const { container } = render(() => (
{
];
setTabFilter("issues", "scope", "all");
setTabFilter("issues", "user", "me");
- setAllExpanded("issues", ["org/monitored"], true);
+ setAllExpanded("issues", true);
render(() => (
{
makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", surfacedBy: ["me"] }),
makeIssue({ id: 2, title: "Tracked User issue", repoFullName: "org/repo", surfacedBy: ["other"] }),
];
- setAllExpanded("issues", ["org/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
{
makeIssue({ id: 2, title: "Community issue", repoFullName: "org/repo", surfacedBy: ["other"] }),
];
setTabFilter("issues", "scope", "all");
- setAllExpanded("issues", ["org/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
{
const issues = [
makeIssue({ id: 1, title: "My monitored issue", repoFullName: "org/monitored", userLogin: "me" }),
];
- setAllExpanded("issues", ["org/monitored"], true);
+ setAllExpanded("issues", true);
render(() => (
{
const issues = [
makeIssue({ id: 1, title: "Community monitored issue", repoFullName: "org/monitored", userLogin: "other-user", assigneeLogins: [] }),
];
- setAllExpanded("issues", ["org/monitored"], true);
+ setAllExpanded("issues", true);
render(() => (
{
const issues = [
makeIssue({ id: 1, title: "Assigned monitored issue", repoFullName: "org/monitored", userLogin: "other-user", assigneeLogins: ["me"] }),
];
- setAllExpanded("issues", ["org/monitored"], true);
+ setAllExpanded("issues", true);
render(() => (
{
makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", surfacedBy: ["me"] }),
];
setTabFilter("issues", "scope", "all");
- setAllExpanded("issues", ["org/repo"], true);
+ setAllExpanded("issues", true);
const { container } = render(() => (
{
makeIssue({ id: 1, title: "Community issue", repoFullName: "org/monitored", userLogin: "other", assigneeLogins: [] }),
];
setTabFilter("issues", "scope", "all");
- setAllExpanded("issues", ["org/monitored"], true);
+ setAllExpanded("issues", true);
const { container } = render(() => (
{
makeIssue({ id: 1, title: "Bot issue", repoFullName: "org/monitored", surfacedBy: ["tracked-bot[bot]"] }),
];
setTabFilter("issues", "scope", "all");
- setAllExpanded("issues", ["org/monitored"], true);
+ setAllExpanded("issues", true);
const { container } = render(() => (
{
const issues = [
makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", surfacedBy: ["me"] }),
];
- setAllExpanded("issues", ["org/repo"], true);
+ setAllExpanded("issues", true);
const { container } = render(() => (
(
{
it("pin button not rendered when enableTracking is false", () => {
updateConfig({ enableTracking: false });
const issue = makeIssue({ id: 1, title: "Pin test issue", repoFullName: "owner/repo", surfacedBy: ["me"] });
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
@@ -649,7 +649,7 @@ describe("IssuesTab — pin button wiring", () => {
it("pin button rendered when enableTracking is true", async () => {
updateConfig({ enableTracking: true });
const issue = makeIssue({ id: 1, title: "Pin test issue", repoFullName: "owner/repo", surfacedBy: ["me"] });
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
@@ -662,7 +662,7 @@ describe("IssuesTab — pin button wiring", () => {
const user = userEvent.setup();
updateConfig({ enableTracking: true });
const issue = makeIssue({ id: 50, title: "My issue", repoFullName: "owner/repo", surfacedBy: ["me"] });
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
@@ -679,7 +679,7 @@ describe("IssuesTab — pin button wiring", () => {
updateConfig({ enableTracking: true });
const issue = makeIssue({ id: 51, title: "Already tracked", repoFullName: "owner/repo", surfacedBy: ["me"] });
updateViewState({ trackedItems: [makeTrackedItem({ id: 51, type: "issue", repoFullName: "owner/repo", title: "Already tracked" })] });
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
@@ -696,7 +696,7 @@ describe("IssuesTab — pin button wiring", () => {
updateConfig({ enableTracking: true });
const issue = makeIssue({ id: 52, title: "Tracked and ignored", repoFullName: "owner/repo", surfacedBy: ["me"] });
updateViewState({ trackedItems: [makeTrackedItem({ id: 52, type: "issue", repoFullName: "owner/repo", title: "Tracked and ignored" })] });
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
@@ -852,7 +852,7 @@ describe("IssuesTab — state filter", () => {
makeIssue({ id: 1, title: "Open issue", repoFullName: "owner/repo", state: "OPEN", surfacedBy: ["me"] }),
makeIssue({ id: 2, title: "Closed issue", repoFullName: "owner/repo", state: "CLOSED", surfacedBy: ["me"] }),
];
- setAllExpanded("issues", ["owner/repo"], true);
+ setAllExpanded("issues", true);
render(() => (
@@ -871,7 +871,7 @@ describe("IssuesTab — customTabId filter preset", () => {
makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", userLogin: "me", surfacedBy: ["me"] }),
makeIssue({ id: 2, title: "Other issue", repoFullName: "org/repo", userLogin: "other", surfacedBy: ["me"] }),
];
- setAllExpanded("custom-tab-1", ["org/repo"], true);
+ setAllExpanded("custom-tab-1", true);
render(() => (
{
makeIssue({ id: 2, title: "Other issue", repoFullName: "org/repo", userLogin: "other", surfacedBy: ["me"] }),
];
setCustomTabFilter("custom-tab-3", "role", "all");
- setAllExpanded("custom-tab-3", ["org/repo"], true);
+ setAllExpanded("custom-tab-3", true);
render(() => (
{
makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", surfacedBy: ["me"] }),
makeIssue({ id: 2, title: "Other issue", repoFullName: "org/repo", surfacedBy: ["other"] }),
];
- setAllExpanded("custom-tab-4", ["org/repo"], true);
+ setAllExpanded("custom-tab-4", true);
render(() => (
{
makeIssue({ id: 1, title: "My issue", repoFullName: "org/repo", userLogin: "me", surfacedBy: ["me"] }),
];
setTabFilter("issues", "role", "assignee");
- setAllExpanded("custom-tab-5", ["org/repo"], true);
+ setAllExpanded("custom-tab-5", true);
render(() => (
({
trackItem: vi.fn(),
untrackJiraItem: vi.fn(),
setAllExpanded: vi.fn(),
+ toggleExpandedRepo: vi.fn(),
+ isRepoExpanded: () => true,
setJiraCustomOrder: vi.fn(),
JIRA_CUSTOM_ORDER_SCOPE: "assigned",
JIRA_CUSTOM_SORT_FIELD: "custom",
@@ -59,7 +61,7 @@ vi.mock("../../../src/app/stores/config", () => ({
import JiraAssignedTab, { _resetJiraTabState, _getItemRefsCount } from "../../../src/app/components/dashboard/JiraAssignedTab";
import type { JiraIssue } from "../../../src/shared/jira-types";
import { config } from "../../../src/app/stores/config";
-import { trackItem, untrackJiraItem, setAllExpanded, setTabFilter, setJiraCustomOrder } from "../../../src/app/stores/view";
+import { trackItem, untrackJiraItem, toggleExpandedRepo, setTabFilter, setJiraCustomOrder } from "../../../src/app/stores/view";
// ── Fixtures ──────────────────────────────────────────────────────────────────
@@ -390,14 +392,14 @@ describe("JiraAssignedTab", () => {
expect(toggleButton.textContent).toContain("PROJ");
});
- it("calls setAllExpanded when project header is clicked", () => {
+ it("calls toggleExpandedRepo when project header is clicked", () => {
const issues = [makeIssue("PROJ-1")];
render(() => );
const header = screen.getByRole("button", { expanded: true });
header.click();
- expect(vi.mocked(setAllExpanded)).toHaveBeenCalled();
+ expect(vi.mocked(toggleExpandedRepo)).toHaveBeenCalledWith("jiraAssigned", "PROJ");
});
it("renders expand-all and collapse-all buttons", () => {
@@ -667,7 +669,10 @@ describe("JiraAssignedTab", () => {
expect(screen.queryByRole("button", { name: /pin alpha to top of list/i })).toBeNull();
});
- it("auto-expands all project groups on first entry to a grouped sort", () => {
+ it("renders each project group header per its store expand state in a grouped sort", () => {
+ // Store expand state is mocked to expanded (isRepoExpanded → true); this verifies the
+ // component wires that state to each group's aria-expanded. The default *value*
+ // (expandDefault.jiraAssigned = true) is covered at the store level in view.test.ts.
mockJiraFilters = { scope: "assigned", statusCategory: "all", priority: "all", sortField: "priority", sortDirection: "asc" };
const issues = [
makeIssue("ALPHA-1", "ALPHA"),
@@ -675,7 +680,8 @@ describe("JiraAssignedTab", () => {
];
render(() => );
- expect(vi.mocked(setAllExpanded)).toHaveBeenCalledWith("jiraAssigned", ["ALPHA", "BETA"], true);
+ const expandedHeaders = screen.getAllByRole("button", { expanded: true });
+ expect(expandedHeaders.length).toBeGreaterThanOrEqual(2);
});
it("clears itemRefs when the sortField changes away from custom while still mounted (live mode switch)", () => {
diff --git a/tests/components/dashboard/PersonalSummaryStrip.test.tsx b/tests/components/dashboard/PersonalSummaryStrip.test.tsx
index 01c0cfce..81a20674 100644
--- a/tests/components/dashboard/PersonalSummaryStrip.test.tsx
+++ b/tests/components/dashboard/PersonalSummaryStrip.test.tsx
@@ -598,7 +598,7 @@ describe("PersonalSummaryStrip — count-to-filter contract", () => {
unmount();
// Render PullRequestsTab with same data and applied filters
- setAllExpanded("pullRequests", ["org/repo-a", "org/repo-b", "org/repo-c", "org/repo-d"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
));
@@ -626,7 +626,7 @@ describe("PersonalSummaryStrip — count-to-filter contract", () => {
fireEvent.click(reviewButton);
unmount();
- setAllExpanded("pullRequests", ["org/repo-a", "org/repo-b", "org/repo-c", "org/repo-d"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
fireEvent.click(mergeButton);
unmount();
- setAllExpanded("pullRequests", ["org/repo-a", "org/repo-b", "org/repo-c", "org/repo-d"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
));
@@ -677,7 +677,7 @@ describe("PersonalSummaryStrip — count-to-filter contract", () => {
fireEvent.click(assignedButton);
unmount();
- setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true);
+ setAllExpanded("issues", true);
render(() => (
));
@@ -705,7 +705,7 @@ describe("PersonalSummaryStrip — count-to-filter contract", () => {
fireEvent.click(blockedButton);
unmount();
- setAllExpanded("pullRequests", ["org/tracked-repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
makePullRequest({ id: 2, title: "Tracked PR", repoFullName: "owner/repo-b", surfacedBy: ["tracked1"] }),
];
setTabFilter("pullRequests", "scope", "all");
- setAllExpanded("pullRequests", ["owner/repo-a", "owner/repo-b"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
setTabFilter("pullRequests", "scope", "all");
setTabFilter("pullRequests", "user", "tracked1");
- setAllExpanded("pullRequests", ["owner/repo-a", "owner/repo-b"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
];
setTabFilter("pullRequests", "user", "me");
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
];
setTabFilter("pullRequests", "user", "tracked1");
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
];
setTabFilter("pullRequests", "scope", "all");
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
];
setTabFilter("pullRequests", "scope", "all");
setTabFilter("pullRequests", "user", "me");
- setAllExpanded("pullRequests", ["org/monitored"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", surfacedBy: ["me"] }),
makePullRequest({ id: 2, title: "Tracked User PR", repoFullName: "org/repo", surfacedBy: ["other"] }),
];
- setAllExpanded("pullRequests", ["org/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
makePullRequest({ id: 2, title: "Community PR", repoFullName: "org/repo", surfacedBy: ["other"] }),
];
setTabFilter("pullRequests", "scope", "all");
- setAllExpanded("pullRequests", ["org/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
const prs = [
makePullRequest({ id: 1, title: "My monitored PR", repoFullName: "org/monitored", userLogin: "me" }),
];
- setAllExpanded("pullRequests", ["org/monitored"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
const prs = [
makePullRequest({ id: 1, title: "Community monitored PR", repoFullName: "org/monitored", userLogin: "other-user", assigneeLogins: [], reviewerLogins: [] }),
];
- setAllExpanded("pullRequests", ["org/monitored"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
const prs = [
makePullRequest({ id: 1, title: "Review monitored PR", repoFullName: "org/monitored", userLogin: "other-user", assigneeLogins: [], reviewerLogins: ["me"], enriched: true }),
];
- setAllExpanded("pullRequests", ["org/monitored"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
{
makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", surfacedBy: ["me"] }),
];
setTabFilter("pullRequests", "scope", "all");
- setAllExpanded("pullRequests", ["org/repo"], true);
+ setAllExpanded("pullRequests", true);
const { container } = render(() => (
{
makePullRequest({ id: 1, title: "Community PR", repoFullName: "org/monitored", userLogin: "other", assigneeLogins: [], reviewerLogins: [] }),
];
setTabFilter("pullRequests", "scope", "all");
- setAllExpanded("pullRequests", ["org/monitored"], true);
+ setAllExpanded("pullRequests", true);
const { container } = render(() => (
{
makePullRequest({ id: 1, title: "Bot PR", repoFullName: "org/monitored", surfacedBy: ["tracked-bot[bot]"] }),
];
setTabFilter("pullRequests", "scope", "all");
- setAllExpanded("pullRequests", ["org/monitored"], true);
+ setAllExpanded("pullRequests", true);
const { container } = render(() => (
{
const prs = [
makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", surfacedBy: ["me"] }),
];
- setAllExpanded("pullRequests", ["org/repo"], true);
+ setAllExpanded("pullRequests", true);
const { container } = render(() => (
{
makePullRequest({ id: 3, title: "Passing PR", repoFullName: "org/repo", checkStatus: "success", surfacedBy: ["me"], enriched: true }),
];
setTabFilter("pullRequests", "checkStatus", "blocked");
- setAllExpanded("pullRequests", ["org/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -589,7 +589,7 @@ describe("PullRequestsTab — reviewDecision=mergeable filter", () => {
makePullRequest({ id: 3, title: "Changes PR", repoFullName: "org/repo", reviewDecision: "CHANGES_REQUESTED", surfacedBy: ["me"], enriched: true }),
];
setTabFilter("pullRequests", "reviewDecision", "mergeable");
- setAllExpanded("pullRequests", ["org/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -607,7 +607,7 @@ describe("PullRequestsTab — pin button wiring", () => {
it("pin button not rendered when enableTracking is false", () => {
updateConfig({ enableTracking: false });
const pr = makePullRequest({ id: 1, title: "Pin test PR", repoFullName: "owner/repo", surfacedBy: ["me"] });
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -620,7 +620,7 @@ describe("PullRequestsTab — pin button wiring", () => {
it("pin button rendered when enableTracking is true", () => {
updateConfig({ enableTracking: true });
const pr = makePullRequest({ id: 1, title: "Pin test PR", repoFullName: "owner/repo", surfacedBy: ["me"] });
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -633,7 +633,7 @@ describe("PullRequestsTab — pin button wiring", () => {
const user = userEvent.setup();
updateConfig({ enableTracking: true });
const pr = makePullRequest({ id: 60, title: "My PR", repoFullName: "owner/repo", surfacedBy: ["me"] });
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -650,7 +650,7 @@ describe("PullRequestsTab — pin button wiring", () => {
updateConfig({ enableTracking: true });
const pr = makePullRequest({ id: 61, title: "Already tracked PR", repoFullName: "owner/repo", surfacedBy: ["me"] });
updateViewState({ trackedItems: [makeTrackedItem({ id: 61, type: "pullRequest", repoFullName: "owner/repo", title: "Already tracked PR" })] });
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -667,7 +667,7 @@ describe("PullRequestsTab — pin button wiring", () => {
updateConfig({ enableTracking: true });
const pr = makePullRequest({ id: 62, title: "Tracked and ignored PR", repoFullName: "owner/repo", surfacedBy: ["me"] });
updateViewState({ trackedItems: [makeTrackedItem({ id: 62, type: "pullRequest", repoFullName: "owner/repo", title: "Tracked and ignored PR" })] });
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -780,7 +780,7 @@ describe("PullRequestsTab — state filter", () => {
makePullRequest({ id: 1, title: "Open PR", repoFullName: "owner/repo", state: "OPEN", surfacedBy: ["me"] }),
makePullRequest({ id: 2, title: "Merged PR", repoFullName: "owner/repo", state: "MERGED", surfacedBy: ["me"] }),
];
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -795,7 +795,7 @@ describe("PullRequestsTab — state filter", () => {
makePullRequest({ id: 3, title: "Open PR", repoFullName: "owner/repo", state: "OPEN", surfacedBy: ["me"] }),
makePullRequest({ id: 4, title: "Closed PR", repoFullName: "owner/repo", state: "CLOSED", surfacedBy: ["me"] }),
];
- setAllExpanded("pullRequests", ["owner/repo"], true);
+ setAllExpanded("pullRequests", true);
render(() => (
@@ -814,7 +814,7 @@ describe("PullRequestsTab — customTabId filter preset", () => {
makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", userLogin: "me", surfacedBy: ["me"] }),
makePullRequest({ id: 2, title: "Other PR", repoFullName: "org/repo", userLogin: "other", surfacedBy: ["me"] }),
];
- setAllExpanded("custom-pr-tab-1", ["org/repo"], true);
+ setAllExpanded("custom-pr-tab-1", true);
render(() => (
{
makePullRequest({ id: 2, title: "Other PR", repoFullName: "org/repo", userLogin: "other", surfacedBy: ["me"] }),
];
setCustomTabFilter("custom-pr-tab-3", "role", "all");
- setAllExpanded("custom-pr-tab-3", ["org/repo"], true);
+ setAllExpanded("custom-pr-tab-3", true);
render(() => (
{
makePullRequest({ id: 1, title: "My PR", repoFullName: "org/repo", userLogin: "me", surfacedBy: ["me"] }),
];
setTabFilter("pullRequests", "role", "assignee");
- setAllExpanded("custom-pr-tab-5", ["org/repo"], true);
+ setAllExpanded("custom-pr-tab-5", true);
render(() => (
{
const { flashingIds, peekUpdates } = createFlashDetection({
getItems: () => items,
getHotIds: () => undefined,
- getExpandedRepos: () => ({}),
+ isRepoExpanded: () => false,
trackKey: (item) => item.status,
itemLabel: (item) => `Item ${item.id}`,
itemStatus: (item) => item.status,
@@ -40,7 +40,7 @@ describe("createFlashDetection", () => {
const { flashingIds } = createFlashDetection({
getItems: items,
getHotIds: () => new Set(),
- getExpandedRepos: () => ({}),
+ isRepoExpanded: () => false,
trackKey: (item) => item.status,
itemLabel: (item) => `Item ${item.id}`,
itemStatus: (item) => item.status,
@@ -62,7 +62,7 @@ describe("createFlashDetection", () => {
const { flashingIds } = createFlashDetection({
getItems: items,
getHotIds: () => undefined,
- getExpandedRepos: () => ({}),
+ isRepoExpanded: () => false,
trackKey: (item) => item.status,
itemLabel: (item) => `Item ${item.id}`,
itemStatus: (item) => item.status,
@@ -84,7 +84,7 @@ describe("createFlashDetection", () => {
const { flashingIds } = createFlashDetection({
getItems: items,
getHotIds: () => undefined,
- getExpandedRepos: () => ({}),
+ isRepoExpanded: () => false,
trackKey: (item) => item.status,
itemLabel: (item) => `Item ${item.id}`,
itemStatus: (item) => item.status,
@@ -99,4 +99,102 @@ describe("createFlashDetection", () => {
dispose();
});
});
+
+ it("builds a peek update when a hot-polled item changes status and its repo is collapsed", async () => {
+ await createRoot(async (dispose) => {
+ const [items, setItems] = createSignal([
+ { id: 1, repoFullName: "org/repo", status: "pending" },
+ ]);
+ const { flashingIds, peekUpdates } = createFlashDetection({
+ getItems: items,
+ getHotIds: () => new Set([1]),
+ isRepoExpanded: () => false,
+ trackKey: (item) => item.status,
+ itemLabel: (item) => `Item ${item.id}`,
+ itemStatus: (item) => item.status,
+ });
+
+ // Let the initial effect seed prevValues (Solid schedules effects as microtasks)
+ await Promise.resolve();
+
+ // Status change on a hot-polled item whose repo is collapsed — peek expected
+ setItems([{ id: 1, repoFullName: "org/repo", status: "success" }]);
+ await Promise.resolve();
+
+ expect(flashingIds().has(1)).toBe(true);
+ expect(peekUpdates().size).toBe(1);
+ expect(peekUpdates().get("org/repo")).toEqual({
+ itemLabel: "Item 1",
+ newStatus: "success",
+ });
+
+ dispose();
+ });
+ });
+
+ it("suppresses the peek update when the item's repo is expanded", async () => {
+ await createRoot(async (dispose) => {
+ const [items, setItems] = createSignal([
+ { id: 1, repoFullName: "org/repo", status: "pending" },
+ ]);
+ const { flashingIds, peekUpdates } = createFlashDetection({
+ getItems: items,
+ getHotIds: () => new Set([1]),
+ isRepoExpanded: () => true,
+ trackKey: (item) => item.status,
+ itemLabel: (item) => `Item ${item.id}`,
+ itemStatus: (item) => item.status,
+ });
+
+ // Let the initial effect seed prevValues (Solid schedules effects as microtasks)
+ await Promise.resolve();
+
+ // Status change on a hot-polled item whose repo is expanded — flash still
+ // fires, but the peek preview is suppressed for the expanded repo
+ setItems([{ id: 1, repoFullName: "org/repo", status: "success" }]);
+ await Promise.resolve();
+
+ expect(flashingIds().has(1)).toBe(true);
+ expect(peekUpdates().size).toBe(0);
+
+ dispose();
+ });
+ });
+
+ it("aggregates the peek label when multiple items in a collapsed repo change", async () => {
+ await createRoot(async (dispose) => {
+ const [items, setItems] = createSignal([
+ { id: 1, repoFullName: "org/repo", status: "pending" },
+ { id: 2, repoFullName: "org/repo", status: "pending" },
+ ]);
+ const { peekUpdates } = createFlashDetection({
+ getItems: items,
+ getHotIds: () => new Set([1, 2]),
+ isRepoExpanded: () => false,
+ trackKey: (item) => item.status,
+ itemLabel: (item) => `Item ${item.id}`,
+ itemStatus: (item) => item.status,
+ });
+
+ // Let the initial effect seed prevValues (Solid schedules effects as microtasks)
+ await Promise.resolve();
+
+ // Two hot-polled items in the same collapsed repo change — the peek label
+ // aggregates into a "first + N more" summary. The differing statuses confirm
+ // the aggregated newStatus reflects the first changed item, not the last.
+ setItems([
+ { id: 1, repoFullName: "org/repo", status: "success" },
+ { id: 2, repoFullName: "org/repo", status: "failure" },
+ ]);
+ await Promise.resolve();
+
+ expect(peekUpdates().size).toBe(1);
+ expect(peekUpdates().get("org/repo")).toEqual({
+ itemLabel: "Item 1 + 1 more",
+ newStatus: "success",
+ });
+
+ dispose();
+ });
+ });
});
diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts
index 092b60c9..1dcfeb74 100644
--- a/tests/stores/view.test.ts
+++ b/tests/stores/view.test.ts
@@ -16,6 +16,7 @@ import {
DependencyFiltersSchema,
toggleExpandedRepo,
setAllExpanded,
+ isRepoExpanded,
pruneExpandedRepos,
trackItem,
untrackItem,
@@ -701,6 +702,11 @@ describe("ViewStateSchema", () => {
expect(result.expandedRepos).toEqual({ issues: {}, pullRequests: {}, actions: {}, jiraAssigned: {} });
});
+ it("missing expandDefault field parses to Jira-expanded default", () => {
+ const result = ViewStateSchema.parse({ lastActiveTab: "actions" });
+ expect(result.expandDefault).toEqual({ jiraAssigned: true });
+ });
+
it("old localStorage data with sortPreferences parses cleanly with globalSort default", () => {
const oldData = {
lastActiveTab: "issues",
@@ -709,6 +715,25 @@ describe("ViewStateSchema", () => {
const result = ViewStateSchema.parse(oldData);
expect(result.globalSort).toEqual({ field: "updatedAt", direction: "desc" });
});
+
+ it("migrates pre-expandDefault data: Jira defaults to expanded, GitHub tabs collapsed, existing entries preserved", () => {
+ // Legacy blob: no expandDefault key; expandedRepos holds explicit per-repo entries.
+ // A Jira project with no entry in the map has no recorded expand/collapse state.
+ const oldData = {
+ lastActiveTab: "issues",
+ expandedRepos: { issues: { "org/repo": true }, jiraAssigned: { "PROJ": true } },
+ };
+ const result = ViewStateSchema.parse(oldData);
+ // Backfilled default: jiraAssigned defaults to expanded; GitHub tabs default to collapsed.
+ expect(result.expandDefault).toEqual({ jiraAssigned: true });
+ // Explicit entries are preserved as exceptions.
+ expect(result.expandedRepos.issues["org/repo"]).toBe(true);
+ expect(result.expandedRepos.jiraAssigned["PROJ"]).toBe(true);
+ // A Jira project absent from the map has no exception recorded, so it reads
+ // expanded via the jiraAssigned default.
+ expect(result.expandedRepos.jiraAssigned["ABSENT-PROJ"]).toBeUndefined();
+ expect(result.expandDefault.jiraAssigned).toBe(true);
+ });
});
describe("expandedRepos helpers", () => {
@@ -732,52 +757,77 @@ describe("expandedRepos helpers", () => {
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("toggleExpandedRepo records an explicit collapse exception when the tab default is expanded", () => {
+ setAllExpanded("issues", true); // default = expanded
+ toggleExpandedRepo("issues", "owner/repo");
+ expect(viewState.expandedRepos.issues["owner/repo"]).toBe(false);
+ expect(isRepoExpanded("issues", "owner/repo")).toBe(false);
+ // Toggling back to the default drops the exception
+ toggleExpandedRepo("issues", "owner/repo");
+ expect("owner/repo" in viewState.expandedRepos.issues).toBe(false);
+ expect(isRepoExpanded("issues", "owner/repo")).toBe(true);
});
- it("setAllExpanded with empty array is a no-op", () => {
- setAllExpanded("issues", ["owner/existing"], true);
- setAllExpanded("issues", [], true);
- expect(viewState.expandedRepos.issues["owner/existing"]).toBe(true);
- setAllExpanded("issues", [], false);
- expect(viewState.expandedRepos.issues["owner/existing"]).toBe(true);
+ it("setAllExpanded sets the tab default so every repo (including unlisted) reads as expanded", () => {
+ setAllExpanded("issues", true);
+ expect(viewState.expandDefault.issues).toBe(true);
+ expect(isRepoExpanded("issues", "owner/a")).toBe(true);
+ expect(isRepoExpanded("issues", "owner/b")).toBe(true);
+ // A repo that was never listed still follows the new default
+ expect(isRepoExpanded("issues", "owner/appears-later")).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("setAllExpanded clears prior per-repo exceptions (resets everything to the new default)", () => {
+ toggleExpandedRepo("issues", "owner/manual"); // exception: expanded while default collapsed
+ expect(isRepoExpanded("issues", "owner/manual")).toBe(true);
+ setAllExpanded("issues", false);
+ expect(viewState.expandDefault.issues).toBe(false);
+ expect(viewState.expandedRepos.issues).toEqual({});
+ expect(isRepoExpanded("issues", "owner/manual")).toBe(false);
});
- it("pruneExpandedRepos removes stale keys and keeps active ones", () => {
- setAllExpanded("actions", ["owner/active", "owner/stale"], true);
+ it("setAllExpanded sets the tab default even when no repos are listed", () => {
+ setAllExpanded("issues", true);
+ expect(viewState.expandDefault.issues).toBe(true);
+ expect(isRepoExpanded("issues", "owner/anything")).toBe(true);
+ setAllExpanded("issues", false);
+ expect(viewState.expandDefault.issues).toBe(false);
+ expect(isRepoExpanded("issues", "owner/anything")).toBe(false);
+ });
+
+ it("jiraAssigned defaults to expanded on a fresh store", () => {
+ expect(isRepoExpanded("jiraAssigned", "SOMEPROJ")).toBe(true);
+ });
+
+ it("setAllExpanded('jiraAssigned', false) persists for a later-surfacing project group", () => {
+ setAllExpanded("jiraAssigned", false);
+ expect(isRepoExpanded("jiraAssigned", "LATER-PROJ")).toBe(false);
+ });
+
+ it("pruneExpandedRepos removes stale exception keys and keeps active ones", () => {
+ toggleExpandedRepo("actions", "owner/active");
+ toggleExpandedRepo("actions", "owner/stale");
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
+ toggleExpandedRepo("pullRequests", "owner/a");
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 () => {
+ it("localStorage round-trip: exceptions and tab defaults persist and restore via schema", async () => {
vi.useFakeTimers();
let dispose!: () => void;
createRoot((d) => {
dispose = d;
initViewPersistence();
- toggleExpandedRepo("issues", "myorg/myrepo");
- setAllExpanded("actions", ["myorg/ci"], true);
+ toggleExpandedRepo("issues", "myorg/myrepo"); // exception on a collapsed-by-default tab
+ setAllExpanded("actions", true); // set actions default = expanded
});
await Promise.resolve();
@@ -787,7 +837,8 @@ describe("expandedRepos helpers", () => {
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.expandDefault.actions).toBe(true);
+ expect(restored.expandedRepos.actions).toEqual({});
expect(restored.expandedRepos.pullRequests).toEqual({});
dispose();
vi.useRealTimers();
@@ -802,18 +853,21 @@ describe("resetViewState", () => {
expect(viewState.globalSort).toEqual({ field: "updatedAt", direction: "desc" });
});
- it("clears dynamically-added expandedRepos keys", () => {
- setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true);
- setAllExpanded("pullRequests", ["org/repo-c"], true);
+ it("clears per-repo exceptions and resets tab defaults", () => {
+ toggleExpandedRepo("issues", "org/repo-a");
+ toggleExpandedRepo("pullRequests", "org/repo-c");
toggleExpandedRepo("actions", "org/repo-d");
+ setAllExpanded("jiraAssigned", false); // flips Jira default; reset should restore it
expect(viewState.expandedRepos.issues["org/repo-a"]).toBe(true);
+ expect(viewState.expandDefault.jiraAssigned).toBe(false);
resetViewState();
- expect("org/repo-a" in viewState.expandedRepos.issues).toBe(false);
- expect("org/repo-b" in viewState.expandedRepos.issues).toBe(false);
+ expect(viewState.expandedRepos.issues).toEqual({});
expect("org/repo-c" in viewState.expandedRepos.pullRequests).toBe(false);
expect("org/repo-d" in viewState.expandedRepos.actions).toBe(false);
+ // Tab defaults reset: Jira back to expanded, GitHub tabs back to collapsed
+ expect(viewState.expandDefault).toEqual({ jiraAssigned: true });
});
});
@@ -1212,6 +1266,13 @@ describe("removeCustomTabState", () => {
expect("tab-abc" in viewState.expandedRepos).toBe(false);
});
+ it("cleans expandDefault for the given tab ID", () => {
+ setAllExpanded("tab-abc", true);
+ expect(viewState.expandDefault["tab-abc"]).toBe(true);
+ removeCustomTabState("tab-abc");
+ expect("tab-abc" in viewState.expandDefault).toBe(false);
+ });
+
it("removes both customTabFilters and expandedRepos in a single call", () => {
setCustomTabFilter("tab-abc", "scope", "all");
toggleExpandedRepo("tab-abc", "owner/repo");
@@ -1264,6 +1325,16 @@ describe("resetViewState — custom tab fields", () => {
expect("tab-custom" in viewState.expandedRepos).toBe(false);
});
+ it("clears custom tab keys from expandDefault while preserving the jiraAssigned default", () => {
+ setAllExpanded("tab-custom", true);
+ expect(viewState.expandDefault["tab-custom"]).toBe(true);
+ resetViewState();
+ // Builtin jiraAssigned default is preserved (not deleted)
+ expect(viewState.expandDefault["jiraAssigned"]).toBe(true);
+ // Custom key is fully deleted
+ expect("tab-custom" in viewState.expandDefault).toBe(false);
+ });
+
it("clears custom tab keys from lockedRepos and resets built-in keys to []", () => {
lockRepo("issues", "owner/repo");
lockRepo("tab-custom", "owner/repo");