fix(web): add load-more pagination for Done column on issue board (#492)

* fix(web): add load-more pagination for Done column on issue board

The Done column was capped at 50 issues with no way to load more.
Track doneTotal in the TQ cache and add a useLoadMoreDoneIssues hook
that fetches the next page and merges it into the unified issue cache.
The Done column now shows a "Load more" button when there are
additional items.

- shared/types/api.ts: add doneTotal to ListIssuesResponse
- core/issues/queries.ts: store doneTotal from the done-status response
- core/issues/mutations.ts: add useLoadMoreDoneIssues hook, update
  create/delete mutations to maintain doneTotal
- core/issues/ws-updaters.ts: maintain doneTotal on WS events
- features/issues/components/board-column.tsx: accept optional footer
- features/issues/components/board-view.tsx: render Load more button
  in Done column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web): address review issues in done-column load-more

1. Fix total over-counting: loadMore no longer inflates total since
   the initial query already includes all done issues in total count.
2. Fix onIssueUpdated: maintain doneTotal when issue status changes
   to/from done via WS events.
3. Make doneTotal optional in ListIssuesResponse since it's a
   frontend-only field not returned by the backend API. All reads
   now use ?? 0 fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
LinYushen 2026-04-08 14:58:51 +08:00 committed by GitHub
parent a7afd4b959
commit ec934f3a8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 142 additions and 49 deletions

View file

@ -8,6 +8,7 @@ export {
} from "./queries"; } from "./queries";
export { export {
useLoadMoreDoneIssues,
useCreateIssue, useCreateIssue,
useUpdateIssue, useUpdateIssue,
useDeleteIssue, useDeleteIssue,

View file

@ -1,6 +1,7 @@
import { useState, useCallback } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/shared/api"; import { api } from "@/shared/api";
import { issueKeys } from "./queries"; import { issueKeys, CLOSED_PAGE_SIZE } from "./queries";
import { useWorkspaceId } from "@core/hooks"; import { useWorkspaceId } from "@core/hooks";
import type { Issue, IssueReaction } from "@/shared/types"; import type { Issue, IssueReaction } from "@/shared/types";
import type { import type {
@ -10,6 +11,49 @@ import type {
} from "@/shared/types"; } from "@/shared/types";
import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types"; import type { TimelineEntry, IssueSubscriber, Reaction } from "@/shared/types";
// ---------------------------------------------------------------------------
// Done issue pagination
// ---------------------------------------------------------------------------
export function useLoadMoreDoneIssues() {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const cache = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const doneLoaded = cache
? cache.issues.filter((i) => i.status === "done").length
: 0;
const doneTotal = cache?.doneTotal ?? 0;
const hasMore = doneLoaded < doneTotal;
const loadMore = useCallback(async () => {
if (isLoading || !hasMore) return;
setIsLoading(true);
try {
const res = await api.listIssues({
status: "done",
limit: CLOSED_PAGE_SIZE,
offset: doneLoaded,
});
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (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,
};
});
} finally {
setIsLoading(false);
}
}, [qc, wsId, doneLoaded, hasMore, isLoading]);
return { loadMore, hasMore, isLoading };
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Issue CRUD // Issue CRUD
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -22,7 +66,12 @@ export function useCreateIssue() {
onSuccess: (newIssue) => { onSuccess: (newIssue) => {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) =>
old && !old.issues.some((i) => i.id === newIssue.id) old && !old.issues.some((i) => i.id === newIssue.id)
? { ...old, issues: [...old.issues, newIssue], total: old.total + 1 } ? {
...old,
issues: [...old.issues, newIssue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (newIssue.status === "done" ? 1 : 0),
}
: old, : old,
); );
}, },
@ -82,15 +131,16 @@ export function useDeleteIssue() {
onMutate: async (id) => { onMutate: async (id) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId)); const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
old if (!old) return old;
? { const deleted = old.issues.find((i) => i.id === id);
...old, return {
issues: old.issues.filter((i) => i.id !== id), ...old,
total: old.total - 1, issues: old.issues.filter((i) => i.id !== id),
} total: old.total - 1,
: old, doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
); };
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) }); qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { prevList }; return { prevList };
}, },
@ -146,15 +196,19 @@ export function useBatchDeleteIssues() {
onMutate: async (ids) => { onMutate: async (ids) => {
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId)); const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
old if (!old) return old;
? { const idSet = new Set(ids);
...old, const doneDeleted = old.issues.filter(
issues: old.issues.filter((i) => !ids.includes(i.id)), (i) => idSet.has(i.id) && i.status === "done",
total: old.total - ids.length, ).length;
} return {
: old, ...old,
); issues: old.issues.filter((i) => !idSet.has(i.id)),
total: old.total - ids.length,
doneTotal: (old.doneTotal ?? 0) - doneDeleted,
};
});
return { prevList }; return { prevList };
}, },
onError: (_err, _ids, ctx) => { onError: (_err, _ids, ctx) => {

View file

@ -12,14 +12,15 @@ export const issueKeys = {
["issues", "subscribers", issueId] as const, ["issues", "subscribers", issueId] as const,
}; };
const CLOSED_PAGE_SIZE = 50; export const CLOSED_PAGE_SIZE = 50;
/** /**
* CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total }), * CACHE SHAPE NOTE: The raw cache stores ListIssuesResponse ({ issues, total, doneTotal }),
* but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters * but `select` transforms it to Issue[] for consumers. Mutations and ws-updaters
* must use setQueryData<ListIssuesResponse>(...) NOT setQueryData<Issue[]>. * must use setQueryData<ListIssuesResponse>(...) NOT setQueryData<Issue[]>.
* *
* Fetches all open issues + first page of closed issues (matching main's pagination strategy). * Fetches all open issues + first page of done issues. Use useLoadMoreDoneIssues()
* to paginate additional done items into the cache.
*/ */
export function issueListOptions(wsId: string) { export function issueListOptions(wsId: string) {
return queryOptions({ return queryOptions({
@ -32,6 +33,7 @@ export function issueListOptions(wsId: string) {
return { return {
issues: [...openRes.issues, ...closedRes.issues], issues: [...openRes.issues, ...closedRes.issues],
total: openRes.total + closedRes.total, total: openRes.total + closedRes.total,
doneTotal: closedRes.total,
}; };
}, },
select: (data) => data.issues, select: (data) => data.issues,

View file

@ -8,11 +8,15 @@ export function onIssueCreated(
wsId: string, wsId: string,
issue: Issue, issue: Issue,
) { ) {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
old && !old.issues.some((i) => i.id === issue.id) if (!old || old.issues.some((i) => i.id === issue.id)) return old;
? { ...old, issues: [...old.issues, issue], total: old.total + 1 } return {
: old, ...old,
); issues: [...old.issues, issue],
total: old.total + 1,
doneTotal: (old.doneTotal ?? 0) + (issue.status === "done" ? 1 : 0),
};
});
} }
export function onIssueUpdated( export function onIssueUpdated(
@ -20,16 +24,25 @@ export function onIssueUpdated(
wsId: string, wsId: string,
issue: Partial<Issue> & { id: string }, issue: Partial<Issue> & { id: string },
) { ) {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
old if (!old) return old;
? { const prev = old.issues.find((i) => i.id === issue.id);
...old, const wasDone = prev?.status === "done";
issues: old.issues.map((i) => const isDone = issue.status === "done";
i.id === issue.id ? { ...i, ...issue } : i, // Only adjust doneTotal when status field is present and actually changed
), let doneDelta = 0;
} if (issue.status !== undefined) {
: old, 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<Issue>(issueKeys.detail(wsId, issue.id), (old) => qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
old ? { ...old, ...issue } : old, old ? { ...old, ...issue } : old,
); );
@ -40,15 +53,16 @@ export function onIssueDeleted(
wsId: string, wsId: string,
issueId: string, issueId: string,
) { ) {
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
old if (!old) return old;
? { const deleted = old.issues.find((i) => i.id === issueId);
...old, return {
issues: old.issues.filter((i) => i.id !== issueId), ...old,
total: old.total - 1, issues: old.issues.filter((i) => i.id !== issueId),
} total: old.total - 1,
: old, doneTotal: (old.doneTotal ?? 0) - (deleted?.status === "done" ? 1 : 0),
); };
});
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) }); qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) }); qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) }); qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, type ReactNode } from "react";
import { EyeOff, MoreHorizontal, Plus } from "lucide-react"; import { EyeOff, MoreHorizontal, Plus } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useDroppable } from "@dnd-kit/core"; import { useDroppable } from "@dnd-kit/core";
@ -23,10 +23,12 @@ export function BoardColumn({
status, status,
issueIds, issueIds,
issueMap, issueMap,
footer,
}: { }: {
status: IssueStatus; status: IssueStatus;
issueIds: string[]; issueIds: string[];
issueMap: Map<string, Issue>; issueMap: Map<string, Issue>;
footer?: ReactNode;
}) { }) {
const cfg = STATUS_CONFIG[status]; const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status }); const { setNodeRef, isOver } = useDroppable({ id: status });
@ -106,6 +108,7 @@ export function BoardColumn({
No issues No issues
</p> </p>
)} )}
{footer}
</div> </div>
</div> </div>
); );

