* 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>
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
pointerWithin,
|
|
closestCenter,
|
|
type CollisionDetection,
|
|
type DragStartEvent,
|
|
type DragEndEvent,
|
|
type DragOverEvent,
|
|
} from "@dnd-kit/core";
|
|
import { arrayMove } from "@dnd-kit/sortable";
|
|
import { Eye, Loader2, MoreHorizontal } from "lucide-react";
|
|
import type { Issue, IssueStatus } from "@/shared/types";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useLoadMoreDoneIssues } from "@core/issues/mutations";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
|
import { useViewStoreApi, useViewStore } from "@/features/issues/stores/view-store-context";
|
|
import type { SortField, SortDirection } from "@/features/issues/stores/view-store";
|
|
import { sortIssues } from "@/features/issues/utils/sort";
|
|
import { StatusIcon } from "./status-icon";
|
|
import { BoardColumn } from "./board-column";
|
|
import { BoardCardContent } from "./board-card";
|
|
|
|
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
|
|
|
|
const kanbanCollision: CollisionDetection = (args) => {
|
|
const pointer = pointerWithin(args);
|
|
if (pointer.length > 0) {
|
|
// Prefer card collisions over column collisions so that
|
|
// dragging down within a column finds the target card
|
|
// instead of the column droppable.
|
|
const cards = pointer.filter((c) => !COLUMN_IDS.has(c.id as string));
|
|
if (cards.length > 0) return cards;
|
|
}
|
|
// Fallback: closestCenter finds the nearest card even when
|
|
// the pointer is in a gap between cards (common when dragging down).
|
|
return closestCenter(args);
|
|
};
|
|
|
|
/** Build column ID arrays from TQ issue data, respecting current sort. */
|
|
function buildColumns(
|
|
issues: Issue[],
|
|
visibleStatuses: IssueStatus[],
|
|
sortBy: SortField,
|
|
sortDirection: SortDirection,
|
|
): Record<IssueStatus, string[]> {
|
|
const cols = {} as Record<IssueStatus, string[]>;
|
|
for (const status of visibleStatuses) {
|
|
const sorted = sortIssues(
|
|
issues.filter((i) => i.status === status),
|
|
sortBy,
|
|
sortDirection,
|
|
);
|
|
cols[status] = sorted.map((i) => i.id);
|
|
}
|
|
return cols;
|
|
}
|
|
|
|
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
|
function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
|
|
const idx = ids.indexOf(activeId);
|
|
if (idx === -1) return 0;
|
|
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
|
|
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
|
|
if (idx === 0) return getPos(ids[1]!) - 1;
|
|
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
|
|
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
|
|
}
|
|
|
|
/** Find which column (status) contains a given ID (issue or column droppable). */
|
|
function findColumn(
|
|
columns: Record<IssueStatus, string[]>,
|
|
id: string,
|
|
visibleStatuses: IssueStatus[],
|
|
): IssueStatus | null {
|
|
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
|
for (const [status, ids] of Object.entries(columns)) {
|
|
if (ids.includes(id)) return status as IssueStatus;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function BoardView({
|
|
issues,
|
|
allIssues,
|
|
visibleStatuses,
|
|
hiddenStatuses,
|
|
onMoveIssue,
|
|
}: {
|
|
issues: Issue[];
|
|
allIssues: Issue[];
|
|
visibleStatuses: IssueStatus[];
|
|
hiddenStatuses: IssueStatus[];
|
|
onMoveIssue: (
|
|
issueId: string,
|
|
newStatus: IssueStatus,
|
|
newPosition?: number
|
|
) => void;
|
|
}) {
|
|
const sortBy = useViewStore((s) => s.sortBy);
|
|
const sortDirection = useViewStore((s) => s.sortDirection);
|
|
const { loadMore, hasMore, isLoading: loadingMore } = useLoadMoreDoneIssues();
|
|
|
|
// --- Drag state ---
|
|
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
|
const isDraggingRef = useRef(false);
|
|
|
|
// --- Local columns state ---
|
|
// Between drags: follows TQ via useEffect.
|
|
// During drag: local-only, driven by onDragOver/onDragEnd.
|
|
const [columns, setColumns] = useState<Record<IssueStatus, string[]>>(() =>
|
|
buildColumns(issues, visibleStatuses, sortBy, sortDirection),
|
|
);
|
|
const columnsRef = useRef(columns);
|
|
columnsRef.current = columns;
|
|
|
|
useEffect(() => {
|
|
if (!isDraggingRef.current) {
|
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
|
}
|
|
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
|
|
|
// After a cross-column move, lock for one animation frame so dnd-kit's
|
|
// collision detection can stabilize before processing the next move.
|
|
// Without this, collision oscillates: A→B→A→B… until React bails out.
|
|
const recentlyMovedRef = useRef(false);
|
|
useEffect(() => {
|
|
const id = requestAnimationFrame(() => {
|
|
recentlyMovedRef.current = false;
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
}, [columns]);
|
|
|
|
// --- Issue map ---
|
|
// Frozen during drag so BoardColumn/DraggableBoardCard props stay
|
|
// referentially stable even if a TQ refetch lands mid-drag.
|
|
const issueMap = useMemo(() => {
|
|
const map = new Map<string, Issue>();
|
|
for (const issue of issues) map.set(issue.id, issue);
|
|
return map;
|
|
}, [issues]);
|
|
|
|
const issueMapRef = useRef(issueMap);
|
|
if (!isDraggingRef.current) {
|
|
issueMapRef.current = issueMap;
|
|
}
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 5 },
|
|
})
|
|
);
|
|
|
|
const handleDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
isDraggingRef.current = true;
|
|
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
|
|
setActiveIssue(issue);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleDragOver = useCallback(
|
|
(event: DragOverEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || recentlyMovedRef.current) return;
|
|
|
|
const activeId = active.id as string;
|
|
const overId = over.id as string;
|
|
|
|
setColumns((prev) => {
|
|
const activeCol = findColumn(prev, activeId, visibleStatuses);
|
|
const overCol = findColumn(prev, overId, visibleStatuses);
|
|
if (!activeCol || !overCol || activeCol === overCol) return prev;
|
|
|
|
recentlyMovedRef.current = true;
|
|
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
|
const newIds = [...prev[overCol]!];
|
|
const overIndex = newIds.indexOf(overId);
|
|
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
|
newIds.splice(insertIndex, 0, activeId);
|
|
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
|
});
|
|
},
|
|
[visibleStatuses],
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
isDraggingRef.current = false;
|
|
setActiveIssue(null);
|
|
|
|
const resetColumns = () =>
|
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
|
|
|
if (!over) {
|
|
resetColumns();
|
|
return;
|
|
}
|
|
|
|
const activeId = active.id as string;
|
|
const overId = over.id as string;
|
|
|
|
const cols = columnsRef.current;
|
|
const activeCol = findColumn(cols, activeId, visibleStatuses);
|
|
const overCol = findColumn(cols, overId, visibleStatuses);
|
|
if (!activeCol || !overCol) {
|
|
resetColumns();
|
|
return;
|
|
}
|
|
|
|
// Same-column reorder
|
|
let finalColumns = cols;
|
|
if (activeCol === overCol) {
|
|
const ids = cols[activeCol]!;
|
|
const oldIndex = ids.indexOf(activeId);
|
|
const newIndex = ids.indexOf(overId);
|
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
|
const reordered = arrayMove(ids, oldIndex, newIndex);
|
|
finalColumns = { ...cols, [activeCol]: reordered };
|
|
setColumns(finalColumns);
|
|
}
|
|
}
|
|
|
|
const finalCol = findColumn(finalColumns, activeId, visibleStatuses);
|
|
if (!finalCol) {
|
|
resetColumns();
|
|
return;
|
|
}
|
|
|
|
const map = issueMapRef.current;
|
|
const finalIds = finalColumns[finalCol]!;
|
|
const newPosition = computePosition(finalIds, activeId, map);
|
|
const currentIssue = map.get(activeId);
|
|
|
|
if (
|
|
currentIssue &&
|
|
currentIssue.status === finalCol &&
|
|
currentIssue.position === newPosition
|
|
) {
|
|
return;
|
|
}
|
|
|
|
onMoveIssue(activeId, finalCol, newPosition);
|
|
},
|
|
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
|
|
);
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={kanbanCollision}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
|
{visibleStatuses.map((status) => (
|
|
<BoardColumn
|
|
key={status}
|
|
status={status}
|
|
issueIds={columns[status] ?? []}
|
|
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
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{hiddenStatuses.length > 0 && (
|
|
<HiddenColumnsPanel
|
|
hiddenStatuses={hiddenStatuses}
|
|
issues={allIssues}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<DragOverlay dropAnimation={null}>
|
|
{activeIssue ? (
|
|
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
|
<BoardCardContent issue={activeIssue} />
|
|
</div>
|
|
) : null}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
}
|
|
|
|
function HiddenColumnsPanel({
|
|
hiddenStatuses,
|
|
issues,
|
|
}: {
|
|
hiddenStatuses: IssueStatus[];
|
|
issues: Issue[];
|
|
}) {
|
|
const viewStoreApi = useViewStoreApi();
|
|
return (
|
|
<div className="flex w-[240px] shrink-0 flex-col">
|
|
<div className="mb-2 flex items-center gap-2 px-1">
|
|
<span className="text-sm font-medium text-muted-foreground">
|
|
Hidden columns
|
|
</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>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|