Skip to content

Commit 64c3da7

Browse files
authored
feat(ui): adds repo-based collapsible grouping to Issues and PRs tabs (#6)
* feat(ui): adds repo-based collapsible grouping to Issues and PRs tabs * perf(ui): replaces collapsedRepos createSignal<Set> with createStore Migrates all 3 tabs (ActionsTab, IssuesTab, PullRequestsTab) from createSignal<Set<string>> to createStore<Record<string, boolean>> for per-repo fine-grained reactivity. Toggling one repo group no longer re-evaluates aria-expanded, ChevronIcon rotation, and Show conditions for all other groups. Adds 6 new tests: aria-expanded toggle (collapse+re-expand for both tabs), multi-page pagination, and oversized single-group behavior. * test(ui): adds collapse-filter interaction test for IssuesTab * fix: wraps filteredSorted/meta accessors in createMemo, fixes test gaps * perf(ui): splits paginateGroups into pageLayout and pageGroups memos * fix(ui): clamps stale page signal when pageCount shrinks * test(ui): adds page-reset-on-data-shrink test for stale page fix * fix(ui): addresses PR review findings across all 10 verified issues - Extracts groupByRepo, computePageLayout, slicePageGroups to shared generic lib/grouping.ts, eliminating verbatim duplication between IssuesTab and PullRequestsTab - Wraps pageCount in createMemo, consistent with commit 7749a68 - Inlines pageSize (removes unnecessary createMemo wrapper) - Adds named isRepoCollapsed accessor in IssuesTab/PullRequestsTab, consistent with ActionsTab pattern - Changes ChevronIcon sizeClass from createMemo to plain arrow fn, consistent with ItemRow isCompact pattern - Adds aria-expanded to ActionsTab workflow-level toggle button - Formats PullRequestsTab FilterChips handlers to multi-line style - Adds unit tests for grouping boundary cases, PullRequestsTab page-reset, sort-order assertions, and ActionsTab aria-expanded tests * fix(test): allocates dynamic port for E2E to avoid collisions * fix(test): adds --strictPort to prevent silent port rebinding
1 parent 5958018 commit 64c3da7

File tree

14 files changed

+798
-155
lines changed

14 files changed

+798
-155
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"test:watch": "vitest --config vitest.config.ts",
1313
"deploy": "wrangler deploy",
1414
"typecheck": "tsc --noEmit",
15-
"test:e2e": "playwright test"
15+
"test:e2e": "E2E_PORT=$(node -e \"const s=require('net').createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\") playwright test"
1616
},
1717
"dependencies": {
1818
"@octokit/core": "^7.0.6",

playwright.config.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { defineConfig, devices } from "@playwright/test";
22

3+
const port = Number(process.env.E2E_PORT) || 5173;
4+
35
export default defineConfig({
46
testDir: "./e2e",
57
reporter: [["html", { open: "never" }]],
68
use: {
7-
baseURL: "http://localhost:5173",
9+
baseURL: `http://localhost:${port}`,
810
trace: "on-first-retry",
911
},
1012
projects: [
@@ -14,8 +16,8 @@ export default defineConfig({
1416
},
1517
],
1618
webServer: {
17-
command: "pnpm dev",
18-
port: 5173,
19+
command: `pnpm exec vite dev --port ${port} --strictPort`,
20+
url: `http://localhost:${port}`,
1921
timeout: 120_000,
2022
reuseExistingServer: !process.env.CI,
2123
},

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 11 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { createMemo, createSignal, For, Show } from "solid-js";
1+
import { createMemo, For, Show } from "solid-js";
2+
import { createStore } from "solid-js/store";
23
import type { WorkflowRun, ApiError } from "../../services/api";
34
import { config } from "../../stores/config";
45
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type ActionsFilterField } from "../../stores/view";
@@ -8,25 +9,7 @@ import ErrorBannerList from "../shared/ErrorBannerList";
89
import SkeletonRows from "../shared/SkeletonRows";
910
import FilterChips from "../shared/FilterChips";
1011
import type { FilterChipGroupDef } from "../shared/FilterChips";
11-
12-
function ChevronIcon(props: { size: "sm" | "md"; rotated: boolean }) {
13-
const sizeClass = () => (props.size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5");
14-
return (
15-
<svg
16-
xmlns="http://www.w3.org/2000/svg"
17-
class={`${sizeClass()} text-gray-400 transition-transform ${props.rotated ? "-rotate-90" : ""}`}
18-
viewBox="0 0 20 20"
19-
fill="currentColor"
20-
aria-hidden="true"
21-
>
22-
<path
23-
fill-rule="evenodd"
24-
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
25-
clip-rule="evenodd"
26-
/>
27-
</svg>
28-
);
29-
}
12+
import ChevronIcon from "../shared/ChevronIcon";
3013

3114
interface ActionsTabProps {
3215
workflowRuns: WorkflowRun[];
@@ -122,35 +105,15 @@ const actionsFilterGroups: FilterChipGroupDef[] = [
122105
];
123106

124107
export default function ActionsTab(props: ActionsTabProps) {
125-
const [collapsedRepos, setCollapsedRepos] = createSignal<Set<string>>(
126-
new Set()
127-
);
128-
const [collapsedWorkflows, setCollapsedWorkflows] = createSignal<Set<string>>(
129-
new Set()
130-
);
108+
const [collapsedRepos, setCollapsedRepos] = createStore<Record<string, boolean>>({});
109+
const [collapsedWorkflows, setCollapsedWorkflows] = createStore<Record<string, boolean>>({});
131110

132111
function toggleRepo(repoFullName: string) {
133-
setCollapsedRepos((prev) => {
134-
const next = new Set(prev);
135-
if (next.has(repoFullName)) {
136-
next.delete(repoFullName);
137-
} else {
138-
next.add(repoFullName);
139-
}
140-
return next;
141-
});
112+
setCollapsedRepos(repoFullName, (v) => !v);
142113
}
143114

144115
function toggleWorkflow(key: string) {
145-
setCollapsedWorkflows((prev) => {
146-
const next = new Set(prev);
147-
if (next.has(key)) {
148-
next.delete(key);
149-
} else {
150-
next.add(key);
151-
}
152-
return next;
153-
});
116+
setCollapsedWorkflows(key, (v) => !v);
154117
}
155118

156119
function handleIgnore(run: WorkflowRun) {
@@ -255,13 +218,14 @@ export default function ActionsTab(props: ActionsTabProps) {
255218
<For each={repoGroups()}>
256219
{(repoGroup) => {
257220
const isRepoCollapsed = () =>
258-
collapsedRepos().has(repoGroup.repoFullName);
221+
collapsedRepos[repoGroup.repoFullName];
259222

260223
return (
261224
<div class="bg-white dark:bg-gray-900">
262225
{/* Repo header */}
263226
<button
264227
onClick={() => toggleRepo(repoGroup.repoFullName)}
228+
aria-expanded={!isRepoCollapsed()}
265229
class="w-full flex items-center gap-2 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
266230
>
267231
<ChevronIcon size="md" rotated={isRepoCollapsed()} />
@@ -274,13 +238,14 @@ export default function ActionsTab(props: ActionsTabProps) {
274238
{(wfGroup) => {
275239
const wfKey = `${repoGroup.repoFullName}:${wfGroup.workflowId}`;
276240
const isWfCollapsed = () =>
277-
collapsedWorkflows().has(wfKey);
241+
collapsedWorkflows[wfKey];
278242

279243
return (
280244
<div class="border-l-2 border-gray-100 dark:border-gray-800 ml-4">
281245
{/* Workflow header */}
282246
<button
283247
onClick={() => toggleWorkflow(wfKey)}
248+
aria-expanded={!isWfCollapsed()}
284249
class="w-full flex items-center gap-2 px-4 py-1.5 text-left text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
285250
>
286251
<ChevronIcon size="sm" rotated={isWfCollapsed()} />

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 62 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { createMemo, createSignal, For, Show } from "solid-js";
1+
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
2+
import { createStore } from "solid-js/store";
23
import { config } from "../../stores/config";
34
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type IssueFilterField } from "../../stores/view";
45
import type { Issue, ApiError } from "../../services/api";
@@ -11,7 +12,9 @@ import FilterChips from "../shared/FilterChips";
1112
import type { FilterChipGroupDef } from "../shared/FilterChips";
1213
import RoleBadge from "../shared/RoleBadge";
1314
import SkeletonRows from "../shared/SkeletonRows";
15+
import ChevronIcon from "../shared/ChevronIcon";
1416
import { deriveInvolvementRoles } from "../../lib/format";
17+
import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/grouping";
1518

1619
export interface IssuesTabProps {
1720
issues: Issue[];
@@ -43,6 +46,11 @@ const issueFilterGroups: FilterChipGroupDef[] = [
4346

4447
export default function IssuesTab(props: IssuesTabProps) {
4548
const [page, setPage] = createSignal(0);
49+
const [collapsedRepos, setCollapsedRepos] = createStore<Record<string, boolean>>({});
50+
51+
function toggleRepo(repoFullName: string) {
52+
setCollapsedRepos(repoFullName, (v) => !v);
53+
}
4654

4755
const sortPref = createMemo(() => {
4856
const pref = viewState.sortPreferences["issues"];
@@ -110,20 +118,19 @@ export default function IssuesTab(props: IssuesTabProps) {
110118
return { items, meta };
111119
});
112120

113-
const filteredSorted = () => filteredSortedWithMeta().items;
114-
const issueMeta = () => filteredSortedWithMeta().meta;
115-
116-
const pageSize = createMemo(() => config.itemsPerPage);
121+
const filteredSorted = createMemo(() => filteredSortedWithMeta().items);
122+
const issueMeta = createMemo(() => filteredSortedWithMeta().meta);
117123

118-
const pageCount = createMemo(() =>
119-
Math.max(1, Math.ceil(filteredSorted().length / pageSize()))
124+
const repoGroups = createMemo(() => groupByRepo(filteredSorted()));
125+
const pageLayout = createMemo(() => computePageLayout(repoGroups(), config.itemsPerPage));
126+
const pageCount = createMemo(() => pageLayout().pageCount);
127+
const pageGroups = createMemo(() =>
128+
slicePageGroups(repoGroups(), pageLayout().boundaries, pageCount(), page())
120129
);
121130

122-
// Reset to first page when filters/sort change
123-
const pagedItems = createMemo(() => {
124-
const p = Math.min(page(), pageCount() - 1);
125-
const start = p * pageSize();
126-
return filteredSorted().slice(start, start + pageSize());
131+
createEffect(() => {
132+
const max = pageCount() - 1;
133+
if (page() > max) setPage(max);
127134
});
128135

129136
function handleSort(field: SortField) {
@@ -213,7 +220,7 @@ export default function IssuesTab(props: IssuesTabProps) {
213220
{/* Issue rows */}
214221
<Show when={!props.loading || props.issues.length > 0}>
215222
<Show
216-
when={pagedItems().length > 0}
223+
when={pageGroups().length > 0}
217224
fallback={
218225
<div class="flex flex-col items-center justify-center gap-2 py-16 text-gray-500 dark:text-gray-400">
219226
<svg
@@ -237,26 +244,48 @@ export default function IssuesTab(props: IssuesTabProps) {
237244
</div>
238245
}
239246
>
240-
<div role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
241-
<For each={pagedItems()}>
242-
{(issue) => (
243-
<div role="listitem">
244-
<ItemRow
245-
repo={issue.repoFullName}
246-
number={issue.number}
247-
title={issue.title}
248-
author={issue.userLogin}
249-
createdAt={issue.createdAt}
250-
url={issue.htmlUrl}
251-
labels={issue.labels}
252-
onIgnore={() => handleIgnore(issue)}
253-
density={config.viewDensity}
254-
commentCount={issue.comments}
255-
>
256-
<RoleBadge roles={issueMeta().get(issue.id)?.roles ?? []} />
257-
</ItemRow>
258-
</div>
259-
)}
247+
<div class="divide-y divide-gray-100 dark:divide-gray-800">
248+
<For each={pageGroups()}>
249+
{(repoGroup) => {
250+
const isRepoCollapsed = () => collapsedRepos[repoGroup.repoFullName];
251+
return (
252+
<div class="bg-white dark:bg-gray-900">
253+
<button
254+
onClick={() => toggleRepo(repoGroup.repoFullName)}
255+
aria-expanded={!isRepoCollapsed()}
256+
class="w-full flex items-center gap-2 px-4 py-2 text-left text-sm font-semibold text-gray-700 dark:text-gray-200 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
257+
>
258+
<ChevronIcon size="md" rotated={isRepoCollapsed()} />
259+
{repoGroup.repoFullName}
260+
</button>
261+
<Show when={!isRepoCollapsed()}>
262+
<div role="list" class="divide-y divide-gray-200 dark:divide-gray-700">
263+
<For each={repoGroup.items}>
264+
{(issue) => (
265+
<div role="listitem">
266+
<ItemRow
267+
hideRepo={true}
268+
repo={issue.repoFullName}
269+
number={issue.number}
270+
title={issue.title}
271+
author={issue.userLogin}
272+
createdAt={issue.createdAt}
273+
url={issue.htmlUrl}
274+
labels={issue.labels}
275+
onIgnore={() => handleIgnore(issue)}
276+
density={config.viewDensity}
277+
commentCount={issue.comments}
278+
>
279+
<RoleBadge roles={issueMeta().get(issue.id)?.roles ?? []} />
280+
</ItemRow>
281+
</div>
282+
)}
283+
</For>
284+
</div>
285+
</Show>
286+
</div>
287+
);
288+
}}
260289
</For>
261290
</div>
262291
</Show>

src/app/components/dashboard/ItemRow.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface ItemRowProps {
1414
onIgnore: () => void;
1515
density: "compact" | "comfortable";
1616
commentCount?: number;
17+
hideRepo?: boolean;
1718
}
1819

1920
export default function ItemRow(props: ItemRowProps) {
@@ -43,14 +44,16 @@ export default function ItemRow(props: ItemRowProps) {
4344
${isCompact() ? "px-4 py-2" : "px-4 py-3"}`}
4445
>
4546
{/* Repo badge */}
46-
<span
47-
class={`shrink-0 inline-flex items-center rounded-full font-mono font-medium
48-
bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200
49-
${isCompact() ? "text-xs px-2 py-0.5" : "text-xs px-2.5 py-1"}`}
50-
title={props.repo}
51-
>
52-
{props.repo}
53-
</span>
47+
<Show when={!props.hideRepo}>
48+
<span
49+
class={`shrink-0 inline-flex items-center rounded-full font-mono font-medium
50+
bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-200
51+
${isCompact() ? "text-xs px-2 py-0.5" : "text-xs px-2.5 py-1"}`}
52+
title={props.repo}
53+
>
54+
{props.repo}
55+
</span>
56+
</Show>
5457

5558
{/* Main content */}
5659
<div class="flex-1 min-w-0">

0 commit comments

Comments
 (0)