View file

@ -15,9 +15,10 @@ import {
type DragOverEvent, type DragOverEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable"; import { arrayMove } from "@dnd-kit/sortable";
import { Eye, MoreHorizontal } from "lucide-react"; import { Eye, Loader2, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types"; import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useLoadMoreDoneIssues } from "@core/issues/mutations";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -110,6 +111,7 @@ export function BoardView({
}) { }) {
const sortBy = useViewStore((s) => s.sortBy); const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection); const sortDirection = useViewStore((s) => s.sortDirection);
const { loadMore, hasMore, isLoading: loadingMore } = useLoadMoreDoneIssues();
// --- Drag state --- // --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null); const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
@ -272,6 +274,21 @@ export function BoardView({
status={status} status={status}
issueIds={columns[status] ?? []} issueIds={columns[status] ?? []}
issueMap={issueMapRef.current} issueMap={issueMapRef.current}
footer={
status === "done" && hasMore ? (
<button
type="button"
className="mt-1 flex w-full items-center justify-center gap-1.5 rounded-md py-2 text-xs text-muted-foreground hover:bg-accent/60 transition-colors disabled:opacity-50"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? (
<Loader2 className="size-3 animate-spin" />
) : null}
{loadingMore ? "Loading..." : "Load more"}
</button>
) : undefined
}
/> />
))} ))}

View file

@ -38,6 +38,8 @@ export interface ListIssuesParams {
export interface ListIssuesResponse { export interface ListIssuesResponse {
issues: Issue[]; issues: Issue[];
total: number; 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;
} }
export interface UpdateMeRequest { export interface UpdateMeRequest {