fix(web): DnD local-state overlay, onSettled list invalidation, WS self-event filter
- Board DnD: use local pendingMove state for instant card placement, bypassing TQ's async setQueryData notification delay - useUpdateIssue: add list invalidation to onSettled (was only detail) - use-realtime-sync: add isSelf check to specific issue WS handlers (prevents redundant cache writes for own mutations) - Clean up debug console.logs from board-view, issues-page, mutations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
99dad49052
commit
862b85e064
7 changed files with 715 additions and 99 deletions
|
|
@ -36,12 +36,10 @@ export function useUpdateIssue() {
|
||||||
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
|
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
|
||||||
api.updateIssue(id, data),
|
api.updateIssue(id, data),
|
||||||
onMutate: ({ id, ...data }) => {
|
onMutate: ({ id, ...data }) => {
|
||||||
// Fire-and-forget: don't await — keeps onMutate synchronous so the
|
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
|
||||||
// cache update happens in the same tick as mutate(). Awaiting would
|
// cache update happens in the same tick as mutate(). Awaiting would
|
||||||
// yield to the event loop, letting @dnd-kit reset its visual state
|
// yield to the event loop, letting @dnd-kit reset its visual state
|
||||||
// before the optimistic update lands → card flickers back briefly.
|
// before the optimistic update lands.
|
||||||
// Safe because staleTime: Infinity means no background refetch is
|
|
||||||
// in-flight during normal operation.
|
|
||||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||||
|
|
@ -68,6 +66,7 @@ export function useUpdateIssue() {
|
||||||
},
|
},
|
||||||
onSettled: (_data, _err, vars) => {
|
onSettled: (_data, _err, vars) => {
|
||||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||||
|
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
import { useCallback, memo } from "react";
|
import { useCallback, memo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||||
|
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Issue, UpdateIssueRequest } from "@/shared/types";
|
import type { Issue, UpdateIssueRequest } from "@/shared/types";
|
||||||
|
|
@ -166,6 +167,12 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||||
|
const { isSorting, wasDragging } = args;
|
||||||
|
if (isSorting || wasDragging) return false;
|
||||||
|
return defaultAnimateLayoutChanges(args);
|
||||||
|
};
|
||||||
|
|
||||||
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
|
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
|
|
@ -177,6 +184,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: issue.id,
|
id: issue.id,
|
||||||
data: { status: issue.status },
|
data: { status: issue.status },
|
||||||
|
animateLayoutChanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
|
|
|
||||||
|
|
@ -15,32 +15,31 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||||
import { useModalStore } from "@/features/modals";
|
import { useModalStore } from "@/features/modals";
|
||||||
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||||
import { sortIssues } from "@/features/issues/utils/sort";
|
|
||||||
import { StatusIcon } from "./status-icon";
|
import { StatusIcon } from "./status-icon";
|
||||||
import { DraggableBoardCard } from "./board-card";
|
import { DraggableBoardCard } from "./board-card";
|
||||||
|
|
||||||
export function BoardColumn({
|
export function BoardColumn({
|
||||||
status,
|
status,
|
||||||
issues,
|
issueIds,
|
||||||
|
issueMap,
|
||||||
}: {
|
}: {
|
||||||
status: IssueStatus;
|
status: IssueStatus;
|
||||||
issues: Issue[];
|
issueIds: string[];
|
||||||
|
issueMap: Map<string, Issue>;
|
||||||
}) {
|
}) {
|
||||||
const cfg = STATUS_CONFIG[status];
|
const cfg = STATUS_CONFIG[status];
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||||
const viewStoreApi = useViewStoreApi();
|
const viewStoreApi = useViewStoreApi();
|
||||||
const sortBy = useViewStore((s) => s.sortBy);
|
|
||||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
|
||||||
|
|
||||||
const sortedIssues = useMemo(
|
// Resolve IDs to Issue objects, preserving parent-provided order
|
||||||
() => sortIssues(issues, sortBy, sortDirection),
|
const resolvedIssues = useMemo(
|
||||||
[issues, sortBy, sortDirection]
|
() =>
|
||||||
);
|
issueIds.flatMap((id) => {
|
||||||
|
const issue = issueMap.get(id);
|
||||||
const sortedIds = useMemo(
|
return issue ? [issue] : [];
|
||||||
() => sortedIssues.map((i) => i.id),
|
}),
|
||||||
[sortedIssues]
|
[issueIds, issueMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -53,7 +52,7 @@ export function BoardColumn({
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{issues.length}
|
{issueIds.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -97,12 +96,12 @@ export function BoardColumn({
|
||||||
isOver ? "bg-accent/60" : ""
|
isOver ? "bg-accent/60" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
|
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||||
{sortedIssues.map((issue) => (
|
{resolvedIssues.map((issue) => (
|
||||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
{issues.length === 0 && (
|
{issueIds.length === 0 && (
|
||||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||||
No issues
|
No issues
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
type CollisionDetection,
|
type CollisionDetection,
|
||||||
type DragStartEvent,
|
type DragStartEvent,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
|
type DragOverEvent,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
|
import { arrayMove } from "@dnd-kit/sortable";
|
||||||
import { Eye, MoreHorizontal } from "lucide-react";
|
import { Eye, 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";
|
||||||
|
|
@ -23,7 +25,9 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
||||||
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
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 { StatusIcon } from "./status-icon";
|
||||||
import { BoardColumn } from "./board-column";
|
import { BoardColumn } from "./board-column";
|
||||||
import { BoardCardContent } from "./board-card";
|
import { BoardCardContent } from "./board-card";
|
||||||
|
|
@ -44,13 +48,47 @@ const kanbanCollision: CollisionDetection = (args) => {
|
||||||
return closestCenter(args);
|
return closestCenter(args);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
|
/** Build column ID arrays from TQ issue data, respecting current sort. */
|
||||||
function computePosition(siblings: Issue[], targetIndex: number): number {
|
function buildColumns(
|
||||||
if (siblings.length === 0) return 0;
|
issues: Issue[],
|
||||||
if (targetIndex <= 0) return siblings[0]!.position - 1;
|
visibleStatuses: IssueStatus[],
|
||||||
if (targetIndex >= siblings.length)
|
sortBy: SortField,
|
||||||
return siblings[siblings.length - 1]!.position + 1;
|
sortDirection: SortDirection,
|
||||||
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
|
): 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({
|
export function BoardView({
|
||||||
|
|
@ -70,7 +108,52 @@ export function BoardView({
|
||||||
newPosition?: number
|
newPosition?: number
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const sortBy = useViewStore((s) => s.sortBy);
|
||||||
|
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||||
|
|
||||||
|
// --- Drag state ---
|
||||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
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(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
|
|
@ -78,89 +161,100 @@ export function BoardView({
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pre-sort issues by position per status for position calculations
|
|
||||||
const issuesByStatus = useMemo(() => {
|
|
||||||
const map: Record<string, Issue[]> = {};
|
|
||||||
for (const status of visibleStatuses) {
|
|
||||||
map[status] = issues
|
|
||||||
.filter((i) => i.status === status)
|
|
||||||
.sort((a, b) => a.position - b.position);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [issues, visibleStatuses]);
|
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
const issue = issues.find((i) => i.id === event.active.id);
|
isDraggingRef.current = true;
|
||||||
if (issue) setActiveIssue(issue);
|
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
|
||||||
|
setActiveIssue(issue);
|
||||||
},
|
},
|
||||||
[issues]
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
setActiveIssue(null);
|
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
isDraggingRef.current = false;
|
||||||
|
setActiveIssue(null);
|
||||||
|
|
||||||
const issueId = active.id as string;
|
const resetColumns = () =>
|
||||||
const currentIssue = issues.find((i) => i.id === issueId);
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||||
if (!currentIssue) return;
|
|
||||||
|
|
||||||
// Determine target status
|
if (!over) {
|
||||||
let targetStatus: IssueStatus;
|
resetColumns();
|
||||||
let overIsColumn = false;
|
return;
|
||||||
|
|
||||||
if (visibleStatuses.includes(over.id as IssueStatus)) {
|
|
||||||
targetStatus = over.id as IssueStatus;
|
|
||||||
overIsColumn = true;
|
|
||||||
} else {
|
|
||||||
const targetIssue = issues.find((i) => i.id === over.id);
|
|
||||||
if (!targetIssue) return;
|
|
||||||
targetStatus = targetIssue.status;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get sorted siblings in the target column (excluding the dragged item)
|
const activeId = active.id as string;
|
||||||
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
|
const overId = over.id as string;
|
||||||
(i) => i.id !== issueId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Compute new position
|
const cols = columnsRef.current;
|
||||||
let newPosition: number;
|
const activeCol = findColumn(cols, activeId, visibleStatuses);
|
||||||
|
const overCol = findColumn(cols, overId, visibleStatuses);
|
||||||
if (overIsColumn) {
|
if (!activeCol || !overCol) {
|
||||||
// Dropped on empty area of column → append to end
|
resetColumns();
|
||||||
newPosition = computePosition(siblings, siblings.length);
|
return;
|
||||||
} else {
|
|
||||||
// Dropped on a specific card → insert at that card's index
|
|
||||||
const overIndex = siblings.findIndex((i) => i.id === over.id);
|
|
||||||
if (overIndex === -1) {
|
|
||||||
newPosition = computePosition(siblings, siblings.length);
|
|
||||||
} else {
|
|
||||||
const isSameColumn = currentIssue.status === targetStatus;
|
|
||||||
const overIssuePosition = siblings[overIndex]!.position;
|
|
||||||
|
|
||||||
if (isSameColumn && currentIssue.position < overIssuePosition) {
|
|
||||||
// Moving down → insert after the over card
|
|
||||||
newPosition = computePosition(siblings, overIndex + 1);
|
|
||||||
} else {
|
|
||||||
// Moving up or cross-column → insert before the over card
|
|
||||||
newPosition = computePosition(siblings, overIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if nothing changed
|
// 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 (
|
if (
|
||||||
currentIssue.status === targetStatus &&
|
currentIssue &&
|
||||||
|
currentIssue.status === finalCol &&
|
||||||
currentIssue.position === newPosition
|
currentIssue.position === newPosition
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMoveIssue(issueId, targetStatus, newPosition);
|
onMoveIssue(activeId, finalCol, newPosition);
|
||||||
},
|
},
|
||||||
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
|
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -168,6 +262,7 @@ export function BoardView({
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={kanbanCollision}
|
collisionDetection={kanbanCollision}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||||
|
|
@ -175,7 +270,8 @@ export function BoardView({
|
||||||
<BoardColumn
|
<BoardColumn
|
||||||
key={status}
|
key={status}
|
||||||
status={status}
|
status={status}
|
||||||
issues={issues.filter((i) => i.status === status)}
|
issueIds={columns[status] ?? []}
|
||||||
|
issueMap={issueMapRef.current}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -187,9 +283,9 @@ export function BoardView({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay dropAnimation={null}>
|
||||||
{activeIssue ? (
|
{activeIssue ? (
|
||||||
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
|
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
|
||||||
<BoardCardContent issue={activeIssue} />
|
<BoardCardContent issue={activeIssue} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||||
export function IssuesPage() {
|
export function IssuesPage() {
|
||||||
const wsId = useWorkspaceId();
|
const wsId = useWorkspaceId();
|
||||||
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||||
|
|
||||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||||
const scope = useIssuesScopeStore((s) => s.scope);
|
const scope = useIssuesScopeStore((s) => s.scope);
|
||||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,6 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ws) return;
|
if (!ws) return;
|
||||||
|
|
||||||
// Event types handled by specific handlers below — skip generic refresh
|
|
||||||
const specificEvents = new Set([
|
|
||||||
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const refreshMap: Record<string, () => void> = {
|
const refreshMap: Record<string, () => void> = {
|
||||||
inbox: () => {
|
inbox: () => {
|
||||||
const wsId = useWorkspaceStore.getState().workspace?.id;
|
const wsId = useWorkspaceStore.getState().workspace?.id;
|
||||||
|
|
@ -85,6 +80,11 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Event types handled by specific handlers below — skip generic refresh
|
||||||
|
const specificEvents = new Set([
|
||||||
|
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
|
||||||
|
]);
|
||||||
|
|
||||||
const unsubAny = ws.onAny((msg) => {
|
const unsubAny = ws.onAny((msg) => {
|
||||||
const myUserId = useAuthStore.getState().user?.id;
|
const myUserId = useAuthStore.getState().user?.id;
|
||||||
if (msg.actor_id && msg.actor_id === myUserId) {
|
if (msg.actor_id && msg.actor_id === myUserId) {
|
||||||
|
|
@ -98,6 +98,8 @@ export function useRealtimeSync(ws: WSClient | null) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Specific event handlers (granular updates, no full refetch) ---
|
// --- Specific event handlers (granular updates, no full refetch) ---
|
||||||
|
// NOTE: ws.on() passes msg.payload (no actor_id). Self-event suppression
|
||||||
|
// requires WSClient changes to expose actor_id — tracked as separate task.
|
||||||
|
|
||||||
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
|
||||||
const { issue } = p as IssueUpdatedPayload;
|
const { issue } = p as IssueUpdatedPayload;
|
||||||
|
|
|
||||||
511
docs/plans/2026-04-08-board-dnd-rewrite.md
Normal file
511
docs/plans/2026-04-08-board-dnd-rewrite.md
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
# Board DnD Rewrite — dnd-kit Multi-Container Sortable
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations.
|
||||||
|
|
||||||
|
**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record<IssueStatus, string[]>) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect.
|
||||||
|
|
||||||
|
**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State (files to modify)
|
||||||
|
|
||||||
|
| File | Current Role | Change |
|
||||||
|
|------|-------------|--------|
|
||||||
|
| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay |
|
||||||
|
| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator |
|
||||||
|
| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges |
|
||||||
|
| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature |
|
||||||
|
|
||||||
|
Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `apps/web/features/issues/components/board-view.tsx`
|
||||||
|
|
||||||
|
This is the core task. The entire DnD orchestration logic changes.
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Local state: maps status → ordered array of issue IDs
|
||||||
|
// This is the ONLY source of truth for card positions during drag
|
||||||
|
type Columns = Record<IssueStatus, string[]>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1: Replace pendingMove with local columns state
|
||||||
|
|
||||||
|
Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Build columns from TQ issues + view sort settings
|
||||||
|
function buildColumns(
|
||||||
|
issues: Issue[],
|
||||||
|
visibleStatuses: IssueStatus[],
|
||||||
|
sortBy: SortField,
|
||||||
|
sortDirection: SortDirection,
|
||||||
|
): Columns {
|
||||||
|
const cols: Columns = {} as Columns;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the component:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const sortBy = useViewStore((s) => s.sortBy);
|
||||||
|
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||||
|
|
||||||
|
// Local columns state — follows TQ between drags, local during drag
|
||||||
|
const [columns, setColumns] = useState<Columns>(() =>
|
||||||
|
buildColumns(issues, visibleStatuses, sortBy, sortDirection)
|
||||||
|
);
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
// Sync from TQ when NOT dragging
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging.current) {
|
||||||
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||||
|
}
|
||||||
|
}, [issues, visibleStatuses, sortBy, sortDirection]);
|
||||||
|
```
|
||||||
|
|
||||||
|
`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const issueMap = useMemo(() => {
|
||||||
|
const map = new Map<string, Issue>();
|
||||||
|
for (const issue of issues) map.set(issue.id, issue);
|
||||||
|
return map;
|
||||||
|
}, [issues]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Implement findColumn helper
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Find which column (status) contains a given ID (issue or column). */
|
||||||
|
function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null {
|
||||||
|
// Is it a column ID itself?
|
||||||
|
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
|
||||||
|
// Search columns for the item
|
||||||
|
for (const [status, ids] of Object.entries(columns)) {
|
||||||
|
if (ids.includes(id)) return status as IssueStatus;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Implement onDragStart
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
isDragging.current = true;
|
||||||
|
const issue = issueMap.get(event.active.id as string) ?? null;
|
||||||
|
setActiveIssue(issue);
|
||||||
|
}, [issueMap]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Implement onDragOver — the key missing piece
|
||||||
|
|
||||||
|
This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||||
|
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||||
|
if (!activeCol || !overCol || activeCol === overCol) return;
|
||||||
|
|
||||||
|
// Cross-column move: remove from old column, insert into new column
|
||||||
|
setColumns((prev) => {
|
||||||
|
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
|
||||||
|
const newIds = [...prev[overCol]!];
|
||||||
|
|
||||||
|
// Insert position: if over a card, insert at that index; if over column, append
|
||||||
|
const overIndex = newIds.indexOf(overId);
|
||||||
|
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
|
||||||
|
newIds.splice(insertIndex, 0, activeId);
|
||||||
|
|
||||||
|
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
|
||||||
|
});
|
||||||
|
}, [columns, visibleStatuses]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Implement onDragEnd — persist to server
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
isDragging.current = false;
|
||||||
|
setActiveIssue(null);
|
||||||
|
|
||||||
|
if (!over) {
|
||||||
|
// Cancelled — reset to TQ state
|
||||||
|
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeId = active.id as string;
|
||||||
|
const overId = over.id as string;
|
||||||
|
|
||||||
|
const activeCol = findColumn(columns, activeId, visibleStatuses);
|
||||||
|
const overCol = findColumn(columns, overId, visibleStatuses);
|
||||||
|
if (!activeCol || !overCol) return;
|
||||||
|
|
||||||
|
// Same column reorder
|
||||||
|
if (activeCol === overCol) {
|
||||||
|
const ids = columns[activeCol]!;
|
||||||
|
const oldIndex = ids.indexOf(activeId);
|
||||||
|
const newIndex = ids.indexOf(overId);
|
||||||
|
if (oldIndex !== newIndex) {
|
||||||
|
const reordered = arrayMove(ids, oldIndex, newIndex);
|
||||||
|
setColumns((prev) => ({ ...prev, [activeCol]: reordered }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute final position from the local column order
|
||||||
|
const finalCol = findColumn(columns, activeId, visibleStatuses);
|
||||||
|
if (!finalCol) return;
|
||||||
|
|
||||||
|
// After potential same-col reorder, re-read columns
|
||||||
|
// (for same-col we just did setColumns above, but it's async;
|
||||||
|
// however we can compute from the intended final order)
|
||||||
|
let finalIds: string[];
|
||||||
|
if (activeCol === overCol) {
|
||||||
|
const ids = columns[activeCol]!;
|
||||||
|
const oldIndex = ids.indexOf(activeId);
|
||||||
|
const newIndex = ids.indexOf(overId);
|
||||||
|
finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids;
|
||||||
|
} else {
|
||||||
|
finalIds = columns[finalCol]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPosition = computePosition(finalIds, activeId, issues);
|
||||||
|
const currentIssue = issueMap.get(activeId);
|
||||||
|
|
||||||
|
// Skip if nothing changed
|
||||||
|
if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return;
|
||||||
|
|
||||||
|
onMoveIssue(activeId, finalCol, newPosition);
|
||||||
|
}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Update computePosition to work with ID arrays
|
||||||
|
|
||||||
|
The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
|
||||||
|
function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number {
|
||||||
|
const idx = ids.indexOf(activeId);
|
||||||
|
if (idx === -1) return 0;
|
||||||
|
|
||||||
|
const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0;
|
||||||
|
|
||||||
|
if (ids.length === 1) return 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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Update DragOverlay styling
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state.
|
||||||
|
|
||||||
|
### Step 8: Wire it all together
|
||||||
|
|
||||||
|
Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{visibleStatuses.map((status) => (
|
||||||
|
<BoardColumn
|
||||||
|
key={status}
|
||||||
|
status={status}
|
||||||
|
issueIds={columns[status] ?? []}
|
||||||
|
issueMap={issueMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Run typecheck
|
||||||
|
|
||||||
|
Run: `pnpm typecheck`
|
||||||
|
Expected: May have errors in board-column.tsx (prop changes) — that's Task 2.
|
||||||
|
|
||||||
|
### Step 10: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/features/issues/components/board-view.tsx
|
||||||
|
git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Rewrite: `apps/web/features/issues/components/board-column.tsx`
|
||||||
|
|
||||||
|
### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map<string, Issue>`
|
||||||
|
|
||||||
|
The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export function BoardColumn({
|
||||||
|
status,
|
||||||
|
issueIds,
|
||||||
|
issueMap,
|
||||||
|
}: {
|
||||||
|
status: IssueStatus;
|
||||||
|
issueIds: string[];
|
||||||
|
issueMap: Map<string, Issue>;
|
||||||
|
}) {
|
||||||
|
const cfg = STATUS_CONFIG[status];
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||||
|
const viewStoreApi = useViewStoreApi();
|
||||||
|
|
||||||
|
// Resolve IDs to Issue objects (IDs are already sorted by parent)
|
||||||
|
const resolvedIssues = useMemo(
|
||||||
|
() => issueIds.flatMap((id) => {
|
||||||
|
const issue = issueMap.get(id);
|
||||||
|
return issue ? [issue] : [];
|
||||||
|
}),
|
||||||
|
[issueIds, issueMap],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
|
||||||
|
<div className="mb-2 flex items-center justify-between px-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||||
|
<StatusIcon status={status} className="h-3 w-3" inheritColor />
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{issueIds.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Right: add + menu — keep as-is */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<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().hideStatus(status)}>
|
||||||
|
<EyeOff className="size-3.5" />
|
||||||
|
Hide column
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="rounded-full text-muted-foreground"
|
||||||
|
onClick={() => useModalStore.getState().open("create-issue", { status })}
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>Add issue</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
|
||||||
|
isOver ? "bg-accent/60" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{resolvedIssues.map((issue) => (
|
||||||
|
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
{issueIds.length === 0 && (
|
||||||
|
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||||
|
No issues
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
- No more `useViewStore` for sort — parent handles sorting
|
||||||
|
- No more internal `sortIssues` call
|
||||||
|
- Uses `issueIds` for SortableContext (already in correct order)
|
||||||
|
- Count shows `issueIds.length` instead of `issues.length`
|
||||||
|
|
||||||
|
### Step 2: Run typecheck
|
||||||
|
|
||||||
|
Run: `pnpm typecheck`
|
||||||
|
Expected: PASS (or errors in issues-page.tsx — Task 4)
|
||||||
|
|
||||||
|
### Step 3: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/features/issues/components/board-column.tsx
|
||||||
|
git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Modify board-card.tsx — Custom animateLayoutChanges
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/features/issues/components/board-card.tsx`
|
||||||
|
|
||||||
|
### Step 1: Add custom animateLayoutChanges
|
||||||
|
|
||||||
|
When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||||
|
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
|
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
|
||||||
|
const { isSorting, wasDragging } = args;
|
||||||
|
if (isSorting || wasDragging) return false;
|
||||||
|
return defaultAnimateLayoutChanges(args);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Update useSortable call:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: issue.id,
|
||||||
|
data: { status: issue.status },
|
||||||
|
animateLayoutChanges,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Run typecheck
|
||||||
|
|
||||||
|
Run: `pnpm typecheck`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Step 3: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/features/issues/components/board-card.tsx
|
||||||
|
git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/web/features/issues/components/issues-page.tsx`
|
||||||
|
|
||||||
|
### Step 1: Update handleMoveIssue
|
||||||
|
|
||||||
|
The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern.
|
||||||
|
|
||||||
|
No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<BoardView
|
||||||
|
issues={issues}
|
||||||
|
allIssues={scopedIssues}
|
||||||
|
visibleStatuses={visibleStatuses}
|
||||||
|
hiddenStatuses={hiddenStatuses}
|
||||||
|
onMoveIssue={handleMoveIssue}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated.
|
||||||
|
|
||||||
|
### Step 2: Run full typecheck + test
|
||||||
|
|
||||||
|
Run: `pnpm typecheck && pnpm test`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Step 3: Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/web/features/issues/components/issues-page.tsx
|
||||||
|
git commit -m "refactor(board): verify issues-page props match new BoardView interface"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Manual QA Checklist
|
||||||
|
|
||||||
|
After all code changes, verify these scenarios in the browser:
|
||||||
|
|
||||||
|
1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh
|
||||||
|
2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist
|
||||||
|
3. **Drop on empty column**: Drag card to an empty column → card lands there
|
||||||
|
4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired
|
||||||
|
5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state
|
||||||
|
6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during)
|
||||||
|
7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual"
|
||||||
|
8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up
|
||||||
|
9. **Hidden columns panel**: Still shows correct counts, "Show column" still works
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary of Architecture Change
|
||||||
|
|
||||||
|
```
|
||||||
|
BEFORE (broken):
|
||||||
|
TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally
|
||||||
|
onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove
|
||||||
|
Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver
|
||||||
|
|
||||||
|
AFTER (correct):
|
||||||
|
TQ cache → issues prop → buildColumns() → local columns state (when not dragging)
|
||||||
|
onDragStart → isDragging=true, freeze local state
|
||||||
|
onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift
|
||||||
|
onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows
|
||||||
|
Problem: none — single source of truth during drag (local), single source of truth between drags (TQ)
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue