mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 08:59:31 +02:00
Compare commits
1 Commits
agent/lamb
...
codex/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07fa7d7dc0 |
@@ -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));
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 不存在或已在该工作区被删除。",
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"issue_count_other": "{{count}} 个 issue",
|
||||
"reset_filters": "重置所有筛选",
|
||||
"display_settings": "显示设置",
|
||||
"grouping": "分组",
|
||||
"group_status": "状态",
|
||||
"group_assignee": "负责人",
|
||||
"ordering": "排序",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
156
server/internal/handler/issue_grouped_test.go
Normal file
156
server/internal/handler/issue_grouped_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user