Skip to content

Commit 144d940

Browse files
committed
feat(ui): adds repo links, scrollbar, dep filter, flash fix
- adds external-link icon on repo group headers linking to GitHub per tab - moves scrollbar to viewport edge via page scroll with sticky tabs - adds depDashboard filter with toggle pill, hidden by default - fixes detectReorderedRepos false flash on repo add/remove - extracts ExternalLinkIcon shared component - preserves depDashboard toggle across resetAllTabFilters - uses schema defaults in resetTabFilter instead of hardcoded all
1 parent 04255f5 commit 144d940

10 files changed

Lines changed: 191 additions & 31 deletions

File tree

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { FilterChipGroupDef } from "../shared/FilterChips";
1111
import ChevronIcon from "../shared/ChevronIcon";
1212
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
1313
import RepoLockControls from "../shared/RepoLockControls";
14+
import ExternalLinkIcon from "../shared/ExternalLinkIcon";
1415
import { orderRepoGroups } from "../../lib/grouping";
1516
import { createReorderHighlight } from "../../lib/reorderHighlight";
1617
import { createFlashDetection } from "../../lib/flashDetection";
@@ -319,6 +320,16 @@ export default function ActionsTab(props: ActionsTabProps) {
319320
</span>
320321
</Show>
321322
</button>
323+
<a
324+
href={`https://github.com/${repoGroup.repoFullName}/actions`}
325+
target="_blank"
326+
rel="noopener noreferrer"
327+
class="opacity-0 group-hover/repo-header:opacity-100 focus:opacity-100 transition-opacity text-base-content/40 hover:text-primary px-1"
328+
title={`Open ${repoGroup.repoFullName} actions on GitHub`}
329+
aria-label={`Open ${repoGroup.repoFullName} actions on GitHub`}
330+
>
331+
<ExternalLinkIcon />
332+
</a>
322333
<RepoLockControls tab="actions" repoFullName={repoGroup.repoFullName} />
323334
</div>
324335
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -342,22 +342,23 @@ export default function DashboardPage() {
342342
<Header />
343343

344344
{/* Offset for fixed header */}
345-
<div class="pt-14 flex flex-col h-screen">
346-
{/* Single constrained panel: tabs + filters + content */}
347-
<div class="max-w-6xl mx-auto w-full flex flex-col flex-1 min-h-0 bg-base-100 shadow-lg border-x border-base-300">
348-
<TabBar
349-
activeTab={activeTab()}
350-
onTabChange={handleTabChange}
351-
counts={tabCounts()}
352-
/>
353-
354-
<FilterBar
355-
isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading}
356-
lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt}
357-
onRefresh={() => _coordinator()?.manualRefresh()}
358-
/>
359-
360-
<main class="flex-1 overflow-auto">
345+
<div class="pt-14 min-h-[calc(100vh-3.5rem)] flex flex-col">
346+
<div class="max-w-6xl mx-auto w-full bg-base-100 shadow-lg border-x border-base-300 flex-1">
347+
<div class="sticky top-14 z-40 bg-base-100">
348+
<TabBar
349+
activeTab={activeTab()}
350+
onTabChange={handleTabChange}
351+
counts={tabCounts()}
352+
/>
353+
354+
<FilterBar
355+
isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading}
356+
lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt}
357+
onRefresh={() => _coordinator()?.manualRefresh()}
358+
/>
359+
</div>
360+
361+
<main class="pb-12">
361362
<Switch>
362363
<Match when={activeTab() === "issues"}>
363364
<IssuesTab
@@ -393,7 +394,7 @@ export default function DashboardPage() {
393394
</main>
394395
</div>
395396

396-
<footer class="border-t border-base-300 bg-base-100 py-3 text-xs text-base-content/50 shrink-0">
397+
<footer class="fixed bottom-0 left-0 right-0 z-30 border-t border-base-300 bg-base-100 py-3 text-xs text-base-content/50">
397398
<div class="max-w-6xl mx-auto w-full px-4 grid grid-cols-3 items-center">
398399
<div />
399400
<div class="flex items-center justify-center gap-3">

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { deriveInvolvementRoles } from "../../lib/format";
1818
import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from "../../lib/grouping";
1919
import { createReorderHighlight } from "../../lib/reorderHighlight";
2020
import RepoLockControls from "../shared/RepoLockControls";
21+
import ExternalLinkIcon from "../shared/ExternalLinkIcon";
2122

2223
export interface IssuesTabProps {
2324
issues: Issue[];
@@ -114,6 +115,8 @@ export default function IssuesTab(props: IssuesTabProps) {
114115
if (tabFilter.comments === "none" && issue.comments > 0) return false;
115116
}
116117

118+
if (tabFilter.depDashboard === "hide" && issue.title === "Dependency Dashboard") return false;
119+
117120
if (tabFilter.user !== "all") {
118121
const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user);
119122
if (validUser) {
@@ -212,7 +215,7 @@ export default function IssuesTab(props: IssuesTabProps) {
212215
return (
213216
<div class="flex flex-col h-full">
214217
{/* Sort dropdown + filter chips + ignore badge toolbar */}
215-
<div class="flex items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
218+
<div class="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
216219
<SortDropdown
217220
options={sortOptions}
218221
value={sortPref().field}
@@ -235,6 +238,18 @@ export default function IssuesTab(props: IssuesTabProps) {
235238
setPage(0);
236239
}}
237240
/>
241+
<button
242+
onClick={() => {
243+
const next = viewState.tabFilters.issues.depDashboard === "show" ? "hide" : "show";
244+
setTabFilter("issues", "depDashboard", next);
245+
setPage(0);
246+
}}
247+
class={`btn btn-xs rounded-full ${viewState.tabFilters.issues.depDashboard === "show" ? "btn-primary" : "btn-ghost text-base-content/50"}`}
248+
aria-pressed={viewState.tabFilters.issues.depDashboard === "show"}
249+
title="Toggle visibility of Dependency Dashboard issues"
250+
>
251+
Show Dep Dashboard
252+
</button>
238253
<div class="flex-1" />
239254
<ExpandCollapseButtons
240255
onExpandAll={() => setAllExpanded("issues", repoGroups().map((g) => g.repoFullName), true)}
@@ -323,6 +338,16 @@ export default function IssuesTab(props: IssuesTabProps) {
323338
</span>
324339
</Show>
325340
</button>
341+
<a
342+
href={`https://github.com/${repoGroup.repoFullName}/issues`}
343+
target="_blank"
344+
rel="noopener noreferrer"
345+
class="opacity-0 group-hover/repo-header:opacity-100 focus:opacity-100 transition-opacity text-base-content/40 hover:text-primary px-1"
346+
title={`Open ${repoGroup.repoFullName} issues on GitHub`}
347+
aria-label={`Open ${repoGroup.repoFullName} issues on GitHub`}
348+
>
349+
<ExternalLinkIcon />
350+
</a>
326351
<RepoLockControls tab="issues" repoFullName={repoGroup.repoFullName} />
327352
</div>
328353
<Show when={isExpanded()}>

src/app/components/dashboard/PullRequestsTab.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { groupByRepo, computePageLayout, slicePageGroups, orderRepoGroups } from
2222
import { createReorderHighlight } from "../../lib/reorderHighlight";
2323
import { createFlashDetection } from "../../lib/flashDetection";
2424
import RepoLockControls from "../shared/RepoLockControls";
25+
import ExternalLinkIcon from "../shared/ExternalLinkIcon";
2526

2627
export interface PullRequestsTabProps {
2728
pullRequests: PullRequest[];
@@ -478,6 +479,16 @@ export default function PullRequestsTab(props: PullRequestsTabProps) {
478479
</span>
479480
</Show>
480481
</button>
482+
<a
483+
href={`https://github.com/${repoGroup.repoFullName}/pulls`}
484+
target="_blank"
485+
rel="noopener noreferrer"
486+
class="opacity-0 group-hover/repo-header:opacity-100 focus:opacity-100 transition-opacity text-base-content/40 hover:text-primary px-1"
487+
title={`Open ${repoGroup.repoFullName} pull requests on GitHub`}
488+
aria-label={`Open ${repoGroup.repoFullName} pull requests on GitHub`}
489+
>
490+
<ExternalLinkIcon />
491+
</a>
481492
<RepoLockControls tab="pullRequests" repoFullName={repoGroup.repoFullName} />
482493
</div>
483494
<Show when={!isExpanded() && peekUpdates().get(repoGroup.repoFullName)}>
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export default function ExternalLinkIcon(props: { class?: string }) {
2+
return (
3+
<svg
4+
xmlns="http://www.w3.org/2000/svg"
5+
viewBox="0 0 20 20"
6+
fill="currentColor"
7+
class={props.class ?? "h-3.5 w-3.5"}
8+
aria-hidden="true"
9+
>
10+
<path
11+
fill-rule="evenodd"
12+
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Z"
13+
clip-rule="evenodd"
14+
/>
15+
<path
16+
fill-rule="evenodd"
17+
d="M6.194 12.753a.75.75 0 0 0 1.06.053L16.5 4.44v2.81a.75.75 0 0 0 1.5 0v-4.5a.75.75 0 0 0-.75-.75h-4.5a.75.75 0 0 0 0 1.5h2.553l-9.056 8.194a.75.75 0 0 0-.053 1.06Z"
18+
clip-rule="evenodd"
19+
/>
20+
</svg>
21+
);
22+
}

src/app/lib/grouping.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,19 @@ export function detectReorderedRepos(
7676
previousOrder: string[],
7777
currentOrder: string[]
7878
): Set<string> {
79+
// Filter both lists to only repos present in both — ignoring additions/removals
80+
// so that inserting or removing a repo doesn't flag everything below it as "moved".
81+
const currentSet = new Set(currentOrder);
82+
const prevCommon = previousOrder.filter((r) => currentSet.has(r));
83+
const prevSet = new Set(previousOrder);
84+
const curCommon = currentOrder.filter((r) => prevSet.has(r));
85+
7986
const moved = new Set<string>();
80-
const prevIndex = new Map(previousOrder.map((name, i) => [name, i]));
81-
for (let i = 0; i < currentOrder.length; i++) {
82-
const prev = prevIndex.get(currentOrder[i]);
87+
const prevIndex = new Map(prevCommon.map((name, i) => [name, i]));
88+
for (let i = 0; i < curCommon.length; i++) {
89+
const prev = prevIndex.get(curCommon[i]);
8390
if (prev !== undefined && prev !== i) {
84-
moved.add(currentOrder[i]);
91+
moved.add(curCommon[i]);
8592
}
8693
}
8794
return moved;

src/app/stores/view.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const IssueFiltersSchema = z.object({
88
role: z.enum(["all", "author", "assignee"]).default("all"),
99
comments: z.enum(["all", "has", "none"]).default("all"),
1010
user: z.enum(["all"]).or(z.string()).default("all"),
11+
depDashboard: z.enum(["hide", "show"]).default("hide"),
1112
});
1213

1314
const PullRequestFiltersSchema = z.object({
@@ -62,11 +63,11 @@ export const ViewStateSchema = z.object({
6263
})
6364
.default({ org: null, repo: null }),
6465
tabFilters: z.object({
65-
issues: IssueFiltersSchema.default({ role: "all", comments: "all", user: "all" }),
66+
issues: IssueFiltersSchema.default({ role: "all", comments: "all", user: "all", depDashboard: "hide" }),
6667
pullRequests: PullRequestFiltersSchema.default({ role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" }),
6768
actions: ActionsFiltersSchema.default({ conclusion: "all", event: "all" }),
6869
}).default({
69-
issues: { role: "all", comments: "all", user: "all" },
70+
issues: { role: "all", comments: "all", user: "all", depDashboard: "hide" },
7071
pullRequests: { role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" },
7172
actions: { conclusion: "all", event: "all" },
7273
}),
@@ -120,7 +121,7 @@ export function resetViewState(): void {
120121
ignoredItems: [],
121122
globalFilter: { org: null, repo: null },
122123
tabFilters: {
123-
issues: { role: "all", comments: "all", user: "all" },
124+
issues: { role: "all", comments: "all", user: "all", depDashboard: "hide" },
124125
pullRequests: { role: "all", reviewDecision: "all", draft: "all", checkStatus: "all", sizeCategory: "all", user: "all" },
125126
actions: { conclusion: "all", event: "all" },
126127
},
@@ -198,13 +199,20 @@ export function setTabFilter<T extends keyof TabFilterField>(
198199
);
199200
}
200201

202+
const tabFilterDefaults: Record<string, Record<string, string>> = {
203+
issues: IssueFiltersSchema.parse({}) as Record<string, string>,
204+
pullRequests: PullRequestFiltersSchema.parse({}) as Record<string, string>,
205+
actions: ActionsFiltersSchema.parse({}) as Record<string, string>,
206+
};
207+
201208
export function resetTabFilter<T extends keyof TabFilterField>(
202209
tab: T,
203210
field: TabFilterField[T]
204211
): void {
212+
const defaultValue = tabFilterDefaults[tab]?.[field as string] ?? "all";
205213
setViewState(
206214
produce((draft) => {
207-
(draft.tabFilters[tab] as Record<string, string>)[field as string] = "all";
215+
(draft.tabFilters[tab] as Record<string, string>)[field as string] = defaultValue;
208216
})
209217
);
210218
}
@@ -215,7 +223,9 @@ export function resetAllTabFilters(
215223
setViewState(
216224
produce((draft) => {
217225
if (tab === "issues") {
226+
const depDashboard = draft.tabFilters.issues.depDashboard;
218227
draft.tabFilters.issues = IssueFiltersSchema.parse({});
228+
draft.tabFilters.issues.depDashboard = depDashboard;
219229
} else if (tab === "pullRequests") {
220230
draft.tabFilters.pullRequests = PullRequestFiltersSchema.parse({});
221231
} else {

tests/components/IssuesTab.test.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,4 +590,48 @@ describe("IssuesTab", () => {
590590
screen.getByText("org/repo-b");
591591
screen.getByText("Repo B issue 0");
592592
});
593+
594+
it("hides Dependency Dashboard issues by default", () => {
595+
const issues = [
596+
makeIssue({ id: 1, title: "Dependency Dashboard" }),
597+
makeIssue({ id: 2, title: "Normal issue" }),
598+
];
599+
setAllExpanded("issues", ["owner/repo"], true);
600+
render(() => <IssuesTab issues={issues} userLogin="" />);
601+
expect(screen.queryByText("Dependency Dashboard")).toBeNull();
602+
screen.getByText("Normal issue");
603+
});
604+
605+
it("shows Dependency Dashboard issues when toggle is active", () => {
606+
const issues = [
607+
makeIssue({ id: 1, title: "Dependency Dashboard" }),
608+
makeIssue({ id: 2, title: "Normal issue" }),
609+
];
610+
viewStore.setTabFilter("issues", "depDashboard", "show");
611+
setAllExpanded("issues", ["owner/repo"], true);
612+
render(() => <IssuesTab issues={issues} userLogin="" />);
613+
screen.getByText("Dependency Dashboard");
614+
screen.getByText("Normal issue");
615+
});
616+
617+
it("toggles Dependency Dashboard visibility via pill button", async () => {
618+
const user = userEvent.setup();
619+
const issues = [
620+
makeIssue({ id: 1, title: "Dependency Dashboard" }),
621+
makeIssue({ id: 2, title: "Normal issue" }),
622+
];
623+
setAllExpanded("issues", ["owner/repo"], true);
624+
render(() => <IssuesTab issues={issues} userLogin="" />);
625+
626+
// Hidden by default
627+
expect(screen.queryByText("Dependency Dashboard")).toBeNull();
628+
629+
// Click toggle pill to show
630+
await user.click(screen.getByText("Show Dep Dashboard"));
631+
screen.getByText("Dependency Dashboard");
632+
633+
// Click again to hide
634+
await user.click(screen.getByText("Show Dep Dashboard"));
635+
expect(screen.queryByText("Dependency Dashboard")).toBeNull();
636+
});
593637
});

tests/lib/grouping-lock.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,17 @@ describe("detectReorderedRepos", () => {
6060
expect(detectReorderedRepos(prev, curr)).toEqual(new Set());
6161
});
6262

63-
it("ignores removed repos", () => {
64-
const prev = ["org/a", "org/b"];
65-
const curr = ["org/b"];
66-
// org/b was at index 1, now at index 0 -> moved
67-
expect(detectReorderedRepos(prev, curr)).toEqual(new Set(["org/b"]));
63+
it("does not flash remaining repos when a repo is removed", () => {
64+
const prev = ["org/a", "org/b", "org/c"];
65+
const curr = ["org/b", "org/c"];
66+
// org/a removed — org/b and org/c kept same relative order
67+
expect(detectReorderedRepos(prev, curr)).toEqual(new Set());
68+
});
69+
70+
it("detects reorder even when a repo is simultaneously removed", () => {
71+
const prev = ["org/a", "org/b", "org/c"];
72+
const curr = ["org/c", "org/b"];
73+
// org/a removed, and org/b + org/c swapped relative order
74+
expect(detectReorderedRepos(prev, curr)).toEqual(new Set(["org/b", "org/c"]));
6875
});
6976
});

tests/stores/view.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
unignoreItem,
99
setSortPreference,
1010
setGlobalFilter,
11+
setTabFilter,
12+
resetTabFilter,
13+
resetAllTabFilters,
1114
initViewPersistence,
1215
ViewStateSchema,
1316
toggleExpandedRepo,
@@ -306,3 +309,22 @@ describe("resetViewState", () => {
306309
expect("org/repo-d" in viewState.expandedRepos.actions).toBe(false);
307310
});
308311
});
312+
313+
describe("depDashboard filter reset", () => {
314+
beforeEach(() => resetViewState());
315+
316+
it("resetTabFilter resets depDashboard to 'hide' (not 'all')", () => {
317+
setTabFilter("issues", "depDashboard", "show");
318+
expect(viewState.tabFilters.issues.depDashboard).toBe("show");
319+
resetTabFilter("issues", "depDashboard");
320+
expect(viewState.tabFilters.issues.depDashboard).toBe("hide");
321+
});
322+
323+
it("resetAllTabFilters preserves depDashboard value", () => {
324+
setTabFilter("issues", "depDashboard", "show");
325+
setTabFilter("issues", "role", "author");
326+
resetAllTabFilters("issues");
327+
expect(viewState.tabFilters.issues.role).toBe("all");
328+
expect(viewState.tabFilters.issues.depDashboard).toBe("show");
329+
});
330+
});

0 commit comments

Comments
 (0)