Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
07fa7d7dc0 Add assignee grouping for issue boards 2026-05-15 18:38:41 +08:00
26 changed files with 1735 additions and 212 deletions

View File

@@ -2,6 +2,7 @@ import type {
Issue,
CreateIssueRequest,
UpdateIssueRequest,
GroupedIssuesResponse,
ListIssuesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
@@ -9,6 +10,7 @@ import type {
CreateMemberRequest,
UpdateMemberRequest,
ListIssuesParams,
ListGroupedIssuesParams,
Agent,
CreateAgentRequest,
AgentTemplate,
@@ -113,8 +115,10 @@ import {
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
EMPTY_ATTACHMENT,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_GROUPED_ISSUES_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_TIMELINE_ENTRIES,
GroupedIssuesResponseSchema,
ListIssuesResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
@@ -473,6 +477,36 @@ export class ApiClient {
});
}
async listGroupedIssues(params: ListGroupedIssuesParams): Promise<GroupedIssuesResponse> {
const search = new URLSearchParams({ group_by: params.group_by });
if (params.limit) search.set("limit", String(params.limit));
if (params.offset) search.set("offset", String(params.offset));
if (params.workspace_id) search.set("workspace_id", params.workspace_id);
if (params.statuses?.length) search.set("statuses", params.statuses.join(","));
if (params.priorities?.length) search.set("priorities", params.priorities.join(","));
if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
if (params.assignee_id) search.set("assignee_id", params.assignee_id);
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
if (params.creator_id) search.set("creator_id", params.creator_id);
if (params.project_id) search.set("project_id", params.project_id);
if (params.assignee_filters?.length) {
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params.include_no_assignee) search.set("include_no_assignee", "true");
if (params.creator_filters?.length) {
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
}
if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
if (params.include_no_project) search.set("include_no_project", "true");
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
endpoint: "GET /api/issues/grouped",
});
}
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
const search = new URLSearchParams({ q: params.q });
if (params.limit !== undefined) search.set("limit", String(params.limit));

View File

