diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index c48aee6d..4af99436 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -350,7 +350,7 @@ The Tracked tab lets you pin issues and PRs into a personal TODO list that you c ## Repo Pinning -Each repo group header has a pin (lock) control, visible on hover on desktop and always visible on mobile. Pinning a repo keeps it at the top of the list on all tabs regardless of sort order or how recently it was updated. +Each repo group header has a pin (lock) control, visible on hover on desktop and always visible on mobile. Pinning a repo keeps it at the top of the list on all tabs regardless of sort order or how recently it was updated. Pinned repos remain visible even when filters exclude all their items — they appear as compact, de-emphasized rows with the repo name and pin controls still accessible. - Click the pin icon to pin a repo to the top. - Click it again to unpin. diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 577ea195..e6d09434 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -11,7 +11,8 @@ import RepoGroupHeader from "../shared/RepoGroupHeader"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; -import { orderRepoGroups } from "../../lib/grouping"; +import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow"; +import { orderRepoGroups, ensureLockedRepoGroups } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; @@ -201,9 +202,15 @@ export default function ActionsTab(props: ActionsTabProps) { }); }); - const repoGroups = createMemo(() => - orderRepoGroups(groupRuns(filteredRuns()), viewState.lockedRepos) - ); + const repoGroups = createMemo(() => { + const groups = groupRuns(filteredRuns()); + const withLocked = ensureLockedRepoGroups( + groups, + viewState.lockedRepos, + (name) => ({ repoFullName: name, workflows: [] }), + ); + return orderRepoGroups(withLocked, viewState.lockedRepos); + }); createEffect(() => { const names = activeRepoNames(); @@ -256,10 +263,10 @@ export default function ActionsTab(props: ActionsTabProps) { - {/* Empty */} + {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} 0) && repoGroups().length === 0 } >
@@ -268,10 +275,11 @@ export default function ActionsTab(props: ActionsTabProps) { {/* Repo groups */} - 0}> + 0) && repoGroups().length > 0}> {(repoGroup) => { - const isExpanded = () => !!viewState.expandedRepos.actions[repoGroup.repoFullName]; + const isEmpty = () => repoGroup.workflows.length === 0; + const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.actions[repoGroup.repoFullName]; const sortedWorkflows = createMemo(() => sortWorkflowsByStatus(repoGroup.workflows) @@ -293,79 +301,86 @@ export default function ActionsTab(props: ActionsTabProps) { }); return ( -
- toggleExpandedRepo("actions", repoGroup.repoFullName)} - trailing={ - <> - - - - } - collapsedSummary={ - - {workflowCounts().total} workflow{workflowCounts().total !== 1 ? "s" : ""} - 0 || workflowCounts().failed > 0 || workflowCounts().running > 0}> - {": "} - 0}> - {workflowCounts().passed} passed - - 0 && (workflowCounts().failed > 0 || workflowCounts().running > 0)}> - {", "} - - 0}> - {workflowCounts().failed} failed - - 0 && workflowCounts().running > 0}> - {", "} - - 0}> - {workflowCounts().running} running + + } + > +
+ toggleExpandedRepo("actions", repoGroup.repoFullName)} + trailing={ + <> + + + + } + collapsedSummary={ + + {workflowCounts().total} workflow{workflowCounts().total !== 1 ? "s" : ""} + 0 || workflowCounts().failed > 0 || workflowCounts().running > 0}> + {": "} + 0}> + {workflowCounts().passed} passed + + 0 && (workflowCounts().failed > 0 || workflowCounts().running > 0)}> + {", "} + + 0}> + {workflowCounts().failed} failed + + 0 && workflowCounts().running > 0}> + {", "} + + 0}> + {workflowCounts().running} running + - - - } - /> - - {(peek) => ( -
- - {peek().itemLabel} - {peek().newStatus} + + } + /> + + {(peek) => ( +
+ + {peek().itemLabel} + {peek().newStatus} +
+ )} +
+ + {/* Workflow cards grid */} + +
+ + {(wfGroup) => { + const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; + const isWfExpanded = () => !!expandedWorkflows[wfKey]; + + return ( +
+ toggleWorkflow(wfKey)} + onIgnoreRun={handleIgnore} + refreshTick={props.refreshTick} + hotPollingRunIds={props.hotPollingRunIds} + flashingRunIds={flashingRunIds()} + /> +
+ ); + }} +
- )} -
- - {/* Workflow cards grid */} - -
- - {(wfGroup) => { - const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`; - const isWfExpanded = () => !!expandedWorkflows[wfKey]; - - return ( -
- toggleWorkflow(wfKey)} - onIgnoreRun={handleIgnore} - refreshTick={props.refreshTick} - hotPollingRunIds={props.hotPollingRunIds} - flashingRunIds={flashingRunIds()} - /> -
- ); - }} -
-
-
-
+
+
+
); }} diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 60013914..3eaeaf02 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -13,10 +13,11 @@ import SkeletonRows from "../shared/SkeletonRows"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import { deriveInvolvementRoles } from "../../lib/format"; import RepoGroupHeader from "../shared/RepoGroupHeader"; -import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, ensureLockedRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow"; import { Tooltip } from "../shared/Tooltip"; export interface IssuesTabProps { @@ -191,9 +192,15 @@ export default function IssuesTab(props: IssuesTabProps) { const filteredSorted = createMemo(() => filteredSortedWithMeta().items); const issueMeta = createMemo(() => filteredSortedWithMeta().meta); - const repoGroups = createMemo(() => - orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos) - ); + const repoGroups = createMemo(() => { + const groups = groupByRepo(filteredSorted()); + const withLocked = ensureLockedRepoGroups( + groups, + viewState.lockedRepos, + (name) => ({ repoFullName: name, items: [] as typeof groups[0]["items"] }), + ); + return orderRepoGroups(withLocked, viewState.lockedRepos); + }); const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => @@ -300,41 +307,41 @@ export default function IssuesTab(props: IssuesTabProps) {
+ {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} + 0) && pageGroups().length === 0}> +
+ +

+ {viewState.tabFilters.issues.scope === "all" ? "No open issues found" : "No open issues involving you"} +

+

+ {viewState.tabFilters.issues.scope === "all" + ? "No issues match your current filters." + : "Issues where you are the author, assignee, or mentioned will appear here."} +

+
+
+ {/* Issue rows */} - 0}> - 0} - fallback={ -
- -

- {viewState.tabFilters.issues.scope === "all" ? "No open issues found" : "No open issues involving you"} -

-

- {viewState.tabFilters.issues.scope === "all" - ? "No issues match your current filters." - : "Issues where you are the author, assignee, or mentioned will appear here."} -

-
- } - > -
- - {(repoGroup) => { - const isExpanded = () => !!viewState.expandedRepos.issues[repoGroup.repoFullName]; + 0) && pageGroups().length > 0}> +
+ + {(repoGroup) => { + const isEmpty = () => repoGroup.items.length === 0; + const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.issues[repoGroup.repoFullName]; const roleSummary = createMemo(() => { const counts: Record = {}; @@ -350,89 +357,95 @@ export default function IssuesTab(props: IssuesTabProps) { }); return ( -
- toggleExpandedRepo("issues", repoGroup.repoFullName)} - badges={ - - - Monitoring all - - - } - trailing={ - <> - - - - } - collapsedSummary={ - - {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} - - {([role, count]) => ( - - {role} ×{count} - + + } + > +
+ toggleExpandedRepo("issues", repoGroup.repoFullName)} + badges={ + + + Monitoring all + + + } + trailing={ + <> + + + + } + collapsedSummary={ + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "issue" : "issues"} + + {([role, count]) => ( + + {role} ×{count} + + )} + + + } + /> + +
+ + {(issue) => ( +
+ handleIgnore(issue)} + onTrack={config.enableTracking ? () => handleTrack(issue) : undefined} + isTracked={config.enableTracking ? trackedIssueIds().has(issue.id) : undefined} + commentCount={issue.comments} + surfacedByBadge={ + props.trackedUsers && props.trackedUsers.length > 0 + ? + : undefined + } + > + + +
)}
- - } - /> - -
- - {(issue) => ( -
- handleIgnore(issue)} - onTrack={config.enableTracking ? () => handleTrack(issue) : undefined} - isTracked={config.enableTracking ? trackedIssueIds().has(issue.id) : undefined} - commentCount={issue.comments} - surfacedByBadge={ - props.trackedUsers && props.trackedUsers.length > 0 - ? - : undefined - } - > - - -
- )} -
-
-
-
+
+
+
+ ); - }} -
-
-
+ }} +
+
0}> diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index 094b7f1e..9bb56f7f 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -17,11 +17,12 @@ import SizeBadge from "../shared/SizeBadge"; import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; import RepoGroupHeader from "../shared/RepoGroupHeader"; -import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, isUserInvolved } from "../../lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups, ensureLockedRepoGroups, isUserInvolved } from "../../lib/grouping"; import { createReorderHighlight } from "../../lib/reorderHighlight"; import { createFlashDetection } from "../../lib/flashDetection"; import RepoLockControls from "../shared/RepoLockControls"; import RepoGitHubLink from "../shared/RepoGitHubLink"; +import EmptyLockedRepoRow from "../shared/EmptyLockedRepoRow"; import { Tooltip } from "../shared/Tooltip"; export interface PullRequestsTabProps { @@ -288,9 +289,15 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const filteredSorted = createMemo(() => filteredSortedWithMeta().items); const prMeta = createMemo(() => filteredSortedWithMeta().meta); - const repoGroups = createMemo(() => - orderRepoGroups(groupByRepo(filteredSorted()), viewState.lockedRepos) - ); + const repoGroups = createMemo(() => { + const groups = groupByRepo(filteredSorted()); + const withLocked = ensureLockedRepoGroups( + groups, + viewState.lockedRepos, + (name) => ({ repoFullName: name, items: [] as typeof groups[0]["items"] }), + ); + return orderRepoGroups(withLocked, viewState.lockedRepos); + }); const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage)); const pageCount = createMemo(() => pageLayout().pageCount); const pageGroups = createMemo(() => @@ -394,41 +401,41 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { + {/* Empty — only when no groups exist at all (locked stubs are handled by EmptyLockedRepoRow) */} + 0) && pageGroups().length === 0}> +
+ +

+ {viewState.tabFilters.pullRequests.scope === "all" ? "No open pull requests found" : "No open pull requests involving you"} +

+

+ {viewState.tabFilters.pullRequests.scope === "all" + ? "No pull requests match your current filters." + : "PRs where you are the author, assignee, or reviewer will appear here."} +

+
+
+ {/* PR rows */} - 0}> - 0} - fallback={ -
- -

- {viewState.tabFilters.pullRequests.scope === "all" ? "No open pull requests found" : "No open pull requests involving you"} -

-

- {viewState.tabFilters.pullRequests.scope === "all" - ? "No pull requests match your current filters." - : "PRs where you are the author, assignee, or reviewer will appear here."} -

-
- } - > -
- - {(repoGroup) => { - const isExpanded = () => !!viewState.expandedRepos.pullRequests[repoGroup.repoFullName]; + 0) && pageGroups().length > 0}> +
+ + {(repoGroup) => { + const isEmpty = () => repoGroup.items.length === 0; + const isExpanded = () => !isEmpty() && !!viewState.expandedRepos.pullRequests[repoGroup.repoFullName]; const summaryMeta = createMemo(() => { const checks = { success: 0, failure: 0, pending: 0, conflict: 0 }; @@ -457,98 +464,104 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { }); return ( -
- toggleExpandedRepo("pullRequests", repoGroup.repoFullName)} - badges={ - - - Monitoring all - - - } - trailing={ - <> - - - - } - collapsedSummary={ - - {repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"} - 0}> - - - {summaryMeta().checks.success} - - - 0}> - - - {summaryMeta().checks.failure} - - - 0}> - - - {summaryMeta().checks.pending} - - - 0}> - - - {summaryMeta().checks.conflict === 1 ? "Conflict" : `Conflicts ×${summaryMeta().checks.conflict}`} - - - 0}> - - {`Approved ×${summaryMeta().reviews.APPROVED}`} - - - 0}> - - {`Changes ×${summaryMeta().reviews.CHANGES_REQUESTED}`} - - - 0}> - - {`Needs review ×${summaryMeta().reviews.REVIEW_REQUIRED}`} - + + } + > +
+ toggleExpandedRepo("pullRequests", repoGroup.repoFullName)} + badges={ + + + Monitoring all + - - {([role, count]) => ( - - {`${role} ×${count}`} + } + trailing={ + <> + + + + } + collapsedSummary={ + + {repoGroup.items.length} {repoGroup.items.length === 1 ? "PR" : "PRs"} + 0}> + + + {summaryMeta().checks.success} - )} - - - } - /> - - {(peek) => ( -
- - {peek().itemLabel} - {peek().newStatus} -
- )} -
- -
- - {(pr) => ( + + 0}> + + + {summaryMeta().checks.failure} + + + 0}> + + + {summaryMeta().checks.pending} + + + 0}> + + + {summaryMeta().checks.conflict === 1 ? "Conflict" : `Conflicts ×${summaryMeta().checks.conflict}`} + + + 0}> + + {`Approved ×${summaryMeta().reviews.APPROVED}`} + + + 0}> + + {`Changes ×${summaryMeta().reviews.CHANGES_REQUESTED}`} + + + 0}> + + {`Needs review ×${summaryMeta().reviews.REVIEW_REQUIRED}`} + + + + {([role, count]) => ( + + {`${role} ×${count}`} + + )} + + + } + /> + + {(peek) => ( +
+ + {peek().itemLabel} + {peek().newStatus} +
+ )} +
+ +
+ + {(pr) => (
- )} -
-
-
-
+ )} + +
+
+
+ ); - }} -
-
-
+ }} +
+
0}> diff --git a/src/app/components/shared/EmptyLockedRepoRow.tsx b/src/app/components/shared/EmptyLockedRepoRow.tsx new file mode 100644 index 00000000..74b1974e --- /dev/null +++ b/src/app/components/shared/EmptyLockedRepoRow.tsx @@ -0,0 +1,21 @@ +import RepoGitHubLink from "./RepoGitHubLink"; +import RepoLockControls from "./RepoLockControls"; + +export default function EmptyLockedRepoRow(props: { + repoFullName: string; + section: "issues" | "pulls" | "actions"; +}) { + return ( +
+ + + {props.repoFullName} + + + +
+ ); +} diff --git a/src/app/lib/grouping.ts b/src/app/lib/grouping.ts index 3e771a8e..3cfc0d82 100644 --- a/src/app/lib/grouping.ts +++ b/src/app/lib/grouping.ts @@ -50,6 +50,23 @@ export function slicePageGroups( return groups.slice(start, end); } +/** + * Ensures every locked repo has a group entry, appending empty stubs for any + * that are absent from the current data. The stubs land at the tail on purpose: + * the caller must pipe the result through orderRepoGroups, which promotes all + * locked groups to the front in the correct order. + */ +export function ensureLockedRepoGroups( + groups: G[], + lockedOrder: readonly string[], + emptyFactory: (repoFullName: string) => G, +): G[] { + const present = new Set(groups.map(g => g.repoFullName)); + const missing = lockedOrder.filter(name => !present.has(name)); + if (missing.length === 0) return groups; + return [...groups, ...missing.map(emptyFactory)]; +} + export function orderRepoGroups( groups: G[], lockedOrder: string[] diff --git a/tests/components/dashboard/ActionsTab.test.tsx b/tests/components/dashboard/ActionsTab.test.tsx index 88768042..31c9bd90 100644 --- a/tests/components/dashboard/ActionsTab.test.tsx +++ b/tests/components/dashboard/ActionsTab.test.tsx @@ -104,6 +104,82 @@ describe("ActionsTab — empty-repo state preservation", () => { // With empty items and no configRepoNames, guard returns early — no pruning expect(viewState.lockedRepos).toEqual([]); }); + + it("renders compact stub row for a locked repo with no workflow runs", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.textContent).toContain("owner/locked-empty"); + const headerBtn = stub?.querySelector('[aria-expanded]'); + expect(headerBtn).toBeNull(); + }); + + it("does not expand a locked repo with no workflow runs even when expandedRepos is set", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + s.expandedRepos.actions["owner/locked-empty"] = true; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.querySelector('[aria-expanded]')).toBeNull(); + }); + + it("hides empty-state message when only locked stubs exist (no double render)", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + // Locked stub renders + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + // Empty-state message does NOT render alongside the stub + expect(screen.queryByText("No workflow runs found.")).toBeNull(); + }); + + it("hides locked stubs during initial load (no skeleton + stub double render)", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + // Loading skeleton shows (label is aria-label, not visible text) + screen.getByRole("status", { name: "Loading workflow runs" }); + // Locked stub does NOT render alongside the skeleton + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).toBeNull(); + }); }); // ── ActionsTab — RepoGroupHeader integration ────────────────────────────────── diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index c4d168e8..9563c0cd 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -740,4 +740,59 @@ describe("IssuesTab — empty-repo state preservation", () => { // With empty items and no configRepoNames, guard returns early — no pruning expect(viewState.lockedRepos).toEqual([]); }); + + it("renders compact stub row for a locked repo with no issues", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.textContent).toContain("owner/locked-empty"); + const headerBtn = stub?.querySelector('[aria-expanded]'); + expect(headerBtn).toBeNull(); + }); + + it("does not expand a locked repo with no issues even when expandedRepos is set", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + s.expandedRepos.issues["owner/locked-empty"] = true; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.querySelector('[aria-expanded]')).toBeNull(); + }); + + it("hides empty-state message when only locked stubs exist (no double render)", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + render(() => ( + + )); + + expect(screen.queryByText(/No open issues/i)).toBeNull(); + }); }); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index 373e94c3..31b5ef66 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -711,4 +711,59 @@ describe("PullRequestsTab — empty-repo state preservation", () => { // With empty items and no configRepoNames, guard returns early — no pruning expect(viewState.lockedRepos).toEqual([]); }); + + it("renders compact stub row for a locked repo with no pull requests", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.textContent).toContain("owner/locked-empty"); + const headerBtn = stub?.querySelector('[aria-expanded]'); + expect(headerBtn).toBeNull(); + }); + + it("does not expand a locked repo with no pull requests even when expandedRepos is set", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + s.expandedRepos.pullRequests["owner/locked-empty"] = true; + })); + + const { container } = render(() => ( + + )); + + const stub = container.querySelector('[data-repo-group="owner/locked-empty"]'); + expect(stub).not.toBeNull(); + expect(stub?.querySelector('[aria-expanded]')).toBeNull(); + }); + + it("hides empty-state message when only locked stubs exist (no double render)", () => { + setViewState(produce((s) => { + s.lockedRepos = ["owner/locked-empty"]; + })); + + render(() => ( + + )); + + expect(screen.queryByText(/No open pull requests/i)).toBeNull(); + }); }); diff --git a/tests/components/shared/EmptyLockedRepoRow.test.tsx b/tests/components/shared/EmptyLockedRepoRow.test.tsx new file mode 100644 index 00000000..df01da0a --- /dev/null +++ b/tests/components/shared/EmptyLockedRepoRow.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@solidjs/testing-library"; +import EmptyLockedRepoRow from "../../../src/app/components/shared/EmptyLockedRepoRow"; + +describe("EmptyLockedRepoRow", () => { + it("renders the repo name", () => { + const { getByText } = render(() => ( + + )); + expect(getByText("owner/repo")).toBeTruthy(); + }); + + it("sets data-repo-group attribute", () => { + const { container } = render(() => ( + + )); + const row = container.querySelector('[data-repo-group="owner/repo"]'); + expect(row).not.toBeNull(); + }); + + it("applies de-emphasis styling", () => { + const { container } = render(() => ( + + )); + const row = container.querySelector('[data-repo-group="owner/repo"]'); + expect(row?.className).toContain("opacity-40"); + }); + + it("has no aria-expanded attribute (non-interactive)", () => { + const { container } = render(() => ( + + )); + expect(container.querySelector("[aria-expanded]")).toBeNull(); + }); +}); diff --git a/tests/lib/grouping.test.ts b/tests/lib/grouping.test.ts index 79165c94..e0b01ecb 100644 --- a/tests/lib/grouping.test.ts +++ b/tests/lib/grouping.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { groupByRepo, computePageLayout, slicePageGroups, isUserInvolved, type RepoGroup } from "../../src/app/lib/grouping"; +import { groupByRepo, computePageLayout, slicePageGroups, isUserInvolved, ensureLockedRepoGroups, type RepoGroup } from "../../src/app/lib/grouping"; interface Item { repoFullName: string; @@ -224,3 +224,66 @@ describe("slicePageGroups", () => { expect(result[1].repoFullName).toBe("org/b"); }); }); + +describe("ensureLockedRepoGroups", () => { + const emptyFactory = (name: string): RepoGroup => ({ + repoFullName: name, + items: [], + }); + + it("returns groups unchanged when all locked repos are present", () => { + const groups = [makeGroup("org/a", 3), makeGroup("org/b", 2)]; + const result = ensureLockedRepoGroups(groups, ["org/a", "org/b"], emptyFactory); + expect(result).toBe(groups); // same reference — no copy + }); + + it("injects empty stubs for missing locked repos", () => { + const groups = [makeGroup("org/a", 3)]; + const result = ensureLockedRepoGroups(groups, ["org/a", "org/b", "org/c"], emptyFactory); + expect(result).toHaveLength(3); + expect(result[0].repoFullName).toBe("org/a"); + expect(result[0].items).toHaveLength(3); + expect(result[1].repoFullName).toBe("org/b"); + expect(result[1].items).toHaveLength(0); + expect(result[2].repoFullName).toBe("org/c"); + expect(result[2].items).toHaveLength(0); + }); + + it("preserves existing groups in original order with stubs appended", () => { + const groups = [makeGroup("org/x", 1), makeGroup("org/y", 2)]; + const result = ensureLockedRepoGroups(groups, ["org/missing"], emptyFactory); + expect(result).toHaveLength(3); + expect(result[0].repoFullName).toBe("org/x"); + expect(result[1].repoFullName).toBe("org/y"); + expect(result[2].repoFullName).toBe("org/missing"); + }); + + it("no-op when lockedOrder is empty", () => { + const groups = [makeGroup("org/a", 3)]; + const result = ensureLockedRepoGroups(groups, [], emptyFactory); + expect(result).toBe(groups); + }); + + it("no-op when groups is empty and lockedOrder is empty", () => { + const result = ensureLockedRepoGroups([], [], emptyFactory); + expect(result).toEqual([]); + }); + + it("injects all locked repos when groups is empty", () => { + const result = ensureLockedRepoGroups([], ["org/a", "org/b"], emptyFactory); + expect(result).toHaveLength(2); + expect(result[0].repoFullName).toBe("org/a"); + expect(result[0].items).toHaveLength(0); + expect(result[1].repoFullName).toBe("org/b"); + expect(result[1].items).toHaveLength(0); + }); + + it("works with custom factory for different group shapes", () => { + interface WfGroup { repoFullName: string; workflows: string[] } + const wfFactory = (name: string): WfGroup => ({ repoFullName: name, workflows: [] }); + const groups: WfGroup[] = [{ repoFullName: "org/a", workflows: ["ci"] }]; + const result = ensureLockedRepoGroups(groups, ["org/a", "org/b"], wfFactory); + expect(result).toHaveLength(2); + expect(result[1].workflows).toEqual([]); + }); +});