diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index 62a24c6c..9f796cd4 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -105,6 +105,9 @@ const mockViewState = { viewMode: "board" as const, statusFilters: [] as string[], priorityFilters: [] as string[], + assigneeFilters: [] as { type: string; id: string }[], + includeNoAssignee: false, + creatorFilters: [] as { type: string; id: string }[], sortBy: "position" as const, sortDirection: "asc" as const, cardProperties: { priority: true, description: true, assignee: true, dueDate: true }, @@ -112,6 +115,9 @@ const mockViewState = { setViewMode: vi.fn(), toggleStatusFilter: vi.fn(), togglePriorityFilter: vi.fn(), + toggleAssigneeFilter: vi.fn(), + toggleNoAssignee: vi.fn(), + toggleCreatorFilter: vi.fn(), hideStatus: vi.fn(), showStatus: vi.fn(), clearFilters: vi.fn(), @@ -122,6 +128,7 @@ const mockViewState = { }; vi.mock("@/features/issues/stores/view-store", () => ({ + initFilterWorkspaceSync: vi.fn(), useIssueViewStore: Object.assign( (selector?: any) => (selector ? selector(mockViewState) : mockViewState), { getState: () => mockViewState, setState: vi.fn() }, diff --git a/apps/web/components.json b/apps/web/components.json index 661ed3de..c38ff545 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -19,7 +19,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "menuColor": "inverted-translucent", + "menuColor": "default", "menuAccent": "subtle", "registries": {} } diff --git a/apps/web/components/ui/button.tsx b/apps/web/components/ui/button.tsx index a91c4b5d..8dfb5192 100644 --- a/apps/web/components/ui/button.tsx +++ b/apps/web/components/ui/button.tsx @@ -6,17 +6,17 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", outline: - "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 dark:aria-expanded:bg-muted dark:aria-expanded:text-foreground", + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", ghost: - "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 dark:aria-expanded:bg-muted dark:aria-expanded:text-foreground", + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", diff --git a/apps/web/components/ui/combobox.tsx b/apps/web/components/ui/combobox.tsx index 76d81f76..39e2b937 100644 --- a/apps/web/components/ui/combobox.tsx +++ b/apps/web/components/ui/combobox.tsx @@ -110,10 +110,7 @@ function ComboboxContent({ diff --git a/apps/web/components/ui/context-menu.tsx b/apps/web/components/ui/context-menu.tsx index c81dcd51..9c0eb98f 100644 --- a/apps/web/components/ui/context-menu.tsx +++ b/apps/web/components/ui/context-menu.tsx @@ -52,10 +52,7 @@ function ContextMenuContent({ > @@ -148,7 +145,7 @@ function ContextMenuSubContent({ return ( diff --git a/apps/web/components/ui/dropdown-menu.tsx b/apps/web/components/ui/dropdown-menu.tsx index 863706d0..f157768d 100644 --- a/apps/web/components/ui/dropdown-menu.tsx +++ b/apps/web/components/ui/dropdown-menu.tsx @@ -41,10 +41,7 @@ function DropdownMenuContent({ > @@ -138,10 +135,7 @@ function DropdownMenuSubContent({ return ( ) @@ -257,10 +254,7 @@ function MenubarSubContent({ return ( ) diff --git a/apps/web/components/ui/select.tsx b/apps/web/components/ui/select.tsx index 2c095fb0..e8021f5f 100644 --- a/apps/web/components/ui/select.tsx +++ b/apps/web/components/ui/select.tsx @@ -83,10 +83,7 @@ function SelectContent({ diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index 558730a4..33b6680d 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -1,15 +1,22 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { ArrowDown, ArrowUp, + Bot, + Check, ChevronDown, + CircleDot, Columns3, Filter, List, Plus, + SignalHigh, SlidersHorizontal, + User, + UserMinus, + UserPen, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useIssueStore } from "@/features/issues/store"; @@ -22,6 +29,9 @@ import { DropdownMenuCheckboxItem, DropdownMenuLabel, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; import { Popover, @@ -37,46 +47,267 @@ import { PRIORITY_CONFIG, } from "@/features/issues/config"; import { StatusIcon, PriorityIcon } from "@/features/issues/components"; +import { useWorkspaceStore, useActorName } from "@/features/workspace"; import { useIssueViewStore, SORT_OPTIONS, CARD_PROPERTY_OPTIONS, + type ActorFilterValue, } from "@/features/issues/stores/view-store"; +import { filterIssues } from "@/features/issues/utils/filter"; +import type { Issue } from "@/shared/types"; + +// --------------------------------------------------------------------------- +// HoverCheck — shadcn official pattern (PR #6862) +// Uses data-selected attr instead of Checkbox component to avoid +// DropdownMenuCheckboxItem's focus:**:text-accent-foreground cascade. +// --------------------------------------------------------------------------- + +const FILTER_ITEM_CLASS = + "group/fitem pr-1.5! [&>[data-slot=dropdown-menu-checkbox-item-indicator]]:hidden"; + +function HoverCheck({ checked }: { checked: boolean }) { + return ( +
+ +
+ ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function getActiveFilterCount(state: { + statusFilters: string[]; + priorityFilters: string[]; + assigneeFilters: ActorFilterValue[]; + includeNoAssignee: boolean; + creatorFilters: ActorFilterValue[]; +}) { + let count = 0; + if (state.statusFilters.length > 0) count++; + if (state.priorityFilters.length > 0) count++; + if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++; + if (state.creatorFilters.length > 0) count++; + return count; +} + +function useIssueCounts(allIssues: Issue[]) { + return useMemo(() => { + const status = new Map(); + const priority = new Map(); + const assignee = new Map(); + const creator = new Map(); + let noAssignee = 0; + + for (const issue of allIssues) { + status.set(issue.status, (status.get(issue.status) ?? 0) + 1); + priority.set(issue.priority, (priority.get(issue.priority) ?? 0) + 1); + + if (!issue.assignee_id) { + noAssignee++; + } else { + const aKey = `${issue.assignee_type}:${issue.assignee_id}`; + assignee.set(aKey, (assignee.get(aKey) ?? 0) + 1); + } + + const cKey = `${issue.creator_type}:${issue.creator_id}`; + creator.set(cKey, (creator.get(cKey) ?? 0) + 1); + } + + return { status, priority, assignee, creator, noAssignee }; + }, [allIssues]); +} + +// --------------------------------------------------------------------------- +// Actor sub-menu content (shared between Assignee and Creator) +// --------------------------------------------------------------------------- + +function ActorSubContent({ + counts, + selected, + onToggle, + showNoAssignee, + includeNoAssignee, + onToggleNoAssignee, + noAssigneeCount, +}: { + counts: Map; + selected: ActorFilterValue[]; + onToggle: (value: ActorFilterValue) => void; + showNoAssignee?: boolean; + includeNoAssignee?: boolean; + onToggleNoAssignee?: () => void; + noAssigneeCount?: number; +}) { + const [search, setSearch] = useState(""); + const members = useWorkspaceStore((s) => s.members); + const agents = useWorkspaceStore((s) => s.agents); + const { getActorInitials } = useActorName(); + + const query = search.toLowerCase(); + const filteredMembers = members.filter((m) => + m.name.toLowerCase().includes(query), + ); + const filteredAgents = agents.filter((a) => + a.name.toLowerCase().includes(query), + ); + + const isSelected = (type: "member" | "agent", id: string) => + selected.some((f) => f.type === type && f.id === id); + + return ( + <> +
+ setSearch(e.target.value)} + placeholder="Filter..." + className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" + autoFocus + /> +
+ +
+ {showNoAssignee && + (!query || "no assignee".includes(query) || "unassigned".includes(query)) && ( + onToggleNoAssignee?.()} + className={FILTER_ITEM_CLASS} + > + + + No assignee + {(noAssigneeCount ?? 0) > 0 && ( + + {noAssigneeCount} + + )} + + )} + + {filteredMembers.length > 0 && ( + + Members + {filteredMembers.map((m) => { + const checked = isSelected("member", m.user_id); + const count = counts.get(`member:${m.user_id}`) ?? 0; + return ( + + onToggle({ type: "member", id: m.user_id }) + } + className={FILTER_ITEM_CLASS} + > + +
+ {getActorInitials("member", m.user_id)} +
+ {m.name} + {count > 0 && ( + + {count} + + )} +
+ ); + })} +
+ )} + + {filteredAgents.length > 0 && ( + + Agents + {filteredAgents.map((a) => { + const checked = isSelected("agent", a.id); + const count = counts.get(`agent:${a.id}`) ?? 0; + return ( + + onToggle({ type: "agent", id: a.id }) + } + className={FILTER_ITEM_CLASS} + > + +
+ +
+ {a.name} + {count > 0 && ( + + {count} + + )} +
+ ); + })} +
+ )} + + {filteredMembers.length === 0 && filteredAgents.length === 0 && search && ( +
+ No results +
+ )} +
+ + ); +} + +// --------------------------------------------------------------------------- +// IssuesHeader +// --------------------------------------------------------------------------- export function IssuesHeader() { const viewMode = useIssueViewStore((s) => s.viewMode); const statusFilters = useIssueViewStore((s) => s.statusFilters); const priorityFilters = useIssueViewStore((s) => s.priorityFilters); + const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters); + const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee); + const creatorFilters = useIssueViewStore((s) => s.creatorFilters); const sortBy = useIssueViewStore((s) => s.sortBy); const sortDirection = useIssueViewStore((s) => s.sortDirection); const cardProperties = useIssueViewStore((s) => s.cardProperties); const setViewMode = useIssueViewStore((s) => s.setViewMode); const toggleStatusFilter = useIssueViewStore((s) => s.toggleStatusFilter); const togglePriorityFilter = useIssueViewStore((s) => s.togglePriorityFilter); + const toggleAssigneeFilter = useIssueViewStore((s) => s.toggleAssigneeFilter); + const toggleNoAssignee = useIssueViewStore((s) => s.toggleNoAssignee); + const toggleCreatorFilter = useIssueViewStore((s) => s.toggleCreatorFilter); + const clearFilters = useIssueViewStore((s) => s.clearFilters); const setSortBy = useIssueViewStore((s) => s.setSortBy); const setSortDirection = useIssueViewStore((s) => s.setSortDirection); const toggleCardProperty = useIssueViewStore((s) => s.toggleCardProperty); - const clearFilters = useIssueViewStore((s) => s.clearFilters); const allIssues = useIssueStore((s) => s.issues); + const counts = useIssueCounts(allIssues); - const filteredCount = useMemo(() => { - return allIssues.filter((i) => { - if (statusFilters.length > 0 && !statusFilters.includes(i.status)) - return false; - if ( - priorityFilters.length > 0 && - !priorityFilters.includes(i.priority) - ) - return false; - return true; - }).length; - }, [allIssues, statusFilters, priorityFilters]); + const filteredCount = useMemo( + () => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }).length, + [allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], + ); + + const filterCount = getActiveFilterCount({ + statusFilters, + priorityFilters, + assigneeFilters, + includeNoAssignee, + creatorFilters, + }); const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual"; - const hasActiveFilters = - statusFilters.length > 0 || priorityFilters.length > 0; + const hasActiveFilters = filterCount > 0; return (
@@ -106,9 +337,9 @@ export function IssuesHeader() { - {/* Filter */} - - + - {statusFilters.length + priorityFilters.length} + {filterCount} )} } /> - + {/* Status */} -
- - Status - -
- {ALL_STATUSES.map((s) => ( - - ))} -
-
+ + ); + })} + + {/* Priority */} -
- - Priority - -
- {PRIORITY_ORDER.map((p) => ( - - ))} -
-
+ + ); + })} + + + + {/* Assignee */} + + + + Assignee + {(assigneeFilters.length > 0 || includeNoAssignee) && ( + + {assigneeFilters.length + (includeNoAssignee ? 1 : 0)} + + )} + + + + + + + {/* Creator */} + + + + Creator + {creatorFilters.length > 0 && ( + + {creatorFilters.length} + + )} + + + + + {/* Reset */} {hasActiveFilters && ( -
- -
+ <> + + + Reset all filters + + )} -
-
+ + {/* Display settings */} @@ -232,7 +496,6 @@ export function IssuesHeader() { } /> - {/* Ordering section */}
Ordering @@ -279,7 +542,6 @@ export function IssuesHeader() {
- {/* Card properties section */}
Card properties @@ -308,7 +570,6 @@ export function IssuesHeader() { {filteredCount} {filteredCount === 1 ? "Issue" : "Issues"} - {/* New issue */}