mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
* feat(issues): paginate every status column, not just done
Previously the workspace issues list fetched all non-done/cancelled
issues in a single unbounded `open_only=true` request and only
paginated the done column. In workspaces with many open issues this
ballooned the initial payload and skipped pagination entirely.
Restructure the issue list cache into per-status buckets
(`{ byStatus: { [status]: { issues, total } } }`) fetched in parallel,
generalize `useLoadMoreDoneIssues` into `useLoadMoreByStatus(status,
myIssuesOpts?)`, and render an infinite-scroll sentinel inside every
accordion group and kanban column. Sort and filter stay client-side,
matching the done column's existing behavior.
Backend `ListIssues` already supports per-status pagination, so no
API changes are required.
* fix(issues): handle project / hidden-column / lookup regressions from paginated list cache
After bucketing the issue list cache by status, three consumers that
treated `issueListOptions()` as a complete local index broke:
- `project-detail.tsx` filtered the workspace list by `project_id`
client-side, so projects whose issues sat past the first 50-per-status
page rendered empty. Switch to `myIssueListOptions(wsId,
'project:<id>', { project_id })` so the server returns only this
project's issues; add `project_id` to `ListIssuesParams` /
`MyIssuesFilter` / api client.
- `board-view.tsx` HiddenColumnsPanel read counts from the in-memory
`issues` array — a paginated fragment. Pass `myIssuesOpts` through to
a per-row subcomponent that reads the real per-status total from the
cache.
- `tasks-tab.tsx` and `search-command.tsx` used the list as a global
lookup for task titles / Recent items / current-issue chrome. Switch
both to per-id `issueDetailOptions` via `useQueries` so they're
independent of which page the issue lands on.
Drop the now-redundant `doneTotal` override prop on BoardView/ListView
and the `allIssues` prop on BoardView (only HiddenColumnsPanel consumed
it).
Tests updated: tasks-tab now mocks `api.getIssue`; search-command mocks
`issueDetailOptions` + `useQueries`; project-issue-metrics drops the
`doneColumnCount` assertion.
153 lines
5.0 KiB
TypeScript
153 lines
5.0 KiB
TypeScript
import { queryOptions } from "@tanstack/react-query";
|
|
import { api } from "../api";
|
|
import type { IssueStatus, ListIssuesParams, ListIssuesCache } from "../types";
|
|
import { BOARD_STATUSES } from "./config";
|
|
|
|
export const issueKeys = {
|
|
all: (wsId: string) => ["issues", wsId] as const,
|
|
list: (wsId: string) => [...issueKeys.all(wsId), "list"] 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,
|
|
detail: (wsId: string, id: string) =>
|
|
[...issueKeys.all(wsId), "detail", id] as const,
|
|
children: (wsId: string, id: string) =>
|
|
[...issueKeys.all(wsId), "children", id] as const,
|
|
childProgress: (wsId: string) =>
|
|
[...issueKeys.all(wsId), "child-progress"] as const,
|
|
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
|
|
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
|
subscribers: (issueId: string) =>
|
|
["issues", "subscribers", issueId] as const,
|
|
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
|
};
|
|
|
|
export type MyIssuesFilter = Pick<
|
|
ListIssuesParams,
|
|
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
|
|
>;
|
|
|
|
/** 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 {@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 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: () => fetchFirstPages(),
|
|
select: flattenIssueBuckets,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Server-filtered issue list for the My Issues page.
|
|
* Each scope gets its own cache entry so switching tabs is instant after first load.
|
|
*/
|
|
export function myIssueListOptions(
|
|
wsId: string,
|
|
scope: string,
|
|
filter: MyIssuesFilter,
|
|
) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.myList(wsId, scope, filter),
|
|
queryFn: () => fetchFirstPages(filter),
|
|
select: flattenIssueBuckets,
|
|
});
|
|
}
|
|
|
|
export function issueDetailOptions(wsId: string, id: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.detail(wsId, id),
|
|
queryFn: () => api.getIssue(id),
|
|
});
|
|
}
|
|
|
|
export function childIssueProgressOptions(wsId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.childProgress(wsId),
|
|
queryFn: () => api.getChildIssueProgress(),
|
|
select: (data) => {
|
|
const map = new Map<string, { done: number; total: number }>();
|
|
for (const entry of data.progress) {
|
|
map.set(entry.parent_issue_id, { done: entry.done, total: entry.total });
|
|
}
|
|
return map;
|
|
},
|
|
});
|
|
}
|
|
|
|
export function childIssuesOptions(wsId: string, id: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.children(wsId, id),
|
|
queryFn: () => api.listChildIssues(id).then((r) => r.issues),
|
|
});
|
|
}
|
|
|
|
export function issueTimelineOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.timeline(issueId),
|
|
queryFn: () => api.listTimeline(issueId),
|
|
});
|
|
}
|
|
|
|
export function issueReactionsOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.reactions(issueId),
|
|
queryFn: async () => {
|
|
const issue = await api.getIssue(issueId);
|
|
return issue.reactions ?? [];
|
|
},
|
|
});
|
|
}
|
|
|
|
export function issueSubscribersOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.subscribers(issueId),
|
|
queryFn: () => api.listIssueSubscribers(issueId),
|
|
});
|
|
}
|
|
|
|
export function issueUsageOptions(issueId: string) {
|
|
return queryOptions({
|
|
queryKey: issueKeys.usage(issueId),
|
|
queryFn: () => api.getIssueUsage(issueId),
|
|
});
|
|
}
|