Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
821b116d31 feat(issues): client-side label filter on the issues list
Adds a Label submenu to the workspace issues filter dropdown, backed by
labelFilters in the shared issue view store. The filter is OR'd within
itself (issue matches if it carries any of the selected labels) and
AND'd with the existing status / priority / assignee / creator /
project dimensions, mirroring the multi-select semantics already in
place. Each label row renders via LabelChip for color parity with the
sidebar picker, and each row's count comes from the same
useIssueCounts pass that drives the other filter chips.

Filtering stays client-side, consistent with all other filters today.
The pagination caveat is a known limitation we'll revisit if real
workspaces start hitting it; this PR intentionally does not change the
fetch path.
2026-04-28 16:38:06 +08:00
8 changed files with 172 additions and 6 deletions

View File

@@ -55,6 +55,7 @@ export interface IssueViewState {
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
sortBy: SortField;
sortDirection: SortDirection;
cardProperties: CardProperties;
@@ -67,6 +68,7 @@ export interface IssueViewState {
toggleCreatorFilter: (value: ActorFilterValue) => void;
toggleProjectFilter: (projectId: string) => void;
toggleNoProject: () => void;
toggleLabelFilter: (labelId: string) => void;
hideStatus: (status: IssueStatus) => void;
showStatus: (status: IssueStatus) => void;
clearFilters: () => void;
@@ -85,6 +87,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
labelFilters: [],
sortBy: "position",
sortDirection: "asc",
cardProperties: {
@@ -147,6 +150,12 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
})),
toggleNoProject: () =>
set((state) => ({ includeNoProject: !state.includeNoProject })),
toggleLabelFilter: (labelId) =>
set((state) => ({
labelFilters: state.labelFilters.includes(labelId)
? state.labelFilters.filter((id) => id !== labelId)
: [...state.labelFilters, labelId],
})),
hideStatus: (status) =>
set((state) => {
// If no filter active, activate filter with all EXCEPT this one
@@ -172,6 +181,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
labelFilters: [],
}),
setSortBy: (field) => set({ sortBy: field }),
setSortDirection: (dir) => set({ sortDirection: dir }),
@@ -202,6 +212,7 @@ export const viewStorePersistOptions = (name: string) => ({
creatorFilters: state.creatorFilters,
projectFilters: state.projectFilters,
includeNoProject: state.includeNoProject,
labelFilters: state.labelFilters,
sortBy: state.sortBy,
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,

View File

@@ -14,6 +14,7 @@ import {
List,
SignalHigh,
SlidersHorizontal,
Tag,
User,
UserMinus,
UserPen,
@@ -49,8 +50,10 @@ 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 { labelListOptions } from "@multica/core/labels/queries";
import { ProjectIcon } from "../../projects/components/project-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import { LabelChip } from "../../labels/label-chip";
import {
SORT_OPTIONS,
CARD_PROPERTY_OPTIONS,
@@ -94,6 +97,7 @@ function getActiveFilterCount(state: {
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
}) {
let count = 0;
if (state.statusFilters.length > 0) count++;
@@ -101,6 +105,7 @@ function getActiveFilterCount(state: {
if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++;
if (state.creatorFilters.length > 0) count++;
if (state.projectFilters.length > 0 || state.includeNoProject) count++;
if (state.labelFilters.length > 0) count++;
return count;
}
@@ -111,6 +116,7 @@ function useIssueCounts(allIssues: Issue[]) {
const assignee = new Map<string, number>();
const creator = new Map<string, number>();
const project = new Map<string, number>();
const label = new Map<string, number>();
let noAssignee = 0;
let noProject = 0;
@@ -133,9 +139,15 @@ function useIssueCounts(allIssues: Issue[]) {
} else {
project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1);
}
if (issue.labels) {
for (const l of issue.labels) {
label.set(l.id, (label.get(l.id) ?? 0) + 1);
}
}
}
return { status, priority, assignee, creator, noAssignee, project, noProject };
return { status, priority, assignee, creator, noAssignee, project, noProject, label };
}, [allIssues]);
}
@@ -375,6 +387,70 @@ function ProjectSubContent({
);
}
// ---------------------------------------------------------------------------
// Label sub-menu content
// ---------------------------------------------------------------------------
function LabelSubContent({
counts,
selected,
onToggle,
}: {
counts: Map<string, number>;
selected: string[];
onToggle: (labelId: string) => void;
}) {
const [search, setSearch] = useState("");
const wsId = useWorkspaceId();
const { data: labels = [] } = useQuery(labelListOptions(wsId));
const query = search.trim().toLowerCase();
const filtered = labels.filter((l) => l.name.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">
{filtered.map((l) => {
const checked = selected.includes(l.id);
const count = counts.get(l.id) ?? 0;
return (
<DropdownMenuCheckboxItem
key={l.id}
checked={checked}
onCheckedChange={() => onToggle(l.id)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<LabelChip label={l} />
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{count}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
{filtered.length === 0 && (
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
{search ? "No results" : "No labels yet"}
</div>
)}
</div>
</>
);
}
// ---------------------------------------------------------------------------
// IssuesHeader
// ---------------------------------------------------------------------------
@@ -391,6 +467,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
const creatorFilters = useViewStore((s) => s.creatorFilters);
const projectFilters = useViewStore((s) => s.projectFilters);
const includeNoProject = useViewStore((s) => s.includeNoProject);
const labelFilters = useViewStore((s) => s.labelFilters);
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const cardProperties = useViewStore((s) => s.cardProperties);
@@ -407,6 +484,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
creatorFilters,
projectFilters,
includeNoProject,
labelFilters,
}) > 0;
const sortLabel =
@@ -600,6 +678,26 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Label */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Tag className="size-3.5" />
<span className="flex-1">Label</span>
{labelFilters.length > 0 && (
<span className="text-xs text-primary font-medium">
{labelFilters.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
<LabelSubContent
counts={counts.label}
selected={labelFilters}
onToggle={act.toggleLabelFilter}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Reset */}
{hasActiveFilters && (
<>

View File

@@ -106,6 +106,7 @@ const mockViewState = {
creatorFilters: [] as { type: string; id: string }[],
projectFilters: [] as string[],
includeNoProject: false,
labelFilters: [] as string[],
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
@@ -118,6 +119,7 @@ const mockViewState = {
toggleCreatorFilter: vi.fn(),
toggleProjectFilter: vi.fn(),
toggleNoProject: vi.fn(),
toggleLabelFilter: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
clearFilters: vi.fn(),

View File

@@ -37,6 +37,7 @@ export function IssuesPage() {
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
const projectFilters = useIssueViewStore((s) => s.projectFilters);
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
const labelFilters = useIssueViewStore((s) => s.labelFilters);
// Clear filter state when switching between workspaces (URL-driven).
useClearFiltersOnWorkspaceChange(useIssueViewStore, wsId);
@@ -55,8 +56,8 @@ export function IssuesPage() {
}, [allIssues, scope]);
const issues = useMemo(
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject],
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters],
);
// Fetch sub-issue progress from the backend so counts are accurate

View File

@@ -10,6 +10,7 @@ const NO_FILTER: IssueFilters = {
creatorFilters: [],
projectFilters: [],
includeNoProject: false,
labelFilters: [],
};
function makeIssue(overrides: Partial<Issue> = {}): Issue {
@@ -161,4 +162,46 @@ describe("filterIssues", () => {
});
expect(result.map((i) => i.id)).toEqual(["1", "4"]);
});
// --- Label ---
// Build a separate fixture for label tests so we can sprinkle labels onto
// specific rows without polluting the assignee/project test cases above.
const makeLabel = (id: string, name: string, color: string) => ({
id,
name,
color,
workspace_id: "ws-1",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
});
const labelBug = makeLabel("lab-bug", "bug", "#ff0000");
const labelFeat = makeLabel("lab-feat", "feature", "#00ff00");
const labelP0 = makeLabel("lab-p0", "p0", "#0000ff");
const labeledIssues: Issue[] = [
makeIssue({ id: "L1", labels: [labelBug] }),
makeIssue({ id: "L2", labels: [labelFeat] }),
makeIssue({ id: "L3", labels: [labelBug, labelP0] }),
makeIssue({ id: "L4", labels: [] }),
makeIssue({ id: "L5" }), // labels field absent
];
it("filters by a single label", () => {
const result = filterIssues(labeledIssues, { ...NO_FILTER, labelFilters: ["lab-bug"] });
expect(result.map((i) => i.id)).toEqual(["L1", "L3"]);
});
it("filters by multiple labels with OR semantics", () => {
const result = filterIssues(labeledIssues, {
...NO_FILTER,
labelFilters: ["lab-bug", "lab-feat"],
});
expect(result.map((i) => i.id)).toEqual(["L1", "L2", "L3"]);
});
it("excludes issues with no labels when a label filter is active", () => {
const result = filterIssues(labeledIssues, { ...NO_FILTER, labelFilters: ["lab-bug"] });
// L4 (empty labels) and L5 (missing labels field) must both be filtered out.
expect(result.map((i) => i.id)).not.toContain("L4");
expect(result.map((i) => i.id)).not.toContain("L5");
});
});

View File

@@ -9,6 +9,7 @@ export interface IssueFilters {
creatorFilters: ActorFilterValue[];
projectFilters: string[];
includeNoProject: boolean;
labelFilters: string[];
}
/**
@@ -21,7 +22,7 @@ export interface IssueFilters {
* - When both → show matching assignees + unassigned
*/
export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject } = filters;
const { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters } = filters;
const hasAssigneeFilter = assigneeFilters.length > 0 || includeNoAssignee;
const hasProjectFilter = projectFilters.length > 0 || includeNoProject;
@@ -67,6 +68,14 @@ export function filterIssues(issues: Issue[], filters: IssueFilters): Issue[] {
}
}
if (labelFilters.length > 0) {
// OR semantics within the filter: keep issues that carry any of the
// selected labels. Matches existing priority / project multi-select.
const issueLabels = issue.labels;
if (!issueLabels || issueLabels.length === 0) return false;
if (!issueLabels.some((l) => labelFilters.includes(l.id))) return false;
}
return true;
});
}

View File

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

View File

@@ -110,10 +110,11 @@ function ProjectIssuesContent({
const assigneeFilters = useViewStore((s) => s.assigneeFilters);
const includeNoAssignee = useViewStore((s) => s.includeNoAssignee);
const creatorFilters = useViewStore((s) => s.creatorFilters);
const labelFilters = useViewStore((s) => s.labelFilters);
const issues = useMemo(
() => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }),
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
() => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters }),
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters],
);
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));