diff --git a/src/app/components/dashboard/ActionsTab.tsx b/src/app/components/dashboard/ActionsTab.tsx index 99925d42..3511a8bb 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"; @@ -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), ); @@ -229,11 +232,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")} /> @@ -243,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..ac31a75c 100644 --- a/src/app/components/dashboard/DashboardPage.tsx +++ b/src/app/components/dashboard/DashboardPage.tsx @@ -8,7 +8,8 @@ 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 type { SortOption } from "../shared/SortDropdown"; import type { Issue, PullRequest, WorkflowRun } from "../../services/api"; import { fetchOrgs } from "../../services/api"; import { @@ -27,6 +28,18 @@ import { formatCount } from "../../lib/format"; import { setsEqual } from "../../lib/collections"; import { Tooltip } from "../shared/Tooltip"; +const globalSortOptions: SortOption[] = [ + { label: "Repo", field: "repo", type: "text" }, + { label: "Title", field: "title", type: "text" }, + { label: "Author", field: "author", type: "text" }, + { label: "Comments", field: "comments", type: "number" }, + { label: "Checks", field: "checkStatus", type: "text" }, + { label: "Review", field: "reviewDecision", type: "text" }, + { label: "Size", field: "size", type: "number" }, + { label: "Created", field: "createdAt", type: "date" }, + { label: "Updated", field: "updatedAt", type: "date" }, +]; + // ── Shared dashboard store (module-level to survive navigation) ───────────── // Bump only for breaking schema changes (renames, type changes). Additive optional @@ -397,6 +410,10 @@ export default function DashboardPage() { isRefreshing={_coordinator()?.isRefreshing() ?? dashboardData.loading} lastRefreshedAt={_coordinator()?.lastRefreshAt() ?? dashboardData.lastRefreshedAt} onRefresh={() => _coordinator()?.manualRefresh()} + sortOptions={globalSortOptions} + sortValue={viewState.globalSort.field} + sortDirection={viewState.globalSort.direction} + onSortChange={(field, dir) => setSortPreference(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 77de0030..2cecc96c 100644 --- a/src/app/components/dashboard/IssuesTab.tsx +++ b/src/app/components/dashboard/IssuesTab.tsx @@ -1,14 +1,13 @@ 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, 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 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"; @@ -51,14 +50,6 @@ const issueFilterGroups: FilterChipGroupDef[] = [ }, ]; -const sortOptions: SortOption[] = [ - { label: "Repo", field: "repo", type: "text" }, - { label: "Title", field: "title", type: "text" }, - { label: "Author", field: "author", type: "text" }, - { label: "Comments", field: "comments", type: "number" }, - { label: "Created", field: "createdAt", type: "date" }, - { label: "Updated", field: "updatedAt", type: "date" }, -]; export default function IssuesTab(props: IssuesTabProps) { const [page, setPage] = createSignal(0); @@ -81,6 +72,10 @@ export default function IssuesTab(props: IssuesTabProps) { (props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1 ); + const ignoredIssues = createMemo(() => + viewState.ignoredItems.filter(i => i.type === "issue") + ); + const filterGroups = createMemo(() => { const users = props.allUsers; const base = showScopeFilter() @@ -97,27 +92,29 @@ export default function IssuesTab(props: IssuesTabProps) { ]; }); - // Auto-reset scope to default when scope chip is hidden (localStorage hygiene) + // Auto-reset scope to default when scope toggle is hidden (localStorage hygiene) createEffect(() => { if (!showScopeFilter() && viewState.tabFilters.issues.scope !== "involves_me") { setTabFilter("issues", "scope", "involves_me"); } }); + // 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()); - const sortPref = createMemo(() => { - const pref = viewState.sortPreferences["issues"]; - return pref ?? { field: "updatedAt", direction: "desc" as const }; - }); - const filteredSortedWithMeta = createMemo(() => { 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) ); @@ -160,7 +157,7 @@ export default function IssuesTab(props: IssuesTabProps) { return true; }); - const { field, direction } = sortPref(); + const { field, direction } = viewState.globalSort; items = [...items].sort((a, b) => { let cmp = 0; switch (field as SortField) { @@ -226,15 +223,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), @@ -247,24 +239,14 @@ export default function IssuesTab(props: IssuesTabProps) { return (
- {/* Sort dropdown + filter chips + ignore badge toolbar */} + {/* Filter chips + ignore badge toolbar */}
- - { - 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={() => { @@ -291,7 +273,7 @@ export default function IssuesTab(props: IssuesTabProps) { onCollapseAll={() => 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 d7678ff7..4e21dcc5 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, 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,10 +9,9 @@ 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 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"; @@ -119,16 +118,6 @@ const prFilterGroups: FilterChipGroupDef[] = [ }, ]; -const sortOptions: SortOption[] = [ - { label: "Repo", field: "repo", type: "text" }, - { label: "Title", field: "title", type: "text" }, - { label: "Author", field: "author", type: "text" }, - { label: "Checks", field: "checkStatus", type: "text" }, - { label: "Review", field: "reviewDecision", type: "text" }, - { label: "Size", field: "size", type: "number" }, - { label: "Created", field: "createdAt", type: "date" }, - { label: "Updated", field: "updatedAt", type: "date" }, -]; export default function PullRequestsTab(props: PullRequestsTabProps) { const [page, setPage] = createSignal(0); @@ -151,6 +140,10 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { (props.monitoredRepos ?? []).length > 0 || (props.allUsers?.length ?? 0) > 1 ); + const ignoredPullRequests = createMemo(() => + viewState.ignoredItems.filter(i => i.type === "pullRequest") + ); + const filterGroups = createMemo(() => { const users = props.allUsers; const base = showScopeFilter() @@ -167,28 +160,30 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { ]; }); - // Auto-reset scope to default when scope chip is hidden (localStorage hygiene) + // Auto-reset scope to default when scope toggle is hidden (localStorage hygiene) createEffect(() => { if (!showScopeFilter() && viewState.tabFilters.pullRequests.scope !== "involves_me") { setTabFilter("pullRequests", "scope", "involves_me"); } }); + // 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); - const sortPref = createMemo(() => { - const pref = viewState.sortPreferences["pullRequests"]; - return pref ?? { field: "updatedAt", direction: "desc" as const }; - }); - const filteredSortedWithMeta = createMemo(() => { 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) ); @@ -253,7 +248,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { return true; }); - const { field, direction } = sortPref(); + const { field, direction } = viewState.globalSort; items = [...items].sort((a, b) => { let cmp = 0; switch (field as SortField) { @@ -334,15 +329,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), @@ -355,24 +345,14 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { return (
- {/* Filter toolbar with SortDropdown */} + {/* Filter toolbar */}
- - { - 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={() => { @@ -387,7 +367,7 @@ export default function PullRequestsTab(props: PullRequestsTabProps) { onCollapseAll={() => 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 9d499df1..00000000 --- a/src/app/components/shared/FilterChips.tsx +++ /dev/null @@ -1,99 +0,0 @@ -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" }, - ], -}; - -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 new file mode 100644 index 00000000..a1c115c7 --- /dev/null +++ b/src/app/components/shared/FilterPopover.tsx @@ -0,0 +1,84 @@ +import { createMemo, 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 value = createMemo(() => props.value ?? (props.group.defaultValue ?? "all")); + + 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 value() === "all" ? "All" : value(); + }); + + return ( + + + {props.group.label}: {activeLabel()}}> + {props.group.label} + + + + + + + + + + {(opt) => ( + + )} + + + + + ); +} diff --git a/src/app/components/shared/FilterToolbar.tsx b/src/app/components/shared/FilterToolbar.tsx new file mode 100644 index 00000000..6d28263a --- /dev/null +++ b/src/app/components/shared/FilterToolbar.tsx @@ -0,0 +1,60 @@ +import { createMemo, For, Show } from "solid-js"; +import FilterPopover from "./FilterPopover"; +import ScopeToggle from "./ScopeToggle"; +import { Tooltip } from "./Tooltip"; +import { scopeFilterGroup } from "./filterTypes"; +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/src/app/components/shared/ScopeToggle.tsx b/src/app/components/shared/ScopeToggle.tsx new file mode 100644 index 00000000..13995156 --- /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 ( + + ); +} 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" }, + ], +}; diff --git a/src/app/index.css b/src/app/index.css index 6b6f5d8d..1e1b1c8f 100644 --- a/src/app/index.css +++ b/src/app/index.css @@ -107,6 +107,24 @@ 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] { + transform-origin: top; + animation: popover-fade-in 0.15s ease-out forwards; +} +.filter-popover-content[data-closed] { + transform-origin: top; + 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 +160,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/src/app/stores/view.ts b/src/app/stores/view.ts index 8e5406ca..18738eb0 100644 --- a/src/app/stores/view.ts +++ b/src/app/stores/view.ts @@ -6,14 +6,14 @@ import { pushNotification } from "../lib/errors"; export const VIEW_STORAGE_KEY = "github-tracker:view"; const IGNORED_ITEMS_CAP = 500; -const IssueFiltersSchema = z.object({ +export const IssueFiltersSchema = z.object({ scope: z.enum(["involves_me", "all"]).default("involves_me"), role: z.enum(["all", "author", "assignee"]).default("all"), comments: z.enum(["all", "has", "none"]).default("all"), user: z.enum(["all"]).or(z.string()).default("all"), }); -const PullRequestFiltersSchema = z.object({ +export const PullRequestFiltersSchema = z.object({ scope: z.enum(["involves_me", "all"]).default("involves_me"), role: z.enum(["all", "author", "reviewer", "assignee"]).default("all"), reviewDecision: z.enum(["all", "APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED", "mergeable"]).default("all"), @@ -23,7 +23,7 @@ const PullRequestFiltersSchema = z.object({ user: z.enum(["all"]).or(z.string()).default("all"), }); -const ActionsFiltersSchema = z.object({ +export const ActionsFiltersSchema = z.object({ conclusion: z.enum(["all", "success", "failure", "cancelled", "running", "other"]).default("all"), event: z.enum(["all", "push", "pull_request", "schedule", "workflow_dispatch", "other"]).default("all"), }); @@ -39,15 +39,10 @@ export const ViewStateSchema = z.object({ lastActiveTab: z .enum(["issues", "pullRequests", "actions"]) .default("issues"), - sortPreferences: z - .record( - z.string(), - z.object({ - field: z.string(), - direction: z.enum(["asc", "desc"]), - }) - ) - .default({}), + globalSort: z.object({ + field: z.string(), + direction: z.enum(["asc", "desc"]), + }).default({ field: "updatedAt", direction: "desc" }), ignoredItems: z .array( z.object({ @@ -99,7 +94,6 @@ export const ViewStateSchema = z.object({ export type ViewState = z.infer; export type IgnoredItem = ViewState["ignoredItems"][number]; -export type SortPreference = ViewState["sortPreferences"][string]; export type LockedReposTab = keyof ViewState["lockedRepos"]; function loadViewState(): ViewState { @@ -122,7 +116,7 @@ export const [viewState, setViewState] = createStore( export function resetViewState(): void { updateViewState({ lastActiveTab: "issues", - sortPreferences: {}, + globalSort: { field: "updatedAt", direction: "desc" }, ignoredItems: [], globalFilter: { org: null, repo: null }, tabFilters: { @@ -180,13 +174,12 @@ export function pruneStaleIgnoredItems(): void { } export function setSortPreference( - tabId: string, field: string, direction: "asc" | "desc" ): void { setViewState( produce((draft) => { - draft.sortPreferences[tabId] = { field, direction }; + draft.globalSort = { field, direction }; }) ); } @@ -220,24 +213,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 { diff --git a/tests/components/ActionsTab.test.tsx b/tests/components/ActionsTab.test.tsx index 1a2957a6..7c5d6796 100644 --- a/tests/components/ActionsTab.test.tsx +++ b/tests/components/ActionsTab.test.tsx @@ -293,7 +293,7 @@ describe("ActionsTab", () => { expect(repoHeader.getAttribute("aria-expanded")).toBe("false"); }); - it("toolbar: Show PR runs checkbox, FilterChips, and IgnoreBadge are present", () => { + it("toolbar: Show PR runs checkbox, FilterToolbar, and IgnoreBadge are present", () => { render(() => ); screen.getByRole("checkbox"); screen.getByText("Show PR runs"); diff --git a/tests/components/IgnoreBadge.test.tsx b/tests/components/IgnoreBadge.test.tsx index 4e1dd554..16772cc4 100644 --- a/tests/components/IgnoreBadge.test.tsx +++ b/tests/components/IgnoreBadge.test.tsx @@ -15,6 +15,11 @@ function makeIgnoredItem(overrides: Partial = {}): IgnoredItem { }; } +// Helper to get the trigger button by aria-label pattern +function getTrigger(count: number) { + return screen.getByRole("button", { name: new RegExp(`${count} ignored`, "i") }); +} + describe("IgnoreBadge", () => { it("renders nothing when items is empty", () => { const { container } = render(() => ( @@ -23,17 +28,20 @@ describe("IgnoreBadge", () => { expect(container.firstChild).toBeNull(); }); - it("shows count of ignored items in badge", () => { + it("shows count badge on the trigger button", () => { const items = [makeIgnoredItem(), makeIgnoredItem(), makeIgnoredItem()]; render(() => {}} />); - screen.getByText("3 ignored"); + // The count badge span shows the number + expect(screen.getByText("3")).toBeDefined(); + // The button has accessible aria-label + getTrigger(3); }); it("clicking badge toggles popover open (aria-expanded)", async () => { const user = userEvent.setup(); const items = [makeIgnoredItem()]; render(() => {}} />); - const button = screen.getByText("1 ignored"); + const button = getTrigger(1); // Initially closed expect(button.getAttribute("aria-expanded")).toBe("false"); @@ -47,7 +55,7 @@ describe("IgnoreBadge", () => { const user = userEvent.setup(); const items = [makeIgnoredItem()]; render(() => {}} />); - const button = screen.getByText("1 ignored"); + const button = getTrigger(1); await user.click(button); expect(button.getAttribute("aria-expanded")).toBe("true"); @@ -63,7 +71,7 @@ describe("IgnoreBadge", () => { makeIgnoredItem({ id: "2", repo: "owner/repo-b", title: "Issue Beta" }), ]; render(() => {}} />); - await user.click(screen.getByText("2 ignored")); + await user.click(getTrigger(2)); screen.getByText("Issue Alpha"); screen.getByText("Issue Beta"); @@ -78,7 +86,7 @@ describe("IgnoreBadge", () => { makeIgnoredItem({ id: "abc-123", title: "My Issue" }), ]; render(() => ); - await user.click(screen.getByText("1 ignored")); + await user.click(getTrigger(1)); const unignoreBtn = screen.getByText("Unignore"); await user.click(unignoreBtn); @@ -95,7 +103,7 @@ describe("IgnoreBadge", () => { makeIgnoredItem({ id: "3" }), ]; render(() => ); - await user.click(screen.getByText("3 ignored")); + await user.click(getTrigger(3)); const unignoreAllBtn = screen.getByText("Unignore All"); await user.click(unignoreAllBtn); @@ -110,7 +118,7 @@ describe("IgnoreBadge", () => { const user = userEvent.setup(); const items = [makeIgnoredItem()]; render(() => {}} />); - const button = screen.getByText("1 ignored"); + const button = getTrigger(1); await user.click(button); expect(button.getAttribute("aria-expanded")).toBe("true"); diff --git a/tests/components/IssuesTab.test.tsx b/tests/components/IssuesTab.test.tsx index 079fcf6d..363c89cc 100644 --- a/tests/components/IssuesTab.test.tsx +++ b/tests/components/IssuesTab.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import { createSignal } from "solid-js"; @@ -91,38 +91,10 @@ describe("IssuesTab", () => { expect(newerIdx).toBeLessThan(olderIdx); }); - it("renders SortDropdown in the toolbar", () => { + it("SortDropdown is not rendered in the tab toolbar (moved to FilterBar)", () => { render(() => ); - const trigger = screen.getByRole("button", { name: /sort by/i }); - expect(trigger).toBeDefined(); - }); - - it("SortDropdown contains all sortable fields", async () => { - const user = userEvent.setup(); - render(() => ); - await user.click(screen.getByRole("button", { name: /sort by/i })); - const opts = screen.getAllByRole("option").map((o) => o.textContent ?? ""); - expect(opts.some((v) => v.toLowerCase().includes("repo"))).toBe(true); - expect(opts.some((v) => v.toLowerCase().includes("title"))).toBe(true); - expect(opts.some((v) => v.toLowerCase().includes("author"))).toBe(true); - expect(opts.some((v) => v.toLowerCase().includes("comments"))).toBe(true); - expect(opts.some((v) => v.toLowerCase().includes("created"))).toBe(true); - expect(opts.some((v) => v.toLowerCase().includes("updated"))).toBe(true); - }); - - it("changes sort order when SortDropdown selection changes", async () => { - const user = userEvent.setup(); - const setSortSpy = vi.spyOn(viewStore, "setSortPreference"); - const issues = [makeIssue({ title: "Issue A" })]; - render(() => ); - - await user.click(screen.getByRole("button", { name: /sort by/i })); - const titleDesc = screen.getAllByRole("option").find((o) => o.textContent?.includes("Title") && o.textContent?.includes("(Z-A)")); - expect(titleDesc).toBeDefined(); - await user.click(titleDesc!); - - expect(setSortSpy).toHaveBeenCalledWith("issues", "title", "desc"); - setSortSpy.mockRestore(); + // SortDropdown was moved to FilterBar; not rendered in tab isolation + expect(screen.queryByRole("button", { name: /sort by/i })).toBeNull(); }); it("does not show pagination when there is only one page", () => { @@ -334,8 +306,8 @@ describe("IssuesTab", () => { ignoredAt: Date.now(), }); render(() => ); - // IgnoreBadge shows count of ignored items - screen.getByText(/1 ignored/i); + // IgnoreBadge now shows an icon button with aria-label + screen.getByRole("button", { name: /1 ignored/i }); }); it("paginates repo groups across pages", async () => { @@ -382,7 +354,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 +383,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/PullRequestsTab.test.tsx b/tests/components/PullRequestsTab.test.tsx index a5fc26b6..b45b1ee9 100644 --- a/tests/components/PullRequestsTab.test.tsx +++ b/tests/components/PullRequestsTab.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { render, screen } from "@solidjs/testing-library"; import userEvent from "@testing-library/user-event"; import { createSignal } from "solid-js"; @@ -90,36 +90,10 @@ describe("PullRequestsTab", () => { expect(newerIdx).toBeLessThan(olderIdx); }); - it("renders SortDropdown with all sort options", async () => { - const user = userEvent.setup(); + it("SortDropdown is not rendered in the tab toolbar (moved to FilterBar)", () => { render(() => ); - const trigger = screen.getByRole("button", { name: /Sort by/ }); - expect(trigger).toBeDefined(); - await user.click(trigger); - const optionText = screen.getAllByRole("option").map((o) => o.textContent ?? ""); - expect(optionText.some((t) => t.includes("Repo"))).toBe(true); - expect(optionText.some((t) => t.includes("Title"))).toBe(true); - expect(optionText.some((t) => t.includes("Author"))).toBe(true); - expect(optionText.some((t) => t.includes("Checks"))).toBe(true); - expect(optionText.some((t) => t.includes("Review"))).toBe(true); - expect(optionText.some((t) => t.includes("Size"))).toBe(true); - expect(optionText.some((t) => t.includes("Created"))).toBe(true); - expect(optionText.some((t) => t.includes("Updated"))).toBe(true); - }); - - it("changes sort when SortDropdown selection changes", async () => { - const user = userEvent.setup(); - const setSortSpy = vi.spyOn(viewStore, "setSortPreference"); - const prs = [makePullRequest({ id: 1, title: "PR A", repoFullName: "org/repo-a" })]; - render(() => ); - - await user.click(screen.getByRole("button", { name: /Sort by/ })); - const titleDesc = screen.getAllByRole("option").find((o) => o.textContent?.includes("Title") && o.textContent?.includes("(Z-A)")); - expect(titleDesc).toBeDefined(); - await user.click(titleDesc!); - - expect(setSortSpy).toHaveBeenCalledWith("pullRequests", "title", "desc"); - setSortSpy.mockRestore(); + // SortDropdown was moved to FilterBar; not rendered in tab isolation + expect(screen.queryByRole("button", { name: /Sort by/ })).toBeNull(); }); it("does not show pagination when there is only one page", () => { @@ -142,9 +116,9 @@ describe("PullRequestsTab", () => { const pr = makePullRequest({ id: 1, title: "Draft PR", draft: true, repoFullName: "org/repo-a" }); setAllExpanded("pullRequests", ["org/repo-a"], true); render(() => ); - // "Draft" appears in both the filter chip button and the PR badge + // "Draft" appears as a PR badge const draftEls = screen.getAllByText("Draft"); - // At least one is a span (the badge), not a button (the chip) + // Badge should be a span element const badgeEl = draftEls.find((el) => el.tagName.toLowerCase() === "span"); expect(badgeEl).toBeDefined(); }); @@ -152,7 +126,7 @@ describe("PullRequestsTab", () => { it("does not show Draft badge for non-draft PRs", () => { const pr = makePullRequest({ id: 1, title: "Normal PR", draft: false, repoFullName: "org/repo-a" }); render(() => ); - // "Draft" may appear as a filter chip button, but should NOT appear as a badge span + // "Draft" should NOT appear as a badge span for non-draft PRs const draftEls = screen.queryAllByText("Draft"); const badgeEl = draftEls.find((el) => el.tagName.toLowerCase() === "span"); expect(badgeEl).toBeUndefined(); @@ -162,7 +136,7 @@ describe("PullRequestsTab", () => { const pr = makePullRequest({ id: 1, title: "My PR", userLogin: "alice", reviewerLogins: [], assigneeLogins: [], repoFullName: "org/repo-a" }); setAllExpanded("pullRequests", ["org/repo-a"], true); render(() => ); - // "Author" appears in both the filter chip button and the role badge + // "Author" appears as a role badge const authorEls = screen.getAllByText("Author"); const badgeEl = authorEls.find((el) => el.tagName.toLowerCase() === "span"); expect(badgeEl).toBeDefined(); @@ -172,7 +146,7 @@ describe("PullRequestsTab", () => { const pr = makePullRequest({ id: 1, title: "Review PR", userLogin: "bob", reviewerLogins: ["alice"], assigneeLogins: [], repoFullName: "org/repo-a" }); setAllExpanded("pullRequests", ["org/repo-a"], true); render(() => ); - // "Reviewer" appears in both the filter chip button and the role badge + // "Reviewer" appears as a role badge const reviewerEls = screen.getAllByText("Reviewer"); const badgeEl = reviewerEls.find((el) => el.tagName.toLowerCase() === "span"); expect(badgeEl).toBeDefined(); @@ -182,7 +156,7 @@ describe("PullRequestsTab", () => { const pr = makePullRequest({ id: 1, title: "Approved PR", reviewDecision: "APPROVED", repoFullName: "org/repo-a" }); setAllExpanded("pullRequests", ["org/repo-a"], true); render(() => ); - // "Approved" appears in both the filter chip button and the review badge + // "Approved" appears as a review badge const approvedEls = screen.getAllByText("Approved"); const badgeEl = approvedEls.find((el) => el.tagName.toLowerCase() === "span"); expect(badgeEl).toBeDefined(); @@ -193,7 +167,7 @@ describe("PullRequestsTab", () => { setAllExpanded("pullRequests", ["org/repo-a"], true); render(() => ); // prSizeCategory(300, 100) = 400 total -> M - // "M" appears in both the filter chip button and the size badge + // "M" appears as a size badge const mEls = screen.getAllByText("M"); const badgeEl = mEls.find((el) => el.tagName.toLowerCase() === "span"); expect(badgeEl).toBeDefined(); @@ -435,8 +409,8 @@ describe("PullRequestsTab", () => { ignoredAt: Date.now(), }); render(() => ); - // IgnoreBadge shows ignored count - screen.getByText(/1 ignored/i); + // IgnoreBadge now shows an icon button with aria-label + screen.getByRole("button", { name: /1 ignored/i }); }); it("paginates repo groups across pages", async () => { diff --git a/tests/components/dashboard/IssuesTab.test.tsx b/tests/components/dashboard/IssuesTab.test.tsx index e5d67c85..d0c84f63 100644 --- a/tests/components/dashboard/IssuesTab.test.tsx +++ b/tests/components/dashboard/IssuesTab.test.tsx @@ -42,8 +42,8 @@ beforeEach(() => { // ── Tests ───────────────────────────────────────────────────────────────────── -describe("IssuesTab — user filter chip", () => { - it("does not show User filter chip when allUsers has only 1 entry (no tracked users)", () => { +describe("IssuesTab — user filter", () => { + it("does not show User filter when allUsers has only 1 entry (no tracked users)", () => { render(() => ( { 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", () => { + it("shows User filter when allUsers has > 1 entry", () => { render(() => ( { ]} /> )); - screen.getByText("User:"); + screen.getByLabelText("Filter by User"); }); - it("does not show User filter chip when allUsers is undefined", () => { + it("does not show User filter when allUsers is undefined", () => { render(() => ( )); - expect(screen.queryByText("User:")).toBeNull(); + expect(screen.queryByLabelText("Filter by User")).toBeNull(); }); }); @@ -546,46 +546,59 @@ describe("IssuesTab — scope filter with undefined surfacedBy (non-monitored re }); }); -// ── IssuesTab — scope chip visibility ────────────────────────────────────── +// ── IssuesTab — scope toggle visibility ──────────────────────────────────── -describe("IssuesTab — scope chip visibility", () => { - it("does not show Scope chip when no monitored repos and no tracked users", () => { +describe("IssuesTab — scope toggle visibility", () => { + it("does not show Scope toggle 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", () => { + it("shows Scope toggle 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)", () => { + it("shows Scope toggle 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 user filter to 'all' when allUsers drops to 1", () => { + setTabFilter("issues", "user", "tracked1"); + expect(viewState.tabFilters.issues.user).toBe("tracked1"); + + render(() => ( + + )); + + expect(viewState.tabFilters.issues.user).toBe("all"); }); - it("auto-resets scope to involves_me when scope chip becomes hidden", () => { + it("auto-resets scope to involves_me when scope toggle becomes hidden", () => { setTabFilter("issues", "scope", "all"); expect(viewState.tabFilters.issues.scope).toBe("all"); - // Render with no monitored repos and no tracked users — scope chip hidden, effect should reset + // Render with no monitored repos and no tracked users — scope toggle hidden, effect should reset render(() => ( )); diff --git a/tests/components/dashboard/PullRequestsTab.test.tsx b/tests/components/dashboard/PullRequestsTab.test.tsx index c7243437..640cc118 100644 --- a/tests/components/dashboard/PullRequestsTab.test.tsx +++ b/tests/components/dashboard/PullRequestsTab.test.tsx @@ -42,8 +42,8 @@ beforeEach(() => { // ── Tests ───────────────────────────────────────────────────────────────────── -describe("PullRequestsTab — user filter chip", () => { - it("does not show User filter chip when allUsers has only 1 entry", () => { +describe("PullRequestsTab — user filter", () => { + it("does not show User filter when allUsers has only 1 entry", () => { render(() => ( { 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", () => { + it("shows User filter when allUsers has > 1 entry", () => { render(() => ( { ]} /> )); - screen.getByText("User:"); + screen.getByLabelText("Filter by User"); }); }); @@ -474,32 +474,57 @@ describe("PullRequestsTab — star count in repo headers", () => { }); }); -// ── PullRequestsTab — scope chip visibility ──────────────────────────────── +// ── PullRequestsTab — scope toggle visibility ────────────────────────────── -describe("PullRequestsTab — scope chip visibility", () => { - it("does not show Scope chip when no monitored repos and no tracked users", () => { +describe("PullRequestsTab — scope toggle visibility", () => { + it("does not show Scope toggle 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", () => { + it("shows Scope toggle 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 user filter to 'all' when allUsers drops to 1", () => { + setTabFilter("pullRequests", "user", "tracked1"); + expect(viewState.tabFilters.pullRequests.user).toBe("tracked1"); + + render(() => ( + + )); + + expect(viewState.tabFilters.pullRequests.user).toBe("all"); }); - it("auto-resets scope to involves_me when scope chip becomes hidden", () => { + it("auto-resets scope to involves_me when scope toggle becomes hidden", () => { setTabFilter("pullRequests", "scope", "all"); expect(viewState.tabFilters.pullRequests.scope).toBe("all"); diff --git a/tests/components/layout/FilterBar.test.tsx b/tests/components/layout/FilterBar.test.tsx index 77493f63..18c453b1 100644 --- a/tests/components/layout/FilterBar.test.tsx +++ b/tests/components/layout/FilterBar.test.tsx @@ -39,69 +39,79 @@ afterEach(() => { vi.useRealTimers(); }); +const defaultSortProps = { + sortOptions: [ + { label: "Updated", field: "updatedAt", type: "date" as const }, + { label: "Created", field: "createdAt", type: "date" as const }, + ], + sortValue: "updatedAt", + sortDirection: "desc" as const, + onSortChange: vi.fn(), +}; + describe("FilterBar", () => { it("renders org and repo filter dropdowns", () => { - render(() => ); + render(() => ); screen.getByLabelText("Filter by organization"); screen.getByLabelText("Filter by repository"); }); it("renders refresh button", () => { - render(() => ); + render(() => ); screen.getByLabelText("Refresh data"); }); it("refresh button is enabled by default", () => { - render(() => ); + render(() => ); const refreshBtn = screen.getByLabelText("Refresh data") as HTMLButtonElement; expect(refreshBtn.disabled).toBe(false); }); it("refresh button is disabled when isRefreshing=true", () => { - render(() => ); + render(() => ); const refreshBtn = screen.getByLabelText("Refresh data") as HTMLButtonElement; expect(refreshBtn.disabled).toBe(true); }); it("shows 'Refreshing...' when isRefreshing=true", () => { - render(() => ); + render(() => ); screen.getByText("Refreshing..."); }); it("shows last refreshed time when lastRefreshedAt provided", () => { const now = new Date(); const tenSecondsAgo = new Date(now.getTime() - 10_000); - render(() => ); + render(() => ); screen.getByText("Updated 10s ago"); }); it("shows minutes when lastRefreshedAt is more than 60s ago", () => { const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); - render(() => ); + render(() => ); screen.getByText("Updated 2m ago"); }); it("does not show updated label when lastRefreshedAt is null", () => { - render(() => ); - expect(screen.queryByText(/Updated/)).toBeNull(); + render(() => ); + expect(screen.queryByText(/Updated \d+[sm] ago/)).toBeNull(); }); it("calls onRefresh when refresh button clicked", async () => { const user = userEvent.setup({ delay: null }); const onRefresh = vi.fn(); - render(() => ); + render(() => ); await user.click(screen.getByLabelText("Refresh data")); expect(onRefresh).toHaveBeenCalledOnce(); }); it("org trigger shows 'All orgs' by default", () => { - render(() => ); + render(() => ); const orgTrigger = screen.getByLabelText("Filter by organization"); expect(orgTrigger.textContent).toContain("All orgs"); }); it("repo trigger shows 'All repos' by default", () => { - render(() => ); + render(() => ); const repoTrigger = screen.getByLabelText("Filter by repository"); expect(repoTrigger.textContent).toContain("All repos"); }); @@ -111,14 +121,14 @@ describe("FilterBar", () => { org: "myorg", repo: null, }; - render(() => ); + render(() => ); const orgTrigger = screen.getByLabelText("Filter by organization"); expect(orgTrigger.textContent).toContain("myorg"); }); it("clicking org trigger opens listbox with org options", async () => { const user = userEvent.setup({ delay: null }); - render(() => ); + render(() => ); const orgTrigger = screen.getByLabelText("Filter by organization"); await user.click(orgTrigger); // Options should now be visible in the listbox @@ -128,7 +138,7 @@ describe("FilterBar", () => { it("clicking org trigger opens listbox with repo options", async () => { const user = userEvent.setup({ delay: null }); - render(() => ); + render(() => ); const repoTrigger = screen.getByLabelText("Filter by repository"); await user.click(repoTrigger); expect(screen.getByRole("option", { name: "myorg/repo-a" })).toBeDefined(); @@ -138,7 +148,7 @@ describe("FilterBar", () => { it("selecting an org option calls setGlobalFilter and resets repo", async () => { const user = userEvent.setup({ delay: null }); - render(() => ); + render(() => ); const orgTrigger = screen.getByLabelText("Filter by organization"); await user.click(orgTrigger); const myorgOption = screen.getByRole("option", { name: "myorg" }); @@ -148,7 +158,7 @@ describe("FilterBar", () => { it("selecting a repo option calls setGlobalFilter with current org and new repo", async () => { const user = userEvent.setup({ delay: null }); - render(() => ); + render(() => ); const repoTrigger = screen.getByLabelText("Filter by repository"); await user.click(repoTrigger); const repoOption = screen.getByRole("option", { name: "myorg/repo-a" }); @@ -156,9 +166,24 @@ describe("FilterBar", () => { expect(viewStore.setGlobalFilter).toHaveBeenCalledWith(null, "myorg/repo-a"); }); + it("renders SortDropdown trigger button", () => { + render(() => ); + screen.getByRole("button", { name: /Sort by/i }); + }); + + it("SortDropdown is not rendered when sort props are omitted", () => { + render(() => ); + expect(screen.queryByRole("button", { name: /Sort by/i })).toBeNull(); + }); + + it("SortDropdown is not rendered when sortOptions is provided but onSortChange is omitted", () => { + render(() => ); + expect(screen.queryByRole("button", { name: /Sort by/i })).toBeNull(); + }); + it("selecting 'All orgs' option calls setGlobalFilter with null org", async () => { const user = userEvent.setup({ delay: null }); - render(() => ); + render(() => ); const orgTrigger = screen.getByLabelText("Filter by organization"); await user.click(orgTrigger); const allOrgsOption = screen.getByRole("option", { name: "All orgs" }); diff --git a/tests/components/shared/FilterChips.test.tsx b/tests/components/shared/FilterChips.test.tsx deleted file mode 100644 index 69d80a5a..00000000 --- a/tests/components/shared/FilterChips.test.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@solidjs/testing-library"; -import FilterChips from "../../../src/app/components/shared/FilterChips"; -import type { FilterChipGroupDef } from "../../../src/app/components/shared/FilterChips"; - -// ── Setup ───────────────────────────────────────────────────────────────────── - -beforeEach(() => { - vi.clearAllMocks(); -}); - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function renderChips(opts: { - groups: FilterChipGroupDef[]; - values?: Record; - onChange?: (field: string, value: string) => void; - onReset?: (field: string) => void; - onResetAll?: () => void; -}) { - const onChange = opts.onChange ?? vi.fn(); - const onReset = opts.onReset ?? vi.fn(); - const onResetAll = opts.onResetAll ?? vi.fn(); - return render(() => ( - - )); -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe("FilterChips — standard group (no defaultValue)", () => { - const groups: FilterChipGroupDef[] = [ - { - label: "Role", - field: "role", - options: [ - { value: "author", label: "Author" }, - { value: "reviewer", label: "Reviewer" }, - ], - }, - ]; - - it("renders the auto-generated 'All' button when no defaultValue", () => { - renderChips({ groups }); - screen.getByText("All"); - }); - - it("'All' button is aria-pressed=true when value is 'all' (default)", () => { - renderChips({ groups, values: { role: "all" } }); - const allBtn = screen.getByText("All"); - expect(allBtn.getAttribute("aria-pressed")).toBe("true"); - }); - - it("does not show reset '×' button when filter is at default 'all'", () => { - renderChips({ groups, values: { role: "all" } }); - expect(screen.queryByLabelText("Reset Role filter")).toBeNull(); - }); - - it("shows reset '×' button when a non-default option is selected", () => { - renderChips({ groups, values: { role: "author" } }); - screen.getByLabelText("Reset Role filter"); - }); - - it("shows 'Reset all' button when filter differs from default", () => { - renderChips({ groups, values: { role: "author" } }); - screen.getByText("Reset all"); - }); - - it("does not show 'Reset all' button when filter is at default", () => { - renderChips({ groups, values: { role: "all" } }); - expect(screen.queryByText("Reset all")).toBeNull(); - }); -}); - -describe("FilterChips — group with defaultValue", () => { - const groups: FilterChipGroupDef[] = [ - { - label: "Scope", - field: "scope", - defaultValue: "involves_me", - options: [ - { value: "involves_me", label: "Involves me" }, - { value: "all", label: "All activity" }, - ], - }, - ]; - - it("does NOT render auto-generated 'All' button when defaultValue is set", () => { - renderChips({ groups }); - // "All activity" is an option, but "All" (auto-generated) should not appear - expect(screen.queryByText("All")).toBeNull(); - // The "All activity" option should still be present - screen.getByText("All activity"); - }); - - it("'Involves me' button is aria-pressed=true when value equals defaultValue", () => { - renderChips({ groups, values: { scope: "involves_me" } }); - const btn = screen.getByText("Involves me"); - expect(btn.getAttribute("aria-pressed")).toBe("true"); - }); - - it("does not show reset '×' button when value equals defaultValue", () => { - renderChips({ groups, values: { scope: "involves_me" } }); - expect(screen.queryByLabelText("Reset Scope filter")).toBeNull(); - }); - - it("shows reset '×' button when value differs from defaultValue", () => { - renderChips({ groups, values: { scope: "all" } }); - screen.getByLabelText("Reset Scope filter"); - }); - - it("'All activity' button is aria-pressed=true when value is 'all'", () => { - renderChips({ groups, values: { scope: "all" } }); - const btn = screen.getByText("All activity"); - expect(btn.getAttribute("aria-pressed")).toBe("true"); - }); - - it("shows 'Reset all' when value differs from defaultValue", () => { - renderChips({ groups, values: { scope: "all" } }); - screen.getByText("Reset all"); - }); - - it("does not show 'Reset all' when value equals defaultValue", () => { - renderChips({ groups, values: { scope: "involves_me" } }); - expect(screen.queryByText("Reset all")).toBeNull(); - }); - - it("does not show 'Reset all' when values object is empty (defaults apply)", () => { - renderChips({ groups, values: {} }); - expect(screen.queryByText("Reset all")).toBeNull(); - }); -}); - -describe("FilterChips — mixed groups (one standard, one with defaultValue)", () => { - const groups: FilterChipGroupDef[] = [ - { - label: "Scope", - field: "scope", - defaultValue: "involves_me", - options: [ - { value: "involves_me", label: "Involves me" }, - { value: "all", label: "All activity" }, - ], - }, - { - label: "Role", - field: "role", - options: [ - { value: "author", label: "Author" }, - { value: "reviewer", label: "Reviewer" }, - ], - }, - ]; - - it("shows 'All' for standard group but not for defaultValue group", () => { - renderChips({ groups }); - // "All" is the auto-generated button for Role group only - const allButtons = screen.getAllByText("All"); - expect(allButtons).toHaveLength(1); - // "All activity" is an option for Scope group - screen.getByText("All activity"); - }); - - it("shows 'Reset all' only when at least one filter differs from its default", () => { - // scope at default, role at default - renderChips({ groups, values: { scope: "involves_me", role: "all" } }); - expect(screen.queryByText("Reset all")).toBeNull(); - }); - - it("shows 'Reset all' when scope differs from defaultValue", () => { - renderChips({ groups, values: { scope: "all", role: "all" } }); - screen.getByText("Reset all"); - }); - - it("shows 'Reset all' when role differs from 'all'", () => { - renderChips({ groups, values: { scope: "involves_me", role: "author" } }); - screen.getByText("Reset all"); - }); - - it("calls onChange when a chip is clicked", () => { - const onChange = vi.fn(); - renderChips({ groups, onChange }); - fireEvent.click(screen.getByText("All activity")); - expect(onChange).toHaveBeenCalledWith("scope", "all"); - }); - - it("calls onReset when reset '×' button is clicked", () => { - const onReset = vi.fn(); - renderChips({ groups, values: { scope: "all" }, onReset }); - fireEvent.click(screen.getByLabelText("Reset Scope filter")); - expect(onReset).toHaveBeenCalledWith("scope"); - }); - - it("calls onResetAll when 'Reset all' is clicked", () => { - const onResetAll = vi.fn(); - renderChips({ groups, values: { scope: "all" }, onResetAll }); - fireEvent.click(screen.getByText("Reset all")); - expect(onResetAll).toHaveBeenCalled(); - }); -}); diff --git a/tests/components/shared/FilterPopover.test.tsx b/tests/components/shared/FilterPopover.test.tsx new file mode 100644 index 00000000..730d01ea --- /dev/null +++ b/tests/components/shared/FilterPopover.test.tsx @@ -0,0 +1,193 @@ +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" option should appear as a button in the popover + const buttons = screen.getAllByRole("button").filter(b => b !== trigger); + const allBtn = buttons.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").filter(b => b !== trigger); + const approvedBtn = buttons.find(b => b.textContent?.includes("Approved")); + 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").filter(b => b !== trigger); + const allBtn = buttons.find(b => b.textContent?.includes("All")); + 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"); + }); + + it("Escape closes popover (focus management)", () => { + 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" }); + vi.advanceTimersByTime(0); + // Popover is closed; focus is managed by Kobalte (returns to trigger or nearby element) + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("selected option has aria-pressed='true', unselected has 'false'", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + const buttons = screen.getAllByRole("button").filter(b => b !== trigger); + const approvedBtn = buttons.find(b => b.textContent?.includes("Approved")); + expect(approvedBtn?.getAttribute("aria-pressed")).toBe("true"); + const changesBtn = buttons.find(b => b.textContent?.includes("Changes requested")); + expect(changesBtn?.getAttribute("aria-pressed")).toBe("false"); + }); + + it("'All' button has aria-pressed='true' when value is 'all'", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + const buttons = screen.getAllByRole("button").filter(b => b !== trigger); + const allBtn = buttons.find(b => b.textContent?.includes("All")); + expect(allBtn?.getAttribute("aria-pressed")).toBe("true"); + const approvedBtn = buttons.find(b => b.textContent?.includes("Approved")); + expect(approvedBtn?.getAttribute("aria-pressed")).toBe("false"); + }); + + it("popover content has aria-label with group label", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + fireEvent.click(trigger); + vi.advanceTimersByTime(0); + const content = document.querySelector("[aria-label='Review']"); + expect(content).toBeTruthy(); + }); + + it("shows raw value as label for unknown/stale filter values", () => { + render(() => {}} />); + const trigger = screen.getByRole("button", { name: /Filter by Review/i }); + expect(trigger.className).toContain("btn-primary"); + expect(trigger.textContent).toContain("Review: STALE_VALUE"); + }); +}); diff --git a/tests/components/shared/FilterToolbar.test.tsx b/tests/components/shared/FilterToolbar.test.tsx new file mode 100644 index 00000000..73c438f8 --- /dev/null +++ b/tests/components/shared/FilterToolbar.test.tsx @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createSignal } from "solid-js"; +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("renders correct trigger count when groups change dynamically", () => { + const [groups, setGroups] = createSignal([roleGroup]); + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + expect(screen.getAllByRole("button", { name: /Filter by/i })).toHaveLength(1); + + setGroups([roleGroup, reviewGroup]); + expect(screen.getAllByRole("button", { name: /Filter by/i })).toHaveLength(2); + }); + + it("shows 'Reset all' when scope is 'all' (non-default 'involves_me') and other filters at default", () => { + render(() => ( + {}} + onResetAll={() => {}} + /> + )); + screen.getByText("Reset all"); + }); + + 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); + }); +}); 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(); + }); +}); diff --git a/tests/stores/view.test.ts b/tests/stores/view.test.ts index ec82edc5..ffc065ad 100644 --- a/tests/stores/view.test.ts +++ b/tests/stores/view.test.ts @@ -92,22 +92,15 @@ describe("setGlobalFilter", () => { }); describe("setSortPreference", () => { - it("sets sort field and direction for a tab", () => { - setSortPreference("issues", "updatedAt", "desc"); - expect(viewState.sortPreferences["issues"]).toEqual({ field: "updatedAt", direction: "desc" }); + it("sets global sort field and direction", () => { + setSortPreference("updatedAt", "desc"); + expect(viewState.globalSort).toEqual({ field: "updatedAt", direction: "desc" }); }); - it("updates existing sort preference for a tab", () => { - setSortPreference("issues", "updatedAt", "desc"); - setSortPreference("issues", "title", "asc"); - expect(viewState.sortPreferences["issues"]).toEqual({ field: "title", direction: "asc" }); - }); - - it("sets preferences for multiple tabs independently", () => { - setSortPreference("issues", "updatedAt", "desc"); - setSortPreference("pullRequests", "createdAt", "asc"); - expect(viewState.sortPreferences["issues"].field).toBe("updatedAt"); - expect(viewState.sortPreferences["pullRequests"].field).toBe("createdAt"); + it("updates existing global sort preference", () => { + setSortPreference("updatedAt", "desc"); + setSortPreference("title", "asc"); + expect(viewState.globalSort).toEqual({ field: "title", direction: "asc" }); }); }); @@ -233,7 +226,7 @@ describe("ViewStateSchema", () => { it("returns defaults for empty object", () => { const result = ViewStateSchema.parse({}); expect(result.lastActiveTab).toBe("issues"); - expect(result.sortPreferences).toEqual({}); + expect(result.globalSort).toEqual({ field: "updatedAt", direction: "desc" }); expect(result.ignoredItems).toEqual([]); expect(result.globalFilter).toEqual({ org: null, repo: null }); expect(result.hideDepDashboard).toBe(true); @@ -254,6 +247,15 @@ describe("ViewStateSchema", () => { const result = ViewStateSchema.parse({ lastActiveTab: "actions" }); expect(result.expandedRepos).toEqual({ issues: {}, pullRequests: {}, actions: {} }); }); + + it("old localStorage data with sortPreferences parses cleanly with globalSort default", () => { + const oldData = { + lastActiveTab: "issues", + sortPreferences: { issues: { field: "title", direction: "asc" } }, + }; + const result = ViewStateSchema.parse(oldData); + expect(result.globalSort).toEqual({ field: "updatedAt", direction: "desc" }); + }); }); describe("expandedRepos helpers", () => { @@ -340,6 +342,13 @@ describe("expandedRepos helpers", () => { }); describe("resetViewState", () => { + it("resets globalSort to default", () => { + setSortPreference("title", "asc"); + expect(viewState.globalSort.field).toBe("title"); + resetViewState(); + expect(viewState.globalSort).toEqual({ field: "updatedAt", direction: "desc" }); + }); + it("clears dynamically-added expandedRepos keys", () => { setAllExpanded("issues", ["org/repo-a", "org/repo-b"], true); setAllExpanded("pullRequests", ["org/repo-c"], true);