Merge pull request #203 from multica-ai/forrestchang/issue-filter-assignee-creator
feat(issues): add assignee and creator filters
This commit is contained in:
commit
2152fec4ee
13 changed files with 667 additions and 178 deletions
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "inverted-translucent",
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -110,10 +110,7 @@ function ComboboxContent({
|
|||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"dark group/combobox-content max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
|
||||
className
|
||||
)}
|
||||
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
|
|
|
|||
|
|
@ -52,10 +52,7 @@ function ContextMenuContent({
|
|||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"dark z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
|
||||
className
|
||||
)}
|
||||
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Positioner>
|
||||
|
|
@ -148,7 +145,7 @@ function ContextMenuSubContent({
|
|||
return (
|
||||
<ContextMenuContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className="dark shadow-lg animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!"
|
||||
className="shadow-lg"
|
||||
side="right"
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -41,10 +41,7 @@ function DropdownMenuContent({
|
|||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"dark z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
|
||||
className
|
||||
)}
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
|
|
@ -138,10 +135,7 @@ function DropdownMenuSubContent({
|
|||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"dark w-auto min-w-[96px] rounded-lg p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
|
||||
className
|
||||
)}
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
|
|
@ -165,7 +159,7 @@ function DropdownMenuCheckboxItem({
|
|||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
|
|
|
|||
|
|
@ -80,10 +80,7 @@ function MenubarContent({
|
|||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"dark min-w-36 rounded-lg p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
|
||||
className
|
||||
)}
|
||||
className={cn("min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -257,10 +254,7 @@ function MenubarSubContent({
|
|||
return (
|
||||
<DropdownMenuSubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"dark min-w-32 rounded-lg p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
|
||||
className
|
||||
)}
|
||||
className={cn("min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -83,10 +83,7 @@ function SelectContent({
|
|||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn(
|
||||
"dark isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 animate-none! relative bg-popover/70 before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:rounded-[inherit] before:backdrop-blur-2xl before:backdrop-saturate-150 **:data-[slot$=-item]:focus:bg-foreground/10 **:data-[slot$=-item]:data-highlighted:bg-foreground/10 **:data-[slot$=-separator]:bg-foreground/5 **:data-[slot$=-trigger]:focus:bg-foreground/10 **:data-[slot$=-trigger]:aria-expanded:bg-foreground/10! **:data-[variant=destructive]:focus:bg-foreground/10! **:data-[variant=destructive]:text-accent-foreground! **:data-[variant=destructive]:**:text-accent-foreground!",
|
||||
className
|
||||
)}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className="border-input data-[selected=true]:border-primary data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground pointer-events-none size-4 shrink-0 rounded-[4px] border transition-all select-none *:[svg]:opacity-0 data-[selected=true]:*:[svg]:opacity-100 opacity-0 group-hover/fitem:opacity-100 group-focus/fitem:opacity-100 data-[selected=true]:opacity-100"
|
||||
data-selected={checked}
|
||||
>
|
||||
<Check className="size-3.5 text-current" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<string, number>();
|
||||
const priority = new Map<string, number>();
|
||||
const assignee = new Map<string, number>();
|
||||
const creator = new Map<string, number>();
|
||||
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<string, number>;
|
||||
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 (
|
||||
<>
|
||||
<div className="px-2 py-1.5 border-b border-foreground/5">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter..."
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto p-1">
|
||||
{showNoAssignee &&
|
||||
(!query || "no assignee".includes(query) || "unassigned".includes(query)) && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={includeNoAssignee ?? false}
|
||||
onCheckedChange={() => onToggleNoAssignee?.()}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={includeNoAssignee ?? false} />
|
||||
<UserMinus className="size-3.5 text-muted-foreground" />
|
||||
No assignee
|
||||
{(noAssigneeCount ?? 0) > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{noAssigneeCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
|
||||
{filteredMembers.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Members</DropdownMenuLabel>
|
||||
{filteredMembers.map((m) => {
|
||||
const checked = isSelected("member", m.user_id);
|
||||
const count = counts.get(`member:${m.user_id}`) ?? 0;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={m.user_id}
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
onToggle({ type: "member", id: m.user_id })
|
||||
}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
<span className="truncate">{m.name}</span>
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
|
||||
{filteredAgents.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Agents</DropdownMenuLabel>
|
||||
{filteredAgents.map((a) => {
|
||||
const checked = isSelected("agent", a.id);
|
||||
const count = counts.get(`agent:${a.id}`) ?? 0;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={a.id}
|
||||
checked={checked}
|
||||
onCheckedChange={() =>
|
||||
onToggle({ type: "agent", id: a.id })
|
||||
}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={checked} />
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span className="truncate">{a.name}</span>
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && search && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="flex h-12 shrink-0 items-center justify-between px-4">
|
||||
|
|
@ -106,9 +337,9 @@ export function IssuesHeader() {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Filter */}
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
{/* Filter — DropdownMenu with sub-menus */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -119,107 +350,140 @@ export function IssuesHeader() {
|
|||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
||||
{statusFilters.length + priorityFilters.length}
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-64 p-0">
|
||||
<DropdownMenuContent align="start" className="w-44">
|
||||
{/* Status */}
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Status
|
||||
</span>
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<label
|
||||
key={s}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent"
|
||||
onClick={() => toggleStatusFilter(s)}
|
||||
>
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded border ${
|
||||
statusFilters.length === 0 || statusFilters.includes(s)
|
||||
? "border-primary bg-primary"
|
||||
: "border-input"
|
||||
}`}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<CircleDot className="size-3.5" />
|
||||
<span className="flex-1">Status</span>
|
||||
{statusFilters.length > 0 && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{statusFilters.length}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-auto min-w-48">
|
||||
{ALL_STATUSES.map((s) => {
|
||||
const checked = statusFilters.includes(s);
|
||||
const count = counts.status.get(s) ?? 0;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={s}
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleStatusFilter(s)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
{(statusFilters.length === 0 ||
|
||||
statusFilters.includes(s)) && (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
className="h-3 w-3 text-primary-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M2 6l3 3 5-5" />
|
||||
</svg>
|
||||
<HoverCheck checked={checked} />
|
||||
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
||||
{STATUS_CONFIG[s].label}
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{count} {count === 1 ? "issue" : "issues"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
||||
<span className="text-sm">{STATUS_CONFIG[s].label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Priority
|
||||
</span>
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{PRIORITY_ORDER.map((p) => (
|
||||
<label
|
||||
key={p}
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 hover:bg-accent"
|
||||
onClick={() => togglePriorityFilter(p)}
|
||||
>
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded border ${
|
||||
priorityFilters.length === 0 ||
|
||||
priorityFilters.includes(p)
|
||||
? "border-primary bg-primary"
|
||||
: "border-input"
|
||||
}`}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<SignalHigh className="size-3.5" />
|
||||
<span className="flex-1">Priority</span>
|
||||
{priorityFilters.length > 0 && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{priorityFilters.length}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-auto min-w-44">
|
||||
{PRIORITY_ORDER.map((p) => {
|
||||
const checked = priorityFilters.includes(p);
|
||||
const count = counts.priority.get(p) ?? 0;
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={p}
|
||||
checked={checked}
|
||||
onCheckedChange={() => togglePriorityFilter(p)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
{(priorityFilters.length === 0 ||
|
||||
priorityFilters.includes(p)) && (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
className="h-3 w-3 text-primary-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M2 6l3 3 5-5" />
|
||||
</svg>
|
||||
<HoverCheck checked={checked} />
|
||||
<PriorityIcon priority={p} />
|
||||
{PRIORITY_CONFIG[p].label}
|
||||
{count > 0 && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{count} {count === 1 ? "issue" : "issues"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<PriorityIcon priority={p} />
|
||||
<span className="text-sm">{PRIORITY_CONFIG[p].label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Assignee */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<User className="size-3.5" />
|
||||
<span className="flex-1">Assignee</span>
|
||||
{(assigneeFilters.length > 0 || includeNoAssignee) && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{assigneeFilters.length + (includeNoAssignee ? 1 : 0)}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
|
||||
<ActorSubContent
|
||||
counts={counts.assignee}
|
||||
selected={assigneeFilters}
|
||||
onToggle={toggleAssigneeFilter}
|
||||
showNoAssignee
|
||||
includeNoAssignee={includeNoAssignee}
|
||||
onToggleNoAssignee={toggleNoAssignee}
|
||||
noAssigneeCount={counts.noAssignee}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Creator */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<UserPen className="size-3.5" />
|
||||
<span className="flex-1">Creator</span>
|
||||
{creatorFilters.length > 0 && (
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{creatorFilters.length}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
|
||||
<ActorSubContent
|
||||
counts={counts.creator}
|
||||
selected={creatorFilters}
|
||||
onToggle={toggleCreatorFilter}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Reset */}
|
||||
{hasActiveFilters && (
|
||||
<div className="px-3 py-2">
|
||||
<Button
|
||||
variant="link"
|
||||
size="xs"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Reset filters
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={clearFilters}>
|
||||
Reset all filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Display settings */}
|
||||
<Popover>
|
||||
|
|
@ -232,7 +496,6 @@ export function IssuesHeader() {
|
|||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-64 p-0">
|
||||
{/* Ordering section */}
|
||||
<div className="border-b px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Ordering
|
||||
|
|
@ -279,7 +542,6 @@ export function IssuesHeader() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card properties section */}
|
||||
<div className="px-3 py-2.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Card properties
|
||||
|
|
@ -308,7 +570,6 @@ export function IssuesHeader() {
|
|||
<span className="text-xs text-muted-foreground">
|
||||
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"}
|
||||
</span>
|
||||
{/* New issue */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => useModalStore.getState().open("create-issue")}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ import { ChevronRight } from "lucide-react";
|
|||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
|
|
@ -32,23 +33,22 @@ export function IssuesPage() {
|
|||
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);
|
||||
|
||||
useEffect(() => {
|
||||
initFilterWorkspaceSync();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
useIssueSelectionStore.getState().clear();
|
||||
}, [viewMode]);
|
||||
|
||||
const issues = useMemo(() => {
|
||||
return allIssues.filter((issue) => {
|
||||
if (statusFilters.length > 0 && !statusFilters.includes(issue.status))
|
||||
return false;
|
||||
if (
|
||||
priorityFilters.length > 0 &&
|
||||
!priorityFilters.includes(issue.priority)
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
});
|
||||
}, [allIssues, statusFilters, priorityFilters]);
|
||||
const issues = useMemo(
|
||||
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
|
||||
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||
);
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority } from "@/shared/types";
|
||||
import { ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
|
||||
import { ALL_STATUSES } from "@/features/issues/config";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
|
||||
|
|
@ -16,6 +16,11 @@ export interface CardProperties {
|
|||
dueDate: boolean;
|
||||
}
|
||||
|
||||
export interface ActorFilterValue {
|
||||
type: "member" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||
{ value: "position", label: "Manual" },
|
||||
{ value: "priority", label: "Priority" },
|
||||
|
|
@ -35,6 +40,9 @@ interface IssueViewState {
|
|||
viewMode: ViewMode;
|
||||
statusFilters: IssueStatus[];
|
||||
priorityFilters: IssuePriority[];
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
includeNoAssignee: boolean;
|
||||
creatorFilters: ActorFilterValue[];
|
||||
sortBy: SortField;
|
||||
sortDirection: SortDirection;
|
||||
cardProperties: CardProperties;
|
||||
|
|
@ -42,6 +50,9 @@ interface IssueViewState {
|
|||
setViewMode: (mode: ViewMode) => void;
|
||||
toggleStatusFilter: (status: IssueStatus) => void;
|
||||
togglePriorityFilter: (priority: IssuePriority) => void;
|
||||
toggleAssigneeFilter: (value: ActorFilterValue) => void;
|
||||
toggleNoAssignee: () => void;
|
||||
toggleCreatorFilter: (value: ActorFilterValue) => void;
|
||||
hideStatus: (status: IssueStatus) => void;
|
||||
showStatus: (status: IssueStatus) => void;
|
||||
clearFilters: () => void;
|
||||
|
|
@ -57,6 +68,9 @@ export const useIssueViewStore = create<IssueViewState>()(
|
|||
viewMode: "board",
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: {
|
||||
|
|
@ -69,38 +83,69 @@ export const useIssueViewStore = create<IssueViewState>()(
|
|||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
? state.statusFilters.filter((s) => s !== status)
|
||||
: [...state.statusFilters, status],
|
||||
})),
|
||||
togglePriorityFilter: (priority) =>
|
||||
set((state) => ({
|
||||
priorityFilters: state.priorityFilters.includes(priority)
|
||||
? state.priorityFilters.filter((p) => p !== priority)
|
||||
: [...state.priorityFilters, priority],
|
||||
})),
|
||||
toggleAssigneeFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.assigneeFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
assigneeFilters: exists
|
||||
? state.assigneeFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.assigneeFilters, value],
|
||||
};
|
||||
}),
|
||||
toggleNoAssignee: () =>
|
||||
set((state) => ({ includeNoAssignee: !state.includeNoAssignee })),
|
||||
toggleCreatorFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.creatorFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
creatorFilters: exists
|
||||
? state.creatorFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.creatorFilters, value],
|
||||
};
|
||||
}),
|
||||
hideStatus: (status) =>
|
||||
set((state) => {
|
||||
// If no filter active, activate filter with all EXCEPT this one
|
||||
if (state.statusFilters.length === 0) {
|
||||
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
|
||||
}
|
||||
const next = state.statusFilters.includes(status)
|
||||
? state.statusFilters.filter((s) => s !== status)
|
||||
: [...state.statusFilters, status];
|
||||
return { statusFilters: next.length >= ALL_STATUSES.length ? [] : next };
|
||||
return {
|
||||
statusFilters: state.statusFilters.filter((s) => s !== status),
|
||||
};
|
||||
}),
|
||||
togglePriorityFilter: (priority) =>
|
||||
set((state) => {
|
||||
if (state.priorityFilters.length === 0) {
|
||||
return { priorityFilters: PRIORITY_ORDER.filter((p) => p !== priority) };
|
||||
}
|
||||
const next = state.priorityFilters.includes(priority)
|
||||
? state.priorityFilters.filter((p) => p !== priority)
|
||||
: [...state.priorityFilters, priority];
|
||||
return { priorityFilters: next.length >= PRIORITY_ORDER.length ? [] : next };
|
||||
}),
|
||||
hideStatus: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.length === 0
|
||||
? ALL_STATUSES.filter((s) => s !== status)
|
||||
: state.statusFilters.filter((s) => s !== status),
|
||||
})),
|
||||
showStatus: (status) =>
|
||||
set((state) => {
|
||||
if (state.statusFilters.length === 0) return state;
|
||||
const next = [...state.statusFilters, status];
|
||||
return { statusFilters: next.length >= ALL_STATUSES.length ? [] : next };
|
||||
if (state.statusFilters.includes(status)) return state;
|
||||
return { statusFilters: [...state.statusFilters, status] };
|
||||
}),
|
||||
clearFilters: () =>
|
||||
set({
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
}),
|
||||
clearFilters: () => set({ statusFilters: [], priorityFilters: [] }),
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
setSortDirection: (dir) => set({ sortDirection: dir }),
|
||||
toggleCardProperty: (key) =>
|
||||
|
|
@ -123,6 +168,9 @@ export const useIssueViewStore = create<IssueViewState>()(
|
|||
viewMode: state.viewMode,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
includeNoAssignee: state.includeNoAssignee,
|
||||
creatorFilters: state.creatorFilters,
|
||||
sortBy: state.sortBy,
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
|
|
@ -131,3 +179,23 @@ export const useIssueViewStore = create<IssueViewState>()(
|
|||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Clear actor-based filters when workspace switches (IDs are workspace-scoped).
|
||||
// Deferred to avoid circular dependency: view-store → workspace → issues → view-store.
|
||||
let _filterSubInitialized = false;
|
||||
export function initFilterWorkspaceSync() {
|
||||
if (_filterSubInitialized) return;
|
||||
_filterSubInitialized = true;
|
||||
|
||||
// Dynamic import breaks the circular module evaluation chain.
|
||||
import("@/features/workspace").then(({ useWorkspaceStore }) => {
|
||||
let prevId: string | undefined;
|
||||
useWorkspaceStore.subscribe((state) => {
|
||||
const id = state.workspace?.id;
|
||||
if (prevId && id !== prevId) {
|
||||
useIssueViewStore.getState().clearFilters();
|
||||
}
|
||||
prevId = id;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
116
apps/web/features/issues/utils/filter.test.ts
Normal file
116
apps/web/features/issues/utils/filter.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { filterIssues, type IssueFilters } from "./filter";
|
||||
|
||||
const NO_FILTER: IssueFilters = {
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
};
|
||||
|
||||
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "i-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "MUL-1",
|
||||
title: "Test",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "u-1",
|
||||
parent_issue_id: null,
|
||||
position: 0,
|
||||
due_date: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const issues: Issue[] = [
|
||||
makeIssue({ id: "1", status: "todo", priority: "high", assignee_type: "member", assignee_id: "u-1", creator_type: "member", creator_id: "u-1" }),
|
||||
makeIssue({ id: "2", status: "in_progress", priority: "medium", assignee_type: "agent", assignee_id: "a-1", creator_type: "agent", creator_id: "a-1" }),
|
||||
makeIssue({ id: "3", status: "done", priority: "low", assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "u-2" }),
|
||||
makeIssue({ id: "4", status: "todo", priority: "urgent", assignee_type: "member", assignee_id: "u-2", creator_type: "member", creator_id: "u-1" }),
|
||||
];
|
||||
|
||||
describe("filterIssues", () => {
|
||||
it("returns all issues when no filters are active", () => {
|
||||
expect(filterIssues(issues, NO_FILTER)).toHaveLength(4);
|
||||
});
|
||||
|
||||
// --- Status ---
|
||||
it("filters by status", () => {
|
||||
const result = filterIssues(issues, { ...NO_FILTER, statusFilters: ["todo"] });
|
||||
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
|
||||
});
|
||||
|
||||
// --- Priority ---
|
||||
it("filters by priority", () => {
|
||||
const result = filterIssues(issues, { ...NO_FILTER, priorityFilters: ["high", "urgent"] });
|
||||
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
|
||||
});
|
||||
|
||||
// --- Assignee ---
|
||||
it("filters by specific assignee", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
assigneeFilters: [{ type: "member", id: "u-1" }],
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("filters by 'No assignee' only", () => {
|
||||
const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true });
|
||||
expect(result.map((i) => i.id)).toEqual(["3"]);
|
||||
});
|
||||
|
||||
it("filters by assignee + No assignee combined", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
assigneeFilters: [{ type: "agent", id: "a-1" }],
|
||||
includeNoAssignee: true,
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["2", "3"]);
|
||||
});
|
||||
|
||||
it("hides assigned issues when only 'No assignee' is selected", () => {
|
||||
const result = filterIssues(issues, { ...NO_FILTER, includeNoAssignee: true });
|
||||
expect(result.every((i) => !i.assignee_id)).toBe(true);
|
||||
});
|
||||
|
||||
// --- Creator ---
|
||||
it("filters by creator", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
creatorFilters: [{ type: "agent", id: "a-1" }],
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["2"]);
|
||||
});
|
||||
|
||||
// --- Combinations ---
|
||||
it("applies status + assignee filters together", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
statusFilters: ["todo"],
|
||||
assigneeFilters: [{ type: "member", id: "u-1" }],
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("applies status + priority + creator filters together", () => {
|
||||
const result = filterIssues(issues, {
|
||||
...NO_FILTER,
|
||||
statusFilters: ["todo"],
|
||||
priorityFilters: ["urgent"],
|
||||
creatorFilters: [{ type: "member", id: "u-1" }],
|
||||
});
|
||||
expect(result.map((i) => i.id)).toEqual(["4"]);
|
||||
});
|
||||
});
|
||||
58
apps/web/features/issues/utils/filter.ts
Normal file
58
apps/web/features/issues/utils/filter.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Issue, IssueStatus, IssuePriority } from "@/shared/types";
|
||||
import type { ActorFilterValue } from "@/features/issues/stores/view-store";
|
||||
|
||||
export interface IssueFilters {
|
||||
statusFilters: IssueStatus[];
|
||||
priorityFilters: IssuePriority[];
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
includeNoAssignee: boolean;
|
||||
creatorFilters: ActorFilterValue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter issues using positive selection model.
|
||||
* Empty arrays = no filter (show all). Non-empty = show only matching.
|
||||
*
|
||||
* Assignee has a special "No assignee" toggle (includeNoAssignee):
|
||||
* - When only includeNoAssignee is true → show only unassigned issues
|
||||
* - When assigneeFilters has items → show only those assignees' issues
|
||||
* - When both → show matching assignees + unassigned
|
||||
*/
|
||||
export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
|
||||
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters } = filters;
|
||||
const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee;
|
||||
|
||||
return issues.filter((issue) => {
|
||||
if (statusFilters.length > 0 && !statusFilters.includes(issue.status))
|
||||
return false;
|
||||
|
||||
if (priorityFilters.length > 0 && !priorityFilters.includes(issue.priority))
|
||||
return false;
|
||||
|
||||
if (hasAssigneeFilter) {
|
||||
if (!issue.assignee_id) {
|
||||
// Unassigned issue — show only if "No assignee" is checked
|
||||
if (!includeNoAssignee) return false;
|
||||
} else if (assigneeFilters.length > 0) {
|
||||
// Assigned issue — show only if assignee is in the filter list
|
||||
if (!assigneeFilters.some(
|
||||
(f) => f.type === issue.assignee_type && f.id === issue.assignee_id,
|
||||
)) return false;
|
||||
} else {
|
||||
// Only "No assignee" is checked, no specific assignees → hide assigned issues
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
creatorFilters.length > 0 &&
|
||||
!creatorFilters.some(
|
||||
(f) => f.type === issue.creator_type && f.id === issue.creator_id,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue