mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 01:19:26 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/j/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
821b116d31 |
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export function MyIssuesPage() {
|
||||
creatorFilters: [],
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
labelFilters: [],
|
||||
}),
|
||||
[myIssues, statusFilters, priorityFilters],
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user