mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-28 01:49:18 +02:00
Compare commits
2 Commits
codex/agen
...
agent/j/d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53d6abc7a4 | ||
|
|
66a65eb65f |
@@ -238,6 +238,7 @@ export class ApiClient {
|
||||
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?.open_only) search.set("open_only", "true");
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
|
||||
100
packages/core/issues/cache-helpers.ts
Normal file
100
packages/core/issues/cache-helpers.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
Issue,
|
||||
IssueStatus,
|
||||
IssueStatusBucket,
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
import { PAGINATED_STATUSES } from "./queries";
|
||||
|
||||
const EMPTY_BUCKET: IssueStatusBucket = { issues: [], total: 0 };
|
||||
|
||||
export function getBucket(
|
||||
resp: ListIssuesCache,
|
||||
status: IssueStatus,
|
||||
): IssueStatusBucket {
|
||||
return resp.byStatus[status] ?? EMPTY_BUCKET;
|
||||
}
|
||||
|
||||
export function setBucket(
|
||||
resp: ListIssuesCache,
|
||||
status: IssueStatus,
|
||||
bucket: IssueStatusBucket,
|
||||
): ListIssuesCache {
|
||||
return { ...resp, byStatus: { ...resp.byStatus, [status]: bucket } };
|
||||
}
|
||||
|
||||
/** Locate which status bucket holds `id`, if any. */
|
||||
export function findIssueLocation(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
): { status: IssueStatus; issue: Issue } | null {
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = resp.byStatus[status];
|
||||
const found = bucket?.issues.find((i) => i.id === id);
|
||||
if (found) return { status, issue: found };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Add an issue to its status bucket (no-op if already present). */
|
||||
export function addIssueToBuckets(
|
||||
resp: ListIssuesCache,
|
||||
issue: Issue,
|
||||
): ListIssuesCache {
|
||||
const bucket = getBucket(resp, issue.status);
|
||||
if (bucket.issues.some((i) => i.id === issue.id)) return resp;
|
||||
return setBucket(resp, issue.status, {
|
||||
issues: [...bucket.issues, issue],
|
||||
total: bucket.total + 1,
|
||||
});
|
||||
}
|
||||
|
||||
/** Remove an issue from whichever bucket contains it. */
|
||||
export function removeIssueFromBuckets(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
): ListIssuesCache {
|
||||
const loc = findIssueLocation(resp, id);
|
||||
if (!loc) return resp;
|
||||
const bucket = getBucket(resp, loc.status);
|
||||
return setBucket(resp, loc.status, {
|
||||
issues: bucket.issues.filter((i) => i.id !== id),
|
||||
total: Math.max(0, bucket.total - 1),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `patch` into the issue with `id`. If `patch.status` differs from the
|
||||
* current bucket, the issue moves to the new bucket and both buckets' totals
|
||||
* are adjusted.
|
||||
*/
|
||||
export function patchIssueInBuckets(
|
||||
resp: ListIssuesCache,
|
||||
id: string,
|
||||
patch: Partial<Issue>,
|
||||
): ListIssuesCache {
|
||||
const loc = findIssueLocation(resp, id);
|
||||
if (!loc) return resp;
|
||||
const merged: Issue = { ...loc.issue, ...patch };
|
||||
const nextStatus = patch.status ?? loc.status;
|
||||
|
||||
if (nextStatus === loc.status) {
|
||||
const bucket = getBucket(resp, loc.status);
|
||||
return setBucket(resp, loc.status, {
|
||||
...bucket,
|
||||
issues: bucket.issues.map((i) => (i.id === id ? merged : i)),
|
||||
});
|
||||
}
|
||||
|
||||
const fromBucket = getBucket(resp, loc.status);
|
||||
const toBucket = getBucket(resp, nextStatus);
|
||||
let next = setBucket(resp, loc.status, {
|
||||
issues: fromBucket.issues.filter((i) => i.id !== id),
|
||||
total: Math.max(0, fromBucket.total - 1),
|
||||
});
|
||||
next = setBucket(next, nextStatus, {
|
||||
issues: [...toBucket.issues, merged],
|
||||
total: toBucket.total + 1,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
@@ -1,14 +1,26 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { issueKeys, CLOSED_PAGE_SIZE, type MyIssuesFilter } from "./queries";
|
||||
import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
getBucket,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
setBucket,
|
||||
} from "./cache-helpers";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useRecentIssuesStore } from "./stores";
|
||||
import type { Issue, IssueReaction } from "../types";
|
||||
import type { Issue, IssueReaction, IssueStatus } from "../types";
|
||||
import type {
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
|
||||
|
||||
@@ -29,10 +41,18 @@ export type ToggleIssueReactionVars = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Done issue pagination
|
||||
// Per-status pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssuesFilter }) {
|
||||
/**
|
||||
* Paginate one status column into the cache. Works for both the workspace
|
||||
* issue list and per-scope My Issues lists (pass `myIssues` to target the
|
||||
* latter).
|
||||
*/
|
||||
export function useLoadMoreByStatus(
|
||||
status: IssueStatus,
|
||||
myIssues?: { scope: string; filter: MyIssuesFilter },
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -40,39 +60,38 @@ export function useLoadMoreDoneIssues(myIssues?: { scope: string; filter: MyIssu
|
||||
const queryKey = myIssues
|
||||
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
|
||||
: issueKeys.list(wsId);
|
||||
const cache = qc.getQueryData<ListIssuesResponse>(queryKey);
|
||||
const doneLoaded = cache
|
||||
? cache.issues.filter((i) => i.status === "done").length
|
||||
: 0;
|
||||
const doneTotal = cache?.doneTotal ?? 0;
|
||||
const hasMore = doneLoaded < doneTotal;
|
||||
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
|
||||
const bucket = cache?.byStatus[status];
|
||||
const loaded = bucket?.issues.length ?? 0;
|
||||
const total = bucket?.total ?? 0;
|
||||
const hasMore = loaded < total;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoading || !hasMore) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.listIssues({
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: doneLoaded,
|
||||
status,
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: loaded,
|
||||
...myIssues?.filter,
|
||||
});
|
||||
qc.setQueryData<ListIssuesResponse>(queryKey, (old) => {
|
||||
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
const existingIds = new Set(old.issues.map((i) => i.id));
|
||||
const newIssues = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
return {
|
||||
...old,
|
||||
issues: [...old.issues, ...newIssues],
|
||||
doneTotal: res.total,
|
||||
};
|
||||
const prev = getBucket(old, status);
|
||||
const existingIds = new Set(prev.issues.map((i) => i.id));
|
||||
const appended = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
return setBucket(old, status, {
|
||||
issues: [...prev.issues, ...appended],
|
||||
total: res.total,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [qc, queryKey, doneLoaded, hasMore, isLoading, myIssues?.filter]);
|
||||
}, [qc, queryKey, status, loaded, hasMore, isLoading, myIssues?.filter]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, doneTotal };
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -85,15 +104,8 @@ export function useCreateIssue() {
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateIssueRequest) => api.createIssue(data),
|
||||
onSuccess: (newIssue) => {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old && !old.issues.some((i) => i.id === newIssue.id)
|
||||
? {
|
||||
...old,
|
||||
issues: [...old.issues, newIssue],
|
||||
total: old.total + 1,
|
||||
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
|
||||
}
|
||||
: old,
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? addIssueToBuckets(old, newIssue) : old,
|
||||
);
|
||||
// Surface the just-created issue in cmd+k's Recent list without
|
||||
// requiring the user to open it first.
|
||||
@@ -122,7 +134,7 @@ export function useUpdateIssue() {
|
||||
// yield to the event loop, letting @dnd-kit reset its visual state
|
||||
// before the optimistic update lands.
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
|
||||
// Resolve parent_issue_id from the freshest source so we can keep the
|
||||
@@ -130,21 +142,14 @@ export function useUpdateIssue() {
|
||||
// sub-issues list).
|
||||
const parentId =
|
||||
prevDetail?.parent_issue_id ??
|
||||
prevList?.issues.find((i) => i.id === id)?.parent_issue_id ??
|
||||
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
|
||||
null;
|
||||
const prevChildren = parentId
|
||||
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
||||
: undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === id ? { ...i, ...data } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? patchIssueInBuckets(old, id, data) : old,
|
||||
);
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, id), (old) =>
|
||||
old ? { ...old, ...data } : old,
|
||||
@@ -198,18 +203,11 @@ export function useDeleteIssue() {
|
||||
mutationFn: (id: string) => api.deleteIssue(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = prevList?.issues.find((i) => i.id === id);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const d = old.issues.find((i) => i.id === id);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== id),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (d?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, id) : old,
|
||||
);
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||
return { prevList, parentIssueId: deleted?.parent_issue_id };
|
||||
},
|
||||
@@ -239,17 +237,13 @@ export function useBatchUpdateIssues() {
|
||||
}) => api.batchUpdateIssues(ids, updates),
|
||||
onMutate: async ({ ids, updates }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
|
||||
old
|
||||
? {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
ids.includes(i.id) ? { ...i, ...updates } : i,
|
||||
),
|
||||
}
|
||||
: old,
|
||||
);
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
|
||||
return next;
|
||||
});
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
@@ -268,24 +262,19 @@ export function useBatchDeleteIssues() {
|
||||
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const idSet = new Set(ids);
|
||||
const parentIssueIds = new Set(
|
||||
prevList?.issues
|
||||
.filter((i) => idSet.has(i.id) && i.parent_issue_id)
|
||||
.map((i) => i.parent_issue_id!) ?? [],
|
||||
);
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const parentIssueIds = new Set<string>();
|
||||
if (prevList) {
|
||||
for (const id of ids) {
|
||||
const loc = findIssueLocation(prevList, id);
|
||||
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
|
||||
}
|
||||
}
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const doneDeleted = old.issues.filter(
|
||||
(i) => idSet.has(i.id) && i.status === "done",
|
||||
).length;
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => !idSet.has(i.id)),
|
||||
total: old.total - ids.length,
|
||||
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
|
||||
};
|
||||
let next = old;
|
||||
for (const id of ids) next = removeIssueFromBuckets(next, id);
|
||||
return next;
|
||||
});
|
||||
return { prevList, parentIssueIds };
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { ListIssuesParams } from "../types";
|
||||
import type { IssueStatus, ListIssuesParams, ListIssuesCache } from "../types";
|
||||
import { BOARD_STATUSES } from "./config";
|
||||
|
||||
export const issueKeys = {
|
||||
all: (wsId: string) => ["issues", wsId] as const,
|
||||
@@ -23,33 +24,55 @@ export const issueKeys = {
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
};
|
||||
|
||||
export type MyIssuesFilter = Pick<ListIssuesParams, "assignee_id" | "assignee_ids" | "creator_id">;
|
||||
export type MyIssuesFilter = Pick<
|
||||
ListIssuesParams,
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
|
||||
>;
|
||||
|
||||
export const CLOSED_PAGE_SIZE = 50;
|
||||
/** Page size per status column. */
|
||||
export const ISSUE_PAGE_SIZE = 50;
|
||||
|
||||
/** Statuses the issues/my-issues pages paginate. Cancelled is intentionally excluded — it has never been surfaced in the list/board views. */
|
||||
export const PAGINATED_STATUSES: readonly IssueStatus[] = BOARD_STATUSES;
|
||||
|
||||
/** Flatten a bucketed response to a single Issue[] for consumers that want the whole list. */
|
||||
export function flattenIssueBuckets(data: ListIssuesCache) {
|
||||
const out = [];
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = data.byStatus[status];
|
||||
if (bucket) out.push(...bucket.issues);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
|
||||
const responses = await Promise.all(
|
||||
PAGINATED_STATUSES.map((status) =>
|
||||
api.listIssues({ status, limit: ISSUE_PAGE_SIZE, offset: 0, ...filter }),
|
||||
),
|
||||
);
|
||||
const byStatus: ListIssuesCache["byStatus"] = {};
|
||||
PAGINATED_STATUSES.forEach((status, i) => {
|
||||
const res = responses[i]!;
|
||||
byStatus[status] = { issues: res.issues, total: res.total };
|
||||
});
|
||||
return { byStatus };
|
||||
}
|
||||
|
||||
/**
|
||||
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
|
||||
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
|
||||
* must use setQueryData<ListIssuesResponse>(...) — NOT setQueryData<Issue[]>.
|
||||
* CACHE SHAPE NOTE: The raw cache stores {@link ListIssuesCache} (buckets keyed
|
||||
* by status, each with `{ issues, total }`), and `select` flattens it to
|
||||
* `Issue[]` for consumers. Mutations and ws-updaters must use
|
||||
* `setQueryData<ListIssuesCache>(...)` and preserve the byStatus shape.
|
||||
*
|
||||
* Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
|
||||
* to paginate additional done items into the cache.
|
||||
* Fetches the first page of each paginated status in parallel. Use
|
||||
* {@link useLoadMoreByStatus} to paginate a specific status into the cache.
|
||||
*/
|
||||
export function issueListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.list(wsId),
|
||||
queryFn: async () => {
|
||||
const [openRes, closedRes] = await Promise.all([
|
||||
api.listIssues({ open_only: true }),
|
||||
api.listIssues({ status: "done", limit: CLOSED_PAGE_SIZE, offset: 0 }),
|
||||
]);
|
||||
return {
|
||||
issues: [...openRes.issues, ...closedRes.issues],
|
||||
total: openRes.total + closedRes.total,
|
||||
doneTotal: closedRes.total,
|
||||
};
|
||||
},
|
||||
select: (data) => data.issues,
|
||||
queryFn: () => fetchFirstPages(),
|
||||
select: flattenIssueBuckets,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -64,23 +87,8 @@ export function myIssueListOptions(
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.myList(wsId, scope, filter),
|
||||
queryFn: async () => {
|
||||
const [openRes, closedRes] = await Promise.all([
|
||||
api.listIssues({ open_only: true, ...filter }),
|
||||
api.listIssues({
|
||||
status: "done",
|
||||
limit: CLOSED_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...filter,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
issues: [...openRes.issues, ...closedRes.issues],
|
||||
total: openRes.total + closedRes.total,
|
||||
doneTotal: closedRes.total,
|
||||
};
|
||||
},
|
||||
select: (data) => data.issues,
|
||||
queryFn: () => fetchFirstPages(filter),
|
||||
select: flattenIssueBuckets,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { issueKeys } from "./queries";
|
||||
import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
} from "./cache-helpers";
|
||||
import type { Issue } from "../types";
|
||||
import type { ListIssuesResponse } from "../types";
|
||||
import type { ListIssuesCache } from "../types";
|
||||
|
||||
export function onIssueCreated(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issue: Issue,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old || old.issues.some((i) => i.id === issue.id)) return old;
|
||||
return {
|
||||
...old,
|
||||
issues: [...old.issues, issue],
|
||||
total: old.total + 1,
|
||||
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? addIssueToBuckets(old, issue) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
@@ -32,36 +32,20 @@ export function onIssueUpdated(
|
||||
// Look up the OLD parent before mutating list state, so we can keep
|
||||
// the parent's children cache in sync (powers the sub-issues list
|
||||
// shown on the parent issue page).
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
||||
const oldParentId =
|
||||
detailData?.parent_issue_id ??
|
||||
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
|
||||
(listData ? findIssueLocation(listData, issue.id)?.issue.parent_issue_id : null) ??
|
||||
null;
|
||||
// The NEW parent comes from the WS payload when parent_issue_id changed
|
||||
const newParentId = issue.parent_issue_id ?? null;
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const prev = old.issues.find((i) => i.id === issue.id);
|
||||
const wasDone = prev?.status === "done";
|
||||
const isDone = issue.status === "done";
|
||||
// Only adjust doneTotal when status field is present and actually changed
|
||||
let doneDelta = 0;
|
||||
if (issue.status !== undefined) {
|
||||
if (!wasDone && isDone) doneDelta = 1;
|
||||
else if (wasDone && !isDone) doneDelta = -1;
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.map((i) =>
|
||||
i.id === issue.id ? { ...i, ...issue } : i,
|
||||
),
|
||||
doneTotal: (old.doneTotal ?? 0) + doneDelta,
|
||||
};
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? patchIssueInBuckets(old, issue.id, issue) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
@@ -94,19 +78,12 @@ export function onIssueDeleted(
|
||||
issueId: string,
|
||||
) {
|
||||
// Look up the issue before removing it to check for parent_issue_id
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const deleted = listData?.issues.find((i) => i.id === issueId);
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
const del = old.issues.find((i) => i.id === issueId);
|
||||
return {
|
||||
...old,
|
||||
issues: old.issues.filter((i) => i.id !== issueId),
|
||||
total: old.total - 1,
|
||||
doneTotal: (old.doneTotal ?? 0) - (del?.status === "done" ? 1 : 0),
|
||||
};
|
||||
});
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
|
||||
@@ -38,14 +38,29 @@ export interface ListIssuesParams {
|
||||
assignee_id?: string;
|
||||
assignee_ids?: string[];
|
||||
creator_id?: string;
|
||||
project_id?: string;
|
||||
open_only?: boolean;
|
||||
}
|
||||
|
||||
/** Raw backend response shape for `GET /api/issues`. */
|
||||
export interface ListIssuesResponse {
|
||||
issues: Issue[];
|
||||
total: number;
|
||||
/** True total of done issues in the workspace (for load-more pagination). Not returned by backend API — set by the frontend query function. */
|
||||
doneTotal?: number;
|
||||
}
|
||||
|
||||
/** 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[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend cache shape for the issue list. Data is bucketed by status so
|
||||
* each column can paginate independently. Assembled from per-status
|
||||
* `api.listIssues` responses by the query functions in `issues/queries.ts`.
|
||||
*/
|
||||
export interface ListIssuesCache {
|
||||
byStatus: Partial<Record<IssueStatus, IssueStatusBucket>>;
|
||||
}
|
||||
|
||||
export interface SearchIssueResult extends Issue {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Agent, AgentTask, Issue } from "@multica/core/types";
|
||||
|
||||
const mockListAgentTasks = vi.hoisted(() => vi.fn());
|
||||
const mockListIssues = vi.hoisted(() => vi.fn());
|
||||
const mockGetIssue = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
@@ -25,7 +25,7 @@ vi.mock("@multica/core/paths", async () => {
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listAgentTasks: (...args: unknown[]) => mockListAgentTasks(...args),
|
||||
listIssues: (...args: unknown[]) => mockListIssues(...args),
|
||||
getIssue: (...args: unknown[]) => mockGetIssue(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -66,13 +66,10 @@ const agent: Agent = {
|
||||
|
||||
function renderTasksTab(tasks: AgentTask[], issues: Issue[]) {
|
||||
mockListAgentTasks.mockResolvedValue(tasks);
|
||||
mockListIssues.mockImplementation(
|
||||
({ open_only, status }: { open_only?: boolean; status?: string }) =>
|
||||
Promise.resolve({
|
||||
issues: open_only ? issues : status === "done" ? [] : [],
|
||||
total: open_only ? issues.length : 0,
|
||||
}),
|
||||
);
|
||||
mockGetIssue.mockImplementation((id: string) => {
|
||||
const found = issues.find((i) => i.id === id);
|
||||
return found ? Promise.resolve(found) : Promise.reject(new Error("not found"));
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { ListTodo } from "lucide-react";
|
||||
import type { Agent, AgentTask } from "@multica/core/types";
|
||||
import type { Agent, AgentTask, Issue } from "@multica/core/types";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { AppLink } from "../../../navigation";
|
||||
import { taskStatusConfig } from "../../config";
|
||||
|
||||
@@ -17,7 +17,6 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const wsId = useWorkspaceId();
|
||||
const paths = useWorkspacePaths();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -28,6 +27,26 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||
.finally(() => setLoading(false));
|
||||
}, [agent.id]);
|
||||
|
||||
// Resolve each task's issue via its own cached detail query. A task's
|
||||
// issue may or may not be in the paginated issue-list cache, so going
|
||||
// through `issueDetailOptions` is the reliable lookup path (and it shares
|
||||
// the same cache as the issue detail page).
|
||||
const issueIds = useMemo(
|
||||
() => Array.from(new Set(tasks.map((t) => t.issue_id))),
|
||||
[tasks],
|
||||
);
|
||||
const issueQueries = useQueries({
|
||||
queries: issueIds.map((id) => issueDetailOptions(wsId, id)),
|
||||
});
|
||||
const issueMap = useMemo(() => {
|
||||
const map = new Map<string, Issue>();
|
||||
issueQueries.forEach((q, i) => {
|
||||
const id = issueIds[i]!;
|
||||
if (q.data) map.set(id, q.data);
|
||||
});
|
||||
return map;
|
||||
}, [issueQueries, issueIds]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -58,8 +77,6 @@ export function TasksTab({ agent }: { agent: Agent }) {
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||
});
|
||||
|
||||
const issueMap = new Map(issues.map((i) => [i.id, i]));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { issueKeys, PAGINATED_STATUSES } from "@multica/core/issues/queries";
|
||||
import type { IssueStatus, ListIssuesCache } from "@multica/core/types";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Mock the workspace id singleton — items() reads it imperatively.
|
||||
@@ -28,10 +29,15 @@ function fakeQc(data: {
|
||||
const map = new Map<string, unknown>();
|
||||
map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []);
|
||||
map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []);
|
||||
map.set(JSON.stringify(issueKeys.list("ws-1")), {
|
||||
issues: data.issues ?? [],
|
||||
total: data.issues?.length ?? 0,
|
||||
});
|
||||
const byStatus: ListIssuesCache["byStatus"] = {};
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = (data.issues ?? []).filter((i) => i.status === status);
|
||||
byStatus[status as IssueStatus] = { issues: bucket as never, total: bucket.length };
|
||||
}
|
||||
map.set(
|
||||
JSON.stringify(issueKeys.list("ws-1")),
|
||||
{ byStatus } satisfies ListIssuesCache,
|
||||
);
|
||||
return {
|
||||
getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)),
|
||||
} as unknown as QueryClient;
|
||||
|
||||
@@ -12,10 +12,10 @@ import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { getCurrentWsId } from "@multica/core/platform";
|
||||
import { issueKeys } from "@multica/core/issues/queries";
|
||||
import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@multica/core/types";
|
||||
import type { Issue, ListIssuesCache, MemberWithUser, Agent } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { StatusIcon } from "../../issues/components/status-icon";
|
||||
import { Badge } from "@multica/ui/components/ui/badge";
|
||||
@@ -254,8 +254,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
|
||||
const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? [];
|
||||
const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? [];
|
||||
const cachedIssues: Issue[] =
|
||||
qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? [];
|
||||
const cachedResponse = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : [];
|
||||
|
||||
const q = query.toLowerCase();
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { Eye, MoreHorizontal } from "lucide-react";
|
||||
import type { Issue, IssueStatus } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
|
||||
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
|
||||
import type { MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -99,17 +99,14 @@ const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
|
||||
|
||||
export function BoardView({
|
||||
issues,
|
||||
allIssues,
|
||||
visibleStatuses,
|
||||
hiddenStatuses,
|
||||
onMoveIssue,
|
||||
childProgressMap = EMPTY_PROGRESS_MAP,
|
||||
doneTotal: doneTotalOverride,
|
||||
myIssuesScope,
|
||||
myIssuesFilter,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
allIssues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
hiddenStatuses: IssueStatus[];
|
||||
onMoveIssue: (
|
||||
@@ -118,18 +115,15 @@ export function BoardView({
|
||||
newPosition?: number
|
||||
) => void;
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
/** Override the done-column count (e.g. with a server-filtered total). */
|
||||
doneTotal?: number;
|
||||
/** When set, use the My Issues load-more hook instead of the workspace one. */
|
||||
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
}) {
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
const myIssuesOpts = myIssuesScope ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } : undefined;
|
||||
const { loadMore, hasMore, isLoading: loadingMore, doneTotal: hookDoneTotal } =
|
||||
useLoadMoreDoneIssues(myIssuesOpts);
|
||||
const displayDoneTotal = doneTotalOverride ?? hookDoneTotal;
|
||||
const myIssuesOpts = myIssuesScope
|
||||
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
|
||||
: undefined;
|
||||
|
||||
// --- Drag state ---
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
@@ -287,25 +281,20 @@ export function BoardView({
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||
{visibleStatuses.map((status) => (
|
||||
<BoardColumn
|
||||
<PaginatedBoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMapRef.current}
|
||||
childProgressMap={childProgressMap}
|
||||
totalCount={status === "done" ? displayDoneTotal : undefined}
|
||||
footer={
|
||||
status === "done" && hasMore ? (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
|
||||
) : undefined
|
||||
}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hiddenStatuses.length > 0 && (
|
||||
<HiddenColumnsPanel
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
issues={allIssues}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -321,14 +310,46 @@ export function BoardView({
|
||||
);
|
||||
}
|
||||
|
||||
function PaginatedBoardColumn({
|
||||
status,
|
||||
issueIds,
|
||||
issueMap,
|
||||
childProgressMap,
|
||||
myIssuesOpts,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
}) {
|
||||
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
|
||||
status,
|
||||
myIssuesOpts,
|
||||
);
|
||||
return (
|
||||
<BoardColumn
|
||||
status={status}
|
||||
issueIds={issueIds}
|
||||
issueMap={issueMap}
|
||||
childProgressMap={childProgressMap}
|
||||
totalCount={total}
|
||||
footer={
|
||||
hasMore ? (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HiddenColumnsPanel({
|
||||
hiddenStatuses,
|
||||
issues,
|
||||
myIssuesOpts,
|
||||
}: {
|
||||
hiddenStatuses: IssueStatus[];
|
||||
issues: Issue[];
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
}) {
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
return (
|
||||
<div className="flex w-[240px] shrink-0 flex-col">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
@@ -337,47 +358,57 @@ function HiddenColumnsPanel({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
{hiddenStatuses.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const count = issues.filter((i) => i.status === status).length;
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span className="text-sm">{cfg.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{count}</span>
|
||||
<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().showStatus(status)
|
||||
}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Show column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hiddenStatuses.map((status) => (
|
||||
<HiddenColumnRow
|
||||
key={status}
|
||||
status={status}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HiddenColumnRow({
|
||||
status,
|
||||
myIssuesOpts,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
const { total } = useLoadMoreByStatus(status, myIssuesOpts);
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span className="text-sm">{cfg.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{total}</span>
|
||||
<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().showStatus(status)}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Show column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -362,11 +362,10 @@ describe("IssuesPage (shared)", () => {
|
||||
|
||||
it("renders issue titles after data loads", async () => {
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(
|
||||
params?.open_only
|
||||
? { issues: mockIssues, total: mockIssues.length }
|
||||
: { issues: [], total: 0 },
|
||||
),
|
||||
Promise.resolve({
|
||||
issues: mockIssues.filter((i) => i.status === params?.status),
|
||||
total: mockIssues.filter((i) => i.status === params?.status).length,
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithQuery(<IssuesPage />);
|
||||
@@ -378,11 +377,10 @@ describe("IssuesPage (shared)", () => {
|
||||
|
||||
it("renders board column headers", async () => {
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(
|
||||
params?.open_only
|
||||
? { issues: mockIssues, total: mockIssues.length }
|
||||
: { issues: [], total: 0 },
|
||||
),
|
||||
Promise.resolve({
|
||||
issues: mockIssues.filter((i) => i.status === params?.status),
|
||||
total: mockIssues.filter((i) => i.status === params?.status).length,
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithQuery(<IssuesPage />);
|
||||
@@ -394,11 +392,10 @@ describe("IssuesPage (shared)", () => {
|
||||
|
||||
it("shows workspace breadcrumb with 'Issues' label", async () => {
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(
|
||||
params?.open_only
|
||||
? { issues: mockIssues, total: mockIssues.length }
|
||||
: { issues: [], total: 0 },
|
||||
),
|
||||
Promise.resolve({
|
||||
issues: mockIssues.filter((i) => i.status === params?.status),
|
||||
total: mockIssues.filter((i) => i.status === params?.status).length,
|
||||
}),
|
||||
);
|
||||
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
@@ -164,7 +164,6 @@ export function IssuesPage() {
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={scopedIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Accordion } from "@base-ui/react/accordion";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import type { Issue, IssueStatus } from "@multica/core/types";
|
||||
import { useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
|
||||
import { useLoadMoreByStatus } from "@multica/core/issues/mutations";
|
||||
import type { MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
@@ -23,16 +23,13 @@ export function ListView({
|
||||
issues,
|
||||
visibleStatuses,
|
||||
childProgressMap = EMPTY_PROGRESS_MAP,
|
||||
doneTotal: doneTotalOverride,
|
||||
myIssuesScope,
|
||||
myIssuesFilter,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
childProgressMap?: Map<string, ChildProgress>;
|
||||
/** Override the done-group count (e.g. with a server-filtered total). */
|
||||
doneTotal?: number;
|
||||
/** When set, use the My Issues load-more hook instead of the workspace one. */
|
||||
/** When set, per-status load-more targets the scoped cache instead of the workspace one. */
|
||||
myIssuesScope?: string;
|
||||
myIssuesFilter?: MyIssuesFilter;
|
||||
}) {
|
||||
@@ -44,13 +41,6 @@ export function ListView({
|
||||
const toggleListCollapsed = useViewStore(
|
||||
(s) => s.toggleListCollapsed
|
||||
);
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
const select = useIssueSelectionStore((s) => s.select);
|
||||
const deselect = useIssueSelectionStore((s) => s.deselect);
|
||||
const myIssuesOpts = myIssuesScope ? { scope: myIssuesScope, filter: myIssuesFilter ?? {} } : undefined;
|
||||
const { loadMore, hasMore, isLoading: loadingMore, doneTotal: hookDoneTotal } =
|
||||
useLoadMoreDoneIssues(myIssuesOpts);
|
||||
const displayDoneTotal = doneTotalOverride ?? hookDoneTotal;
|
||||
|
||||
const issuesByStatus = useMemo(() => {
|
||||
const map = new Map<IssueStatus, Issue[]>();
|
||||
@@ -69,6 +59,10 @@ export function ListView({
|
||||
[visibleStatuses, listCollapsedStatuses]
|
||||
);
|
||||
|
||||
const myIssuesOpts = myIssuesScope
|
||||
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
<Accordion.Root
|
||||
@@ -85,86 +79,111 @@ export function ListView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{visibleStatuses.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const statusIssues = issuesByStatus.get(status) ?? [];
|
||||
const statusIssueIds = statusIssues.map((i) => i.id);
|
||||
const selectedCount = statusIssueIds.filter((id) => selectedIds.has(id)).length;
|
||||
const allSelected = statusIssues.length > 0 && selectedCount === statusIssues.length;
|
||||
const someSelected = selectedCount > 0;
|
||||
|
||||
return (
|
||||
<Accordion.Item key={status} value={status}>
|
||||
<Accordion.Header className="group/header flex h-10 items-center rounded-lg bg-muted/40 transition-colors hover:bg-accent/30">
|
||||
<div className="pl-3 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected && !allSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allSelected) {
|
||||
deselect(statusIssueIds);
|
||||
} else {
|
||||
select(statusIssueIds);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-2 h-full text-left outline-none">
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
|
||||
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||
<StatusIcon status={status} className="h-3 w-3" inheritColor />
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status === "done" ? displayDoneTotal : statusIssues.length}
|
||||
</span>
|
||||
</Accordion.Trigger>
|
||||
<div className="pr-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground opacity-0 group-hover/header:opacity-100 transition-opacity"
|
||||
onClick={() =>
|
||||
useModalStore
|
||||
.getState()
|
||||
.open("create-issue", { status })
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Accordion.Header>
|
||||
<Accordion.Panel className="pt-1">
|
||||
{statusIssues.length > 0 ? (
|
||||
<>
|
||||
{statusIssues.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
|
||||
))}
|
||||
{status === "done" && hasMore && (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={loadingMore} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
{visibleStatuses.map((status) => (
|
||||
<StatusAccordionItem
|
||||
key={status}
|
||||
status={status}
|
||||
issues={issuesByStatus.get(status) ?? []}
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesOpts={myIssuesOpts}
|
||||
/>
|
||||
))}
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusAccordionItem({
|
||||
status,
|
||||
issues,
|
||||
childProgressMap,
|
||||
myIssuesOpts,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issues: Issue[];
|
||||
childProgressMap: Map<string, ChildProgress>;
|
||||
myIssuesOpts?: { scope: string; filter: MyIssuesFilter };
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
const select = useIssueSelectionStore((s) => s.select);
|
||||
const deselect = useIssueSelectionStore((s) => s.deselect);
|
||||
const { loadMore, hasMore, isLoading, total } = useLoadMoreByStatus(
|
||||
status,
|
||||
myIssuesOpts,
|
||||
);
|
||||
|
||||
const issueIds = issues.map((i) => i.id);
|
||||
const selectedCount = issueIds.filter((id) => selectedIds.has(id)).length;
|
||||
const allSelected = issues.length > 0 && selectedCount === issues.length;
|
||||
const someSelected = selectedCount > 0;
|
||||
|
||||
return (
|
||||
<Accordion.Item value={status}>
|
||||
<Accordion.Header className="group/header flex h-10 items-center rounded-lg bg-muted/40 transition-colors hover:bg-accent/30">
|
||||
<div className="pl-3 flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someSelected && !allSelected;
|
||||
}}
|
||||
onChange={() => {
|
||||
if (allSelected) {
|
||||
deselect(issueIds);
|
||||
} else {
|
||||
select(issueIds);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-2 h-full text-left outline-none">
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
|
||||
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||
<StatusIcon status={status} className="h-3 w-3" inheritColor />
|
||||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{total}</span>
|
||||
</Accordion.Trigger>
|
||||
<div className="pr-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground opacity-0 group-hover/header:opacity-100 transition-opacity"
|
||||
onClick={() =>
|
||||
useModalStore
|
||||
.getState()
|
||||
.open("create-issue", { status })
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add issue</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Accordion.Header>
|
||||
<Accordion.Panel className="pt-1">
|
||||
{issues.length > 0 ? (
|
||||
<>
|
||||
{issues.map((issue) => (
|
||||
<ListRow key={issue.id} issue={issue} childProgress={childProgressMap.get(issue.id)} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<InfiniteScrollSentinel onVisible={loadMore} loading={isLoading} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 { useUpdateIssue, useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { myIssuesViewStore } from "@multica/core/issues/stores/my-issues-view-store";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { MyIssuesHeader } from "./my-issues-header";
|
||||
@@ -71,8 +71,6 @@ export function MyIssuesPage() {
|
||||
myIssueListOptions(wsId, scope, filter),
|
||||
);
|
||||
|
||||
const { doneTotal } = useLoadMoreDoneIssues({ scope, filter });
|
||||
|
||||
// Apply status/priority filters from view store
|
||||
const issues = useMemo(
|
||||
() =>
|
||||
@@ -190,12 +188,10 @@ export function MyIssuesPage() {
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={myIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
childProgressMap={childProgressMap}
|
||||
doneTotal={doneTotal}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
/>
|
||||
@@ -204,7 +200,6 @@ export function MyIssuesPage() {
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
childProgressMap={childProgressMap}
|
||||
doneTotal={doneTotal}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 { issueListOptions, childIssueProgressOptions } from "@multica/core/issues/queries";
|
||||
import { myIssueListOptions, childIssueProgressOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -94,7 +94,15 @@ function PropRow({
|
||||
|
||||
const projectViewStore = createIssueViewStore("project_issues_view");
|
||||
|
||||
function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
|
||||
function ProjectIssuesContent({
|
||||
projectIssues,
|
||||
scope,
|
||||
filter,
|
||||
}: {
|
||||
projectIssues: Issue[];
|
||||
scope: string;
|
||||
filter: MyIssuesFilter;
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const viewMode = useViewStore((s) => s.viewMode);
|
||||
const statusFilters = useViewStore((s) => s.statusFilters);
|
||||
@@ -107,10 +115,6 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
|
||||
() => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false }),
|
||||
[projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||
);
|
||||
const doneColumnCount = useMemo(
|
||||
() => projectIssues.filter((issue) => issue.status === "done").length,
|
||||
[projectIssues],
|
||||
);
|
||||
|
||||
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
|
||||
|
||||
@@ -158,19 +162,20 @@ function ProjectIssuesContent({ projectIssues }: { projectIssues: Issue[] }) {
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={projectIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
childProgressMap={childProgressMap}
|
||||
doneTotal={doneColumnCount}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
childProgressMap={childProgressMap}
|
||||
doneTotal={doneColumnCount}
|
||||
myIssuesScope={scope}
|
||||
myIssuesFilter={filter}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -189,7 +194,14 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
const workspace = useCurrentWorkspace();
|
||||
const workspaceName = workspace?.name;
|
||||
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
const projectScope = `project:${projectId}`;
|
||||
const projectFilter = useMemo<MyIssuesFilter>(
|
||||
() => ({ 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();
|
||||
@@ -231,11 +243,6 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(leadQuery));
|
||||
const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(leadQuery));
|
||||
|
||||
const projectIssues = useMemo(
|
||||
() => allIssues.filter((i) => i.project_id === projectId),
|
||||
[allIssues, projectId],
|
||||
);
|
||||
|
||||
const handleUpdateField = useCallback(
|
||||
(data: Parameters<typeof updateProject.mutate>[0] extends { id: string } & infer R ? R : never) => {
|
||||
if (!project) return;
|
||||
@@ -269,7 +276,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
return <div className="flex items-center justify-center h-full text-muted-foreground">Project not found</div>;
|
||||
}
|
||||
|
||||
const issueMetrics = getProjectIssueMetrics(project, projectIssues);
|
||||
const issueMetrics = getProjectIssueMetrics(project);
|
||||
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
|
||||
const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority];
|
||||
|
||||
@@ -573,7 +580,11 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
|
||||
<ViewStoreProvider store={projectViewStore}>
|
||||
<IssuesHeader scopedIssues={projectIssues} />
|
||||
<ProjectIssuesContent projectIssues={projectIssues} />
|
||||
<ProjectIssuesContent
|
||||
projectIssues={projectIssues}
|
||||
scope={projectScope}
|
||||
filter={projectFilter}
|
||||
/>
|
||||
<BatchActionToolbar />
|
||||
</ViewStoreProvider>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import { getProjectIssueMetrics } from "./project-issue-metrics";
|
||||
|
||||
function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
return {
|
||||
id: "issue-1",
|
||||
workspace_id: "ws-1",
|
||||
number: 1,
|
||||
identifier: "MUL-1",
|
||||
title: "Test issue",
|
||||
description: null,
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: null,
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "member-1",
|
||||
parent_issue_id: null,
|
||||
project_id: "project-1",
|
||||
position: 0,
|
||||
due_date: null,
|
||||
created_at: "2026-04-10T00:00:00Z",
|
||||
updated_at: "2026-04-10T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getProjectIssueMetrics", () => {
|
||||
it("uses project totals for progress and project-local done issues for the kanban done count", () => {
|
||||
const metrics = getProjectIssueMetrics(
|
||||
{ issue_count: 9, done_count: 5 },
|
||||
[
|
||||
makeIssue({ id: "issue-1", status: "done" }),
|
||||
makeIssue({ id: "issue-2", status: "done" }),
|
||||
makeIssue({ id: "issue-3", status: "cancelled" }),
|
||||
makeIssue({ id: "issue-4", status: "todo" }),
|
||||
],
|
||||
);
|
||||
it("surfaces project-level totals from the project record", () => {
|
||||
const metrics = getProjectIssueMetrics({ issue_count: 9, done_count: 5 });
|
||||
|
||||
expect(metrics).toEqual({
|
||||
totalCount: 9,
|
||||
completedCount: 5,
|
||||
doneColumnCount: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { Issue, Project } from "@multica/core/types";
|
||||
import type { Project } from "@multica/core/types";
|
||||
|
||||
export function getProjectIssueMetrics(
|
||||
project: Pick<Project, "issue_count" | "done_count">,
|
||||
projectIssues: Issue[],
|
||||
) {
|
||||
return {
|
||||
totalCount: project.issue_count,
|
||||
completedCount: project.done_count,
|
||||
doneColumnCount: projectIssues.filter((issue) => issue.status === "done").length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@ vi.mock("@multica/core/paths", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/queries", () => ({
|
||||
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
|
||||
issueDetailOptions: (_wsId: string, id: string) => ({
|
||||
queryKey: ["issues", "ws-test", "detail", id],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
@@ -94,12 +96,24 @@ vi.mock("@multica/core/modals", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
function resolveIssue(key: readonly unknown[]) {
|
||||
// issueDetailOptions key shape: ["issues", wsId, "detail", id]
|
||||
if (key[0] === "issues" && key[2] === "detail") {
|
||||
const id = key[3];
|
||||
return mockAllIssues.current.find((i) => i.id === id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: { queryKey: readonly unknown[] }) => {
|
||||
useQuery: (opts: { queryKey: readonly unknown[]; enabled?: boolean }) => {
|
||||
const key = opts.queryKey;
|
||||
if (key[0] === "workspaces") return { data: mockWorkspaces.current };
|
||||
return { data: mockAllIssues.current };
|
||||
if (opts.enabled === false) return { data: undefined };
|
||||
return { data: resolveIssue(key) };
|
||||
},
|
||||
useQueries: (opts: { queries: Array<{ queryKey: readonly unknown[] }> }) =>
|
||||
opts.queries.map((q) => ({ data: resolveIssue(q.queryKey) })),
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
|
||||
@@ -24,12 +24,12 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core";
|
||||
import { paths, useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import type { WorkspacePaths } from "@multica/core/paths";
|
||||
@@ -140,18 +140,22 @@ export function SearchCommand() {
|
||||
const recentItems = useRecentIssuesStore((s) => s.items);
|
||||
const wsId = useWorkspaceId();
|
||||
const p: WorkspacePaths = useWorkspacePaths();
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
const { theme, setTheme } = useTheme();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
const recentIssues = useMemo(() => {
|
||||
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
|
||||
return recentItems.flatMap((item) => {
|
||||
const issue = issueMap.get(item.id);
|
||||
return issue ? [issue] : [];
|
||||
});
|
||||
}, [recentItems, allIssues]);
|
||||
// Resolve each recent issue via its cached detail entry. Recent items are
|
||||
// typically already in the detail cache because the user has opened them;
|
||||
// if not, this triggers a lookup per id so Recent never depends on whether
|
||||
// the issue falls inside the paginated list cache.
|
||||
const recentDetailQueries = useQueries({
|
||||
queries: recentItems.map((item) => issueDetailOptions(wsId, item.id)),
|
||||
});
|
||||
const recentIssues = useMemo(
|
||||
() =>
|
||||
recentDetailQueries.flatMap((q) => (q.data ? [q.data] : [])),
|
||||
[recentDetailQueries],
|
||||
);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
|
||||
@@ -171,13 +175,15 @@ export function SearchCommand() {
|
||||
|
||||
// Detect if current route is an issue detail page — /{slug}/issues/{id}.
|
||||
// Falls back to null on any other route; used to gate issue-specific commands.
|
||||
const currentIssue = useMemo(() => {
|
||||
const currentIssueId = useMemo(() => {
|
||||
const match = pathname.match(/\/issues\/([^/]+)$/);
|
||||
const raw = match?.[1];
|
||||
if (!raw) return null;
|
||||
const id = decodeURIComponent(raw);
|
||||
return allIssues.find((i) => i.id === id) ?? null;
|
||||
}, [pathname, allIssues]);
|
||||
return raw ? decodeURIComponent(raw) : null;
|
||||
}, [pathname]);
|
||||
const { data: currentIssue = null } = useQuery({
|
||||
...issueDetailOptions(wsId, currentIssueId ?? ""),
|
||||
enabled: !!currentIssueId,
|
||||
});
|
||||
|
||||
const commands = useMemo<CommandItem[]>(() => {
|
||||
const activeThemeCheck = (value: ThemeValue) =>
|
||||
|
||||
Reference in New Issue
Block a user