Skip to content

Commit 9c84fa1

Browse files
authored
feat(filters): compact filter popovers with global sort (#53)
* refactor(filters): extracts FilterChipGroupDef and scopeFilterGroup to filterTypes.ts * feat(filters): adds ScopeToggle component for scope filter * feat(filters): adds FilterPopover component with Kobalte Popover * feat(filters): adds FilterToolbar composite component with tests * refactor(filters): migrates IssuesTab from FilterChips to FilterToolbar * refactor(filters): migrates PullRequestsTab to FilterToolbar * refactor(filters): migrates ActionsTab from FilterChips to FilterToolbar * chore(filters): removes dead resetTabFilter and tabFilterDefaults * fix(filters): review findings — a11y, memos, defensive defaults * fix(tests): updates stale FilterChips reference in ActionsTab test * refactor(filters): converts value() to createMemo in FilterPopover * fix(tests): updates stale chip references in test descriptions * style(filters): adds font-medium to selected popover options * fix: updates stale chip comments in tab source files * feat(filters): adds Tooltip to ScopeToggle and Reset all button * fix(filters): addresses remaining review findings * refactor(sort): makes sort preference global instead of per-tab * fix(filters): addresses PR review findings - removes orphaned sortPreferences schema field, SortPreference type, and _tabId parameter - adds globalSort to resetViewState, hoists globalSortOptions to module scope - replaces invalid role=listbox/option ARIA with aria-pressed on filter buttons - makes FilterBar sort props optional with Show guard - removes Tooltip wrapper from ScopeToggle, changes Reset all to btn-xs - inlines isValidUserFilter using local tabFilter snapshot - removes redundant sortPref createMemo passthrough in both tabs - adds tests for user-filter auto-reset, globalSort defaults, migration, scope-only active filter, stale value fallback, FilterBar sort rendering
1 parent 142e8f3 commit 9c84fa1

26 files changed

+921
-618
lines changed

src/app/components/dashboard/ActionsTab.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { createEffect, createMemo, For, Show } from "solid-js";
22
import { createStore } from "solid-js/store";
33
import type { WorkflowRun } from "../../services/api";
44
import { config } from "../../stores/config";
5-
import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type ActionsFilterField } from "../../stores/view";
5+
import { viewState, setViewState, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type ActionsFilterField } from "../../stores/view";
66
import WorkflowSummaryCard from "./WorkflowSummaryCard";
77
import IgnoreBadge from "./IgnoreBadge";
88
import SkeletonRows from "../shared/SkeletonRows";
9-
import FilterChips from "../shared/FilterChips";
10-
import type { FilterChipGroupDef } from "../shared/FilterChips";
9+
import type { FilterChipGroupDef } from "../shared/filterTypes";
10+
import FilterToolbar from "../shared/FilterToolbar";
1111
import ChevronIcon from "../shared/ChevronIcon";
1212
import ExpandCollapseButtons from "../shared/ExpandCollapseButtons";
1313
import RepoLockControls from "../shared/RepoLockControls";
@@ -135,6 +135,10 @@ export default function ActionsTab(props: ActionsTabProps) {
135135
[...new Set(props.workflowRuns.map((r) => r.repoFullName))]
136136
);
137137

138+
const ignoredWorkflowRuns = createMemo(() =>
139+
viewState.ignoredItems.filter(i => i.type === "workflowRun")
140+
);
141+
138142
createEffect(() => {
139143
const names = activeRepoNames();
140144
if (names.length === 0) return;
@@ -163,8 +167,7 @@ export default function ActionsTab(props: ActionsTabProps) {
163167
const filteredRuns = createMemo(() => {
164168
const { org, repo } = viewState.globalFilter;
165169
const ignoredIds = new Set(
166-
viewState.ignoredItems
167-
.filter((i) => i.type === "workflowRun")
170+
ignoredWorkflowRuns()
168171
.map((i) => i.id)
169172
);
170173
const conclusionFilter = viewState.tabFilters.actions.conclusion;
@@ -211,7 +214,7 @@ export default function ActionsTab(props: ActionsTabProps) {
211214
const highlightedReposActions = createReorderHighlight(
212215
() => repoGroups().map(g => g.repoFullName),
213216
() => viewState.lockedRepos.actions,
214-
() => viewState.ignoredItems.filter(i => i.type === "workflowRun").length,
217+
() => ignoredWorkflowRuns().length,
215218
() => JSON.stringify(viewState.tabFilters.actions),
216219
);
217220

@@ -229,11 +232,10 @@ export default function ActionsTab(props: ActionsTabProps) {
229232
/>
230233
Show PR runs
231234
</label>
232-
<FilterChips
235+
<FilterToolbar
233236
groups={actionsFilterGroups}
234237
values={viewState.tabFilters.actions}
235-
onChange={(field, value) => setTabFilter("actions", field as ActionsFilterField, value)}
236-
onReset={(field) => resetTabFilter("actions", field as ActionsFilterField)}
238+
onChange={(f, v) => setTabFilter("actions", f as ActionsFilterField, v)}
237239
onResetAll={() => resetAllTabFilters("actions")}
238240
/>
239241
</div>
@@ -243,7 +245,7 @@ export default function ActionsTab(props: ActionsTabProps) {
243245
onCollapseAll={() => setAllExpanded("actions", repoGroups().map((g) => g.repoFullName), false)}
244246
/>
245247
<IgnoreBadge
246-
items={viewState.ignoredItems.filter((i) => i.type === "workflowRun")}
248+
items={ignoredWorkflowRuns()}
247249
onUnignore={unignoreItem}
248250
/>
249251
</div>

src/app/components/dashboard/DashboardPage.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import IssuesTab from "./IssuesTab";
88
import PullRequestsTab from "./PullRequestsTab";
99
import PersonalSummaryStrip from "./PersonalSummaryStrip";
1010
import { config, setConfig, type TrackedUser } from "../../stores/config";
11-
import { viewState, updateViewState } from "../../stores/view";
11+
import { viewState, updateViewState, setSortPreference } from "../../stores/view";
12+
import type { SortOption } from "../shared/SortDropdown";
1213
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
1314
import { fetchOrgs } from "../../services/api";
1415
import {
@@ -27,6 +28,18 @@ import { formatCount } from "../../lib/format";
2728
import { setsEqual } from "../../lib/collections";
2829
import { Tooltip } from "../shared/Tooltip";
2930

31+
const globalSortOptions: SortOption[] = [
32+
{ label: "Repo", field: "repo", type: "text" },
33+
{ label: "Title", field: "title", type: "text" },
34+
{ label: "Author", field: "author", type: "text" },
35+
{ label: "Comments", field: "comments", type: "number" },
36+
{ label: "Checks", field: "checkStatus", type: "text" },
37+
{ label: "Review", field: "reviewDecision", type: "text" },
38+
{ label: "Size", field: "size", type: "number" },
39+
{ label: "Created", field: "createdAt", type: "date" },
40+
{ label: "Updated", field: "updatedAt", type: "date" },
41+
];
42+
3043
// ── Shared dashboard store (module-level to survive navigation) ─────────────
3144

3245
// Bump only for breaking schema changes (renames, type changes). Additive optional
@@ -397,6 +410,10 @@ export default function DashboardPage() {
397410
isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading}
398411
lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt}
399412
onRefresh={() => _coordinator()?.manualRefresh()}
413+
sortOptions={globalSortOptions}
414+
sortValue={viewState.globalSort.field}
415+
sortDirection={viewState.globalSort.direction}
416+
onSortChange={(field, dir) => setSortPreference(field, dir)}
400417
/>
401418
</div>
402419

src/app/components/dashboard/IgnoreBadge.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,21 @@ export default function IgnoreBadge(props: IgnoreBadgeProps) {
4444
return (
4545
<Show when={props.items.length > 0}>
4646
<div class="relative">
47-
<button
48-
onClick={() => setOpen((v) => !v)}
49-
class="badge badge-neutral badge-sm cursor-pointer"
50-
aria-haspopup="true"
51-
aria-expanded={open()}
52-
>
53-
{props.items.length} ignored
54-
</button>
47+
<Tooltip content={`${props.items.length} ignored item${props.items.length === 1 ? "" : "s"}`}>
48+
<button
49+
onClick={() => setOpen((v) => !v)}
50+
class="btn btn-ghost btn-sm relative"
51+
aria-haspopup="true"
52+
aria-expanded={open()}
53+
aria-label={`${props.items.length} ignored items`}
54+
>
55+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
56+
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clip-rule="evenodd" />
57+
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 5.065 7 9.542 7 .847 0 1.669-.105 2.454-.303z" />
58+
</svg>
59+
<span class="badge badge-neutral badge-xs absolute -top-1 -right-1">{props.items.length}</span>
60+
</button>
61+
</Tooltip>
5562

5663
<Show when={open()}>
5764
{/* Backdrop */}

src/app/components/dashboard/IssuesTab.tsx

Lines changed: 24 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
22
import { config, type TrackedUser } from "../../stores/config";
3-
import { viewState, updateViewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
3+
import { viewState, updateViewState, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view";
44
import type { Issue, RepoRef } from "../../services/api";
55
import ItemRow from "./ItemRow";
66
import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge";
77
import IgnoreBadge from "./IgnoreBadge";
8-
import SortDropdown from "../shared/SortDropdown";
9-
import type { SortOption } from "../shared/SortDropdown";
108
import PaginationControls from "../shared/PaginationControls";
11-
import FilterChips, { scopeFilterGroup, type FilterChipGroupDef } from "../shared/FilterChips";
9+
import { scopeFilterGroup, type FilterChipGroupDef } from "../shared/filterTypes";
10+
import FilterToolbar from "../shared/FilterToolbar";
1211
import RoleBadge from "../shared/RoleBadge";
1312
import SkeletonRows from "../shared/SkeletonRows";
1413
import ChevronIcon from "../shared/ChevronIcon";
@@ -51,14 +50,6 @@ const issueFilterGroups: FilterChipGroupDef[] = [
5150
},
5251
];
5352

54-
const sortOptions: SortOption[] = [
55-
{ label: "Repo", field: "repo", type: "text" },
56-
{ label: "Title", field: "title", type: "text" },
57-
{ label: "Author", field: "author", type: "text" },
58-
{ label: "Comments", field: "comments", type: "number" },
59-
{ label: "Created", field: "createdAt", type: "date" },
60-
{ label: "Updated", field: "updatedAt", type: "date" },
61-
];
6253

6354
export default function IssuesTab(props: IssuesTabProps) {
6455
const [page, setPage] = createSignal(0);
@@ -81,6 +72,10 @@ export default function IssuesTab(props: IssuesTabProps) {
8172
(props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1
8273
);
8374

75+
const ignoredIssues = createMemo(() =>
76+
viewState.ignoredItems.filter(i => i.type === "issue")
77+
);
78+
8479
const filterGroups = createMemo<FilterChipGroupDef[]>(() => {
8580
const users = props.allUsers;
8681
const base = showScopeFilter()
@@ -97,27 +92,29 @@ export default function IssuesTab(props: IssuesTabProps) {
9792
];
9893
});
9994

100-
// Auto-reset scope to default when scope chip is hidden (localStorage hygiene)
95+
// Auto-reset scope to default when scope toggle is hidden (localStorage hygiene)
10196
createEffect(() => {
10297
if (!showScopeFilter() && viewState.tabFilters.issues.scope !== "involves_me") {
10398
setTabFilter("issues", "scope", "involves_me");
10499
}
105100
});
106101

102+
// Auto-reset user filter when User filter group is hidden
103+
createEffect(() => {
104+
const users = props.allUsers;
105+
if ((!users || users.length <= 1) && viewState.tabFilters.issues.user !== "all") {
106+
setTabFilter("issues", "user", "all");
107+
}
108+
});
109+
107110
const isInvolvedItem = (item: Issue) =>
108111
isUserInvolved(item, userLoginLower(), monitoredRepoNameSet());
109112

110-
const sortPref = createMemo(() => {
111-
const pref = viewState.sortPreferences["issues"];
112-
return pref ?? { field: "updatedAt", direction: "desc" as const };
113-
});
114-
115113
const filteredSortedWithMeta = createMemo(() => {
116114
const filter = viewState.globalFilter;
117115
const tabFilter = viewState.tabFilters.issues;
118116
const ignored = new Set(
119-
viewState.ignoredItems
120-
.filter((i) => i.type === "issue")
117+
ignoredIssues()
121118
.map((i) => i.id)
122119
);
123120

@@ -160,7 +157,7 @@ export default function IssuesTab(props: IssuesTabProps) {
160157
return true;
161158
});
162159

163-
const { field, direction } = sortPref();
160+
const { field, direction } = viewState.globalSort;
164161
items = [...items].sort((a, b) => {
165162
let cmp = 0;
166163
switch (field as SortField) {
@@ -226,15 +223,10 @@ export default function IssuesTab(props: IssuesTabProps) {
226223
const highlightedReposIssues = createReorderHighlight(
227224
() => repoGroups().map(g => g.repoFullName),
228225
() => viewState.lockedRepos.issues,
229-
() => viewState.ignoredItems.filter(i => i.type === "issue").length,
226+
() => ignoredIssues().length,
230227
() => JSON.stringify(viewState.tabFilters.issues),
231228
);
232229

233-
function handleSort(field: string, direction: "asc" | "desc") {
234-
setSortPreference("issues", field, direction);
235-
setPage(0);
236-
}
237-
238230
function handleIgnore(issue: Issue) {
239231
ignoreItem({
240232
id: String(issue.id),
@@ -247,24 +239,14 @@ export default function IssuesTab(props: IssuesTabProps) {
247239

248240
return (
249241
<div class="flex flex-col h-full">
250-
{/* Sort dropdown + filter chips + ignore badge toolbar */}
242+
{/* Filter chips + ignore badge toolbar */}
251243
<div class="flex items-start gap-3 px-4 py-2 border-b border-base-300 bg-base-100">
252244
<div class="flex flex-wrap items-center gap-3 min-w-0 flex-1">
253-
<SortDropdown
254-
options={sortOptions}
255-
value={sortPref().field}
256-
direction={sortPref().direction}
257-
onChange={handleSort}
258-
/>
259-
<FilterChips
245+
<FilterToolbar
260246
groups={filterGroups()}
261247
values={viewState.tabFilters.issues}
262-
onChange={(field, value) => {
263-
setTabFilter("issues", field as IssueFilterField, value);
264-
setPage(0);
265-
}}
266-
onReset={(field) => {
267-
resetTabFilter("issues", field as IssueFilterField);
248+
onChange={(f, v) => {
249+
setTabFilter("issues", f as IssueFilterField, v);
268250
setPage(0);
269251
}}
270252
onResetAll={() => {
@@ -291,7 +273,7 @@ export default function IssuesTab(props: IssuesTabProps) {
291273
onCollapseAll={() => setAllExpanded("issues", repoGroups().map((g) => g.repoFullName), false)}
292274
/>
293275
<IgnoreBadge
294-
items={viewState.ignoredItems.filter((i) => i.type === "issue")}
276+
items={ignoredIssues()}
295277
onUnignore={unignoreItem}
296278
/>
297279
</div>

src/app/components/dashboard/PersonalSummaryStrip.tsx

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { createMemo, For, Show } from "solid-js";
2+
import { produce } from "solid-js/store";
23
import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
34
import type { TabId } from "../layout/TabBar";
4-
import { viewState, updateViewState, resetAllTabFilters, setTabFilter } from "../../stores/view";
5+
import { viewState, setViewState, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view";
56
import { InfoTooltip } from "../shared/Tooltip";
67

78
interface SummaryCount {
@@ -104,55 +105,50 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) {
104105
count: assignedIssues,
105106
tab: "issues",
106107
applyFilters: () => {
107-
resetAllTabFilters("issues");
108-
setTabFilter("issues", "scope", "all");
109-
setTabFilter("issues", "role", "assignee");
108+
setViewState(produce(draft => {
109+
draft.tabFilters.issues = { ...IssueFiltersSchema.parse({}), scope: "all", role: "assignee" };
110+
}));
110111
},
111112
});
112113
if (prsAwaitingReview > 0) items.push({
113114
label: prsAwaitingReview === 1 ? "PR awaiting review" : "PRs awaiting review",
114115
count: prsAwaitingReview,
115116
tab: "pullRequests",
116117
applyFilters: () => {
117-
resetAllTabFilters("pullRequests");
118-
setTabFilter("pullRequests", "scope", "all");
119-
setTabFilter("pullRequests", "role", "reviewer");
120-
setTabFilter("pullRequests", "reviewDecision", "REVIEW_REQUIRED");
118+
setViewState(produce(draft => {
119+
draft.tabFilters.pullRequests = { ...PullRequestFiltersSchema.parse({}), scope: "all", role: "reviewer", reviewDecision: "REVIEW_REQUIRED" };
120+
}));
121121
},
122122
});
123123
if (prsReadyToMerge > 0) items.push({
124124
label: prsReadyToMerge === 1 ? "PR ready to merge" : "PRs ready to merge",
125125
count: prsReadyToMerge,
126126
tab: "pullRequests",
127127
applyFilters: () => {
128-
resetAllTabFilters("pullRequests");
129-
setTabFilter("pullRequests", "scope", "all");
130-
setTabFilter("pullRequests", "role", "author");
131-
setTabFilter("pullRequests", "draft", "ready");
132-
setTabFilter("pullRequests", "checkStatus", "success");
133-
setTabFilter("pullRequests", "reviewDecision", "mergeable");
128+
setViewState(produce(draft => {
129+
draft.tabFilters.pullRequests = { ...PullRequestFiltersSchema.parse({}), scope: "all", role: "author", draft: "ready", checkStatus: "success", reviewDecision: "mergeable" };
130+
}));
134131
},
135132
});
136133
if (prsBlocked > 0) items.push({
137134
label: prsBlocked === 1 ? "PR blocked" : "PRs blocked",
138135
count: prsBlocked,
139136
tab: "pullRequests",
140137
applyFilters: () => {
141-
resetAllTabFilters("pullRequests");
142-
setTabFilter("pullRequests", "scope", "all");
143-
setTabFilter("pullRequests", "role", "author");
144-
setTabFilter("pullRequests", "draft", "ready");
145-
setTabFilter("pullRequests", "checkStatus", "blocked");
138+
setViewState(produce(draft => {
139+
draft.tabFilters.pullRequests = { ...PullRequestFiltersSchema.parse({}), scope: "all", role: "author", draft: "ready", checkStatus: "blocked" };
140+
}));
146141
},
147142
});
148143
if (running > 0) items.push({
149144
label: running === 1 ? "action running" : "actions running",
150145
count: running,
151146
tab: "actions",
152147
applyFilters: () => {
153-
resetAllTabFilters("actions");
154-
setTabFilter("actions", "conclusion", "running");
155-
updateViewState({ showPrRuns: true });
148+
setViewState(produce(draft => {
149+
draft.tabFilters.actions = { ...ActionsFiltersSchema.parse({}), conclusion: "running" };
150+
draft.showPrRuns = true;
151+
}));
156152
},
157153
});
158154
return items;

0 commit comments

Comments
 (0)