@@ -91,6 +91,15 @@ describe("ApiClient schema fallback", () => {
});
});
describe("listGroupedIssues", () => {
it("falls back to empty groups when the response is malformed", async () => {
stubFetchJson({ groups: "not-an-array" });
const client = new ApiClient("https://api.example.test");
const res = await client.listGroupedIssues({ group_by: "assignee" });
expect(res).toEqual({ groups: [] });
});
});
describe("listComments", () => {
it("returns [] when the response is not an array", async () => {
stubFetchJson({ wrong: "shape" });

View File

@@ -5,6 +5,7 @@ import type {
AgentTemplateSummary,
Attachment,
CreateAgentFromTemplateResponse,
GroupedIssuesResponse,
ListIssuesResponse,
TimelineEntry,
} from "../types";
@@ -164,6 +165,22 @@ export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
total: 0,
};
const IssueAssigneeGroupSchema = z.object({
id: z.string(),
assignee_type: z.string().nullable(),
assignee_id: z.string().nullable(),
issues: z.array(IssueSchema).default([]),
total: z.number().default(0),
}).loose();
export const GroupedIssuesResponseSchema = z.object({
groups: z.array(IssueAssigneeGroupSchema).default([]),
}).loose();
export const EMPTY_GROUPED_ISSUES_RESPONSE: GroupedIssuesResponse = {
groups: [],
};
const SubscriberSchema = z.object({
issue_id: z.string(),
user_type: z.string(),

View File

@@ -1,9 +1,10 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient, type QueryKey } from "@tanstack/react-query";
import { api } from "../api";
import {
issueKeys,
ISSUE_PAGE_SIZE,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "./queries";
import {
@@ -24,7 +25,7 @@ import {
} from "./delete-cache";
import { useWorkspaceId } from "../hooks";
import { useRecentIssuesStore } from "./stores";
import type { Issue, IssueReaction, IssueStatus } from "../types";
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
import type {
CreateIssueRequest,
UpdateIssueRequest,
@@ -102,6 +103,58 @@ export function useLoadMoreByStatus(
return { loadMore, hasMore, isLoading, total };
}
export function useLoadMoreByAssigneeGroup(
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
queryKey: QueryKey,
filter: AssigneeGroupedIssuesFilter,
) {
const qc = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const cache = qc.getQueryData<GroupedIssuesResponse>(queryKey);
const cachedGroup = cache?.groups.find((g) => g.id === group.id);
const loaded = cachedGroup?.issues.length ?? 0;
const total = cachedGroup?.total ?? 0;
const hasMore = loaded < total;
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const res = await api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: loaded,
...filter,
group_assignee_type: group.assignee_type ?? "none",
group_assignee_id: group.assignee_id ?? undefined,
});
const nextGroup = res.groups[0];
if (!nextGroup) return;
qc.setQueryData<GroupedIssuesResponse>(queryKey, (old) => {
if (!old) return old;
return {
groups: old.groups.map((existing) => {
if (existing.id !== nextGroup.id) return existing;
const existingIds = new Set(existing.issues.map((issue) => issue.id));
const appended = nextGroup.issues.filter((issue) => !existingIds.has(issue.id));
return {
...existing,
issues: [...existing.issues, ...appended],
total: nextGroup.total,
};
}),
};
});
} finally {
setIsLoading(false);
}
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey]);
return { loadMore, hasMore, isLoading, total };
}
// ---------------------------------------------------------------------------
// Issue CRUD
// ---------------------------------------------------------------------------
@@ -126,6 +179,8 @@ export function useCreateIssue() {
},
onSettled: () => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
},
});
}
@@ -200,6 +255,8 @@ export function useUpdateIssue() {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
@@ -281,6 +338,8 @@ export function useDeleteIssue() {
},
onSettled: (_data, _err, _id, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (ctx?.metadata) invalidateDeletedIssueParentCaches(qc, wsId, ctx.metadata);
},
});
@@ -338,6 +397,8 @@ export function useBatchUpdateIssues() {
},
onSettled: (_data, _err, _vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({
@@ -438,6 +499,8 @@ export function useBatchDeleteIssues() {
},
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
invalidateDeletedIssueParentCaches(qc, wsId, {
parentIssueIds: Array.from(ctx.parentIssueIds),

View File

@@ -1,7 +1,9 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type {
GroupedIssuesResponse,
IssueStatus,
ListGroupedIssuesParams,
ListIssuesParams,
ListIssuesCache,
} from "../types";
@@ -10,11 +12,22 @@ import { BOARD_STATUSES } from "./config";
export const issueKeys = {
all: (wsId: string) => ["issues", wsId] as const,
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
assigneeGroupsAll: (wsId: string) =>
[...issueKeys.all(wsId), "assignee-groups"] as const,
assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) =>
[...issueKeys.assigneeGroupsAll(wsId), filter] as const,
/** All "my issues" queries — use for bulk invalidation. */
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
/** Per-scope "my issues" list with filter identity baked into the key. */
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
[...issueKeys.myAll(wsId), scope, filter] as const,
myAssigneeGroupsAll: (wsId: string) =>
[...issueKeys.myAll(wsId), "assignee-groups"] as const,
myAssigneeGroups: (
wsId: string,
scope: string,
filter: AssigneeGroupedIssuesFilter,
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
detail: (wsId: string, id: string) =>
[...issueKeys.all(wsId), "detail", id] as const,
children: (wsId: string, id: string) =>
@@ -45,6 +58,11 @@ export type MyIssuesFilter = Pick<
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
>;
export type AssigneeGroupedIssuesFilter = Omit<
ListGroupedIssuesParams,
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
>;
/** Page size per status column. */
export const ISSUE_PAGE_SIZE = 50;
@@ -92,6 +110,22 @@ export function issueListOptions(wsId: string) {
});
}
export function issueAssigneeGroupsOptions(
wsId: string,
filter: AssigneeGroupedIssuesFilter,
) {
return queryOptions<GroupedIssuesResponse>({
queryKey: issueKeys.assigneeGroups(wsId, filter),
queryFn: () =>
api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...filter,
}),
});
}
/**
* Server-filtered issue list for the My Issues page.
* Each scope gets its own cache entry so switching tabs is instant after first load.
@@ -108,6 +142,23 @@ export function myIssueListOptions(
});
}
export function myIssueAssigneeGroupsOptions(
wsId: string,
scope: string,
filter: AssigneeGroupedIssuesFilter,
) {
return queryOptions<GroupedIssuesResponse>({
queryKey: issueKeys.myAssigneeGroups(wsId, scope, filter),
queryFn: () =>
api.listGroupedIssues({
group_by: "assignee",
limit: ISSUE_PAGE_SIZE,
offset: 0,
...filter,
}),
});
}
export function issueDetailOptions(wsId: string, id: string) {
return queryOptions({
queryKey: issueKeys.detail(wsId, id),

View File

@@ -10,6 +10,7 @@ import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "..
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
@@ -36,6 +37,11 @@ export const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: "title", label: "Title" },
];
export const GROUPING_OPTIONS: { value: IssueGrouping; label: string }[] = [
{ value: "status", label: "Status" },
{ value: "assignee", label: "Assignee" },
];
export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }[] = [
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
@@ -48,6 +54,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
export interface IssueViewState {
viewMode: ViewMode;
grouping: IssueGrouping;
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
assigneeFilters: ActorFilterValue[];
@@ -61,6 +68,7 @@ export interface IssueViewState {
cardProperties: CardProperties;
listCollapsedStatuses: IssueStatus[];
setViewMode: (mode: ViewMode) => void;
setGrouping: (grouping: IssueGrouping) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
toggleAssigneeFilter: (value: ActorFilterValue) => void;
@@ -80,6 +88,7 @@ export interface IssueViewState {
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
viewMode: "board",
grouping: "status",
statusFilters: [],
priorityFilters: [],
assigneeFilters: [],
@@ -102,6 +111,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
listCollapsedStatuses: [],
setViewMode: (mode) => set({ viewMode: mode }),
setGrouping: (grouping) => set({ grouping }),
toggleStatusFilter: (status) =>
set((state) => ({
statusFilters: state.statusFilters.includes(status)
@@ -205,6 +215,7 @@ export const viewStorePersistOptions = (name: string) => ({
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state: IssueViewState) => ({
viewMode: state.viewMode,
grouping: state.grouping,
statusFilters: state.statusFilters,
priorityFilters: state.priorityFilters,
assigneeFilters: state.assigneeFilters,

View File

@@ -19,6 +19,8 @@ export function onIssueCreated(
old ? addIssueToBuckets(old, issue) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
if (issue.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
@@ -48,6 +50,8 @@ export function onIssueUpdated(
old ? patchIssueInBuckets(old, issue.id, issue) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old,
);
@@ -100,6 +104,8 @@ export function onIssueLabelsChanged(
old ? { ...old, labels } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
}
export function onIssueDeleted(
@@ -108,4 +114,6 @@ export function onIssueDeleted(
issueId: string,
) {
cleanupDeletedIssueCaches(qc, wsId, issueId);
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
}

View File

@@ -46,12 +46,52 @@ export interface ListIssuesParams {
open_only?: boolean;
}
export interface IssueActorRef {
type: IssueAssigneeType;
id: string;
}
export interface ListGroupedIssuesParams {
group_by: "assignee";
limit?: number;
offset?: number;
workspace_id?: string;
statuses?: IssueStatus[];
priorities?: IssuePriority[];
assignee_types?: IssueAssigneeType[];
assignee_id?: string;
assignee_ids?: string[];
creator_id?: string;
project_id?: string;
assignee_filters?: IssueActorRef[];
include_no_assignee?: boolean;
creator_filters?: IssueActorRef[];
project_ids?: string[];
include_no_project?: boolean;
label_ids?: string[];
group_assignee_type?: IssueAssigneeType | "none";
group_assignee_id?: string;
}
/** Raw backend response shape for `GET /api/issues`. */
export interface ListIssuesResponse {
issues: Issue[];
total: number;
}
export interface IssueAssigneeGroup {
id: string;
assignee_type: IssueAssigneeType | null;
assignee_id: string | null;
issues: Issue[];
total: number;
}
/** Raw backend response shape for `GET /api/issues/grouped?group_by=assignee`. */
export interface GroupedIssuesResponse {
groups: IssueAssigneeGroup[];
}
/** Per-status bucket in the paginated issue cache. `total` is the server count (all pages), not the length of `issues`. */
export interface IssueStatusBucket {
issues: Issue[];

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "../hooks";
import { memberListOptions, agentListOptions, squadListOptions } from "./queries";
@@ -10,30 +11,30 @@ export function useActorName() {
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
const getMemberName = (userId: string) => {
const getMemberName = useCallback((userId: string) => {
const m = members.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";
};
}, [members]);
const getAgentName = (agentId: string) => {
const getAgentName = useCallback((agentId: string) => {
const a = agents.find((a) => a.id === agentId);
return a?.name ?? "Unknown Agent";
};
}, [agents]);
const getSquadName = (squadId: string) => {
const getSquadName = useCallback((squadId: string) => {
const s = squads.find((s) => s.id === squadId);
return s?.name ?? "Unknown Squad";
};
}, [squads]);
const getActorName = (type: string, id: string) => {
const getActorName = useCallback((type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
if (type === "squad") return getSquadName(id);
if (type === "system") return "Multica";
return "System";
};
}, [getAgentName, getMemberName, getSquadName]);
const getActorInitials = (type: string, id: string) => {
const getActorInitials = useCallback((type: string, id: string) => {
const name = getActorName(type, id);
return name
.split(" ")
@@ -41,14 +42,31 @@ export function useActorName() {
.join("")
.toUpperCase()
.slice(0, 2);
};
}, [getActorName]);
const getActorAvatarUrl = (type: string, id: string): string | null => {
const getActorAvatarUrl = useCallback((type: string, id: string): string | null => {
if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null;
if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null;
if (type === "squad") return squads.find((s) => s.id === id)?.avatar_url ?? null;
return null;
};
}, [agents, members, squads]);
return { getMemberName, getAgentName, getSquadName, getActorName, getActorInitials, getActorAvatarUrl };
return useMemo(
() => ({
getMemberName,
getAgentName,
getSquadName,
getActorName,
getActorInitials,
getActorAvatarUrl,
}),
[
getActorAvatarUrl,
getActorInitials,
getActorName,
getAgentName,
getMemberName,
getSquadName,
],
);
}

View File

@@ -5,12 +5,14 @@ import { useStore } from "zustand";
import { toast } from "sonner";
import { ListTodo } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { IssueStatus } from "@multica/core/types";
import type { UpdateIssueRequest } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { BOARD_STATUSES } from "@multica/core/issues/config";
import {
childIssueProgressOptions,
myIssueAssigneeGroupsOptions,
myIssueListOptions,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
@@ -47,6 +49,7 @@ export function ActorIssuesPanel({
const scope = useStore(actorIssuesViewStore, (s) => s.scope);
const setScope = useStore(actorIssuesViewStore, (s) => s.setScope);
const viewMode = useStore(actorIssuesViewStore, (s) => s.viewMode);
const grouping = useStore(actorIssuesViewStore, (s) => s.grouping);
const statusFilters = useStore(actorIssuesViewStore, (s) => s.statusFilters);
const priorityFilters = useStore(actorIssuesViewStore, (s) => s.priorityFilters);
const assigneeFilters = useStore(actorIssuesViewStore, (s) => s.assigneeFilters);
@@ -70,19 +73,70 @@ export function ActorIssuesPanel({
[scope, actorId],
);
const queryScope = `${actorType}:${actorId}:${scope}`;
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
const { data: rawIssues = [], isLoading } = useQuery(
myIssueListOptions(wsId, queryScope, queryFilter),
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(() => {
const filter: AssigneeGroupedIssuesFilter = {
...queryFilter,
statuses: statusFilters.length > 0 ? statusFilters : [...BOARD_STATUSES],
priorities: priorityFilters,
assignee_filters: assigneeFilters,
include_no_assignee: includeNoAssignee,
creator_filters: creatorFilters,
project_ids: projectFilters,
include_no_project: includeNoProject,
label_ids: labelFilters,
};
if (scope === "assigned") {
filter.assignee_types = [actorType];
}
return filter;
}, [
actorType,
assigneeFilters,
creatorFilters,
includeNoAssignee,
includeNoProject,
labelFilters,
priorityFilters,
projectFilters,
queryFilter,
scope,
statusFilters,
]);
const assigneeGroupsOptions = myIssueAssigneeGroupsOptions(
wsId,
queryScope,
assigneeGroupFilter,
);
const rawIssuesQuery = useQuery({
...myIssueListOptions(wsId, queryScope, queryFilter),
enabled: !usesAssigneeBoard,
});
const assigneeGroupsQuery = useQuery({
...assigneeGroupsOptions,
enabled: usesAssigneeBoard,
});
const rawIssues = useMemo(
() => rawIssuesQuery.data ?? [],
[rawIssuesQuery.data],
);
const groupedIssues = useMemo(
() => assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [],
[assigneeGroupsQuery.data],
);
const isLoading = usesAssigneeBoard
? assigneeGroupsQuery.isLoading
: rawIssuesQuery.isLoading;
const actorIssues = useMemo(
() =>
rawIssues.filter((issue) =>
(usesAssigneeBoard ? groupedIssues : rawIssues).filter((issue) =>
scope === "assigned"
? issue.assignee_type === actorType && issue.assignee_id === actorId
: issue.creator_type === actorType && issue.creator_id === actorId,
),
[rawIssues, scope, actorType, actorId],
[actorId, actorType, groupedIssues, rawIssues, scope, usesAssigneeBoard],
);
const issues = useMemo(
@@ -128,12 +182,7 @@ export function ActorIssuesPanel({
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
const updates: Partial<{ status: IssueStatus; position: number }> = {
status: newStatus,
};
if (newPosition !== undefined) updates.position = newPosition;
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error(t(($) => $.page.move_failed)) },
@@ -192,7 +241,10 @@ export function ActorIssuesPanel({
<div className="flex flex-1 min-h-0 flex-col">
{viewMode === "board" ? (
<BoardView
issues={issues}
issues={usesAssigneeBoard ? actorIssues : issues}
assigneeGroups={usesAssigneeBoard ? assigneeGroupsQuery.data?.groups : undefined}
assigneeGroupQueryKey={usesAssigneeBoard ? assigneeGroupsOptions.queryKey : undefined}
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}

View File

@@ -1,11 +1,11 @@
"use client";
import { useMemo, type ReactNode } from "react";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { EyeOff, MoreHorizontal, Plus, UserMinus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import type { Issue, IssueStatus } from "@multica/core/types";
import type { Issue, IssueAssigneeType, IssueStatus } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
@@ -20,9 +20,20 @@ import { StatusHeading } from "./status-heading";
import { DraggableBoardCard } from "./board-card";
import type { ChildProgress } from "./list-row";
import { useT } from "../../i18n";
import { ActorAvatar } from "../../common/actor-avatar";
export interface BoardColumnGroup {
id: string;
title: string;
status?: IssueStatus;
assigneeType?: IssueAssigneeType | null;
assigneeId?: string | null;
totalCount?: number;
createData?: Record<string, unknown>;
}
export function BoardColumn({
status,
group,
issueIds,
issueMap,
childProgressMap,
@@ -30,7 +41,7 @@ export function BoardColumn({
footer,
projectId,
}: {
status: IssueStatus;
group: BoardColumnGroup;
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap?: Map<string, ChildProgress>;
@@ -39,8 +50,9 @@ export function BoardColumn({
/** When set, the per-column "+" pre-fills the project on the create form. */
projectId?: string;
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
const status = group.status;
const cfg = status ? STATUS_CONFIG[status] : null;
const { setNodeRef, isOver } = useDroppable({ id: group.id });
const viewStoreApi = useViewStoreApi();
const { t } = useT("issues");
@@ -55,27 +67,29 @@ export function BoardColumn({
);
return (
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg?.columnBg ?? "bg-muted/40"} p-2`}>
<div className="mb-2 flex items-center justify-between px-1.5">
<StatusHeading status={status} count={totalCount ?? issueIds.length} />
<BoardGroupHeading group={group} count={totalCount ?? issueIds.length} />
{/* Right: add + menu */}
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
{t(($) => $.board.hide_column)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{status && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
{t(($) => $.board.hide_column)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Tooltip>
<TooltipTrigger
render={
@@ -83,11 +97,13 @@ export function BoardColumn({
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() =>
useModalStore
.getState()
.open("create-issue", { status, ...(projectId ? { project_id: projectId } : {}) })
}
onClick={() => {
const data = {
...(group.createData ?? {}),
...(projectId ? { project_id: projectId } : {}),
};
useModalStore.getState().open("create-issue", data);
}}
>
<Plus className="size-3.5" />
</Button>
@@ -118,3 +134,41 @@ export function BoardColumn({
</div>
);
}
function BoardGroupHeading({
group,
count,
}: {
group: BoardColumnGroup;
count: number;
}) {
if (group.status) {
return <StatusHeading status={group.status} count={count} />;
}
const actorIcon =
group.assigneeType && group.assigneeId ? (
<ActorAvatar
actorType={group.assigneeType}
actorId={group.assigneeId}
size={18}
showStatusDot={group.assigneeType === "agent"}
/>
) : (
<span className="flex size-[18px] shrink-0 items-center justify-center rounded-full bg-background text-muted-foreground">
<UserMinus className="size-3.5" />
</span>
);
return (
<div className="flex min-w-0 items-center gap-2">
{actorIcon}
<span className="truncate text-sm font-medium" title={group.title}>
{group.title}
</span>
<span className="shrink-0 rounded-full bg-background px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-muted-foreground">
{count}
</span>
</div>
);
}

View File

@@ -14,60 +14,153 @@ import {
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import type { QueryKey } from "@tanstack/react-query";
import { arrayMove } from "@dnd-kit/sortable";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@multica/core/types";
import type { Issue, IssueAssigneeGroup, IssueAssigneeType, IssueStatus, UpdateIssueRequest } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
import type { MyIssuesFilter } from "@multica/core/issues/queries";
import { useLoadMoreByAssigneeGroup, useLoadMoreByStatus } from "@multica/core/issues/mutations";
import type { AssigneeGroupedIssuesFilter, MyIssuesFilter } from "@multica/core/issues/queries";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@multica/ui/components/ui/dropdown-menu";
import { ALL_STATUSES } from "@multica/core/issues/config";
import { useViewStoreApi, useViewStore } from "@multica/core/issues/stores/view-store-context";
import type { SortField, SortDirection } from "@multica/core/issues/stores/view-store";
import type { IssueGrouping, SortField, SortDirection } from "@multica/core/issues/stores/view-store";
import { useActorName } from "@multica/core/workspace/hooks";
import { sortIssues } from "../utils/sort";
import { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column";
import { BoardColumn, type BoardColumnGroup } from "./board-column";
import { BoardCardContent } from "./board-card";
import { InfiniteScrollSentinel } from "./infinite-scroll-sentinel";
import type { ChildProgress } from "./list-row";
import { useT } from "../../i18n";
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
type BoardMoveUpdates = Pick<
UpdateIssueRequest,
"status" | "assignee_type" | "assignee_id" | "position"
>;
const kanbanCollision: CollisionDetection = (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) {
// Prefer card collisions over column collisions so that
// dragging down within a column finds the target card
// instead of the column droppable.
const cards = pointer.filter((c) => !COLUMN_IDS.has(c.id as string));
if (cards.length > 0) return cards;
const UNASSIGNED_GROUP_ID = "assignee:unassigned";
function makeKanbanCollision(columnIds: Set<string>): CollisionDetection {
return (args) => {
const pointer = pointerWithin(args);
if (pointer.length > 0) {
// Prefer card collisions over column collisions so that
// dragging down within a column finds the target card
// instead of the column droppable.
const cards = pointer.filter((c) => !columnIds.has(c.id as string));
if (cards.length > 0) return cards;
}
// Fallback: closestCenter finds the nearest card even when
// the pointer is in a gap between cards (common when dragging down).
return closestCenter(args);
};
}
function statusGroupId(status: IssueStatus): string {
return `status:${status}`;
}
function assigneeGroupId(
type: IssueAssigneeType | null,
id: string | null,
): string {
return type && id ? `assignee:${type}:${id}` : UNASSIGNED_GROUP_ID;
}
function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
if (grouping === "status") return statusGroupId(issue.status);
return assigneeGroupId(issue.assignee_type, issue.assignee_id);
}
function isStatusGroup(
group: BoardColumnGroup,
): group is BoardColumnGroup & { status: IssueStatus } {
return group.status !== undefined;
}
function buildGroups(
issues: Issue[],
visibleStatuses: IssueStatus[],
grouping: IssueGrouping,
getActorName: (type: string, id: string) => string,
noAssigneeLabel: string,
): BoardColumnGroup[] {
if (grouping === "status") {
return visibleStatuses.map((status) => ({
id: statusGroupId(status),
title: status,
status,
createData: { status },
}));
}
// Fallback: closestCenter finds the nearest card even when
// the pointer is in a gap between cards (common when dragging down).
return closestCenter(args);
};
const groups = new Map<string, BoardColumnGroup>();
for (const issue of issues) {
const id = assigneeGroupId(issue.assignee_type, issue.assignee_id);
if (groups.has(id)) continue;
if (issue.assignee_type && issue.assignee_id) {
groups.set(id, {
id,
title: getActorName(issue.assignee_type, issue.assignee_id),
assigneeType: issue.assignee_type,
assigneeId: issue.assignee_id,
createData: {
assignee_type: issue.assignee_type,
assignee_id: issue.assignee_id,
},
});
continue;
}
groups.set(id, {
id,
title: noAssigneeLabel,
assigneeType: null,
assigneeId: null,
createData: {
assignee_type: null,
assignee_id: null,
},
});
}
const order: Record<string, number> = {
member: 0,
agent: 1,
squad: 2,
none: 3,
};
return [...groups.values()].sort((a, b) => {
const aOrder = order[a.assigneeType ?? "none"] ?? 99;
const bOrder = order[b.assigneeType ?? "none"] ?? 99;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.title.localeCompare(b.title);
});
}
/** Build column ID arrays from TQ issue data, respecting current sort. */
function buildColumns(
issues: Issue[],
visibleStatuses: IssueStatus[],
groups: BoardColumnGroup[],
grouping: IssueGrouping,
sortBy: SortField,
sortDirection: SortDirection,
): Record<IssueStatus, string[]> {
const cols = {} as Record<IssueStatus, string[]>;
for (const status of visibleStatuses) {
): Record<string, string[]> {
const cols: Record<string, string[]> = {};
for (const group of groups) {
const sorted = sortIssues(
issues.filter((i) => i.status === status),
issues.filter((i) => getIssueGroupId(i, grouping) === group.id),
sortBy,
sortDirection,
);
cols[status] = sorted.map((i) => i.id);
cols[group.id] = sorted.map((i) => i.id);
}
return cols;
}
@@ -83,23 +176,46 @@ function computePosition(ids: string[], activeId: string, issueMap: Map<string,
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
/** Find which column (status) contains a given ID (issue or column droppable). */
/** Find which column contains a given ID (issue or column droppable). */
function findColumn(
columns: Record<IssueStatus, string[]>,
columns: Record<string, string[]>,
id: string,
visibleStatuses: IssueStatus[],
): IssueStatus | null {
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
for (const [status, ids] of Object.entries(columns)) {
if (ids.includes(id)) return status as IssueStatus;
columnIds: Set<string>,
): string | null {
if (columnIds.has(id)) return id;
for (const [columnId, ids] of Object.entries(columns)) {
if (ids.includes(id)) return columnId;
}
return null;
}
function issueMatchesGroup(issue: Issue, group: BoardColumnGroup): boolean {
if (group.status) return issue.status === group.status;
return (
(issue.assignee_type ?? null) === (group.assigneeType ?? null) &&
(issue.assignee_id ?? null) === (group.assigneeId ?? null)
);
}
function getMoveUpdates(
group: BoardColumnGroup,
position: number,
): BoardMoveUpdates {
if (group.status) return { status: group.status, position };
return {
assignee_type: group.assigneeType ?? null,
assignee_id: group.assigneeId ?? null,
position,
};
}
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
export function BoardView({
issues,
assigneeGroups,
assigneeGroupQueryKey,
assigneeGroupFilter,
visibleStatuses,
hiddenStatuses,
onMoveIssue,
@@ -109,13 +225,12 @@ export function BoardView({
projectId,
}: {
issues: Issue[];
assigneeGroups?: IssueAssigneeGroup[];
assigneeGroupQueryKey?: QueryKey;
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
visibleStatuses: IssueStatus[];
hiddenStatuses: IssueStatus[];
onMoveIssue: (
issueId: string,
newStatus: IssueStatus,
newPosition?: number
) => void;
onMoveIssue: (issueId: string, updates: BoardMoveUpdates) => void;
childProgressMap?: Map<string, ChildProgress>;
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
myIssuesScope?: string;
@@ -123,11 +238,75 @@ export function BoardView({
/** When set, the per-column "+" pre-fills the project on the create form. */
projectId?: string;
}) {
const { t } = useT("issues");
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const grouping = useViewStore((s) => s.grouping);
const { getActorName } = useActorName();
const myIssuesOpts = myIssuesScope
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined;
const groupedIssues = useMemo(
() =>
grouping === "assignee" && assigneeGroups
? assigneeGroups.flatMap((group) => group.issues)
: issues,
[assigneeGroups, grouping, issues],
);
const hydratedAssigneeGroups = useMemo(() => {
if (grouping !== "assignee" || !assigneeGroups) return undefined;
const order: Record<string, number> = {
member: 0,
agent: 1,
squad: 2,
none: 3,
};
return assigneeGroups
.map((group) => ({
id: group.id,
title:
group.assignee_type && group.assignee_id
? getActorName(group.assignee_type, group.assignee_id)
: t(($) => $.filters.no_assignee),
assigneeType: group.assignee_type,
assigneeId: group.assignee_id,
totalCount: group.total,
createData: {
assignee_type: group.assignee_type,
assignee_id: group.assignee_id,
},
}))
.sort((a, b) => {
const aOrder = order[a.assigneeType ?? "none"] ?? 99;
const bOrder = order[b.assigneeType ?? "none"] ?? 99;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.title.localeCompare(b.title);
});
}, [assigneeGroups, getActorName, grouping, t]);
const groups = useMemo(
() =>
hydratedAssigneeGroups ??
buildGroups(
issues,
visibleStatuses,
grouping,
getActorName,
t(($) => $.filters.no_assignee),
),
[hydratedAssigneeGroups, issues, visibleStatuses, grouping, getActorName, t],
);
const groupIds = useMemo(
() => new Set(groups.map((group) => group.id)),
[groups],
);
const groupMap = useMemo(
() => new Map(groups.map((group) => [group.id, group])),
[groups],
);
const collisionDetection = useMemo(
() => makeKanbanCollision(groupIds),
[groupIds],
);
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
@@ -136,17 +315,17 @@ export function BoardView({
// --- Local columns state ---
// Between drags: follows TQ via useEffect.
// During drag: local-only, driven by onDragOver/onDragEnd.
const [columns, setColumns] = useState<Record<IssueStatus, string[]>>(() =>
buildColumns(issues, visibleStatuses, sortBy, sortDirection),
const [columns, setColumns] = useState<Record<string, string[]>>(() =>
buildColumns(groupedIssues, groups, grouping, sortBy, sortDirection),
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
useEffect(() => {
if (!isDraggingRef.current) {
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
setColumns(buildColumns(groupedIssues, groups, grouping, sortBy, sortDirection));
}
}, [issues, visibleStatuses, sortBy, sortDirection]);
}, [groupedIssues, groups, grouping, sortBy, sortDirection]);
// After a cross-column move, lock for one animation frame so dnd-kit's
// collision detection can stabilize before processing the next move.
@@ -164,9 +343,9 @@ export function BoardView({
// referentially stable even if a TQ refetch lands mid-drag.
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues) map.set(issue.id, issue);
for (const issue of groupedIssues) map.set(issue.id, issue);
return map;
}, [issues]);
}, [groupedIssues]);
const issueMapRef = useRef(issueMap);
if (!isDraggingRef.current) {
@@ -197,8 +376,8 @@ export function BoardView({
const overId = over.id as string;
setColumns((prev) => {
const activeCol = findColumn(prev, activeId, visibleStatuses);
const overCol = findColumn(prev, overId, visibleStatuses);
const activeCol = findColumn(prev, activeId, groupIds);
const overCol = findColumn(prev, overId, groupIds);
if (!activeCol || !overCol || activeCol === overCol) return prev;
recentlyMovedRef.current = true;
@@ -210,7 +389,7 @@ export function BoardView({
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
},
[visibleStatuses],
[groupIds],
);
const handleDragEnd = useCallback(
@@ -220,7 +399,7 @@ export function BoardView({
setActiveIssue(null);
const resetColumns = () =>
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
setColumns(buildColumns(groupedIssues, groups, grouping, sortBy, sortDirection));
if (!over) {
resetColumns();
@@ -231,8 +410,8 @@ export function BoardView({
const overId = over.id as string;
const cols = columnsRef.current;
const activeCol = findColumn(cols, activeId, visibleStatuses);
const overCol = findColumn(cols, overId, visibleStatuses);
const activeCol = findColumn(cols, activeId, groupIds);
const overCol = findColumn(cols, overId, groupIds);
if (!activeCol || !overCol) {
resetColumns();
return;
@@ -251,11 +430,16 @@ export function BoardView({
}
}
const finalCol = findColumn(finalColumns, activeId, visibleStatuses);
const finalCol = findColumn(finalColumns, activeId, groupIds);
if (!finalCol) {
resetColumns();
return;
}
const finalGroup = groupMap.get(finalCol);
if (!finalGroup) {
resetColumns();
return;
}
const map = issueMapRef.current;
const finalIds = finalColumns[finalCol]!;
@@ -264,39 +448,70 @@ export function BoardView({
if (
currentIssue &&
currentIssue.status === finalCol &&
issueMatchesGroup(currentIssue, finalGroup) &&
currentIssue.position === newPosition
) {
return;
}
onMoveIssue(activeId, finalCol, newPosition);
onMoveIssue(activeId, getMoveUpdates(finalGroup, newPosition));
},
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
[groupedIssues, groups, grouping, sortBy, sortDirection, onMoveIssue, groupIds, groupMap],
);
return (
<DndContext
sensors={sensors}
collisionDetection={kanbanCollision}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
{visibleStatuses.map((status) => (
<PaginatedBoardColumn
key={status}
status={status}
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
myIssuesOpts={myIssuesOpts}
projectId={projectId}
/>
))}
{groups.length === 0 ? (
<div className="flex min-w-full flex-1 items-center justify-center text-sm text-muted-foreground">
{t(($) => $.board.empty_grouping)}
</div>
) : (
groups.map((group) =>
isStatusGroup(group) ? (
<PaginatedBoardColumn
key={group.id}
group={group}
issueIds={columns[group.id] ?? []}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
myIssuesOpts={myIssuesOpts}
projectId={projectId}
/>
) : (
assigneeGroupQueryKey && assigneeGroupFilter ? (
<PaginatedAssigneeBoardColumn
key={group.id}
group={group}
issueIds={columns[group.id] ?? []}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
queryKey={assigneeGroupQueryKey}
filter={assigneeGroupFilter}
projectId={projectId}
/>
) : (
<BoardColumn
key={group.id}
group={group}
issueIds={columns[group.id] ?? []}
issueMap={issueMapRef.current}
childProgressMap={childProgressMap}
projectId={projectId}
totalCount={group.totalCount}
/>
)
),
)
)}
{hiddenStatuses.length > 0 && (
{grouping === "status" && hiddenStatuses.length > 0 && (
<HiddenColumnsPanel
hiddenStatuses={hiddenStatuses}
myIssuesOpts={myIssuesOpts}
@@ -315,15 +530,58 @@ export function BoardView({
);
}
function PaginatedAssigneeBoardColumn({
group,
issueIds,
issueMap,
childProgressMap,
queryKey,
filter,
projectId,
}: {
group: BoardColumnGroup;
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap?: Map<string, ChildProgress>;
queryKey: QueryKey;
filter: AssigneeGroupedIssuesFilter;
projectId?: string;
}) {
const { loadMore, hasMore, isLoading, total } = useLoadMoreByAssigneeGroup(
{
id: group.id,
assignee_type: group.assigneeType ?? null,
assignee_id: group.assigneeId ?? null,
},
queryKey,
filter,
);
return (
<BoardColumn
group={group}
issueIds={issueIds}
issueMap={issueMap}
childProgressMap={childProgressMap}
totalCount={total}
projectId={projectId}
footer={
hasMore ? (
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
) : undefined
}
/>
);
}
function PaginatedBoardColumn({
status,
group,
issueIds,
issueMap,
childProgressMap,
myIssuesOpts,
projectId,
}: {
status: IssueStatus;
group: BoardColumnGroup & { status: IssueStatus };
issueIds: string[];
issueMap: Map<string, Issue>;
childProgressMap?: Map<string, ChildProgress>;
@@ -331,12 +589,12 @@ function PaginatedBoardColumn({
projectId?: string;
}) {
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
status,
group.status,
myIssuesOpts,
);
return (
<BoardColumn
status={status}
group={group}
issueIds={issueIds}
issueMap={issueMap}
childProgressMap={childProgressMap}

View File

@@ -54,6 +54,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
import { LabelChip } from "../../labels/label-chip";
import {
SORT_OPTIONS,
GROUPING_OPTIONS,
CARD_PROPERTY_OPTIONS,
type ActorFilterValue,
} from "@multica/core/issues/stores/view-store";
@@ -549,6 +550,7 @@ export function IssueDisplayControls({ scopedIssues }: { scopedIssues: Issue[] }
const labelFilters = useViewStore((s) => s.labelFilters);
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const grouping = useViewStore((s) => s.grouping);
const cardProperties = useViewStore((s) => s.cardProperties);
const act = useViewStoreApi().getState();
@@ -573,6 +575,10 @@ export function IssueDisplayControls({ scopedIssues }: { scopedIssues: Issue[] }
created_at: "sort_created",
title: "sort_title",
};
const GROUPING_LABEL_KEY: Record<typeof GROUPING_OPTIONS[number]["value"], "group_status" | "group_assignee"> = {
status: "group_status",
assignee: "group_assignee",
};
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
priority: "card_priority",
description: "card_description",
@@ -583,6 +589,7 @@ export function IssueDisplayControls({ scopedIssues }: { scopedIssues: Issue[] }
childProgress: "card_child_progress",
};
const sortLabel = t(($) => $.display[SORT_LABEL_KEY[sortBy]]);
const groupingLabel = t(($) => $.display[GROUPING_LABEL_KEY[grouping]]);
return (
<div className="flex items-center gap-1">
@@ -795,6 +802,40 @@ export function IssueDisplayControls({ scopedIssues }: { scopedIssues: Issue[] }
<TooltipContent side="bottom">{t(($) => $.display.tooltip)}</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-64 p-0">
{viewMode === "board" && (
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.display.grouping_section)}
</span>
<div className="mt-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs"
>
{groupingLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{GROUPING_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setGrouping(opt.value)}
>
{t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.display.ordering_section)}

View File

@@ -61,18 +61,74 @@ vi.mock("../../workspace/workspace-avatar", () => ({
// Mock api (queries use api internally)
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
const mockListGroupedIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ groups: [] }));
const mockListMembers = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "member-1",
workspace_id: "ws-1",
user_id: "user-1",
role: "member",
created_at: "2026-01-01T00:00:00Z",
name: "Test User",
email: "test@test.com",
avatar_url: null,
},
]),
);
const mockListAgents = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "agent-1",
workspace_id: "ws-1",
name: "Agent One",
description: "",
instructions: "",
status: "idle",
runtime_id: null,
owner_id: "user-1",
avatar_url: null,
visibility: "workspace",
archived_at: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
]),
);
const mockListSquads = vi.hoisted(() =>
vi.fn().mockResolvedValue([
{
id: "squad-1",
workspace_id: "ws-1",
name: "Squad One",
description: "",
instructions: "",
avatar_url: null,
leader_id: "agent-1",
creator_id: "user-1",
archived_at: null,
archived_by: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
]),
);
vi.mock("@multica/core/api", () => ({
api: {
listIssues: (...args: any[]) => mockListIssues(...args),
listGroupedIssues: (...args: any[]) => mockListGroupedIssues(...args),
updateIssue: vi.fn(),
listMembers: () => Promise.resolve([]),
listAgents: () => Promise.resolve([]),
listMembers: (...args: any[]) => mockListMembers(...args),
listAgents: (...args: any[]) => mockListAgents(...args),
listSquads: (...args: any[]) => mockListSquads(...args),
},
getApi: () => ({
listIssues: (...args: any[]) => mockListIssues(...args),
listGroupedIssues: (...args: any[]) => mockListGroupedIssues(...args),
updateIssue: vi.fn(),
listMembers: () => Promise.resolve([]),
listAgents: () => Promise.resolve([]),
listMembers: (...args: any[]) => mockListMembers(...args),
listAgents: (...args: any[]) => mockListAgents(...args),
listSquads: (...args: any[]) => mockListSquads(...args),
}),
setApiInstance: vi.fn(),
}));
@@ -104,6 +160,7 @@ vi.mock("@multica/core/issues/config", () => ({
// Mock view store
const mockViewState = {
viewMode: "board" as "board" | "list",
grouping: "status" as "status" | "assignee",
statusFilters: [] as string[],
priorityFilters: [] as string[],
assigneeFilters: [] as { type: string; id: string }[],
@@ -117,6 +174,7 @@ const mockViewState = {
cardProperties: { priority: true, description: true, assignee: true, dueDate: true, project: true, childProgress: true, labels: true },
listCollapsedStatuses: [] as string[],
setViewMode: vi.fn(),
setGrouping: vi.fn(),
toggleStatusFilter: vi.fn(),
togglePriorityFilter: vi.fn(),
toggleAssigneeFilter: vi.fn(),
@@ -155,6 +213,10 @@ vi.mock("@multica/core/issues/stores/view-store", () => ({
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
],
GROUPING_OPTIONS: [
{ value: "status", label: "Status" },
{ value: "assignee", label: "Assignee" },
],
CARD_PROPERTY_OPTIONS: [
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
@@ -352,6 +414,33 @@ const mockIssues: Issue[] = [
},
];
function mockAssigneeGroups(issues: Issue[]) {
const groups = new Map<string, { assignee_type: Issue["assignee_type"]; assignee_id: string | null; issues: Issue[] }>();
for (const issue of issues) {
const id =
issue.assignee_type && issue.assignee_id
? `assignee:${issue.assignee_type}:${issue.assignee_id}`
: "assignee:unassigned";
if (!groups.has(id)) {
groups.set(id, {
assignee_type: issue.assignee_type,
assignee_id: issue.assignee_id,
issues: [],
});
}
groups.get(id)!.issues.push(issue);
}
return {
groups: [...groups.entries()].map(([id, group]) => ({
id,
assignee_type: group.assignee_type,
assignee_id: group.assignee_id,
issues: group.issues,
total: group.issues.length,
})),
};
}
// ---------------------------------------------------------------------------
// Import component under test (after mocks)
// ---------------------------------------------------------------------------
@@ -386,7 +475,9 @@ describe("IssuesPage (shared)", () => {
beforeEach(() => {
vi.clearAllMocks();
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
mockListGroupedIssues.mockResolvedValue({ groups: [] });
mockViewState.viewMode = "board";
mockViewState.grouping = "status";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
mockScope = "all";
@@ -429,6 +520,36 @@ describe("IssuesPage (shared)", () => {
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
});
it("groups board columns by assignee", async () => {
mockViewState.grouping = "assignee";
mockListGroupedIssues.mockResolvedValue(mockAssigneeGroups(mockIssues));
renderWithQuery(<IssuesPage />);
await screen.findByText("Test User");
expect(screen.getByText("Agent One")).toBeInTheDocument();
expect(screen.getByText("Squad One")).toBeInTheDocument();
expect(screen.getByText("No assignee")).toBeInTheDocument();
});
it("uses grouped assignee endpoint instead of status page sweep", async () => {
mockViewState.grouping = "assignee";
mockListGroupedIssues.mockResolvedValue(mockAssigneeGroups(mockIssues));
renderWithQuery(<IssuesPage />);
await screen.findByText("Implement auth");
expect(mockListGroupedIssues).toHaveBeenCalledWith(
expect.objectContaining({
group_by: "assignee",
limit: 50,
offset: 0,
statuses: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
}),
);
expect(mockListIssues).not.toHaveBeenCalled();
});
it("shows workspace breadcrumb with 'Issues' label", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve({

View File

@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo } from "react";
import { toast } from "sonner";
import { ChevronRight, ListTodo } from "lucide-react";
import type { IssueStatus } from "@multica/core/types";
import type { UpdateIssueRequest } from "@multica/core/types";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
import { useIssueViewStore, useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
@@ -14,7 +14,7 @@ import { BOARD_STATUSES } from "@multica/core/issues/config";
import { useCurrentWorkspace } from "@multica/core/paths";
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueListOptions, childIssueProgressOptions } from "@multica/core/issues/queries";
import { issueAssigneeGroupsOptions, issueListOptions, childIssueProgressOptions, type AssigneeGroupedIssuesFilter } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
import { PageHeader } from "../../layout/page-header";
@@ -27,11 +27,11 @@ import { useT } from "../../i18n";
export function IssuesPage() {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
const workspace = useCurrentWorkspace();
const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode);
const grouping = useIssueViewStore((s) => s.grouping);
const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters);
@@ -40,6 +40,44 @@ export function IssuesPage() {
const projectFilters = useIssueViewStore((s) => s.projectFilters);
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
const labelFilters = useIssueViewStore((s) => s.labelFilters);
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(() => {
const filter: AssigneeGroupedIssuesFilter = {
statuses: statusFilters.length > 0 ? statusFilters : [...BOARD_STATUSES],
priorities: priorityFilters,
assignee_filters: assigneeFilters,
include_no_assignee: includeNoAssignee,
creator_filters: creatorFilters,
project_ids: projectFilters,
include_no_project: includeNoProject,
label_ids: labelFilters,
};
if (scope === "members") filter.assignee_types = ["member"];
if (scope === "agents") filter.assignee_types = ["agent", "squad"];
return filter;
}, [assigneeFilters, creatorFilters, includeNoAssignee, includeNoProject, labelFilters, priorityFilters, projectFilters, scope, statusFilters]);
const assigneeGroupsOptions = issueAssigneeGroupsOptions(wsId, assigneeGroupFilter);
const statusIssuesQuery = useQuery({
...issueListOptions(wsId),
enabled: !usesAssigneeBoard,
});
const assigneeGroupsQuery = useQuery({
...assigneeGroupsOptions,
enabled: usesAssigneeBoard,
});
const allIssues = useMemo(
() => statusIssuesQuery.data ?? [],
[statusIssuesQuery.data],
);
const assigneeIssues = useMemo(
() => assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [],
[assigneeGroupsQuery.data],
);
const loading = usesAssigneeBoard
? assigneeGroupsQuery.isLoading
: statusIssuesQuery.isLoading;
// Clear filter state when switching between workspaces (URL-driven).
useClearFiltersOnWorkspaceChange(useIssueViewStore, wsId);
@@ -57,6 +95,8 @@ export function IssuesPage() {
return allIssues;
}, [allIssues, scope]);
const headerIssues = usesAssigneeBoard ? assigneeIssues : scopedIssues;
const issues = useMemo(
() => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters }),
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters],
@@ -78,12 +118,7 @@ export function IssuesPage() {
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
const updates: Partial<{ status: IssueStatus; position: number }> = {
status: newStatus,
};
if (newPosition !== undefined) updates.position = newPosition;
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error(t(($) => $.page.move_failed)) },
@@ -146,10 +181,10 @@ export function IssuesPage() {
<ViewStoreProvider store={useIssueViewStore}>
{/* Header 2: Scope tabs + filters */}
<IssuesHeader scopedIssues={scopedIssues} />
<IssuesHeader scopedIssues={headerIssues} />
{/* Content: scrollable */}
{scopedIssues.length === 0 ? (
{headerIssues.length === 0 ? (
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm">{t(($) => $.page.empty_title)}</p>
@@ -159,7 +194,10 @@ export function IssuesPage() {
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
issues={usesAssigneeBoard ? assigneeIssues : issues}
assigneeGroups={usesAssigneeBoard ? assigneeGroupsQuery.data?.groups : undefined}
assigneeGroupQueryKey={usesAssigneeBoard ? assigneeGroupsOptions.queryKey : undefined}
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}

View File

@@ -52,10 +52,13 @@
},
"display": {
"tooltip": "Display settings",
"grouping_section": "Grouping",
"ordering_section": "Ordering",
"card_properties_section": "Card properties",
"ascending_title": "Ascending",
"descending_title": "Descending",
"group_status": "Status",
"group_assignee": "Assignee",
"sort_manual": "Manual",
"sort_priority": "Priority",
"sort_due_date": "Due date",
@@ -107,7 +110,8 @@
"hide_column": "Hide column",
"show_column": "Show column",
"add_issue_tooltip": "Add issue",
"empty_column": "No issues"
"empty_column": "No issues",
"empty_grouping": "No matching issues"
},
"detail": {
"not_found": "This issue does not exist or has been deleted in this workspace.",

View File

@@ -21,6 +21,9 @@
"issue_count_other": "{{count}} issues",
"reset_filters": "Reset all filters",
"display_settings": "Display settings",
"grouping": "Grouping",
"group_status": "Status",
"group_assignee": "Assignee",
"ordering": "Ordering",
"ascending": "Ascending",
"descending": "Descending",

View File

@@ -51,10 +51,13 @@
},
"display": {
"tooltip": "显示设置",
"grouping_section": "分组",
"ordering_section": "排序",
"card_properties_section": "卡片属性",
"ascending_title": "升序",
"descending_title": "降序",
"group_status": "状态",
"group_assignee": "负责人",
"sort_manual": "手动",
"sort_priority": "优先级",
"sort_due_date": "截止日期",
@@ -106,7 +109,8 @@
"hide_column": "隐藏列",
"show_column": "显示列",
"add_issue_tooltip": "新建 issue",
"empty_column": "无 issue"
"empty_column": "无 issue",
"empty_grouping": "没有匹配的 issue"
},
"detail": {
"not_found": "这个 issue 不存在或已在该工作区被删除。",

View File

@@ -20,6 +20,9 @@
"issue_count_other": "{{count}} 个 issue",
"reset_filters": "重置所有筛选",
"display_settings": "显示设置",
"grouping": "分组",
"group_status": "状态",
"group_assignee": "负责人",
"ordering": "排序",
"ascending": "升序",
"descending": "降序",

View File

@@ -100,8 +100,18 @@ export function ManualCreatePanel({
const [status, setStatus] = useState<IssueStatus>((data?.status as IssueStatus) || draft.status);
const [priority, setPriority] = useState<IssuePriority>(draft.priority);
const [submitting, setSubmitting] = useState(false);
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>(draft.assigneeType);
const [assigneeId, setAssigneeId] = useState<string | undefined>(draft.assigneeId);
const [assigneeType, setAssigneeType] = useState<IssueAssigneeType | undefined>(() => {
if (data && "assignee_type" in data) {
return (data.assignee_type as IssueAssigneeType | null) ?? undefined;
}
return draft.assigneeType;
});
const [assigneeId, setAssigneeId] = useState<string | undefined>(() => {
if (data && "assignee_id" in data) {
return (data.assignee_id as string | null) ?? undefined;
}
return draft.assigneeId;
});
const [dueDate, setDueDate] = useState<string | null>(draft.dueDate);
const [projectId, setProjectId] = useState<string | undefined>(
(data?.project_id as string) || undefined,

View File

@@ -43,6 +43,7 @@ import {
import { StatusIcon, PriorityIcon } from "../../issues/components";
import {
SORT_OPTIONS,
GROUPING_OPTIONS,
CARD_PROPERTY_OPTIONS,
} from "@multica/core/issues/stores/view-store";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
@@ -116,6 +117,7 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
const grouping = useStore(myIssuesViewStore, (s) => s.grouping);
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
const act = myIssuesViewStore.getState();
@@ -127,6 +129,11 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? t(($) => $.header.sort_manual);
const GROUPING_LABEL_KEY: Record<typeof GROUPING_OPTIONS[number]["value"], "group_status" | "group_assignee"> = {
status: "group_status",
assignee: "group_assignee",
};
const groupingLabel = t(($) => $.header[GROUPING_LABEL_KEY[grouping]]);
return (
<div className="flex h-12 shrink-0 items-center justify-between px-4">
@@ -278,6 +285,40 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
<TooltipContent side="bottom">{t(($) => $.header.display_settings)}</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-64 p-0">
{viewMode === "board" && (
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.header.grouping)}
</span>
<div className="mt-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
size="sm"
className="w-full justify-between text-xs"
>
{groupingLabel}
<ChevronDown className="size-3 text-muted-foreground" />
</Button>
}
/>
<DropdownMenuContent align="start" className="w-auto">
{GROUPING_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
onClick={() => act.setGrouping(opt.value)}
>
{t(($) => $.header[GROUPING_LABEL_KEY[opt.value]])}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
<div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground">
{t(($) => $.header.ordering)}

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo } from "react";
import { useStore } from "zustand";
import { toast } from "sonner";
import { ChevronRight, ListTodo } from "lucide-react";
import type { IssueStatus } from "@multica/core/types";
import type { UpdateIssueRequest } from "@multica/core/types";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { useAuthStore } from "@multica/core/auth";
import { useCurrentWorkspace } from "@multica/core/paths";
@@ -20,7 +20,7 @@ import { ListView } from "../../issues/components/list-view";
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
import { useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
import { useWorkspaceId } from "@multica/core/hooks";
import { myIssueListOptions, childIssueProgressOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
import { myIssueAssigneeGroupsOptions, myIssueListOptions, childIssueProgressOptions, type AssigneeGroupedIssuesFilter, type MyIssuesFilter } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { myIssuesViewStore } from "@multica/core/issues/stores/my-issues-view-store";
import { PageHeader } from "../../layout/page-header";
@@ -38,6 +38,8 @@ export function MyIssuesPage() {
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
const grouping = useStore(myIssuesViewStore, (s) => s.grouping);
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
// Clear filter state when switching between workspaces (URL-driven).
useClearFiltersOnWorkspaceChange(myIssuesViewStore, wsId);
@@ -69,9 +71,37 @@ export function MyIssuesPage() {
}
}, [scope, user, myAgentIds]);
const { data: myIssues = [], isLoading: loading } = useQuery(
myIssueListOptions(wsId, scope, filter),
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(
() => ({
...filter,
statuses: statusFilters.length > 0 ? statusFilters : [...BOARD_STATUSES],
priorities: priorityFilters,
}),
[filter, priorityFilters, statusFilters],
);
const assigneeGroupsOptions = myIssueAssigneeGroupsOptions(
wsId,
scope,
assigneeGroupFilter,
);
const statusIssuesQuery = useQuery({
...myIssueListOptions(wsId, scope, filter),
enabled: !usesAssigneeBoard,
});
const assigneeGroupsQuery = useQuery({
...assigneeGroupsOptions,
enabled: usesAssigneeBoard,
});
const myIssues = useMemo(
() =>
usesAssigneeBoard
? (assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [])
: (statusIssuesQuery.data ?? []),
[assigneeGroupsQuery.data, statusIssuesQuery.data, usesAssigneeBoard],
);
const loading = usesAssigneeBoard
? assigneeGroupsQuery.isLoading
: statusIssuesQuery.isLoading;
// Apply status/priority filters from view store
const issues = useMemo(
@@ -103,12 +133,7 @@ export function MyIssuesPage() {
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
const updates: Partial<{ status: IssueStatus; position: number }> = {
status: newStatus,
};
if (newPosition !== undefined) updates.position = newPosition;
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error(t(($) => $.errors.move_failed)) },
@@ -184,7 +209,10 @@ export function MyIssuesPage() {
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
issues={usesAssigneeBoard ? myIssues : issues}
assigneeGroups={usesAssigneeBoard ? assigneeGroupsQuery.data?.groups : undefined}
assigneeGroupQueryKey={usesAssigneeBoard ? assigneeGroupsOptions.queryKey : undefined}
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}

View File

@@ -3,16 +3,16 @@
import { useMemo, useState, useCallback, useRef, useEffect } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Plus, Trash2, UserMinus } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, type QueryKey } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
import type { Issue, IssueAssigneeGroup, ProjectStatus, ProjectPriority, UpdateIssueRequest } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
import { useCreatePin, useDeletePin } from "@multica/core/pins";
import { myIssueListOptions, childIssueProgressOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
import { myIssueAssigneeGroupsOptions, myIssueListOptions, childIssueProgressOptions, type AssigneeGroupedIssuesFilter, type MyIssuesFilter } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useModalStore } from "@multica/core/modals";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
@@ -102,11 +102,17 @@ const projectViewStore = createIssueViewStore("project_issues_view");
function ProjectIssuesContent({
projectId,
projectIssues,
assigneeGroups,
assigneeGroupQueryKey,
assigneeGroupFilter,
scope,
filter,
}: {
projectId: string;
projectIssues: Issue[];
assigneeGroups?: IssueAssigneeGroup[];
assigneeGroupQueryKey?: QueryKey;
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
scope: string;
filter: MyIssuesFilter;
}) {
@@ -140,9 +146,7 @@ function ProjectIssuesContent({
const updateIssueMutation = useUpdateIssue();
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
const updates: Partial<{ status: IssueStatus; position: number }> = { status: newStatus };
if (newPosition !== undefined) updates.position = newPosition;
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
updateIssueMutation.mutate(
{ id: issueId, ...updates },
{ onError: () => toast.error(t(($) => $.detail.toast_move_issue_failed)) },
@@ -176,7 +180,10 @@ function ProjectIssuesContent({
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
<BoardView
issues={issues}
issues={assigneeGroups ? projectIssues : issues}
assigneeGroups={assigneeGroups}
assigneeGroupQueryKey={assigneeGroupQueryKey}
assigneeGroupFilter={assigneeGroupFilter}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
@@ -199,6 +206,71 @@ function ProjectIssuesContent({
);
}
function ProjectIssuesSurface({
projectId,
scope,
filter,
}: {
projectId: string;
scope: string;
filter: MyIssuesFilter;
}) {
const wsId = useWorkspaceId();
const viewMode = useViewStore((s) => s.viewMode);
const grouping = useViewStore((s) => s.grouping);
const statusFilters = useViewStore((s) => s.statusFilters);
const priorityFilters = useViewStore((s) => s.priorityFilters);
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 usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(
() => ({
...filter,
statuses: statusFilters.length > 0 ? statusFilters : [...BOARD_STATUSES],
priorities: priorityFilters,
assignee_filters: assigneeFilters,
include_no_assignee: includeNoAssignee,
creator_filters: creatorFilters,
label_ids: labelFilters,
}),
[assigneeFilters, creatorFilters, filter, includeNoAssignee, labelFilters, priorityFilters, statusFilters],
);
const assigneeGroupsOptions = myIssueAssigneeGroupsOptions(
wsId,
scope,
assigneeGroupFilter,
);
const statusIssuesQuery = useQuery({
...myIssueListOptions(wsId, scope, filter),
enabled: !usesAssigneeBoard,
});
const assigneeGroupsQuery = useQuery({
...assigneeGroupsOptions,
enabled: usesAssigneeBoard,
});
const projectIssues = usesAssigneeBoard
? (assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [])
: (statusIssuesQuery.data ?? []);
return (
<>
<IssuesHeader scopedIssues={projectIssues} />
<ProjectIssuesContent
projectId={projectId}
projectIssues={projectIssues}
assigneeGroups={usesAssigneeBoard ? assigneeGroupsQuery.data?.groups : undefined}
assigneeGroupQueryKey={usesAssigneeBoard ? assigneeGroupsOptions.queryKey : undefined}
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
scope={scope}
filter={filter}
/>
<BatchActionToolbar />
</>
);
}
// ---------------------------------------------------------------------------
// ProjectDetail
// ---------------------------------------------------------------------------
@@ -219,9 +291,6 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
() => ({ project_id: projectId }),
[projectId],
);
const { data: projectIssues = [] } = useQuery(
myIssueListOptions(wsId, projectScope, projectFilter),
);
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { getActorName } = useActorName();
@@ -606,14 +675,11 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
</PageHeader>
<ViewStoreProvider store={projectViewStore}>
<IssuesHeader scopedIssues={projectIssues} />
<ProjectIssuesContent
<ProjectIssuesSurface
projectId={projectId}
projectIssues={projectIssues}
scope={projectScope}
filter={projectFilter}
/>
<BatchActionToolbar />
</ViewStoreProvider>
</div>
</ResizablePanel>

View File

@@ -332,6 +332,7 @@ func NewRouterWithOptions(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus
r.Route("/api/issues", func(r chi.Router) {
r.Get("/search", h.SearchIssues)
r.Get("/child-progress", h.ChildIssueProgress)
r.Get("/grouped", h.ListGroupedIssues)
r.Get("/", h.ListIssues)
r.Post("/", h.CreateIssue)
r.Post("/quick-create", h.QuickCreateIssue)

View File

@@ -26,33 +26,33 @@ import (
// IssueResponse is the JSON response for an issue.
type IssueResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Number int32 `json:"number"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Position float64 `json:"position"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Number int32 `json:"number"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Position float64 `json:"position"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
// Labels are bulk-attached by list/detail endpoints so the client can render
// chips without an N+1 round-trip per row. Pointer + omitempty so paths that
// don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated
// WS broadcast) emit no `labels` field at all — the client merge then
// preserves whatever labels are already in cache. nil pointer = "field
// absent, do not touch"; non-nil (incl. empty slice) = authoritative list.
Labels *[]LabelResponse `json:"labels,omitempty"`
Labels *[]LabelResponse `json:"labels,omitempty"`
}
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
@@ -159,6 +159,30 @@ func openIssueRowToResponse(i db.ListOpenIssuesRow, issuePrefix string) IssueRes
}
}
type IssueAssigneeGroupResponse struct {
ID string `json:"id"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
Issues []IssueResponse `json:"issues"`
Total int64 `json:"total"`
}
type GroupedIssuesResponse struct {
Groups []IssueAssigneeGroupResponse `json:"groups"`
}
type groupedIssueRow struct {
db.ListIssuesRow
GroupTotal int64
}
func assigneeGroupID(assigneeType pgtype.Text, assigneeID pgtype.UUID) string {
if assigneeType.Valid && assigneeID.Valid {
return "assignee:" + assigneeType.String + ":" + uuidToString(assigneeID)
}
return "assignee:unassigned"
}
// SearchIssueResponse extends IssueResponse with search metadata.
type SearchIssueResponse struct {
IssueResponse
@@ -286,7 +310,7 @@ func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool,
}
escapedPhrase := escapeLike(phrase)
phraseParam := nextArg(escapedPhrase) // $1
phraseParam := nextArg(escapedPhrase) // $1
phraseContains := "'%' || " + phraseParam + " || '%'"
phraseStartsWith := phraseParam + " || '%'"
@@ -761,6 +785,374 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
})
}
type issueActorFilter struct {
actorType string
actorID pgtype.UUID
}
func splitCommaParam(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if trimmed := strings.TrimSpace(part); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func isIssueActorType(s string) bool {
return s == "member" || s == "agent" || s == "squad"
}
func parseUUIDParamList(w http.ResponseWriter, raw, fieldName string) ([]pgtype.UUID, bool) {
parts := splitCommaParam(raw)
if len(parts) == 0 {
return nil, true
}
ids := make([]pgtype.UUID, 0, len(parts))
for _, part := range parts {
id, ok := parseUUIDOrBadRequest(w, part, fieldName)
if !ok {
return nil, false
}
ids = append(ids, id)
}
return ids, true
}
func parseActorFilterList(w http.ResponseWriter, raw, fieldName string) ([]issueActorFilter, bool) {
parts := splitCommaParam(raw)
if len(parts) == 0 {
return nil, true
}
filters := make([]issueActorFilter, 0, len(parts))
for _, part := range parts {
pieces := strings.SplitN(part, ":", 2)
if len(pieces) != 2 || !isIssueActorType(pieces[0]) || strings.TrimSpace(pieces[1]) == "" {
writeError(w, http.StatusBadRequest, "invalid "+fieldName)
return nil, false
}
id, ok := parseUUIDOrBadRequest(w, strings.TrimSpace(pieces[1]), fieldName)
if !ok {
return nil, false
}
filters = append(filters, issueActorFilter{
actorType: pieces[0],
actorID: id,
})
}
return filters, true
}
func (h *Handler) ListGroupedIssues(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if h.DB == nil {
writeError(w, http.StatusInternalServerError, "database is unavailable")
return
}
groupBy := r.URL.Query().Get("group_by")
if groupBy == "" {
groupBy = "assignee"
}
if groupBy != "assignee" {
writeError(w, http.StatusBadRequest, "unsupported group_by")
return
}
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
if !ok {
return
}
limit := 50
offset := 0
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 {
limit = v
}
}
if limit > 100 {
limit = 100
}
if o := r.URL.Query().Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil && v > 0 {
offset = v
}
}
where := []string{"i.workspace_id = $1"}
args := []any{wsUUID}
addArg := func(v any) string {
args = append(args, v)
return "$" + strconv.Itoa(len(args))
}
statuses := splitCommaParam(r.URL.Query().Get("statuses"))
if len(statuses) == 0 {
statuses = splitCommaParam(r.URL.Query().Get("status"))
}
if len(statuses) > 0 {
where = append(where, fmt.Sprintf("i.status = ANY(%s::text[])", addArg(statuses)))
}
priorities := splitCommaParam(r.URL.Query().Get("priorities"))
if len(priorities) == 0 {
priorities = splitCommaParam(r.URL.Query().Get("priority"))
}
if len(priorities) > 0 {
where = append(where, fmt.Sprintf("i.priority = ANY(%s::text[])", addArg(priorities)))
}
assigneeTypes := splitCommaParam(r.URL.Query().Get("assignee_types"))
if len(assigneeTypes) > 0 {
for _, assigneeType := range assigneeTypes {
if !isIssueActorType(assigneeType) {
writeError(w, http.StatusBadRequest, "invalid assignee_types")
return
}
}
where = append(where, fmt.Sprintf("i.assignee_type = ANY(%s::text[])", addArg(assigneeTypes)))
}
if raw := r.URL.Query().Get("assignee_id"); raw != "" {
id, ok := parseUUIDOrBadRequest(w, raw, "assignee_id")
if !ok {
return
}
where = append(where, fmt.Sprintf("i.assignee_id = %s::uuid", addArg(id)))
}
if raw := r.URL.Query().Get("assignee_ids"); raw != "" {
ids, ok := parseUUIDParamList(w, raw, "assignee_ids")
if !ok {
return
}
if len(ids) > 0 {
where = append(where, fmt.Sprintf("i.assignee_id = ANY(%s::uuid[])", addArg(ids)))
}
}
if raw := r.URL.Query().Get("creator_id"); raw != "" {
id, ok := parseUUIDOrBadRequest(w, raw, "creator_id")
if !ok {
return
}
where = append(where, fmt.Sprintf("i.creator_id = %s::uuid", addArg(id)))
}
if raw := r.URL.Query().Get("project_id"); raw != "" {
id, ok := parseUUIDOrBadRequest(w, raw, "project_id")
if !ok {
return
}
where = append(where, fmt.Sprintf("i.project_id = %s::uuid", addArg(id)))
}
assigneeFilters, ok := parseActorFilterList(w, r.URL.Query().Get("assignee_filters"), "assignee_filters")
if !ok {
return
}
includeNoAssignee := r.URL.Query().Get("include_no_assignee") == "true"
if len(assigneeFilters) > 0 || includeNoAssignee {
ors := make([]string, 0, len(assigneeFilters)+1)
for _, filter := range assigneeFilters {
ors = append(ors, fmt.Sprintf(
"(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)",
addArg(filter.actorType),
addArg(filter.actorID),
))
}
if includeNoAssignee {
ors = append(ors, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)")
}
where = append(where, "("+strings.Join(ors, " OR ")+")")
}
creatorFilters, ok := parseActorFilterList(w, r.URL.Query().Get("creator_filters"), "creator_filters")
if !ok {
return
}
if len(creatorFilters) > 0 {
ors := make([]string, 0, len(creatorFilters))
for _, filter := range creatorFilters {
ors = append(ors, fmt.Sprintf(
"(i.creator_type = %s::text AND i.creator_id = %s::uuid)",
addArg(filter.actorType),
addArg(filter.actorID),
))
}
where = append(where, "("+strings.Join(ors, " OR ")+")")
}
projectIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("project_ids"), "project_ids")
if !ok {
return
}
includeNoProject := r.URL.Query().Get("include_no_project") == "true"
if len(projectIDs) > 0 || includeNoProject {
ors := make([]string, 0, 2)
if len(projectIDs) > 0 {
ors = append(ors, fmt.Sprintf("i.project_id = ANY(%s::uuid[])", addArg(projectIDs)))
}
if includeNoProject {
ors = append(ors, "i.project_id IS NULL")
}
where = append(where, "("+strings.Join(ors, " OR ")+")")
}
labelIDs, ok := parseUUIDParamList(w, r.URL.Query().Get("label_ids"), "label_ids")
if !ok {
return
}
if len(labelIDs) > 0 {
where = append(where, fmt.Sprintf(
"EXISTS (SELECT 1 FROM issue_to_label itl WHERE itl.issue_id = i.id AND itl.label_id = ANY(%s::uuid[]))",
addArg(labelIDs),
))
}
if groupAssigneeType := r.URL.Query().Get("group_assignee_type"); groupAssigneeType != "" {
if groupAssigneeType == "none" {
where = append(where, "(i.assignee_type IS NULL AND i.assignee_id IS NULL)")
} else {
if !isIssueActorType(groupAssigneeType) {
writeError(w, http.StatusBadRequest, "invalid group_assignee_type")
return
}
rawID := r.URL.Query().Get("group_assignee_id")
if rawID == "" {
writeError(w, http.StatusBadRequest, "invalid group_assignee_id")
return
}
assigneeID, ok := parseUUIDOrBadRequest(w, rawID, "group_assignee_id")
if !ok {
return
}
where = append(where, fmt.Sprintf(
"(i.assignee_type = %s::text AND i.assignee_id = %s::uuid)",
addArg(groupAssigneeType),
addArg(assigneeID),
))
}
}
offsetRef := addArg(int64(offset))
limitRef := addArg(int64(limit))
query := fmt.Sprintf(`
WITH ranked AS (
SELECT
i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
i.assignee_type, i.assignee_id, i.creator_type, i.creator_id,
i.parent_issue_id, i.position, i.due_date, i.created_at, i.updated_at,
i.number, i.project_id,
COUNT(*) OVER (PARTITION BY i.assignee_type, i.assignee_id) AS group_total,
ROW_NUMBER() OVER (
PARTITION BY i.assignee_type, i.assignee_id
ORDER BY i.position ASC, i.created_at DESC
) AS rn
FROM issue i
WHERE %s
)
SELECT
id, workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, due_date, created_at, updated_at,
number, project_id, group_total
FROM ranked
WHERE rn > %s AND rn <= %s + %s
ORDER BY
CASE assignee_type
WHEN 'member' THEN 0
WHEN 'agent' THEN 1
WHEN 'squad' THEN 2
ELSE 3
END,
assignee_type NULLS LAST,
assignee_id NULLS LAST,
rn`, strings.Join(where, " AND "), offsetRef, offsetRef, limitRef)
rows, err := h.DB.Query(ctx, query, args...)
if err != nil {
slog.Warn("ListGroupedIssues query failed", "error", err)
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
return
}
defer rows.Close()
groupedRows := []groupedIssueRow{}
for rows.Next() {
var row groupedIssueRow
if err := rows.Scan(
&row.ID,
&row.WorkspaceID,
&row.Title,
&row.Description,
&row.Status,
&row.Priority,
&row.AssigneeType,
&row.AssigneeID,
&row.CreatorType,
&row.CreatorID,
&row.ParentIssueID,
&row.Position,
&row.DueDate,
&row.CreatedAt,
&row.UpdatedAt,
&row.Number,
&row.ProjectID,
&row.GroupTotal,
); err != nil {
slog.Warn("ListGroupedIssues scan failed", "error", err)
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
return
}
groupedRows = append(groupedRows, row)
}
if err := rows.Err(); err != nil {
slog.Warn("ListGroupedIssues rows failed", "error", err)
writeError(w, http.StatusInternalServerError, "failed to list grouped issues")
return
}
ids := make([]pgtype.UUID, len(groupedRows))
for i, row := range groupedRows {
ids[i] = row.ID
}
labelsMap := h.labelsByIssue(ctx, wsUUID, ids)
prefix := h.getIssuePrefix(ctx, wsUUID)
groups := []IssueAssigneeGroupResponse{}
groupIndex := map[string]int{}
for _, row := range groupedRows {
groupID := assigneeGroupID(row.AssigneeType, row.AssigneeID)
idx, exists := groupIndex[groupID]
if !exists {
idx = len(groups)
groupIndex[groupID] = idx
groups = append(groups, IssueAssigneeGroupResponse{
ID: groupID,
AssigneeType: textToPtr(row.AssigneeType),
AssigneeID: uuidToPtr(row.AssigneeID),
Issues: []IssueResponse{},
Total: row.GroupTotal,
})
}
issue := issueListRowToResponse(row.ListIssuesRow, prefix)
labels := labelsMap[issue.ID]
if labels == nil {
labels = []LabelResponse{}
}
issue.Labels = &labels
groups[idx].Issues = append(groups[idx].Issues, issue)
}
writeJSON(w, http.StatusOK, GroupedIssuesResponse{Groups: groups})
}
func (h *Handler) GetIssue(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
issue, ok := h.loadIssueForUser(w, r, id)
@@ -1118,16 +1510,16 @@ func readRuntimeCLIVersion(metadata []byte) string {
}
type CreateIssueRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
DueDate *string `json:"due_date"`
AttachmentIDs []string `json:"attachment_ids,omitempty"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
DueDate *string `json:"due_date"`
AttachmentIDs []string `json:"attachment_ids,omitempty"`
// OriginType / OriginID stamp the new issue with its provenance so
// platform-internal flows can deterministically locate it later. Only
// trusted callers should set these — currently the daemon CLI passes
@@ -1406,16 +1798,16 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
}
type UpdateIssueRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
Position *float64 `json:"position"`
DueDate *string `json:"due_date"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
Position *float64 `json:"position"`
DueDate *string `json:"due_date"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
// AttachmentIDs lets the description editor bind newly uploaded files to
// this issue so they surface in `GET /api/issues/:id/attachments` and the
// editor's preview Eye keeps working past a refresh. Existing bindings

View File

@@ -0,0 +1,156 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestListGroupedIssuesAssigneePaginatesPerGroup(t *testing.T) {
ctx := context.Background()
suffix := time.Now().UnixNano()
var assigneeID string
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email)
VALUES ($1, $2)
RETURNING id
`, "Grouped Issues Test User", fmt.Sprintf("grouped-%d@multica.ai", suffix)).Scan(&assigneeID); err != nil {
t.Fatalf("create assignee user: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, assigneeID)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role)
VALUES ($1, $2, 'member')
`, testWorkspaceID, assigneeID); err != nil {
t.Fatalf("create assignee member: %v", err)
}
var agentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
RETURNING id
`, testWorkspaceID, "Grouped Issues Test Agent", testRuntimeID, testUserID).Scan(&agentID); err != nil {
t.Fatalf("create agent: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
createIssue := func(title, assigneeType, assigneeID string, position float64) string {
t.Helper()
var number int32
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 1
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&number); err != nil {
t.Fatalf("next issue number: %v", err)
}
var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
position, number
)
VALUES ($1, $2, NULL, 'todo', 'none', $3, $4, 'member', $5, $6, $7)
RETURNING id
`, testWorkspaceID, title, assigneeType, assigneeID, testUserID, position, number).Scan(&id); err != nil {
t.Fatalf("create issue %q: %v", title, err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, id)
})
return id
}
createIssue("Grouped member one", "member", assigneeID, 1)
createIssue("Grouped member two", "member", assigneeID, 2)
createIssue("Grouped member three", "member", assigneeID, 3)
createIssue("Grouped agent one", "agent", agentID, 1)
path := fmt.Sprintf(
"/api/issues/grouped?workspace_id=%s&group_by=assignee&statuses=todo&limit=2&assignee_filters=member:%s,agent:%s",
testWorkspaceID,
assigneeID,
agentID,
)
w := httptest.NewRecorder()
testHandler.ListGroupedIssues(w, newRequest("GET", path, nil))
if w.Code != http.StatusOK {
t.Fatalf("ListGroupedIssues: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp GroupedIssuesResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode grouped response: %v", err)
}
memberGroupID := "assignee:member:" + assigneeID
agentGroupID := "assignee:agent:" + agentID
groups := map[string]IssueAssigneeGroupResponse{}
for _, group := range resp.Groups {
groups[group.ID] = group
}
memberGroup, ok := groups[memberGroupID]
if !ok {
t.Fatalf("missing member group %s in %#v", memberGroupID, resp.Groups)
}
if memberGroup.Total != 3 || len(memberGroup.Issues) != 2 {
t.Fatalf("member group total/page mismatch: total=%d len=%d", memberGroup.Total, len(memberGroup.Issues))
}
if memberGroup.Issues[0].Title != "Grouped member one" || memberGroup.Issues[1].Title != "Grouped member two" {
t.Fatalf("member group order mismatch: %#v", memberGroup.Issues)
}
agentGroup, ok := groups[agentGroupID]
if !ok {
t.Fatalf("missing agent group %s in %#v", agentGroupID, resp.Groups)
}
if agentGroup.Total != 1 || len(agentGroup.Issues) != 1 {
t.Fatalf("agent group total/page mismatch: total=%d len=%d", agentGroup.Total, len(agentGroup.Issues))
}
nextPath := fmt.Sprintf(
"/api/issues/grouped?workspace_id=%s&group_by=assignee&statuses=todo&limit=2&offset=2&group_assignee_type=member&group_assignee_id=%s",
testWorkspaceID,
assigneeID,
)
next := httptest.NewRecorder()
testHandler.ListGroupedIssues(next, newRequest("GET", nextPath, nil))
if next.Code != http.StatusOK {
t.Fatalf("ListGroupedIssues next page: expected 200, got %d: %s", next.Code, next.Body.String())
}
var nextResp GroupedIssuesResponse
if err := json.NewDecoder(next.Body).Decode(&nextResp); err != nil {
t.Fatalf("decode next grouped response: %v", err)
}
if len(nextResp.Groups) != 1 {
t.Fatalf("expected one next-page group, got %#v", nextResp.Groups)
}
if nextResp.Groups[0].ID != memberGroupID || nextResp.Groups[0].Total != 3 || len(nextResp.Groups[0].Issues) != 1 {
t.Fatalf("unexpected next-page group: %#v", nextResp.Groups[0])
}
if nextResp.Groups[0].Issues[0].Title != "Grouped member three" {
t.Fatalf("unexpected next-page issue: %#v", nextResp.Groups[0].Issues[0])
}
}