From 1efd320e01587100d1e1d4f2cc1f191612c83efc Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 26 Aug 2026 15:30:23 -0400 Subject: [PATCH 1/2] feat(dashboard): expand/collapse sets a per-tab default for new repos Expand All / Collapse All now set a persistent per-tab default so repos that appear later inherit it, rather than only affecting the repos visible at click time. A manual per-repo expand/collapse is stored as an exception to that default and is cleared when Expand/Collapse All resets the tab. Jira project groups keep their expanded-by-default behavior via the new schema default (replacing the old one-shot auto-expand effect). USER_GUIDE updated. --- docs/USER_GUIDE.md | 2 +- src/app/components/dashboard/ActionsTab.tsx | 10 +- .../components/dashboard/DashboardPage.tsx | 1 + src/app/components/dashboard/IssuesTab.tsx | 8 +- .../components/dashboard/JiraAssignedTab.tsx | 24 +--- .../components/dashboard/PullRequestsTab.tsx | 10 +- src/app/lib/flashDetection.ts | 5 +- src/app/stores/view.ts | 57 +++++++--- tests/components/ActionsTab.test.tsx | 14 +-- tests/components/IssuesTab.test.tsx | 79 +++++++++----- tests/components/PullRequestsTab.test.tsx | 52 ++++----- tests/components/dashboard/IssuesTab.test.tsx | 56 +++++----- .../dashboard/JiraAssignedTab.test.tsx | 16 ++- .../dashboard/PersonalSummaryStrip.test.tsx | 10 +- .../dashboard/PullRequestsTab.test.tsx | 54 ++++----- tests/lib/flashDetection.test.ts | 8 +- tests/stores/view.test.ts | 103 +++++++++++++----- 17 files changed, 295 insertions(+), 214 deletions(-) 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..eba38784 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,11 +383,11 @@ 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" })]); @@ -442,7 +442,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 +461,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/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..c349c7e4 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" })]); @@ -618,7 +618,7 @@ describe("PullRequestsTab", () => { 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 +634,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, diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index 092b60c9..7d0e1ba4 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", () => { + // Pre-refactor blob: no expandDefault key; expandedRepos has explicit manual-expand + // entries. Projects the user previously collapsed are simply absent from the map. + const oldData = { + lastActiveTab: "issues", + expandedRepos: { issues: { "org/repo": true }, jiraAssigned: { "PROJ": true } }, + }; + const result = ViewStateSchema.parse(oldData); + // Backfilled default: Jira expanded (preserves prior auto-expand), GitHub tabs 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 (default true, no exception) now reads expanded — + // the intended one-time reset for projects collapsed under the old delete-on-collapse model. + expect(result.expandedRepos.jiraAssigned["ABSENT-PROJ"]).toBeUndefined(); + expect(result.expandDefault.jiraAssigned).toBe(true); + }); }); describe("expandedRepos helpers", () => { @@ -732,52 +757,68 @@ 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 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 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 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("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 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("pruneExpandedRepos removes stale keys and keeps active ones", () => { - setAllExpanded("actions", ["owner/active", "owner/stale"], true); + 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 +828,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 +844,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 }); }); }); From 9dd671b05d24a53cb93d2d01d8cb69ee2f5a3639 Mon Sep 17 00:00:00 2001 From: Will Gordon Date: Wed, 26 Aug 2026 16:48:45 -0400 Subject: [PATCH 2/2] test(dashboard): adds expandDefault and flash-peek coverage Adds tests for the per-tab expandDefault custom-tab cleanup (removeCustomTabState, resetViewState, and DashboardPage mount/delete pruning including the expandDefault-only footprint path), the jiraAssigned expanded-by-default behavior via isRepoExpanded, later-surfacing-repo default inheritance in the PullRequests and Actions tabs, and the flashDetection peek suppression and aggregation branches. --- tests/components/ActionsTab.test.tsx | 22 +++++ tests/components/DashboardPage.test.tsx | 24 +++++- tests/components/PullRequestsTab.test.tsx | 22 +++++ tests/lib/flashDetection.test.ts | 98 +++++++++++++++++++++++ tests/stores/view.test.ts | 36 +++++++-- 5 files changed, 195 insertions(+), 7 deletions(-) diff --git a/tests/components/ActionsTab.test.tsx b/tests/components/ActionsTab.test.tsx index eba38784..669d992e 100644 --- a/tests/components/ActionsTab.test.tsx +++ b/tests/components/ActionsTab.test.tsx @@ -394,6 +394,28 @@ describe("ActionsTab", () => { 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 = [ 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/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index c349c7e4..67b2017d 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -613,6 +613,28 @@ 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" }), diff --git a/tests/lib/flashDetection.test.ts b/tests/lib/flashDetection.test.ts index 6481d223..e681cc12 100644 --- a/tests/lib/flashDetection.test.ts +++ b/tests/lib/flashDetection.test.ts @@ -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 7d0e1ba4..1dcfeb74 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -717,20 +717,20 @@ describe("ViewStateSchema", () => { }); it("migrates pre-expandDefault data: Jira defaults to expanded, GitHub tabs collapsed, existing entries preserved", () => { - // Pre-refactor blob: no expandDefault key; expandedRepos has explicit manual-expand - // entries. Projects the user previously collapsed are simply absent from the map. + // 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: Jira expanded (preserves prior auto-expand), GitHub tabs collapsed. + // 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 (default true, no exception) now reads expanded — - // the intended one-time reset for projects collapsed under the old delete-on-collapse model. + // 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); }); @@ -795,6 +795,15 @@ describe("expandedRepos helpers", () => { 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"); @@ -1257,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"); @@ -1309,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");