Files
multica/packages/core/issues/cache-helpers.ts
Bohan Jiang 642844c736 feat(issues): paginate every status column, not just done (#1422)
* 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.
2026-04-21 16:48:55 +08:00

101 lines
2.8 KiB
TypeScript

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;
}