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) =>
|
||||
api.updateIssue(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
|
||||
// yield to the event loop, letting @dnd-kit reset its visual state
|
||||
// before the optimistic update lands → card flickers back briefly.
|
||||
// Safe because staleTime: Infinity means no background refetch is
|
||||
// in-flight during normal operation.
|
||||
// before the optimistic update lands.
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
|
|
@ -68,6 +66,7 @@ export function useUpdateIssue() {
|
|||
},
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { useCallback, memo } from "react";
|
||||
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 { toast } from "sonner";
|
||||
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 }) {
|
||||
const {
|
||||
attributes,
|
||||
|
|
@ -177,6 +184,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
|
|||
} = useSortable({
|
||||
id: issue.id,
|
||||
data: { status: issue.status },
|
||||
animateLayoutChanges,
|
||||
});
|
||||
|
||||
const style = {
|
||||
|
|
|
|||
|
|
@ -15,32 +15,31 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { sortIssues } from "@/features/issues/utils/sort";
|
||||
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { DraggableBoardCard } from "./board-card";
|
||||
|
||||
export function BoardColumn({
|
||||
status,
|
||||
issues,
|
||||
issueIds,
|
||||
issueMap,
|
||||
}: {
|
||||
status: IssueStatus;
|
||||
issues: Issue[];
|
||||
issueIds: string[];
|
||||
issueMap: Map<string, Issue>;
|
||||
}) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const { setNodeRef, isOver } = useDroppable({ id: status });
|
||||
const viewStoreApi = useViewStoreApi();
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
|
||||
const sortedIssues = useMemo(
|
||||
() => sortIssues(issues, sortBy, sortDirection),
|
||||
[issues, sortBy, sortDirection]
|
||||
);
|
||||
|
||||
const sortedIds = useMemo(
|
||||
() => sortedIssues.map((i) => i.id),
|
||||
[sortedIssues]
|
||||
// Resolve IDs to Issue objects, preserving parent-provided order
|
||||
const resolvedIssues = useMemo(
|
||||
() =>
|
||||
issueIds.flatMap((id) => {
|
||||
const issue = issueMap.get(id);
|
||||
return issue ? [issue] : [];
|
||||
}),
|
||||
[issueIds, issueMap],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -53,7 +52,7 @@ export function BoardColumn({
|
|||
{cfg.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{issues.length}
|
||||
{issueIds.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -97,12 +96,12 @@ export function BoardColumn({
|
|||
isOver ? "bg-accent/60" : ""
|
||||
}`}
|
||||
>
|
||||
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
|
||||
{sortedIssues.map((issue) => (
|
||||
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
|
||||
{resolvedIssues.map((issue) => (
|
||||
<DraggableBoardCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issues.length === 0 && (
|
||||
{issueIds.length === 0 && (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">
|
||||
No issues
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
|
|
@ -12,7 +12,9 @@ import {
|
|||
type CollisionDetection,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { Eye, MoreHorizontal } from "lucide-react";
|
||||
import type { Issue, IssueStatus } from "@/shared/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -23,7 +25,9 @@ import {
|
|||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 { BoardColumn } from "./board-column";
|
||||
import { BoardCardContent } from "./board-card";
|
||||
|
|
@ -44,13 +48,47 @@ const kanbanCollision: CollisionDetection = (args) => {
|
|||
return closestCenter(args);
|
||||
};
|
||||
|
||||
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
|
||||
function computePosition(siblings: Issue[], targetIndex: number): number {
|
||||
if (siblings.length === 0) return 0;
|
||||
if (targetIndex <= 0) return siblings[0]!.position - 1;
|
||||
if (targetIndex >= siblings.length)
|
||||
return siblings[siblings.length - 1]!.position + 1;
|
||||
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
|
||||
/** 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({
|
||||
|
|
@ -70,7 +108,52 @@ export function BoardView({
|
|||
newPosition?: number
|
||||
) => void;
|
||||
}) {
|
||||
const sortBy = useViewStore((s) => s.sortBy);
|
||||
const sortDirection = useViewStore((s) => s.sortDirection);
|
||||
|
||||
// --- 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, {
|
||||
|
|
@ -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(
|
||||
(event: DragStartEvent) => {
|
||||
const issue = issues.find((i) => i.id === event.active.id);
|
||||
if (issue) setActiveIssue(issue);
|
||||
isDraggingRef.current = true;
|
||||
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(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveIssue(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
isDraggingRef.current = false;
|
||||
setActiveIssue(null);
|
||||
|
||||
const issueId = active.id as string;
|
||||
const currentIssue = issues.find((i) => i.id === issueId);
|
||||
if (!currentIssue) return;
|
||||
const resetColumns = () =>
|
||||
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
|
||||
|
||||
// Determine target status
|
||||
let targetStatus: IssueStatus;
|
||||
let overIsColumn = false;
|
||||
|
||||
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;
|
||||
if (!over) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
|
||||
// Get sorted siblings in the target column (excluding the dragged item)
|
||||
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
|
||||
(i) => i.id !== issueId
|
||||
);
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Compute new position
|
||||
let newPosition: number;
|
||||
const cols = columnsRef.current;
|
||||
const activeCol = findColumn(cols, activeId, visibleStatuses);
|
||||
const overCol = findColumn(cols, overId, visibleStatuses);
|
||||
if (!activeCol || !overCol) {
|
||||
resetColumns();
|
||||
return;
|
||||
}
|
||||
|
||||
if (overIsColumn) {
|
||||
// Dropped on empty area of column → append to end
|
||||
newPosition = computePosition(siblings, siblings.length);
|
||||
} 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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if nothing changed
|
||||
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.status === targetStatus &&
|
||||
currentIssue &&
|
||||
currentIssue.status === finalCol &&
|
||||
currentIssue.position === newPosition
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMoveIssue(issueId, targetStatus, newPosition);
|
||||
onMoveIssue(activeId, finalCol, newPosition);
|
||||
},
|
||||
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
|
||||
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -168,6 +262,7 @@ export function BoardView({
|
|||
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">
|
||||
|
|
@ -175,7 +270,8 @@ export function BoardView({
|
|||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issues={issues.filter((i) => i.status === status)}
|
||||
issueIds={columns[status] ?? []}
|
||||
issueMap={issueMapRef.current}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
|
@ -187,9 +283,9 @@ export function BoardView({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{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} />
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { BatchActionToolbar } from "./batch-action-toolbar";
|
|||
export function IssuesPage() {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
|
||||
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const scope = useIssuesScopeStore((s) => s.scope);
|
||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||
|
|
|
|||
|
|
@ -45,11 +45,6 @@ export function useRealtimeSync(ws: WSClient | null) {
|
|||
useEffect(() => {
|
||||
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> = {
|
||||
inbox: () => {
|
||||
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 myUserId = useAuthStore.getState().user?.id;
|
||||
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) ---
|
||||
// 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 { issue } = p as IssueUpdatedPayload;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue