Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
6a38464bb0 feat(issues): add project filter to Issues tab
Support filtering issues by project in the Issues tab filter dropdown,
including a "No project" option for issues without a project assigned.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:16:38 +08:00
8 changed files with 229 additions and 9 deletions

View File

@@ -46,6 +46,8 @@ export interface IssueViewState {
assigneeFilters: ActorFilterValue[];
includeNoAssignee: boolean;
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
@@ -56,6 +58,8 @@ export interface IssueViewState {
toggleAssigneeFilter: (value: ActorFilterValue) => void;
toggleNoAssignee: () => void;
toggleCreatorFilter: (value: ActorFilterValue) => void;
toggleProjectFilter: (projectId: string) => void;
toggleNoProject: () => void;
hideStatus: (status: IssueStatus) => void;
showStatus: (status: IssueStatus) => void;
clearFilters: () => void;
@@ -72,6 +76,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
sortBy: "position",
sortDirection: "asc",
cardProperties: {
@@ -123,6 +129,14 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
: [...state.creatorFilters, value],
};
}),
toggleProjectFilter: (projectId) =>
set((state) => ({
projectFilters: state.projectFilters.includes(projectId)
? state.projectFilters.filter((id) => id !== projectId)
: [...state.projectFilters, projectId],
})),
toggleNoProject: () =>
set((state) => ({ includeNoProject: !state.includeNoProject })),
hideStatus: (status) =>
set((state) => {
// If no filter active, activate filter with all EXCEPT this one
@@ -146,6 +160,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
}),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
@@ -174,6 +190,8 @@ export const viewStorePersistOptions = (name: string) => ({
assigneeFilters: state.assigneeFilters,
includeNoAssignee: state.includeNoAssignee,
creatorFilters: state.creatorFilters,
projectFilters: state.projectFilters,
includeNoProject: state.includeNoProject,
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,

View File

@@ -9,6 +9,8 @@ import {
CircleDot,
Columns3,
Filter,
FolderKanban,
FolderMinus,
List,
SignalHigh,
SlidersHorizontal,
@@ -46,6 +48,7 @@ import { StatusIcon, PriorityIcon } from ".";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { projectListOptions } from "@multica/core/projects/queries";
import { ActorAvatar } from "../../common/actor-avatar";
import {
SORT_OPTIONS,
@@ -88,12 +91,15 @@ function getActiveFilterCount(state: {
assigneeFilters: ActorFilterValue[];
includeNoAssignee: boolean;
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
}) {
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++;
if (state.projectFilters.length > 0 || state.includeNoProject) count++;
return count;
}
@@ -103,7 +109,9 @@ function useIssueCounts(allIssues: Issue[]) {
const priority = new Map<string, number>();
const assignee = new Map<string, number>();
const creator = new Map<string, number>();
const project = new Map<string, number>();
let noAssignee = 0;
let noProject = 0;
for (const issue of allIssues) {
status.set(issue.status, (status.get(issue.status) ?? 0) + 1);
@@ -118,9 +126,15 @@ function useIssueCounts(allIssues: Issue[]) {
const cKey = `${issue.creator_type}:${issue.creator_id}`;
creator.set(cKey, (creator.get(cKey) ?? 0) + 1);
if (!issue.project_id) {
noProject++;
} else {
project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1);
}
}
return { status, priority, assignee, creator, noAssignee };
return { status, priority, assignee, creator, noAssignee, project, noProject };
}, [allIssues]);
}
@@ -270,6 +284,98 @@ function ActorSubContent({
);
}
// ---------------------------------------------------------------------------
// Project sub-menu content
// ---------------------------------------------------------------------------
function ProjectSubContent({
counts,
selected,
onToggle,
includeNoProject,
onToggleNoProject,
noProjectCount,
}: {
counts: Map<string, number>;
selected: string[];
onToggle: (projectId: string) => void;
includeNoProject: boolean;
onToggleNoProject: () => void;
noProjectCount: number;
}) {
const [search, setSearch] = useState("");
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const query = search.toLowerCase();
const filtered = projects.filter((p) =>
p.title.toLowerCase().includes(query),
);
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">
{(!query || "no project".includes(query) || "unassigned".includes(query)) && (
<DropdownMenuCheckboxItem
checked={includeNoProject}
onCheckedChange={() => onToggleNoProject()}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={includeNoProject} />
<FolderMinus className="size-3.5 text-muted-foreground" />
No project
{noProjectCount > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{noProjectCount}
</span>
)}
</DropdownMenuCheckboxItem>
)}
{filtered.map((p) => {
const checked = selected.includes(p.id);
const count = counts.get(p.id) ?? 0;
return (
<DropdownMenuCheckboxItem
key={p.id}
checked={checked}
onCheckedChange={() => onToggle(p.id)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<span className="size-3.5 flex items-center justify-center shrink-0">
{p.icon || <FolderKanban className="size-3.5 text-muted-foreground" />}
</span>
<span className="truncate">{p.title}</span>
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{count}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
{filtered.length === 0 && search && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
No results
</div>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// IssuesHeader
// ---------------------------------------------------------------------------
@@ -284,6 +390,8 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
const assigneeFilters = useViewStore((s) => s.assigneeFilters);
const includeNoAssignee = useViewStore((s) => s.includeNoAssignee);
const creatorFilters = useViewStore((s) => s.creatorFilters);
const projectFilters = useViewStore((s) => s.projectFilters);
const includeNoProject = useViewStore((s) => s.includeNoProject);
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const cardProperties = useViewStore((s) => s.cardProperties);
@@ -298,6 +406,8 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
assigneeFilters,
includeNoAssignee,
creatorFilters,
projectFilters,
includeNoProject,
}) > 0;
const sortLabel =
@@ -468,6 +578,29 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Project */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<FolderKanban className="size-3.5" />
<span className="flex-1">Project</span>
{(projectFilters.length > 0 || includeNoProject) && (
<span className="text-xs text-primary font-medium">
{projectFilters.length + (includeNoProject ? 1 : 0)}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
<ProjectSubContent
counts={counts.project}
selected={projectFilters}
onToggle={act.toggleProjectFilter}
includeNoProject={includeNoProject}
onToggleNoProject={act.toggleNoProject}
noProjectCount={counts.noProject}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Reset */}
{hasActiveFilters && (
<>

View File

@@ -110,6 +110,8 @@ const mockViewState = {
assigneeFilters: [] as { type: string; id: string }[],
includeNoAssignee: false,
creatorFilters: [] as { type: string; id: string }[],
projectFilters: [] as string[],
includeNoProject: false,
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true },
@@ -120,6 +122,8 @@ const mockViewState = {
toggleAssigneeFilter: vi.fn(),
toggleNoAssignee: vi.fn(),
toggleCreatorFilter: vi.fn(),
toggleProjectFilter: vi.fn(),
toggleNoProject: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
clearFilters: vi.fn(),

View File

@@ -34,6 +34,8 @@ export function IssuesPage() {
const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters);
const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee);
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
const projectFilters = useIssueViewStore((s) => s.projectFilters);
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
useEffect(() => {
initFilterWorkspaceSync();
@@ -53,8 +55,8 @@ export function IssuesPage() {
}, [allIssues, scope]);
const issues = useMemo(
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject],
);
// Compute sub-issue progress for each parent from the full (unfiltered) issue list

View File

@@ -8,6 +8,8 @@ const NO_FILTER: IssueFilters = {
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
};
function makeIssue(overrides: Partial<Issue> = {}): Issue {
@@ -35,10 +37,10 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
}
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" }),
makeIssue({ id: "1", status: "todo", priority: "high", assignee_type: "member", assignee_id: "u-1", creator_type: "member", creator_id: "u-1", project_id: "p-1" }),
makeIssue({ id: "2", status: "in_progress", priority: "medium", assignee_type: "agent", assignee_id: "a-1", creator_type: "agent", creator_id: "a-1", project_id: "p-2" }),
makeIssue({ id: "3", status: "done", priority: "low", assignee_type: null, assignee_id: null, creator_type: "member", creator_id: "u-2", project_id: null }),
makeIssue({ id: "4", status: "todo", priority: "urgent", assignee_type: "member", assignee_id: "u-2", creator_type: "member", creator_id: "u-1", project_id: "p-1" }),
];
describe("filterIssues", () => {
@@ -114,4 +116,49 @@ describe("filterIssues", () => {
});
expect(result.map((i) => i.id)).toEqual(["4"]);
});
// --- Project ---
it("filters by specific project", () => {
const result = filterIssues(issues, {
...NO_FILTER,
projectFilters: ["p-1"],
});
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
});
it("filters by multiple projects", () => {
const result = filterIssues(issues, {
...NO_FILTER,
projectFilters: ["p-1", "p-2"],
});
expect(result.map((i) => i.id)).toEqual(["1", "2", "4"]);
});
it("filters by 'No project' only", () => {
const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true });
expect(result.map((i) => i.id)).toEqual(["3"]);
});
it("filters by project + No project combined", () => {
const result = filterIssues(issues, {
...NO_FILTER,
projectFilters: ["p-2"],
includeNoProject: true,
});
expect(result.map((i) => i.id)).toEqual(["2", "3"]);
});
it("hides project issues when only 'No project' is selected", () => {
const result = filterIssues(issues, { ...NO_FILTER, includeNoProject: true });
expect(result.every((i) => !i.project_id)).toBe(true);
});
it("applies status + project filters together", () => {
const result = filterIssues(issues, {
...NO_FILTER,
statusFilters: ["todo"],
projectFilters: ["p-1"],
});
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
});
});

View File

@@ -7,6 +7,8 @@ export interface IssueFilters {
assigneeFilters: ActorFilterValue[];
includeNoAssignee: boolean;
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
}
/**
@@ -19,8 +21,9 @@ export interface IssueFilters {
* - When both → show matching assignees + unassigned
*/
export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters } = filters;
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject } = filters;
const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee;
const hasProjectFilter = projectFilters.length > 0 || includeNoProject;
return issues.filter((issue) => {
if (statusFilters.length > 0 && !statusFilters.includes(issue.status))
@@ -53,6 +56,17 @@ export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
return false;
}
if (hasProjectFilter) {
if (!issue.project_id) {
if (!includeNoProject) return false;
} else if (projectFilters.length > 0) {
if (!projectFilters.includes(issue.project_id)) return false;
} else {
// Only "No project" is checked → hide issues that have a project
return false;
}
}
return true;
});
}

View File

@@ -82,6 +82,8 @@ export function MyIssuesPage() {
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
}),
[myIssues, statusFilters, priorityFilters],
);

View File

@@ -99,7 +99,7 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
const creatorFilters = useViewStore((s) => s.creatorFilters);
const issues = useMemo(
() => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
() => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }),
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
);
const doneColumnCount = useMemo(