From 08322e1eb3151feb754896fc625099ecbd89e2c1 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:23:24 -0400 Subject: [PATCH 01/18] refactor(filters): extracts FilterChipGroupDef and scopeFilterGroup to filterTypes.ts --- src/app/components/shared/FilterChips.tsx | 18 ++---------------- src/app/components/shared/filterTypes.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 16 deletions(-) create mode 100644 src/app/components/shared/filterTypes.ts diff --git a/src/app/components/shared/FilterChips.tsx b/src/app/components/shared/FilterChips.tsx index 9d499df1..749fdf1b 100644 --- a/src/app/components/shared/FilterChips.tsx +++ b/src/app/components/shared/FilterChips.tsx @@ -1,21 +1,7 @@ import { For, Show } from "solid-js"; -export interface FilterChipGroupDef { - label: string; - field: string; - options: { value: string; label: string }[]; - defaultValue?: string; // When set, replaces "all" as the "no filter active" value -} - -export const scopeFilterGroup: FilterChipGroupDef = { - label: "Scope", - field: "scope", - defaultValue: "involves_me", - options: [ - { value: "involves_me", label: "Involves me" }, - { value: "all", label: "All activity" }, - ], -}; +import { scopeFilterGroup, type FilterChipGroupDef } from "./filterTypes"; +export { scopeFilterGroup, type FilterChipGroupDef }; interface FilterChipsProps { groups: FilterChipGroupDef[]; diff --git a/src/app/components/shared/filterTypes.ts b/src/app/components/shared/filterTypes.ts new file mode 100644 index 00000000..d3a8df8b --- /dev/null +++ b/src/app/components/shared/filterTypes.ts @@ -0,0 +1,16 @@ +export interface FilterChipGroupDef { + label: string; + field: string; + options: { value: string; label: string }[]; + defaultValue?: string; // When set, replaces "all" as the "no filter active" value +} + +export const scopeFilterGroup: FilterChipGroupDef = { + label: "Scope", + field: "scope", + defaultValue: "involves_me", + options: [ + { value: "involves_me", label: "Involves me" }, + { value: "all", label: "All activity" }, + ], +}; From 4816e6dc894245223eb86b0504c5e47d23aeb16c Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:24:12 -0400 Subject: [PATCH 02/18] feat(filters): adds ScopeToggle component for scope filter --- src/app/components/shared/ScopeToggle.tsx | 25 ++++++++++ tests/components/shared/ScopeToggle.test.tsx | 49 ++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 src/app/components/shared/ScopeToggle.tsx create mode 100644 tests/components/shared/ScopeToggle.test.tsx diff --git a/src/app/components/shared/ScopeToggle.tsx b/src/app/components/shared/ScopeToggle.tsx new file mode 100644 index 00000000..cb76e245 --- /dev/null +++ b/src/app/components/shared/ScopeToggle.tsx @@ -0,0 +1,25 @@ +interface ScopeToggleProps { + value: string; + onChange: (field: string, value: string) => void; +} + +export default function ScopeToggle(props: ScopeToggleProps) { + const checked = () => props.value === "involves_me"; + + return ( +
+ + props.onChange("scope", e.currentTarget.checked ? "involves_me" : "all") + } + /> + + {checked() ? "Involves me" : "All activity"} + +
+ ); +} diff --git a/tests/components/shared/ScopeToggle.test.tsx b/tests/components/shared/ScopeToggle.test.tsx new file mode 100644 index 00000000..ecb48581 --- /dev/null +++ b/tests/components/shared/ScopeToggle.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import ScopeToggle from "../../../src/app/components/shared/ScopeToggle"; + +describe("ScopeToggle", () => { + it("renders checkbox checked when value is 'involves_me'", () => { + render(() => {}} />); + const checkbox = screen.getByRole("checkbox", { name: /Scope filter/i }); + expect((checkbox as HTMLInputElement).checked).toBe(true); + }); + + it("renders checkbox unchecked when value is 'all'", () => { + render(() => {}} />); + const checkbox = screen.getByRole("checkbox", { name: /Scope filter/i }); + expect((checkbox as HTMLInputElement).checked).toBe(false); + }); + + it("shows 'Involves me' text when checked", () => { + render(() => {}} />); + screen.getByText("Involves me"); + }); + + it("shows 'All activity' text when unchecked", () => { + render(() => {}} />); + screen.getByText("All activity"); + }); + + it("toggling calls onChange('scope', 'all') when unchecking", () => { + const onChange = vi.fn(); + render(() => ); + const checkbox = screen.getByRole("checkbox", { name: /Scope filter/i }); + fireEvent.click(checkbox); + expect(onChange).toHaveBeenCalledWith("scope", "all"); + }); + + it("toggling calls onChange('scope', 'involves_me') when checking", () => { + const onChange = vi.fn(); + render(() => ); + const checkbox = screen.getByRole("checkbox", { name: /Scope filter/i }); + fireEvent.click(checkbox); + expect(onChange).toHaveBeenCalledWith("scope", "involves_me"); + }); + + it("has aria-label='Scope filter' on checkbox", () => { + render(() => {}} />); + const checkbox = screen.getByRole("checkbox", { name: "Scope filter" }); + expect(checkbox).toBeDefined(); + }); +}); From 8c6a4e43c3b620f32d931dea08ad019d9c3281f2 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:29:18 -0400 Subject: [PATCH 03/18] feat(filters): adds FilterPopover component with Kobalte Popover --- src/app/components/shared/FilterPopover.tsx | 79 ++++++++++ src/app/index.css | 17 +++ .../components/shared/FilterPopover.test.tsx | 140 ++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 src/app/components/shared/FilterPopover.tsx create mode 100644 tests/components/shared/FilterPopover.test.tsx diff --git a/src/app/components/shared/FilterPopover.tsx b/src/app/components/shared/FilterPopover.tsx new file mode 100644 index 00000000..1c1d1882 --- /dev/null +++ b/src/app/components/shared/FilterPopover.tsx @@ -0,0 +1,79 @@ +import { createSignal, For, Show } from "solid-js"; +import { Popover } from "@kobalte/core/popover"; +import type { FilterChipGroupDef } from "./filterTypes"; + +interface FilterPopoverProps { + group: FilterChipGroupDef; + value: string; + onChange: (field: string, value: string) => void; +} + +export default function FilterPopover(props: FilterPopoverProps) { + const [open, setOpen] = createSignal(false); + + const isDefault = () => + props.value === (props.group.defaultValue ?? "all"); + + const activeLabel = () => { + const opt = props.group.options.find((o) => o.value === props.value); + if (opt) return opt.label; + return props.value === "all" ? "All" : props.value; + }; + + return ( + + + {props.group.label}: {activeLabel()}}> + {props.group.label} + + + + + + + + + + {(opt) => ( + + )} + + + + + ); +} diff --git a/src/app/index.css b/src/app/index.css index 6b6f5d8d..af2903f3 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -107,6 +107,22 @@ animation: overlay-fade-in 0.3s ease-out forwards; } +/* Kobalte Popover animations */ +@keyframes popover-fade-in { + from { opacity: 0; transform: scale(0.95); } + to { opacity: 1; transform: scale(1); } +} +@keyframes popover-fade-out { + from { opacity: 1; transform: scale(1); } + to { opacity: 0; transform: scale(0.95); } +} +.filter-popover-content[data-expanded] { + animation: popover-fade-in 0.15s ease-out forwards; +} +.filter-popover-content[data-closed] { + animation: popover-fade-out 0.15s ease-in forwards; +} + /* ── Fixed-element scrollbar compensation ────────────────────────────────── */ /* solid-prevent-scroll sets --scrollbar-width on body when scroll is locked; fixed elements don't inherit body padding-right, so compensate explicitly. @@ -142,6 +158,7 @@ .drawer-content[data-expanded], .drawer-content[data-closed], .drawer-overlay[data-expanded], .drawer-overlay[data-closed], .animate-shimmer, .animate-flash, .animate-reorder-highlight, + .filter-popover-content[data-expanded], .filter-popover-content[data-closed], .loading { animation: none; } diff --git a/tests/components/shared/FilterPopover.test.tsx b/tests/components/shared/FilterPopover.test.tsx new file mode 100644 index 00000000..2ad2eecc --- /dev/null +++ b/tests/components/shared/FilterPopover.test.tsx @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import FilterPopover from "../../../src/app/components/shared/FilterPopover"; +import type { FilterChipGroupDef } from "../../../src/app/components/shared/filterTypes"; + +const reviewGroup: FilterChipGroupDef = { + label: "Review", + field: "reviewDecision", + options: [ + { value: "APPROVED", label: "Approved" }, + { value: "CHANGES_REQUESTED", label: "Changes requested" }, + ], +}; + +const scopeGroup: FilterChipGroupDef = { + label: "Scope", + field: "scope", + defaultValue: "involves_me", + options: [ + { value: "involves_me", label: "Involves me" }, + { value: "all", label: "All activity" }, + ], +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("FilterPopover", () => { + it("renders trigger with group label when no active filter", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + expect(trigger.textContent).toContain("Review"); + }); + + it("has btn-ghost class when value is default ('all')", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + expect(trigger.className).toContain("btn-ghost"); + expect(trigger.className).not.toContain("btn-primary"); + }); + + it("has btn-primary class and 'Review: Approved' text when value is 'APPROVED'", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + expect(trigger.className).toContain("btn-primary"); + expect(trigger.textContent).toContain("Review: Approved"); + }); + + it("has aria-label='Filter by Review' on trigger", () => { + render(() => {}} />); + screen.getByRole("button", { name: "Filter by Review" }); + }); + + it("clicking trigger opens popover", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("popover shows 'All' option plus options when group has no defaultValue", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + // "All" button should appear + const allBtn = screen.getAllByRole("button").find((b) => b.textContent?.includes("All")); + expect(allBtn).toBeDefined(); + screen.getByText("Approved"); + screen.getByText("Changes requested"); + }); + + it("popover shows only options when group has defaultValue (no extra 'All')", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Scope/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + // Should not have a standalone "All" button (scopeGroup has defaultValue) + const allBtn = screen.queryAllByRole("button").filter( + (b) => b !== trigger && (b.textContent?.trim() === "All" || b.textContent?.trim() === "✓ All") + ); + expect(allBtn.length).toBe(0); + // Options from scopeGroup should be visible (either inline or in portal) + const hasInvolves = screen.queryAllByText(/Involves me/i).length > 0 + || document.body.textContent?.includes("Involves me"); + const hasAllActivity = screen.queryAllByText(/All activity/i).length > 0 + || document.body.textContent?.includes("All activity"); + expect(hasInvolves).toBe(true); + expect(hasAllActivity).toBe(true); + }); + + it("selected option shows ✓ prefix", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + const buttons = screen.getAllByRole("button"); + const approvedBtn = buttons.find((b) => b.textContent?.includes("Approved") && b !== trigger); + expect(approvedBtn?.textContent).toContain("✓"); + }); + + it("clicking option calls onChange and closes popover", () => { + const onChange = vi.fn(); + render(() => ); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + const approvedBtn = screen.getByText("Approved"); + fireEvent.click(approvedBtn); + expect(onChange).toHaveBeenCalledWith("reviewDecision", "APPROVED"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("selecting 'All' calls onChange(field, 'all')", () => { + const onChange = vi.fn(); + render(() => ); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + const buttons = screen.getAllByRole("button"); + const allBtn = buttons.find((b) => b.textContent?.includes("All") && b !== trigger); + fireEvent.click(allBtn!); + expect(onChange).toHaveBeenCalledWith("reviewDecision", "all"); + }); + + it("Escape closes popover", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + fireEvent.keyDown(document, { key: "Escape" }); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); +}); From 27c2bb76ac9535dad1dff43d8d1cb09e0fd69d65 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:30:19 -0400 Subject: [PATCH 04/18] feat(filters): adds FilterToolbar composite component with tests --- src/app/components/shared/FilterToolbar.tsx | 56 ++++++++ .../components/shared/FilterToolbar.test.tsx | 134 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 src/app/components/shared/FilterToolbar.tsx create mode 100644 tests/components/shared/FilterToolbar.test.tsx diff --git a/src/app/components/shared/FilterToolbar.tsx b/src/app/components/shared/FilterToolbar.tsx new file mode 100644 index 00000000..5883fd2f --- /dev/null +++ b/src/app/components/shared/FilterToolbar.tsx @@ -0,0 +1,56 @@ +import { createMemo, For, Show } from "solid-js"; +import FilterPopover from "./FilterPopover"; +import ScopeToggle from "./ScopeToggle"; +import type { FilterChipGroupDef } from "./filterTypes"; + +interface FilterToolbarProps { + groups: FilterChipGroupDef[]; + values: Record; + onChange: (field: string, value: string) => void; + onResetAll: () => void; +} + +export default function FilterToolbar(props: FilterToolbarProps) { + const showScope = createMemo(() => props.groups.some((g) => g.field === "scope")); + + const popoverGroups = createMemo(() => + showScope() ? props.groups.filter((g) => g.field !== "scope") : props.groups + ); + + const hasActiveFilter = createMemo(() => + props.groups.some((g) => { + const val = props.values[g.field]; + return val !== undefined && val !== (g.defaultValue ?? "all"); + }) + ); + + return ( +
+ + +
+ + + {(group) => ( + + )} + + + + +
+ ); +} diff --git a/tests/components/shared/FilterToolbar.test.tsx b/tests/components/shared/FilterToolbar.test.tsx new file mode 100644 index 00000000..3a133b7f --- /dev/null +++ b/tests/components/shared/FilterToolbar.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@solidjs/testing-library"; +import FilterToolbar from "../../../src/app/components/shared/FilterToolbar"; +import type { FilterChipGroupDef } from "../../../src/app/components/shared/filterTypes"; +import { scopeFilterGroup } from "../../../src/app/components/shared/filterTypes"; + +const roleGroup: FilterChipGroupDef = { + label: "Role", + field: "role", + options: [ + { value: "author", label: "Author" }, + { value: "assignee", label: "Assignee" }, + ], +}; + +const reviewGroup: FilterChipGroupDef = { + label: "Review", + field: "reviewDecision", + options: [ + { value: "APPROVED", label: "Approved" }, + { value: "CHANGES_REQUESTED", label: "Changes requested" }, + ], +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("FilterToolbar", () => { + it("does not show ScopeToggle when no scope group", () => { + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + expect(screen.queryByRole("checkbox", { name: /Scope filter/i })).toBeNull(); + }); + + it("shows ScopeToggle when scope group is present", () => { + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + expect(screen.getByRole("checkbox", { name: /Scope filter/i })).toBeDefined(); + }); + + it("renders trigger button for each non-scope group", () => { + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + screen.getByRole("button", { name: /Filter by Role/i }); + screen.getByRole("button", { name: /Filter by Review/i }); + }); + + it("scope group is not rendered as a popover trigger", () => { + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + expect(screen.queryByRole("button", { name: /Filter by Scope/i })).toBeNull(); + screen.getByRole("button", { name: /Filter by Role/i }); + }); + + it("does not show 'Reset all' when no active filters", () => { + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + expect(screen.queryByText("Reset all")).toBeNull(); + }); + + it("shows 'Reset all' when a filter is active", () => { + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + screen.getByText("Reset all"); + }); + + it("calls onResetAll when 'Reset all' is clicked", () => { + const onResetAll = vi.fn(); + render(() => ( + {}} + onResetAll={onResetAll} + /> + )); + fireEvent.click(screen.getByText("Reset all")); + expect(onResetAll).toHaveBeenCalled(); + }); + + it("scope toggle defaults to 'involves_me' when value not set", () => { + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + const checkbox = screen.getByRole("checkbox", { name: /Scope filter/i }); + expect((checkbox as HTMLInputElement).checked).toBe(true); + }); +}); From fd9475ccc7d38bd2f5e155a858b02bdd7a957298 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:31:47 -0400 Subject: [PATCH 05/18] refactor(filters): migrates IssuesTab from FilterChips to FilterToolbar --- src/app/components/dashboard/IssuesTab.tsx | 15 ++++++-------- tests/components/IssuesTab.test.tsx | 4 ++-- tests/components/dashboard/IssuesTab.test.tsx | 20 +++++++++---------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 77de0030..434ba3a9 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, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view"; +import { viewState, updateViewState, setSortPreference, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view"; import type { Issue, RepoRef } from "../../services/api"; import ItemRow from "./ItemRow"; import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge"; @@ -8,7 +8,8 @@ import IgnoreBadge from "./IgnoreBadge"; import SortDropdown from "../shared/SortDropdown"; import type { SortOption } from "../shared/SortDropdown"; import PaginationControls from "../shared/PaginationControls"; -import FilterChips, { scopeFilterGroup, type FilterChipGroupDef } from "../shared/FilterChips"; +import { scopeFilterGroup, type FilterChipGroupDef } from "../shared/filterTypes"; +import FilterToolbar from "../shared/FilterToolbar"; import RoleBadge from "../shared/RoleBadge"; import SkeletonRows from "../shared/SkeletonRows"; import ChevronIcon from "../shared/ChevronIcon"; @@ -256,15 +257,11 @@ export default function IssuesTab(props: IssuesTabProps) { direction={sortPref().direction} onChange={handleSort} /> - { - setTabFilter("issues", field as IssueFilterField, value); - setPage(0); - }} - onReset={(field) => { - resetTabFilter("issues", field as IssueFilterField); + onChange={(f, v) => { + setTabFilter("issues", f as IssueFilterField, v); setPage(0); }} onResetAll={() => { diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 079fcf6d..e524fd62 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -382,7 +382,7 @@ describe("IssuesTab", () => { expect(screen.queryByText("org/repo-b")).toBeNull(); // Clear filter — repo-b reappears, repo-a stays expanded - viewStore.resetTabFilter("issues", "role"); + viewStore.setTabFilter("issues", "role", "all"); screen.getByText("org/repo-a"); screen.getByText("org/repo-b"); screen.getByText("Alice issue"); @@ -411,7 +411,7 @@ describe("IssuesTab", () => { expect(screen.queryByText("Alice issue")).toBeNull(); // Remove filter — repo-b should still be expanded (was hidden during collapse-all) - viewStore.resetTabFilter("issues", "role"); + viewStore.setTabFilter("issues", "role", "all"); screen.getByText("Bob issue"); // repo-a was collapsed by collapse-all expect(screen.queryByText("Alice issue")).toBeNull(); diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index e5d67c85..e80a0d98 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -51,8 +51,8 @@ describe("IssuesTab — user filter chip", () => { allUsers={[{ login: "me", label: "Me" }]} /> )); - // FilterChips renders "User:" label — absent when only 1 user - expect(screen.queryByText("User:")).toBeNull(); + // FilterToolbar renders a popover trigger — absent when only 1 user + expect(screen.queryByLabelText("Filter by User")).toBeNull(); }); it("shows User filter chip when allUsers has > 1 entry", () => { @@ -66,14 +66,14 @@ describe("IssuesTab — user filter chip", () => { ]} /> )); - screen.getByText("User:"); + screen.getByLabelText("Filter by User"); }); it("does not show User filter chip when allUsers is undefined", () => { render(() => ( )); - expect(screen.queryByText("User:")).toBeNull(); + expect(screen.queryByLabelText("Filter by User")).toBeNull(); }); }); @@ -552,33 +552,33 @@ describe("IssuesTab — scope chip visibility", () => { it("does not show Scope chip when no monitored repos and no tracked users", () => { const issues = [makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"] })]; - const { container } = render(() => ( + render(() => ( )); - expect(container.textContent).not.toContain("Scope:"); + expect(screen.queryByRole("checkbox", { name: /Scope filter/i })).toBeNull(); }); it("shows Scope chip when monitored repos exist", () => { const issues = [makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"] })]; - const { container } = render(() => ( + render(() => ( )); - expect(container.textContent).toContain("Scope:"); + expect(screen.queryByRole("checkbox", { name: /Scope filter/i })).not.toBeNull(); }); it("shows Scope chip when tracked users exist (allUsers > 1)", () => { const issues = [makeIssue({ id: 1, title: "Issue", repoFullName: "org/repo", surfacedBy: ["me"] })]; - const { container } = render(() => ( + render(() => ( )); - expect(container.textContent).toContain("Scope:"); + expect(screen.queryByRole("checkbox", { name: /Scope filter/i })).not.toBeNull(); }); it("auto-resets scope to involves_me when scope chip becomes hidden", () => { From 1f2b84fdf8bc3044874bac1845b9e4de6fc0e590 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:32:53 -0400 Subject: [PATCH 06/18] refactor(filters): migrates PullRequestsTab to FilterToolbar --- .../components/dashboard/PullRequestsTab.tsx | 15 +++++------- .../dashboard/PullRequestsTab.test.tsx | 24 ++++++++++++++----- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index d7678ff7..9604bffc 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { config, type TrackedUser } from "../../stores/config"; -import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type PullRequestFilterField } from "../../stores/view"; +import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type PullRequestFilterField } from "../../stores/view"; import type { PullRequest, RepoRef } from "../../services/api"; import { deriveInvolvementRoles, prSizeCategory, formatStarCount } from "../../lib/format"; import { isSafeGitHubUrl } from "../../lib/url"; @@ -12,7 +12,8 @@ import IgnoreBadge from "./IgnoreBadge"; import SortDropdown from "../shared/SortDropdown"; import type { SortOption } from "../shared/SortDropdown"; import PaginationControls from "../shared/PaginationControls"; -import FilterChips, { scopeFilterGroup, type FilterChipGroupDef } from "../shared/FilterChips"; +import { scopeFilterGroup, type FilterChipGroupDef } from "../shared/filterTypes"; +import FilterToolbar from "../shared/FilterToolbar"; import ReviewBadge from "../shared/ReviewBadge"; import SizeBadge from "../shared/SizeBadge"; import RoleBadge from "../shared/RoleBadge"; @@ -364,15 +365,11 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { direction={sortPref().direction} onChange={handleSort} /> - { - setTabFilter("pullRequests", field as PullRequestFilterField, value); - setPage(0); - }} - onReset={(field) => { - resetTabFilter("pullRequests", field as PullRequestFilterField); + onChange={(f, v) => { + setTabFilter("pullRequests", f as PullRequestFilterField, v); setPage(0); }} onResetAll={() => { diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index c7243437..3c0d6be4 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -51,7 +51,7 @@ describe("PullRequestsTab — user filter chip", () => { allUsers={[{ login: "me", label: "Me" }]} /> )); - expect(screen.queryByText("User:")).toBeNull(); + expect(screen.queryByLabelText("Filter by User")).toBeNull(); }); it("shows User filter chip when allUsers has > 1 entry", () => { @@ -65,7 +65,7 @@ describe("PullRequestsTab — user filter chip", () => { ]} /> )); - screen.getByText("User:"); + screen.getByLabelText("Filter by User"); }); }); @@ -480,23 +480,35 @@ describe("PullRequestsTab — scope chip visibility", () => { it("does not show Scope chip when no monitored repos and no tracked users", () => { const prs = [makePullRequest({ id: 1, title: "PR", repoFullName: "org/repo", surfacedBy: ["me"] })]; - const { container } = render(() => ( + render(() => ( )); - expect(container.textContent).not.toContain("Scope:"); + expect(screen.queryByRole("checkbox", { name: /Scope filter/i })).toBeNull(); }); it("shows Scope chip when monitored repos exist", () => { const prs = [makePullRequest({ id: 1, title: "PR", repoFullName: "org/repo", surfacedBy: ["me"] })]; - const { container } = render(() => ( + render(() => ( )); - expect(container.textContent).toContain("Scope:"); + expect(screen.queryByRole("checkbox", { name: /Scope filter/i })).not.toBeNull(); + }); + + it("shows Scope toggle when allUsers > 1", () => { + const prs = [makePullRequest({ id: 1, title: "PR", repoFullName: "org/repo", surfacedBy: ["me"] })]; + + render(() => ( + + )); + + expect(screen.queryByRole("checkbox", { name: /Scope filter/i })).not.toBeNull(); }); it("auto-resets scope to involves_me when scope chip becomes hidden", () => { From abf75b21fa9c39ef01e0b687ff8f2cf72d3bd11e Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:33:39 -0400 Subject: [PATCH 07/18] refactor(filters): migrates ActionsTab from FilterChips to FilterToolbar --- src/app/components/dashboard/ActionsTab.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 99925d42..29d7fe96 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -2,12 +2,12 @@ import { createEffect, createMemo, For, Show } from "solid-js"; import { createStore } from "solid-js/store"; import type { WorkflowRun } from "../../services/api"; import { config } from "../../stores/config"; -import { viewState, setViewState, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type ActionsFilterField } from "../../stores/view"; +import { viewState, setViewState, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type ActionsFilterField } from "../../stores/view"; import WorkflowSummaryCard from "./WorkflowSummaryCard"; import IgnoreBadge from "./IgnoreBadge"; import SkeletonRows from "../shared/SkeletonRows"; -import FilterChips from "../shared/FilterChips"; -import type { FilterChipGroupDef } from "../shared/FilterChips"; +import type { FilterChipGroupDef } from "../shared/filterTypes"; +import FilterToolbar from "../shared/FilterToolbar"; import ChevronIcon from "../shared/ChevronIcon"; import ExpandCollapseButtons from "../shared/ExpandCollapseButtons"; import RepoLockControls from "../shared/RepoLockControls"; @@ -229,11 +229,10 @@ export default function ActionsTab(props: ActionsTabProps) { /> Show PR runs - setTabFilter("actions", field as ActionsFilterField, value)} - onReset={(field) => resetTabFilter("actions", field as ActionsFilterField)} + onChange={(f, v) => setTabFilter("actions", f as ActionsFilterField, v)} onResetAll={() => resetAllTabFilters("actions")} />
From d0259ca0b10d920fcb1de97a7e8cf36481ae7860 Mon Sep 17 00:00:00 2001 From: testvalue Date: Mon, 6 Apr 2026 22:34:27 -0400 Subject: [PATCH 08/18] chore(filters): removes dead resetTabFilter and tabFilterDefaults --- src/app/stores/view.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/app/stores/view.ts b/src/app/stores/view.ts index 8e5406ca..e18894f5 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -220,24 +220,6 @@ export function setTabFilter( ); } -const tabFilterDefaults: Record> = { - issues: IssueFiltersSchema.parse({}) as Record, - pullRequests: PullRequestFiltersSchema.parse({}) as Record, - actions: ActionsFiltersSchema.parse({}) as Record, -}; - -export function resetTabFilter( - tab: T, - field: TabFilterField[T] -): void { - const defaultValue = tabFilterDefaults[tab]?.[field as string] ?? "all"; - setViewState( - produce((draft) => { - (draft.tabFilters[tab] as Record)[field as string] = defaultValue; - }) - ); -} - export function resetAllTabFilters( tab: "issues" | "pullRequests" | "actions" ): void { From 3ec475387f33dd40ee88c1d8b6b1aa3e0db41f2c Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 7 Apr 2026 08:08:13 -0400 Subject: [PATCH 09/18] =?UTF-8?q?fix(filters):=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20a11y,=20memos,=20defensive=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/components/shared/FilterPopover.tsx | 27 +++++++++++-------- src/app/components/shared/FilterToolbar.tsx | 5 ++-- src/app/components/shared/ScopeToggle.tsx | 4 +-- .../components/shared/FilterToolbar.test.tsx | 17 ++++++++++++ 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/app/components/shared/FilterPopover.tsx b/src/app/components/shared/FilterPopover.tsx index 1c1d1882..39157eff 100644 --- a/src/app/components/shared/FilterPopover.tsx +++ b/src/app/components/shared/FilterPopover.tsx @@ -1,4 +1,4 @@ -import { createSignal, For, Show } from "solid-js"; +import { createMemo, createSignal, For, Show } from "solid-js"; import { Popover } from "@kobalte/core/popover"; import type { FilterChipGroupDef } from "./filterTypes"; @@ -11,14 +11,17 @@ interface FilterPopoverProps { export default function FilterPopover(props: FilterPopoverProps) { const [open, setOpen] = createSignal(false); - const isDefault = () => - props.value === (props.group.defaultValue ?? "all"); + const value = () => props.value ?? (props.group.defaultValue ?? "all"); - const activeLabel = () => { - const opt = props.group.options.find((o) => o.value === props.value); + const isDefault = createMemo( + () => value() === (props.group.defaultValue ?? "all") + ); + + const activeLabel = createMemo(() => { + const opt = props.group.options.find((o) => o.value === value()); if (opt) return opt.label; - return props.value === "all" ? "All" : props.value; - }; + return value() === "all" ? "All" : value(); + }); return ( @@ -49,26 +52,28 @@ export default function FilterPopover(props: FilterPopoverProps) { {(opt) => ( )} diff --git a/src/app/components/shared/FilterToolbar.tsx b/src/app/components/shared/FilterToolbar.tsx index 5883fd2f..9edd5a23 100644 --- a/src/app/components/shared/FilterToolbar.tsx +++ b/src/app/components/shared/FilterToolbar.tsx @@ -1,6 +1,7 @@ import { createMemo, For, Show } from "solid-js"; import FilterPopover from "./FilterPopover"; import ScopeToggle from "./ScopeToggle"; +import { scopeFilterGroup } from "./filterTypes"; import type { FilterChipGroupDef } from "./filterTypes"; interface FilterToolbarProps { @@ -28,7 +29,7 @@ export default function FilterToolbar(props: FilterToolbarProps) {
@@ -45,7 +46,7 @@ export default function FilterToolbar(props: FilterToolbarProps) { + + +
); From 806df07e182d55bc9560a6b6cc9eb759cf1a11d4 Mon Sep 17 00:00:00 2001 From: testvalue Date: Tue, 7 Apr 2026 09:52:46 -0400 Subject: [PATCH 16/18] fix(filters): addresses remaining review findings --- src/app/components/dashboard/ActionsTab.tsx | 11 +- .../components/dashboard/DashboardPage.tsx | 23 +- src/app/components/dashboard/IgnoreBadge.tsx | 23 +- src/app/components/dashboard/IssuesTab.tsx | 43 ++-- .../dashboard/PersonalSummaryStrip.tsx | 40 ++-- .../components/dashboard/PullRequestsTab.tsx | 43 ++-- src/app/components/layout/FilterBar.tsx | 14 ++ src/app/components/shared/FilterChips.tsx | 85 -------- src/app/components/shared/FilterPopover.tsx | 8 +- src/app/index.css | 2 + src/app/stores/view.ts | 6 +- tests/components/IgnoreBadge.test.tsx | 24 +- tests/components/IssuesTab.test.tsx | 40 +--- tests/components/PullRequestsTab.test.tsx | 38 +--- tests/components/shared/FilterChips.test.tsx | 206 ------------------ .../components/shared/FilterPopover.test.tsx | 53 ++++- 16 files changed, 204 insertions(+), 455 deletions(-) delete mode 100644 src/app/components/shared/FilterChips.tsx delete mode 100644 tests/components/shared/FilterChips.test.tsx diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 29d7fe96..3511a8bb 100644 --- a/src/app/components/dashboard/ActionsTab.tsx +++ b/src/app/components/dashboard/ActionsTab.tsx @@ -135,6 +135,10 @@ export default function ActionsTab(props: ActionsTabProps) { [...new Set(props.workflowRuns.map((r) => r.repoFullName))] ); + const ignoredWorkflowRuns = createMemo(() => + viewState.ignoredItems.filter(i => i.type === "workflowRun") + ); + createEffect(() => { const names = activeRepoNames(); if (names.length === 0) return; @@ -163,8 +167,7 @@ export default function ActionsTab(props: ActionsTabProps) { const filteredRuns = createMemo(() => { const { org, repo } = viewState.globalFilter; const ignoredIds = new Set( - viewState.ignoredItems - .filter((i) => i.type === "workflowRun") + ignoredWorkflowRuns() .map((i) => i.id) ); const conclusionFilter = viewState.tabFilters.actions.conclusion; @@ -211,7 +214,7 @@ export default function ActionsTab(props: ActionsTabProps) { const highlightedReposActions = createReorderHighlight( () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.actions, - () => viewState.ignoredItems.filter(i => i.type === "workflowRun").length, + () => ignoredWorkflowRuns().length, () => JSON.stringify(viewState.tabFilters.actions), ); @@ -242,7 +245,7 @@ export default function ActionsTab(props: ActionsTabProps) { onCollapseAll={() => setAllExpanded("actions", repoGroups().map((g) => g.repoFullName), false)} /> i.type === "workflowRun")} + items={ignoredWorkflowRuns()} onUnignore={unignoreItem} />
diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx index 5d0a2c01..38ae9a49 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -8,7 +8,9 @@ import IssuesTab from "./IssuesTab"; import PullRequestsTab from "./PullRequestsTab"; import PersonalSummaryStrip from "./PersonalSummaryStrip"; import { config, setConfig, type TrackedUser } from "../../stores/config"; -import { viewState, updateViewState } from "../../stores/view"; +import { viewState, updateViewState, setSortPreference } from "../../stores/view"; +import { sortOptions as issueSortOptions } from "./IssuesTab"; +import { sortOptions as prSortOptions } from "./PullRequestsTab"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; import { @@ -372,6 +374,21 @@ export default function DashboardPage() { ]; }); + const activeSortOptions = createMemo(() => { + switch (activeTab()) { + case "issues": return issueSortOptions; + case "pullRequests": return prSortOptions; + default: return undefined; + } + }); + + const activeSortPref = createMemo(() => { + const tab = activeTab(); + if (tab === "actions") return undefined; + const pref = viewState.sortPreferences[tab]; + return pref ?? { field: "updatedAt", direction: "desc" as const }; + }); + return (
@@ -397,6 +414,10 @@ export default function DashboardPage() { isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading} lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt} onRefresh={() => _coordinator()?.manualRefresh()} + sortOptions={activeSortOptions()} + sortValue={activeSortPref()?.field} + sortDirection={activeSortPref()?.direction} + onSortChange={(field, dir) => setSortPreference(activeTab(), field, dir)} />
diff --git a/src/app/components/dashboard/IgnoreBadge.tsx b/src/app/components/dashboard/IgnoreBadge.tsx index 0b5bc3db..ca96f994 100644 --- a/src/app/components/dashboard/IgnoreBadge.tsx +++ b/src/app/components/dashboard/IgnoreBadge.tsx @@ -44,14 +44,21 @@ export default function IgnoreBadge(props: IgnoreBadgeProps) { return ( 0}>
- + + + {/* Backdrop */} diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx index 5031a493..880a7e8b 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -1,11 +1,10 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { config, type TrackedUser } from "../../stores/config"; -import { viewState, updateViewState, setSortPreference, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view"; +import { viewState, updateViewState, setTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type IssueFilterField } from "../../stores/view"; import type { Issue, RepoRef } from "../../services/api"; import ItemRow from "./ItemRow"; import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge"; import IgnoreBadge from "./IgnoreBadge"; -import SortDropdown from "../shared/SortDropdown"; import type { SortOption } from "../shared/SortDropdown"; import PaginationControls from "../shared/PaginationControls"; import { scopeFilterGroup, type FilterChipGroupDef } from "../shared/filterTypes"; @@ -52,7 +51,7 @@ const issueFilterGroups: FilterChipGroupDef[] = [ }, ]; -const sortOptions: SortOption[] = [ +export const sortOptions: SortOption[] = [ { label: "Repo", field: "repo", type: "text" }, { label: "Title", field: "title", type: "text" }, { label: "Author", field: "author", type: "text" }, @@ -82,6 +81,14 @@ export default function IssuesTab(props: IssuesTabProps) { (props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1 ); + const isValidUserFilter = createMemo(() => + !props.allUsers || props.allUsers.some(u => u.login === viewState.tabFilters.issues.user) + ); + + const ignoredIssues = createMemo(() => + viewState.ignoredItems.filter(i => i.type === "issue") + ); + const filterGroups = createMemo(() => { const users = props.allUsers; const base = showScopeFilter() @@ -105,6 +112,14 @@ export default function IssuesTab(props: IssuesTabProps) { } }); + // Auto-reset user filter when User filter group is hidden + createEffect(() => { + const users = props.allUsers; + if ((!users || users.length <= 1) && viewState.tabFilters.issues.user !== "all") { + setTabFilter("issues", "user", "all"); + } + }); + const isInvolvedItem = (item: Issue) => isUserInvolved(item, userLoginLower(), monitoredRepoNameSet()); @@ -117,8 +132,7 @@ export default function IssuesTab(props: IssuesTabProps) { const filter = viewState.globalFilter; const tabFilter = viewState.tabFilters.issues; const ignored = new Set( - viewState.ignoredItems - .filter((i) => i.type === "issue") + ignoredIssues() .map((i) => i.id) ); @@ -149,7 +163,7 @@ export default function IssuesTab(props: IssuesTabProps) { if (tabFilter.user !== "all") { // Items from monitored repos bypass the surfacedBy filter (all activity is shown) if (!monitoredRepoNameSet().has(issue.repoFullName)) { - const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilter.user); + const validUser = isValidUserFilter(); if (validUser) { const surfacedBy = issue.surfacedBy ?? [userLoginLower()]; if (!surfacedBy.includes(tabFilter.user)) return false; @@ -227,15 +241,10 @@ export default function IssuesTab(props: IssuesTabProps) { const highlightedReposIssues = createReorderHighlight( () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.issues, - () => viewState.ignoredItems.filter(i => i.type === "issue").length, + () => ignoredIssues().length, () => JSON.stringify(viewState.tabFilters.issues), ); - function handleSort(field: string, direction: "asc" | "desc") { - setSortPreference("issues", field, direction); - setPage(0); - } - function handleIgnore(issue: Issue) { ignoreItem({ id: String(issue.id), @@ -248,15 +257,9 @@ export default function IssuesTab(props: IssuesTabProps) { return (
- {/* Sort dropdown + filter chips + ignore badge toolbar */} + {/* Filter chips + ignore badge toolbar */}
- setAllExpanded("issues", repoGroups().map((g) => g.repoFullName), false)} /> i.type === "issue")} + items={ignoredIssues()} onUnignore={unignoreItem} />
diff --git a/src/app/components/dashboard/PersonalSummaryStrip.tsx b/src/app/components/dashboard/PersonalSummaryStrip.tsx index 04d05f8b..86282dc0 100644 --- a/src/app/components/dashboard/PersonalSummaryStrip.tsx +++ b/src/app/components/dashboard/PersonalSummaryStrip.tsx @@ -1,7 +1,8 @@ import { createMemo, For, Show } from "solid-js"; +import { produce } from "solid-js/store"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import type { TabId } from "../layout/TabBar"; -import { viewState, updateViewState, resetAllTabFilters, setTabFilter } from "../../stores/view"; +import { viewState, setViewState, IssueFiltersSchema, PullRequestFiltersSchema, ActionsFiltersSchema } from "../../stores/view"; import { InfoTooltip } from "../shared/Tooltip"; interface SummaryCount { @@ -104,9 +105,9 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { count: assignedIssues, tab: "issues", applyFilters: () => { - resetAllTabFilters("issues"); - setTabFilter("issues", "scope", "all"); - setTabFilter("issues", "role", "assignee"); + setViewState(produce(draft => { + draft.tabFilters.issues = { ...IssueFiltersSchema.parse({}), scope: "all", role: "assignee" }; + })); }, }); if (prsAwaitingReview > 0) items.push({ @@ -114,10 +115,9 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { count: prsAwaitingReview, tab: "pullRequests", applyFilters: () => { - resetAllTabFilters("pullRequests"); - setTabFilter("pullRequests", "scope", "all"); - setTabFilter("pullRequests", "role", "reviewer"); - setTabFilter("pullRequests", "reviewDecision", "REVIEW_REQUIRED"); + setViewState(produce(draft => { + draft.tabFilters.pullRequests = { ...PullRequestFiltersSchema.parse({}), scope: "all", role: "reviewer", reviewDecision: "REVIEW_REQUIRED" }; + })); }, }); if (prsReadyToMerge > 0) items.push({ @@ -125,12 +125,9 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { count: prsReadyToMerge, tab: "pullRequests", applyFilters: () => { - resetAllTabFilters("pullRequests"); - setTabFilter("pullRequests", "scope", "all"); - setTabFilter("pullRequests", "role", "author"); - setTabFilter("pullRequests", "draft", "ready"); - setTabFilter("pullRequests", "checkStatus", "success"); - setTabFilter("pullRequests", "reviewDecision", "mergeable"); + setViewState(produce(draft => { + draft.tabFilters.pullRequests = { ...PullRequestFiltersSchema.parse({}), scope: "all", role: "author", draft: "ready", checkStatus: "success", reviewDecision: "mergeable" }; + })); }, }); if (prsBlocked > 0) items.push({ @@ -138,11 +135,9 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { count: prsBlocked, tab: "pullRequests", applyFilters: () => { - resetAllTabFilters("pullRequests"); - setTabFilter("pullRequests", "scope", "all"); - setTabFilter("pullRequests", "role", "author"); - setTabFilter("pullRequests", "draft", "ready"); - setTabFilter("pullRequests", "checkStatus", "blocked"); + setViewState(produce(draft => { + draft.tabFilters.pullRequests = { ...PullRequestFiltersSchema.parse({}), scope: "all", role: "author", draft: "ready", checkStatus: "blocked" }; + })); }, }); if (running > 0) items.push({ @@ -150,9 +145,10 @@ export default function PersonalSummaryStrip(props: PersonalSummaryStripProps) { count: running, tab: "actions", applyFilters: () => { - resetAllTabFilters("actions"); - setTabFilter("actions", "conclusion", "running"); - updateViewState({ showPrRuns: true }); + setViewState(produce(draft => { + draft.tabFilters.actions = { ...ActionsFiltersSchema.parse({}), conclusion: "running" }; + draft.showPrRuns = true; + })); }, }); return items; diff --git a/src/app/components/dashboard/PullRequestsTab.tsx b/src/app/components/dashboard/PullRequestsTab.tsx index e3af1c6e..4d9f8511 100644 --- a/src/app/components/dashboard/PullRequestsTab.tsx +++ b/src/app/components/dashboard/PullRequestsTab.tsx @@ -1,6 +1,6 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import { config, type TrackedUser } from "../../stores/config"; -import { viewState, setSortPreference, ignoreItem, unignoreItem, setTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type PullRequestFilterField } from "../../stores/view"; +import { viewState, ignoreItem, unignoreItem, setTabFilter, resetAllTabFilters, toggleExpandedRepo, setAllExpanded, pruneExpandedRepos, pruneLockedRepos, type PullRequestFilterField } from "../../stores/view"; import type { PullRequest, RepoRef } from "../../services/api"; import { deriveInvolvementRoles, prSizeCategory, formatStarCount } from "../../lib/format"; import { isSafeGitHubUrl } from "../../lib/url"; @@ -9,7 +9,6 @@ import ItemRow from "./ItemRow"; import UserAvatarBadge, { buildSurfacedByUsers } from "../shared/UserAvatarBadge"; import StatusDot from "../shared/StatusDot"; import IgnoreBadge from "./IgnoreBadge"; -import SortDropdown from "../shared/SortDropdown"; import type { SortOption } from "../shared/SortDropdown"; import PaginationControls from "../shared/PaginationControls"; import { scopeFilterGroup, type FilterChipGroupDef } from "../shared/filterTypes"; @@ -120,7 +119,7 @@ const prFilterGroups: FilterChipGroupDef[] = [ }, ]; -const sortOptions: SortOption[] = [ +export const sortOptions: SortOption[] = [ { label: "Repo", field: "repo", type: "text" }, { label: "Title", field: "title", type: "text" }, { label: "Author", field: "author", type: "text" }, @@ -152,6 +151,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { (props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1 ); + const isValidUserFilter = createMemo(() => + !props.allUsers || props.allUsers.some(u => u.login === viewState.tabFilters.pullRequests.user) + ); + + const ignoredPullRequests = createMemo(() => + viewState.ignoredItems.filter(i => i.type === "pullRequest") + ); + const filterGroups = createMemo(() => { const users = props.allUsers; const base = showScopeFilter() @@ -175,6 +182,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { } }); + // Auto-reset user filter when User filter group is hidden + createEffect(() => { + const users = props.allUsers; + if ((!users || users.length <= 1) && viewState.tabFilters.pullRequests.user !== "all") { + setTabFilter("pullRequests", "user", "all"); + } + }); + const isInvolvedItem = (item: PullRequest) => isUserInvolved(item, userLoginLower(), monitoredRepoNameSet(), item.enriched !== false ? item.reviewerLogins : undefined); @@ -188,8 +203,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const filter = viewState.globalFilter; const tabFilters = viewState.tabFilters.pullRequests; const ignored = new Set( - viewState.ignoredItems - .filter((i) => i.type === "pullRequest") + ignoredPullRequests() .map((i) => i.id) ); @@ -242,7 +256,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { if (tabFilters.user !== "all") { // Items from monitored repos bypass the surfacedBy filter (all activity is shown) if (!monitoredRepoNameSet().has(pr.repoFullName)) { - const validUser = !props.allUsers || props.allUsers.some(u => u.login === tabFilters.user); + const validUser = isValidUserFilter(); if (validUser) { const surfacedBy = pr.surfacedBy ?? [userLoginLower()]; if (!surfacedBy.includes(tabFilters.user)) return false; @@ -335,15 +349,10 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { const highlightedReposPRs = createReorderHighlight( () => repoGroups().map(g => g.repoFullName), () => viewState.lockedRepos.pullRequests, - () => viewState.ignoredItems.filter(i => i.type === "pullRequest").length, + () => ignoredPullRequests().length, () => JSON.stringify(viewState.tabFilters.pullRequests), ); - function handleSort(field: string, direction: "asc" | "desc") { - setSortPreference("pullRequests", field, direction); - setPage(0); - } - function handleIgnore(pr: PullRequest) { ignoreItem({ id: String(pr.id), @@ -356,15 +365,9 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { return (
- {/* Filter toolbar with SortDropdown */} + {/* Filter toolbar */}
- setAllExpanded("pullRequests", repoGroups().map((g) => g.repoFullName), false)} /> i.type === "pullRequest")} + items={ignoredPullRequests()} onUnignore={unignoreItem} />
diff --git a/src/app/components/layout/FilterBar.tsx b/src/app/components/layout/FilterBar.tsx index 74150ec3..76450e85 100644 --- a/src/app/components/layout/FilterBar.tsx +++ b/src/app/components/layout/FilterBar.tsx @@ -3,11 +3,16 @@ import { Select } from "@kobalte/core/select"; import { config } from "../../stores/config"; import { viewState, setGlobalFilter } from "../../stores/view"; import { Tooltip } from "../shared/Tooltip"; +import SortDropdown, { type SortOption } from "../shared/SortDropdown"; interface FilterBarProps { isRefreshing?: boolean; lastRefreshedAt?: Date | null; onRefresh?: () => void; + sortOptions?: SortOption[]; + sortValue?: string; + sortDirection?: "asc" | "desc"; + onSortChange?: (field: string, direction: "asc" | "desc") => void; } export default function FilterBar(props: FilterBarProps) { @@ -107,6 +112,15 @@ export default function FilterBar(props: FilterBarProps) { + + + +
diff --git a/src/app/components/shared/FilterChips.tsx b/src/app/components/shared/FilterChips.tsx deleted file mode 100644 index 749fdf1b..00000000 --- a/src/app/components/shared/FilterChips.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { For, Show } from "solid-js"; - -import { scopeFilterGroup, type FilterChipGroupDef } from "./filterTypes"; -export { scopeFilterGroup, type FilterChipGroupDef }; - -interface FilterChipsProps { - groups: FilterChipGroupDef[]; - values: Record; - onChange: (field: string, value: string) => void; - onReset: (field: string) => void; - onResetAll: () => void; -} - -export default function FilterChips(props: FilterChipsProps) { - const hasActiveFilter = () => - props.groups.some((g) => props.values[g.field] !== undefined && props.values[g.field] !== (g.defaultValue ?? "all")); - - return ( -
- - {(group) => { - const current = () => props.values[group.field] ?? (group.defaultValue ?? "all"); - const isActive = () => current() !== (group.defaultValue ?? "all"); - - return ( -
- {group.label}: -
- - - - - {(opt) => ( - - )} - -
- - - -
- ); - }} -
- - - -
- ); -} diff --git a/src/app/components/shared/FilterPopover.tsx b/src/app/components/shared/FilterPopover.tsx index 2d63d9c4..707b20b7 100644 --- a/src/app/components/shared/FilterPopover.tsx +++ b/src/app/components/shared/FilterPopover.tsx @@ -48,11 +48,12 @@ export default function FilterPopover(props: FilterPopoverProps) { - +