mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 08:29:18 +02:00
Compare commits
1 Commits
agent/lamb
...
agent/emac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a38464bb0 |
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,6 +82,8 @@ export function MyIssuesPage() {
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
}),
|
||||
[myIssues, statusFilters, priorityFilters],
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user