mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +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.
101 lines
2.8 KiB
TypeScript
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;
|
|
}
|