From 8d7afe642a38bc1dfaabf3c10708a302bdedd8fb Mon Sep 17 00:00:00 2001 From: VictorOnwukwe Date: Fri, 2 Oct 2026 11:53:37 +0100 Subject: [PATCH 01/13] feat: make cross-column dragged card sortable --- .../kanban-board/column/column-dropzone.tsx | 9 +- .../components/kanban-board/column/index.tsx | 26 ++ .../kanban-board/filtered-drag.test.tsx | 208 +++++++++++++- .../web/src/components/kanban-board/index.tsx | 264 +++++++++++++++++- .../components/kanban-board/move-task.test.ts | 61 +++- .../src/components/kanban-board/move-task.ts | 34 ++- .../src/components/kanban-board/task-card.tsx | 10 +- .../$workspaceId/project/$projectId/board.tsx | 5 +- i18n/az-AZ.json | 3 +- i18n/de-DE.json | 3 +- i18n/el-GR.json | 3 +- i18n/en-US.json | 3 +- i18n/es-ES.json | 3 +- i18n/fr-FR.json | 3 +- i18n/hi-IN.json | 3 +- i18n/id-ID.json | 3 +- i18n/it-IT.json | 3 +- i18n/ja-JP.json | 3 +- i18n/ko-KR.json | 3 +- i18n/mk-MK.json | 3 +- i18n/nl-NL.json | 3 +- i18n/pl-PL.json | 3 +- i18n/pt-BR.json | 3 +- i18n/ru-RU.json | 3 +- i18n/tr-TR.json | 3 +- i18n/uk-UA.json | 3 +- i18n/vi-VN.json | 3 +- i18n/zh-CN.json | 3 +- 28 files changed, 637 insertions(+), 40 deletions(-) diff --git a/apps/web/src/components/kanban-board/column/column-dropzone.tsx b/apps/web/src/components/kanban-board/column/column-dropzone.tsx index 12a98b611d..9cede8cc89 100644 --- a/apps/web/src/components/kanban-board/column/column-dropzone.tsx +++ b/apps/web/src/components/kanban-board/column/column-dropzone.tsx @@ -9,6 +9,7 @@ import type { ProjectWithTasks } from "@/types/project"; import TaskCard from "../task-card"; type ColumnDropzoneProps = { + isDragPreview?: boolean; column: ProjectWithTasks["columns"][number]; disableDragDrop?: boolean; onIsOverChange?: (isOver: boolean) => void; @@ -16,6 +17,7 @@ type ColumnDropzoneProps = { export function ColumnDropzone({ column, + isDragPreview = false, disableDragDrop = false, onIsOverChange, }: ColumnDropzoneProps) { @@ -44,6 +46,7 @@ export function ColumnDropzone({ {column.tasks.map((task) => ( - + ))} diff --git a/apps/web/src/components/kanban-board/column/index.tsx b/apps/web/src/components/kanban-board/column/index.tsx index 80238534ee..24deeacded 100644 --- a/apps/web/src/components/kanban-board/column/index.tsx +++ b/apps/web/src/components/kanban-board/column/index.tsx @@ -1,12 +1,18 @@ import { cva } from "class-variance-authority"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useBackgroundStore } from "@/store/background"; import type { ProjectWithTasks } from "@/types/project"; import { ColumnDropzone } from "./column-dropzone"; import { ColumnHeader } from "./column-header"; type ColumnProps = { + isDragPreview?: boolean; column: ProjectWithTasks["columns"][number]; + activeTaskId?: string | null; + sourceColumnId?: string; + isPriorityOverlaySuppressed?: boolean; + priorityOverlayColumnId?: string | null; disableDragDrop?: boolean; disableCollectionActions?: boolean; }; @@ -55,11 +61,23 @@ export const columnVariants = cva( function Column({ column, + isDragPreview = false, + activeTaskId = null, + sourceColumnId, + isPriorityOverlaySuppressed = false, + priorityOverlayColumnId = null, disableDragDrop = false, disableCollectionActions = false, }: ColumnProps) { const [isDropzoneOver, setIsDropzoneOver] = useState(false); const { background } = useBackgroundStore(); + const { t } = useTranslation(); + const isActiveTaskColumn = column.id === sourceColumnId; + const showPriorityOverlay = + priorityOverlayColumnId === column.id && + activeTaskId !== null && + !isActiveTaskColumn && + !isPriorityOverlaySuppressed; return (
+ {showPriorityOverlay && ( +
+ + {t("tasks:kanban.priorityOrderedOverlayHint")} + +
+ )} ); } diff --git a/apps/web/src/components/kanban-board/filtered-drag.test.tsx b/apps/web/src/components/kanban-board/filtered-drag.test.tsx index 6932e5454b..5561d50aaa 100644 --- a/apps/web/src/components/kanban-board/filtered-drag.test.tsx +++ b/apps/web/src/components/kanban-board/filtered-drag.test.tsx @@ -7,6 +7,7 @@ import { it, vi, } from "vite-plus/test"; +import type { ReactNode } from "react"; import type { ProjectWithTasks } from "@/types/project"; import KanbanBoard from "./index"; @@ -45,25 +46,103 @@ vi.mock("@/hooks/use-project-background", () => ({ useProjectBackground: () => null, })); vi.mock("../bulk-selection/bulk-toolbar", () => ({ default: () => null })); -vi.mock("./column", () => ({ default: () => null })); +vi.mock("./column", () => ({ + default: ({ + column, + isPriorityOverlaySuppressed, + priorityOverlayColumnId, + }: { + column: { id: string; tasks: { id: string }[] }; + isPriorityOverlaySuppressed: boolean; + priorityOverlayColumnId: string | null; + }) => ( +
task.id).join(",")} + data-priority-overlay={ + priorityOverlayColumnId === column.id && !isPriorityOverlaySuppressed + ? "visible" + : "hidden" + } + /> + ), +})); vi.mock("./task-card", () => ({ default: () => null })); vi.mock("@dnd-kit/core", () => ({ DndContext: ({ + children, onDragStart, + onDragOver, onDragEnd, }: { + children: ReactNode; onDragStart: (event: unknown) => void; + onDragOver: (event: unknown) => void; onDragEnd: (event: unknown) => void; }) => ( <> - + + + + + ), DragOverlay: () => null, @@ -144,3 +223,128 @@ it("rejects an in-flight Kanban drop after a failed refresh disables dragging", expect(mocks.setProject).not.toHaveBeenCalled(); expect(mocks.setQueryData).not.toHaveBeenCalled(); }); + +it("places a cross-column drop at the bottom without Command", () => { + const canonical = { + id: "p", + columns: [ + { + id: "todo", + slug: "todo", + tasks: [{ id: "a", status: "todo", position: 0, priority: "low" }], + }, + { + id: "doing", + slug: "doing", + tasks: [ + { id: "c", status: "doing", position: 0, priority: "high" }, + { id: "d", status: "doing", position: 1, priority: "medium" }, + ], + }, + ], + plannedTasks: [], + archivedTasks: [], + } as unknown as ProjectWithTasks; + mocks.project = canonical; + const view = render(); + + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("drop-c")); + + expect( + mocks.setProject.mock.calls[0][0].columns[1].tasks.map( + (task: { id: string }) => task.id, + ), + ).toEqual(["c", "d", "a"]); + expect(mocks.setProject.mock.calls[0][0].columns[1].tasks[2].priority).toBe( + "low", + ); +}); + +it.each(["held", "released", "blurred"])( + "uses the matching drop behavior when Command is %s", + (commandState) => { + const canonical = { + id: "p", + columns: [ + { + id: "todo", + slug: "todo", + tasks: [{ id: "a", status: "todo", position: 0, priority: "low" }], + }, + { + id: "doing", + slug: "doing", + tasks: [{ id: "c", status: "doing", position: 0, priority: "high" }], + }, + ], + plannedTasks: [], + archivedTasks: [], + } as unknown as ProjectWithTasks; + mocks.project = canonical; + const view = render(); + + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over")); + expect(view.getByTestId("column-doing")).toHaveAttribute( + "data-priority-overlay", + "visible", + ); + + fireEvent.keyDown(window, { key: "Meta", metaKey: true }); + expect(view.getByTestId("column-doing")).toHaveAttribute( + "data-priority-overlay", + "hidden", + ); + expect(view.getByTestId("column-doing")).toHaveAttribute( + "data-task-ids", + "a,c", + ); + + if (commandState === "held") { + fireEvent.click(view.getByText("over-center")); + expect(view.getByTestId("column-doing")).toHaveAttribute( + "data-task-ids", + "c,a", + ); + fireEvent.click(view.getByText("over-active")); + fireEvent.click(view.getByText("drop-over")); + expect( + mocks.setProject.mock.calls[0][0].columns[1].tasks.map( + (task: { id: string }) => task.id, + ), + ).toEqual(["c", "a"]); + return; + } + + if (commandState === "released") { + fireEvent.keyUp(window, { key: "Meta", metaKey: false }); + } else { + fireEvent.blur(window); + } + expect(view.getByTestId("column-doing")).toHaveAttribute( + "data-priority-overlay", + "visible", + ); + expect(view.getByTestId("column-doing")).toHaveAttribute( + "data-task-ids", + "c", + ); + + fireEvent.click(view.getByText("over-active")); + expect(view.getByTestId("column-doing")).toHaveAttribute( + "data-task-ids", + "c", + ); + + fireEvent.click(view.getByText("drop-over")); + expect( + mocks.setProject.mock.calls[0][0].columns[1].tasks.map( + (task: { id: string }) => task.id, + ), + ).toEqual(["c", "a"]); + expect(mocks.setProject.mock.calls[0][0].columns[1].tasks[1].priority).toBe( + "low", + ); + }, +); diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index 3b0e7f09fb..f9d53c1040 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -4,7 +4,9 @@ import { closestCorners, DndContext, type DragEndEvent, + type DragMoveEvent, DragOverlay, + type DragOverEvent, type DragStartEvent, type DropAnimation, defaultDropAnimationSideEffects, @@ -19,11 +21,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; import reorderTasks, { type TaskReorder } from "@/fetchers/task/reorder-tasks"; +import updateTaskPriority from "@/fetchers/task/update-task-priority"; import { toast } from "@/lib/toast"; import { useTranslation } from "react-i18next"; import { rollbackBoardReorder } from "./apply-reorder"; -import { moveBoardTask } from "./move-task"; -import { useEffect, useState } from "react"; +import { getVisualTaskPlacement, moveBoardTask } from "./move-task"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { produce } from "immer"; import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useProjectBackground } from "@/hooks/use-project-background"; import { cn } from "@/lib/cn"; @@ -40,6 +44,12 @@ type KanbanBoardProps = { disableDragDrop?: boolean; disableCollectionActions?: boolean; sortedByNumber?: boolean; + sortedByPriority?: boolean; +}; + +type HoverPlacement = { + insertAfterTarget?: boolean; + overId: string; }; function KanbanBoard({ @@ -47,6 +57,7 @@ function KanbanBoard({ disableDragDrop = false, disableCollectionActions = false, sortedByNumber = false, + sortedByPriority = false, }: KanbanBoardProps) { const queryClient = useQueryClient(); const { project: storedProject, setProject } = useProjectStore(); @@ -58,6 +69,21 @@ function KanbanBoard({ clearFocus, } = useBulkSelectionStore(); const [activeId, setActiveId] = useState(null); + const [overColumnId, setOverColumnId] = useState(null); + const [hoverPlacement, setHoverPlacement] = useState( + null, + ); + const [dragPreviewProject, setDragPreviewProject] = + useState(null); + const [isSortedReorderActive, setIsSortedReorderActive] = useState(false); + const [isCommandHeld, setIsCommandHeld] = useState(false); + const activeIdRef = useRef(null); + const dragPreviewProjectRef = useRef(null); + const hoverPlacementRef = useRef(null); + const isSortedReorderActiveRef = useRef(false); + useLayoutEffect(() => { + dragPreviewProjectRef.current = dragPreviewProject; + }, [dragPreviewProject]); const { t } = useTranslation(); const { mutate: reorder, isPending: isReordering } = useMutation({ mutationFn: ({ @@ -93,6 +119,27 @@ function KanbanBoard({ void queryClient.invalidateQueries({ queryKey: ["tasks", project.id] }); }, }); + const { mutate: updatePriority } = useMutation({ + mutationFn: ({ + task, + taskId, + }: { + task: Parameters[1]; + taskId: string; + }) => updateTaskPriority(taskId, task), + onSuccess: (_updated, variables) => { + void queryClient.invalidateQueries({ + queryKey: ["task", variables.taskId], + }); + void queryClient.invalidateQueries({ + queryKey: ["activities", variables.taskId], + }); + }, + onError: () => { + toast.error(t("tasks:popover.priority.updateError")); + void queryClient.invalidateQueries({ queryKey: ["tasks", project.id] }); + }, + }); const background = useProjectBackground({ backgroundVersion: project.backgroundVersion, projectId: project.id, @@ -179,17 +226,111 @@ function KanbanBoard({ }; const handleDragStart = (event: DragStartEvent) => { + activeIdRef.current = event.active.id; setActiveId(event.active.id); + setOverColumnId(null); + dragPreviewProjectRef.current = null; + hoverPlacementRef.current = null; + setHoverPlacement(null); + setDragPreviewProject(null); + const activatorEvent = event.activatorEvent as + | (Event & { metaKey?: boolean }) + | undefined; + const metaKey = Boolean(activatorEvent?.metaKey); + setIsCommandHeld(metaKey); + isSortedReorderActiveRef.current = metaKey; + setIsSortedReorderActive(metaKey); + }; + + const getColumnIdForOver = (overId: string) => { + return ( + project.columns.find( + (column) => + column.id === overId || + column.tasks.some((task) => task.id === overId), + )?.id ?? null + ); }; + const handleDragHover = (event: DragOverEvent | DragMoveEvent) => { + const overId = event.over?.id.toString(); + if (overId === activeIdRef.current?.toString()) return; + setOverColumnId(overId ? getColumnIdForOver(overId) : null); + if (!overId) { + hoverPlacementRef.current = null; + setHoverPlacement(null); + return; + } + + const translated = event.active.rect.current.translated; + const insertAfterTarget = + overId === getColumnIdForOver(overId) + ? undefined + : Boolean( + translated && + translated.top + translated.height / 2 > + event.over!.rect.top + event.over!.rect.height / 2, + ); + hoverPlacementRef.current = { overId, insertAfterTarget }; + setHoverPlacement((current) => + current?.overId === overId && + current.insertAfterTarget === insertAfterTarget + ? current + : { overId, insertAfterTarget }, + ); + }; + + useEffect(() => { + if (!isSortedReorderActive || !activeId || !hoverPlacement) { + dragPreviewProjectRef.current = null; + setDragPreviewProject(null); + return; + } + + setDragPreviewProject((current) => { + const next = + moveBoardTask( + current ?? project, + activeId.toString(), + hoverPlacement.overId, + false, + true, + hoverPlacement.insertAfterTarget, + )?.project ?? current; + return next; + }); + }, [activeId, hoverPlacement, isSortedReorderActive, project]); + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; + const shouldAllowSortedReorder = isSortedReorderActiveRef.current; + const finalHoverPlacement = hoverPlacementRef.current; + const finalPreviewProject = dragPreviewProjectRef.current; + const visualPlacement = + shouldAllowSortedReorder && finalPreviewProject + ? getVisualTaskPlacement(finalPreviewProject, active.id.toString()) + : null; + const finalPlacement = visualPlacement ?? finalHoverPlacement; + setIsCommandHeld(false); + activeIdRef.current = null; setActiveId(null); - - if (!over || !project?.columns) return; + setOverColumnId(null); + dragPreviewProjectRef.current = null; + hoverPlacementRef.current = null; + setHoverPlacement(null); + setDragPreviewProject(null); + isSortedReorderActiveRef.current = false; + setIsSortedReorderActive(false); const activeId = active.id.toString(); - const overId = over.id.toString(); + const overId = + shouldAllowSortedReorder && finalPlacement + ? finalPlacement.overId + : over?.id === active.id + ? overColumnId + : over?.id.toString(); + + if (!overId || !project?.columns) return; if ( disableDragDrop || @@ -206,12 +347,62 @@ function KanbanBoard({ ); if (!canonical) return; - const moved = moveBoardTask(canonical, activeId, overId, sortedByNumber); + const sourceColumn = canonical.columns.find((column) => + column.tasks.some((task) => task.id === activeId), + ); + const destinationColumn = canonical.columns.find( + (column) => + column.id === overId || column.tasks.some((task) => task.id === overId), + ); + const isCrossColumnMove = + sourceColumn && + destinationColumn && + sourceColumn.id !== destinationColumn.id; + const usesAppendOnlySortedMove = + sortedByNumber || + (!shouldAllowSortedReorder && + (sortedByPriority || Boolean(isCrossColumnMove))); + const moved = moveBoardTask( + canonical, + activeId, + overId, + usesAppendOnlySortedMove, + shouldAllowSortedReorder, + shouldAllowSortedReorder && finalPlacement + ? finalPlacement.insertAfterTarget + : undefined, + ); if (!moved || !moved.tasks.length) return; + const activeTask = canonical.columns + .flatMap((column) => column.tasks) + .find((task) => task.id === activeId); + const priorityTarget = shouldAllowSortedReorder + ? canonical.columns + .flatMap((column) => column.tasks) + .find((task) => task.id === overId) + : null; + const movedProject = + activeTask && priorityTarget + ? produce(moved.project, (draft) => { + const task = draft.columns + .flatMap((column) => column.tasks) + .find((task) => task.id === activeId); + if (task) task.priority = priorityTarget.priority; + }) + : moved.project; + if (activeTask && priorityTarget) { + updatePriority({ + taskId: activeId, + task: { + ...activeTask, + priority: priorityTarget.priority, + }, + }); + } for (const task of moved.tasks) markBoardCacheChanged(queryClient, project.id, task.id); - setProject(moved.project); - queryClient.setQueryData(["tasks", project.id], moved.project); + setProject(movedProject); + queryClient.setQueryData(["tasks", project.id], movedProject); reorder({ projectId: project.id, tasks: moved.tasks, @@ -220,6 +411,37 @@ function KanbanBoard({ }); }; + useEffect(() => { + const stopSorting = () => { + setIsCommandHeld(false); + isSortedReorderActiveRef.current = false; + setIsSortedReorderActive(false); + dragPreviewProjectRef.current = null; + setDragPreviewProject(null); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Meta" && !event.metaKey) return; + if (!activeIdRef.current) return; + setIsCommandHeld(true); + isSortedReorderActiveRef.current = true; + setIsSortedReorderActive(true); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (!event.metaKey) stopSorting(); + }; + const handleBlur = stopSorting; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + window.addEventListener("keyup", handleKeyUp, { capture: true }); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + window.removeEventListener("keyup", handleKeyUp, { capture: true }); + window.removeEventListener("blur", handleBlur); + }; + }, []); + if (!project?.columns) { return (
@@ -262,6 +484,7 @@ function KanbanBoard({ ); } + const displayedProject = dragPreviewProject ?? project; const activeTask = activeId ? project.columns .flatMap((col) => col.tasks) @@ -273,7 +496,21 @@ function KanbanBoard({ sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} + onDragMove={handleDragHover} + onDragOver={handleDragHover} onDragEnd={handleDragEnd} + onDragCancel={() => { + setIsCommandHeld(false); + activeIdRef.current = null; + setActiveId(null); + setOverColumnId(null); + dragPreviewProjectRef.current = null; + hoverPlacementRef.current = null; + setHoverPlacement(null); + setDragPreviewProject(null); + isSortedReorderActiveRef.current = false; + setIsSortedReorderActive(false); + }} >
- {project.columns?.map((column) => ( + {displayedProject.columns?.map((column) => (
+ column.tasks.some((task) => task.id === activeId), + )?.id + } + isPriorityOverlaySuppressed={isCommandHeld} + priorityOverlayColumnId={overColumnId} disableDragDrop={disableDragDrop} disableCollectionActions={disableCollectionActions} /> diff --git a/apps/web/src/components/kanban-board/move-task.test.ts b/apps/web/src/components/kanban-board/move-task.test.ts index 714415367a..40654ce8e7 100644 --- a/apps/web/src/components/kanban-board/move-task.test.ts +++ b/apps/web/src/components/kanban-board/move-task.test.ts @@ -1,7 +1,7 @@ import { applyBoardReorder, rollbackBoardReorder } from "./apply-reorder"; import { describe, expect, it } from "vite-plus/test"; import type { ProjectWithTasks } from "@/types/project"; -import { moveBoardTask } from "./move-task"; +import { getVisualTaskPlacement, moveBoardTask } from "./move-task"; function board() { return { @@ -74,6 +74,65 @@ describe("board moves", () => { { id: "a", position: 1, status: "doing" }, ]); }); + + it("can reorder against a target card when a sorted board override is active", () => { + const moved = moveBoardTask(board(), "a", "c", true, true)!; + + expect(moved.project.columns[1].tasks.map((task) => task.id)).toEqual([ + "c", + "a", + ]); + expect(moved.tasks).toEqual([ + { id: "hidden", position: 0 }, + { id: "b", position: 1 }, + { id: "a", position: 1, status: "doing" }, + ]); + }); + + it("can insert before a cross-column target for a visual drag preview", () => { + const moved = moveBoardTask(board(), "a", "c", false, true, false)!; + + expect(moved.project.columns[1].tasks.map((task) => task.id)).toEqual([ + "a", + "c", + ]); + }); + + it("derives the persisted position from the visual preview neighbors", () => { + const preview = moveBoardTask(board(), "a", "c", false, true, false)!; + expect(getVisualTaskPlacement(preview.project, "a")).toEqual({ + overId: "c", + insertAfterTarget: false, + }); + + const bottomPreview = moveBoardTask(board(), "a", "c", false, true, true)!; + expect(getVisualTaskPlacement(bottomPreview.project, "a")).toEqual({ + overId: "c", + insertAfterTarget: true, + }); + + const middleBoard = board(); + middleBoard.columns[1].tasks.push({ + id: "d", + status: "doing", + position: 1, + } as (typeof middleBoard.columns)[number]["tasks"][number]); + const middlePreview = moveBoardTask( + middleBoard, + "a", + "d", + false, + true, + false, + )!; + expect( + middlePreview.project.columns[1].tasks.map((task) => task.id), + ).toEqual(["c", "a", "d"]); + expect(getVisualTaskPlacement(middlePreview.project, "a")).toEqual({ + overId: "d", + insertAfterTarget: false, + }); + }); }); it("patches a remote reorder without removing hidden cards or replacing concurrent fields", () => { diff --git a/apps/web/src/components/kanban-board/move-task.ts b/apps/web/src/components/kanban-board/move-task.ts index 36ef926c27..c9be324e50 100644 --- a/apps/web/src/components/kanban-board/move-task.ts +++ b/apps/web/src/components/kanban-board/move-task.ts @@ -1,11 +1,32 @@ import { produce } from "immer"; import type { ProjectWithTasks } from "@/types/project"; +export function getVisualTaskPlacement( + project: ProjectWithTasks, + activeId: string, +) { + const column = project.columns.find((column) => + column.tasks.some((task) => task.id === activeId), + ); + if (!column) return null; + + const index = column.tasks.findIndex((task) => task.id === activeId); + const nextTask = column.tasks[index + 1]; + if (nextTask) return { overId: nextTask.id, insertAfterTarget: false }; + + const previousTask = column.tasks[index - 1]; + if (previousTask) return { overId: previousTask.id, insertAfterTarget: true }; + + return { overId: column.id, insertAfterTarget: undefined }; +} + export function moveBoardTask( project: ProjectWithTasks, activeId: string, overId: string, sortedByNumber = false, + allowSortedReorder = false, + insertAfterTarget?: boolean, ) { const source = project.columns.find((column) => column.tasks.some((task) => task.id === activeId), @@ -18,7 +39,7 @@ export function moveBoardTask( !source || !destination || activeId === overId || - (sortedByNumber && source.id === destination.id) + (sortedByNumber && !allowSortedReorder && source.id === destination.id) ) return null; const next = produce(project, (draft) => { @@ -27,7 +48,7 @@ export function moveBoardTask( const sourceIndex = from.tasks.findIndex((task) => task.id === activeId); const [task] = from.tasks.splice(sourceIndex, 1); task.status = to.slug; - if (sortedByNumber) { + if (sortedByNumber && !allowSortedReorder) { task.position = Math.max(-1, ...to.tasks.map((task) => task.position ?? -1)) + 1; to.tasks.push(task); @@ -37,8 +58,13 @@ export function moveBoardTask( overId === to.id ? to.tasks.length : to.tasks.findIndex((task) => task.id === overId); - if (from.id !== to.id || sourceIndex <= index) - index += overId === to.id ? 0 : 1; + if (overId !== to.id) { + if (insertAfterTarget !== undefined) { + index += insertAfterTarget ? 1 : 0; + } else if (from.id !== to.id || sourceIndex <= index) { + index += 1; + } + } to.tasks.splice(index, 0, task); for (const column of new Set([from, to])) column.tasks.forEach((task, position) => { diff --git a/apps/web/src/components/kanban-board/task-card.tsx b/apps/web/src/components/kanban-board/task-card.tsx index 2ec92801d4..38b44680a4 100644 --- a/apps/web/src/components/kanban-board/task-card.tsx +++ b/apps/web/src/components/kanban-board/task-card.tsx @@ -50,10 +50,15 @@ import { TaskLabels } from "./task-labels"; type TaskCardProps = { task: Task; + isDragPreview?: boolean; disableDragDrop?: boolean; }; -function TaskCard({ task, disableDragDrop = false }: TaskCardProps) { +function TaskCard({ + task, + disableDragDrop = false, + isDragPreview = false, +}: TaskCardProps) { const { t } = useTranslation(); const { attributes, @@ -109,7 +114,8 @@ function TaskCard({ task, disableDragDrop = false }: TaskCardProps) { ); const style: CSSProperties = { - transform: CSS.Transform.toString(transform), + // The preview already reorders the DOM; sortable transforms would shift it twice. + transform: isDragPreview ? undefined : CSS.Transform.toString(transform), transition: transition || "transform 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94)", opacity: isDragging ? 0.6 : 1, diff --git a/apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/board.tsx b/apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/board.tsx index c4116ad3a9..bc2a1fb3ed 100644 --- a/apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/board.tsx +++ b/apps/web/src/routes/_layout/_authenticated/dashboard/workspace/$workspaceId/project/$projectId/board.tsx @@ -330,9 +330,12 @@ function RouteComponent() { disableDragDrop={ boardFetching || boardError || - (sort.field !== "position" && sort.field !== "number") + (sort.field !== "position" && + sort.field !== "number" && + sort.field !== "priority") } sortedByNumber={sort.field === "number"} + sortedByPriority={sort.field === "priority"} /> ) : ( Date: Fri, 2 Oct 2026 13:00:11 +0100 Subject: [PATCH 02/13] fix: make cross-column drag sort activator key compatible with windows --- .../components/kanban-board/column/index.tsx | 5 +- .../kanban-board/filtered-drag.test.tsx | 175 ++++++++++-------- .../web/src/components/kanban-board/index.tsx | 41 ++-- i18n/az-AZ.json | 2 +- i18n/de-DE.json | 2 +- i18n/el-GR.json | 2 +- i18n/en-US.json | 2 +- i18n/es-ES.json | 2 +- i18n/fr-FR.json | 2 +- i18n/hi-IN.json | 2 +- i18n/id-ID.json | 2 +- i18n/it-IT.json | 2 +- i18n/ja-JP.json | 2 +- i18n/ko-KR.json | 2 +- i18n/mk-MK.json | 2 +- i18n/nl-NL.json | 2 +- i18n/pl-PL.json | 2 +- i18n/pt-BR.json | 2 +- i18n/ru-RU.json | 2 +- i18n/tr-TR.json | 2 +- i18n/uk-UA.json | 2 +- i18n/vi-VN.json | 2 +- i18n/zh-CN.json | 2 +- 23 files changed, 148 insertions(+), 113 deletions(-) diff --git a/apps/web/src/components/kanban-board/column/index.tsx b/apps/web/src/components/kanban-board/column/index.tsx index 24deeacded..36dbb2ba5b 100644 --- a/apps/web/src/components/kanban-board/column/index.tsx +++ b/apps/web/src/components/kanban-board/column/index.tsx @@ -1,3 +1,4 @@ +import { getModifierKeyText } from "@/hooks/use-keyboard-shortcuts"; import { cva } from "class-variance-authority"; import { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -103,7 +104,9 @@ function Column({ {showPriorityOverlay && (
- {t("tasks:kanban.priorityOrderedOverlayHint")} + {t("tasks:kanban.priorityOrderedOverlayHint", { + key: getModifierKeyText(), + })}
)} diff --git a/apps/web/src/components/kanban-board/filtered-drag.test.tsx b/apps/web/src/components/kanban-board/filtered-drag.test.tsx index 5561d50aaa..29296d826e 100644 --- a/apps/web/src/components/kanban-board/filtered-drag.test.tsx +++ b/apps/web/src/components/kanban-board/filtered-drag.test.tsx @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ setProject: vi.fn(), reorder: vi.fn(), setQueryData: vi.fn(), + modifierKey: "Ctrl", })); vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ @@ -41,6 +42,7 @@ vi.mock("@/store/bulk-selection", () => ({ })); vi.mock("@/hooks/use-keyboard-shortcuts", () => ({ useRegisterShortcuts: vi.fn(), + getModifierKeyText: () => mocks.modifierKey, })); vi.mock("@/hooks/use-project-background", () => ({ useProjectBackground: () => null, @@ -83,8 +85,11 @@ vi.mock("@dnd-kit/core", () => ({ <> {children} - - - - - - - - ), + onDragCancel: () => void; + }) => { + const hover = (overId: string) => () => + onDragOver({ active: { id: "a" }, over: { id: overId } }); + const drop = (overId: string) => () => + onDragEnd({ active: { id: "a" }, over: { id: overId } }); + return ( + <> + {children} + + + + + + + + + ); + }, DragOverlay: () => null, MouseSensor: {}, TouchSensor: {}, @@ -181,10 +117,7 @@ vi.mock("@dnd-kit/core", () => ({ defaultDropAnimationSideEffects: vi.fn(), })); -beforeEach(() => { - vi.clearAllMocks(); - mocks.modifierKey = "Ctrl"; -}); +beforeEach(() => vi.clearAllMocks()); afterEach(cleanup); describe("filtered board dragging", () => { it("moves visible tasks in canonical state and sends one ordering mutation", () => { @@ -297,8 +230,8 @@ it("refreshes personal work after a cross-column status change", () => { }); }); -it("places a cross-column drop at the bottom without Command", () => { - const canonical = { +function crossColumnBoard() { + return { id: "p", columns: [ { @@ -318,257 +251,111 @@ it("places a cross-column drop at the bottom without Command", () => { plannedTasks: [], archivedTasks: [], } as unknown as ProjectWithTasks; - mocks.project = canonical; - const view = render(); +} - fireEvent.click(view.getByText("start")); - fireEvent.click(view.getByText("drop-c")); +function shownIds(view: ReturnType, columnId: string) { + return view.getByTestId(`column-${columnId}`).getAttribute("data-task-ids"); +} - expect( - mocks.setProject.mock.calls[0][0].columns[1].tasks.map( - (task: { id: string }) => task.id, - ), - ).toEqual(["c", "d", "a"]); - expect(mocks.setProject.mock.calls[0][0].columns[1].tasks[2].priority).toBe( - "low", +function savedIds(columnIndex: number) { + return mocks.setProject.mock.calls[0][0].columns[columnIndex].tasks.map( + (task: { id: string }) => task.id, ); -}); - -it("cancels a stale cross-column drop after returning to the active card", () => { - const canonical = { - id: "p", - columns: [ - { - id: "todo", - slug: "todo", - tasks: [{ id: "a", status: "todo", position: 0, priority: "low" }], - }, - { - id: "doing", - slug: "doing", - tasks: [{ id: "c", status: "doing", position: 0, priority: "high" }], - }, - ], - plannedTasks: [], - archivedTasks: [], - } as unknown as ProjectWithTasks; - mocks.project = canonical; - const view = render(); +} - fireEvent.click(view.getByText("start")); - fireEvent.click(view.getByText("over")); - fireEvent.click(view.getByText("over-active")); - fireEvent.click(view.getByText("drop-over")); +describe("cross-column dragging", () => { + it("previews the card in the hovered card's slot and drops it there", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); - expect(mocks.setProject).not.toHaveBeenCalled(); - expect(mocks.reorder).not.toHaveBeenCalled(); -}); + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + expect(shownIds(view, "todo")).toBe(""); + expect(shownIds(view, "doing")).toBe("a,c,d"); -it("keeps modifier-assisted drops append-only on number-sorted boards", () => { - const canonical = { - id: "p", - columns: [ - { - id: "todo", - slug: "todo", - tasks: [{ id: "a", status: "todo", position: 0, priority: "low" }], - }, - { - id: "doing", - slug: "doing", - tasks: [ - { id: "c", status: "doing", position: 0, priority: "high" }, - { id: "d", status: "doing", position: 1, priority: "medium" }, - ], - }, - ], - plannedTasks: [], - archivedTasks: [], - } as unknown as ProjectWithTasks; - mocks.project = canonical; - const view = render( - , - ); + fireEvent.click(view.getByText("drop-on-card")); + expect(savedIds(1)).toEqual(["a", "c", "d"]); + expect(mocks.setProject.mock.calls[0][0].columns[1].tasks[0].priority).toBe( + "low", + ); + expect(mocks.reorder).toHaveBeenCalledOnce(); + expect(mocks.reorder.mock.calls[0][0].tasks).toContainEqual({ + id: "a", + position: 0, + status: "doing", + }); + expect(shownIds(view, "doing")).toBe("c,d"); + }); - fireEvent.click(view.getByText("start"), { ctrlKey: true }); - fireEvent.click(view.getByText("over")); - fireEvent.keyDown(window, { key: "Control", ctrlKey: true }); - fireEvent.click(view.getByText("over-center")); + it("applies a final sortable hover inside the destination column", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-sort-overlay", - "visible", - ); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-automatic-sort-label", - "tasks:sort.fields.number", - ); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-task-ids", - "c,d", - ); + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + fireEvent.click(view.getByText("drop-c")); + expect(savedIds(1)).toEqual(["c", "a", "d"]); + }); - fireEvent.click(view.getByText("drop-c")); + it("leaves the board unchanged when the card returns to its column", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); - expect( - mocks.setProject.mock.calls[0][0].columns[1].tasks.map( - (task: { id: string }) => task.id, - ), - ).toEqual(["c", "d", "a"]); - expect(mocks.reorder).toHaveBeenCalledOnce(); -}); + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + fireEvent.click(view.getByText("over-todo")); + expect(shownIds(view, "todo")).toBe("a"); + expect(shownIds(view, "doing")).toBe("c,d"); -it("keeps modifier-assisted drops append-only on priority-sorted boards", () => { - const canonical = { - id: "p", - columns: [ - { - id: "todo", - slug: "todo", - tasks: [{ id: "a", status: "todo", position: 0, priority: "low" }], - }, - { - id: "doing", - slug: "doing", - tasks: [ - { id: "c", status: "doing", position: 0, priority: "high" }, - { id: "d", status: "doing", position: 1, priority: "medium" }, - ], - }, - ], - plannedTasks: [], - archivedTasks: [], - } as unknown as ProjectWithTasks; - mocks.project = canonical; - const view = render( - , - ); - - fireEvent.click(view.getByText("start"), { ctrlKey: true }); - fireEvent.click(view.getByText("over")); - fireEvent.keyDown(window, { key: "Control", ctrlKey: true }); - fireEvent.click(view.getByText("over-center")); - - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-sort-overlay", - "visible", - ); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-automatic-sort-label", - "tasks:sort.fields.priority", - ); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-task-ids", - "c,d", - ); + fireEvent.click(view.getByText("drop-on-card")); + expect(mocks.setProject).not.toHaveBeenCalled(); + expect(mocks.reorder).not.toHaveBeenCalled(); + }); - fireEvent.click(view.getByText("drop-c")); + it("restores the board when the drag is cancelled", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); - expect( - mocks.setProject.mock.calls[0][0].columns[1].tasks.map( - (task: { id: string }) => task.id, - ), - ).toEqual(["c", "d", "a"]); - expect(mocks.setProject.mock.calls[0][0].columns[1].tasks[2].priority).toBe( - "low", - ); - expect(mocks.reorder).toHaveBeenCalledOnce(); + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + fireEvent.click(view.getByText("cancel")); + expect(shownIds(view, "todo")).toBe("a"); + expect(shownIds(view, "doing")).toBe("c,d"); + expect(mocks.reorder).not.toHaveBeenCalled(); + }); }); describe.each([ - ["⌘", "Meta", "metaKey"], - ["Ctrl", "Control", "ctrlKey"], - ["Ctrl", "Meta", "metaKey"], -])("sorting with %s and %s", (label, key, modifier) => { - it.each(["held", "released", "blurred", "held-at-start"])( - "uses the matching drop behavior when modifier is %s", - (commandState) => { - mocks.modifierKey = label; - const canonical = { - id: "p", - columns: [ - { - id: "todo", - slug: "todo", - tasks: [{ id: "a", status: "todo", position: 0, priority: "low" }], - }, - { - id: "doing", - slug: "doing", - tasks: [ - { id: "c", status: "doing", position: 0, priority: "high" }, - ], - }, - ], - plannedTasks: [], - archivedTasks: [], - } as unknown as ProjectWithTasks; - mocks.project = canonical; - const view = render(); - - fireEvent.click(view.getByText("start"), { - [modifier]: commandState === "held-at-start", - }); - fireEvent.click(view.getByText("over")); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-sort-overlay", - commandState === "held-at-start" ? "hidden" : "visible", - ); - expect(view.getByTestId("column-doing")).not.toHaveAttribute( - "data-automatic-sort-label", - ); + ["sortedByNumber", "tasks:sort.fields.number"], + ["sortedByPriority", "tasks:sort.fields.priority"], +])("%s boards", (sortProp, sortLabel) => { + it("hints at the sort and appends the dropped card", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render( + , + ); - if (commandState !== "held-at-start") - fireEvent.keyDown(window, { key, [modifier]: true }); - fireEvent.keyUp(window, { key: "a", [modifier]: true }); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-sort-overlay", - "hidden", - ); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-task-ids", - "a,c", - ); + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + expect(shownIds(view, "doing")).toBe("c,d"); + expect(view.getByTestId("column-doing")).toHaveAttribute( + "data-sort-hint", + `tasks:kanban.automaticallySortedHint:${sortLabel}`, + ); + expect(view.getByTestId("column-todo")).not.toHaveAttribute( + "data-sort-hint", + ); - if (commandState === "held" || commandState === "held-at-start") { - fireEvent.click(view.getByText("over-center")); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-task-ids", - "c,a", - ); - fireEvent.click(view.getByText("over-active")); - fireEvent.click(view.getByText("drop-over")); - expect( - mocks.setProject.mock.calls[0][0].columns[1].tasks.map( - (task: { id: string }) => task.id, - ), - ).toEqual(["c", "a"]); - return; - } - - if (commandState === "released") { - fireEvent.keyUp(window, { key, [modifier]: false }); - } else { - fireEvent.blur(window); - } - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-sort-overlay", - "visible", - ); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-task-ids", - "c", - ); - - fireEvent.click(view.getByText("over-active")); - expect(view.getByTestId("column-doing")).toHaveAttribute( - "data-task-ids", - "c", - ); - - fireEvent.click(view.getByText("drop-over")); - expect(mocks.setProject).not.toHaveBeenCalled(); - expect(mocks.reorder).not.toHaveBeenCalled(); - }, - ); + fireEvent.click(view.getByText("drop-c")); + expect(savedIds(1)).toEqual(["c", "d", "a"]); + expect(mocks.reorder).toHaveBeenCalledOnce(); + expect(view.getByTestId("column-doing")).not.toHaveAttribute( + "data-sort-hint", + ); + }); }); diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index 4ff7ebcdbe..c02f4f3163 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -5,7 +5,6 @@ import { closestCorners, DndContext, type DragEndEvent, - type DragMoveEvent, DragOverlay, type DragOverEvent, type DragStartEvent, @@ -22,17 +21,14 @@ import { useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useMutation } from "@tanstack/react-query"; import reorderTasks, { type TaskReorder } from "@/fetchers/task/reorder-tasks"; -import updateTaskPriority from "@/fetchers/task/update-task-priority"; import { toast } from "@/lib/toast"; import { useTranslation } from "react-i18next"; import { rollbackBoardReorder } from "./apply-reorder"; -import { getVisualTaskPlacement, moveBoardTask } from "./move-task"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { produce } from "immer"; -import { - getModifierKeyText, - useRegisterShortcuts, -} from "@/hooks/use-keyboard-shortcuts"; +import { getHoveredOtherColumnId } from "./drag-preview"; +import { moveBoardTask } from "./move-task"; +import { useDragPreview } from "./use-drag-preview"; +import { useEffect, useState } from "react"; +import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useProjectBackground } from "@/hooks/use-project-background"; import { cn } from "@/lib/cn"; import { useBackgroundStore } from "@/store/background"; @@ -51,11 +47,6 @@ type KanbanBoardProps = { sortedByPriority?: boolean; }; -type HoverPlacement = { - insertAfterTarget?: boolean; - overId: string; -}; - function KanbanBoard({ project, disableDragDrop = false, @@ -63,7 +54,6 @@ function KanbanBoard({ sortedByNumber = false, sortedByPriority = false, }: KanbanBoardProps) { - const isMac = getModifierKeyText() === "⌘"; const isAutomaticallySorted = sortedByNumber || sortedByPriority; const queryClient = useQueryClient(); const { project: storedProject, setProject } = useProjectStore(); @@ -76,21 +66,8 @@ function KanbanBoard({ const clearFocus = useBulkSelectionStore((state) => state.clearFocus); const [activeIsFinal, setActiveIsFinal] = useState(); const [activeId, setActiveId] = useState(null); - const [overColumnId, setOverColumnId] = useState(null); - const [hoverPlacement, setHoverPlacement] = useState( - null, - ); - const [dragPreviewProject, setDragPreviewProject] = - useState(null); - const [isSortedReorderActive, setIsSortedReorderActive] = useState(false); - const [isSortModifierHeld, setIsSortModifierHeld] = useState(false); - const activeIdRef = useRef(null); - const dragPreviewProjectRef = useRef(null); - const hoverPlacementRef = useRef(null); - const isSortedReorderActiveRef = useRef(false); - useLayoutEffect(() => { - dragPreviewProjectRef.current = dragPreviewProject; - }, [dragPreviewProject]); + const [sortHintColumnId, setSortHintColumnId] = useState(null); + const dragPreview = useDragPreview(project); const { t } = useTranslation(); const { mutate: reorder, isPending: isReordering } = useMutation({ mutationFn: ({ @@ -130,27 +107,6 @@ function KanbanBoard({ void queryClient.invalidateQueries({ queryKey: ["tasks", project.id] }); }, }); - const { mutate: updatePriority } = useMutation({ - mutationFn: ({ - task, - taskId, - }: { - task: Parameters[1]; - taskId: string; - }) => updateTaskPriority(taskId, task), - onSuccess: (_updated, variables) => { - void queryClient.invalidateQueries({ - queryKey: ["task", variables.taskId], - }); - void queryClient.invalidateQueries({ - queryKey: ["activities", variables.taskId], - }); - }, - onError: () => { - toast.error(t("tasks:popover.priority.updateError")); - void queryClient.invalidateQueries({ queryKey: ["tasks", project.id] }); - }, - }); const background = useProjectBackground({ backgroundVersion: project.backgroundVersion, projectId: project.id, @@ -237,134 +193,42 @@ function KanbanBoard({ }; const handleDragStart = (event: DragStartEvent) => { - activeIdRef.current = event.active.id; setActiveId(event.active.id); - setOverColumnId(null); - dragPreviewProjectRef.current = null; - hoverPlacementRef.current = null; - setHoverPlacement(null); - setDragPreviewProject(null); - const activatorEvent = event.activatorEvent as - | (Event & { metaKey?: boolean; ctrlKey?: boolean }) - | undefined; - const modifierHeld = - !isAutomaticallySorted && - Boolean(activatorEvent?.metaKey || (!isMac && activatorEvent?.ctrlKey)); - setIsSortModifierHeld(modifierHeld); - isSortedReorderActiveRef.current = modifierHeld; - setIsSortedReorderActive(modifierHeld); const isFinal = event.active.data?.current?.isFinalColumn; setActiveIsFinal(typeof isFinal === "boolean" ? isFinal : undefined); }; - const getColumnIdForOver = (overId: string) => { - return ( - project.columns.find( - (column) => - column.id === overId || - column.tasks.some((task) => task.id === overId), - )?.id ?? null - ); + const resetDrag = () => { + setActiveId(null); + setSortHintColumnId(null); + dragPreview.clear(); }; - const handleDragHover = (event: DragOverEvent | DragMoveEvent) => { - const overId = event.over?.id.toString(); - if (overId === activeIdRef.current?.toString()) { - if (!isSortedReorderActiveRef.current) { - setOverColumnId(null); - hoverPlacementRef.current = null; - setHoverPlacement(null); - } + const handleDragOver = ({ active, over }: DragOverEvent) => { + if (!isAutomaticallySorted) { + if (over) dragPreview.hover(active, over); return; } - setOverColumnId(overId ? getColumnIdForOver(overId) : null); - if (!overId) { - hoverPlacementRef.current = null; - setHoverPlacement(null); - return; - } - - const translated = event.active.rect.current.translated; - const insertAfterTarget = - overId === getColumnIdForOver(overId) - ? undefined - : Boolean( - translated && - translated.top + translated.height / 2 > - event.over!.rect.top + event.over!.rect.height / 2, - ); - hoverPlacementRef.current = { overId, insertAfterTarget }; - setHoverPlacement((current) => - current?.overId === overId && - current.insertAfterTarget === insertAfterTarget - ? current - : { overId, insertAfterTarget }, + setSortHintColumnId( + over + ? getHoveredOtherColumnId( + project, + active.id.toString(), + over.id.toString(), + ) + : null, ); }; - useEffect(() => { - if ( - isAutomaticallySorted || - !isSortedReorderActive || - !activeId || - !hoverPlacement - ) { - dragPreviewProjectRef.current = null; - setDragPreviewProject(null); - return; - } - - setDragPreviewProject((current) => { - const next = - moveBoardTask( - current ?? project, - activeId.toString(), - hoverPlacement.overId, - false, - true, - hoverPlacement.insertAfterTarget, - )?.project ?? current; - return next; - }); - }, [ - activeId, - hoverPlacement, - isSortedReorderActive, - project, - isAutomaticallySorted, - ]); - const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - const shouldAllowSortedReorder = - !isAutomaticallySorted && isSortedReorderActiveRef.current; - const finalHoverPlacement = hoverPlacementRef.current; - const finalPreviewProject = dragPreviewProjectRef.current; - const visualPlacement = - shouldAllowSortedReorder && finalPreviewProject - ? getVisualTaskPlacement(finalPreviewProject, active.id.toString()) - : null; - const finalPlacement = visualPlacement ?? finalHoverPlacement; - setIsSortModifierHeld(false); - activeIdRef.current = null; - setActiveId(null); - setOverColumnId(null); - dragPreviewProjectRef.current = null; - hoverPlacementRef.current = null; - setHoverPlacement(null); - setDragPreviewProject(null); - isSortedReorderActiveRef.current = false; - setIsSortedReorderActive(false); + const placement = over ? dragPreview.getDropPlacement(active, over) : null; + resetDrag(); - const activeId = active.id.toString(); - const overId = - shouldAllowSortedReorder && finalPlacement - ? finalPlacement.overId - : over?.id === active.id - ? overColumnId - : over?.id.toString(); + if (!over || !project?.columns) return; - if (!overId || !project?.columns) return; + const activeId = active.id.toString(); + const overId = over.id.toString(); if ( disableDragDrop || @@ -381,62 +245,20 @@ function KanbanBoard({ ); if (!canonical) return; - const sourceColumn = canonical.columns.find((column) => - column.tasks.some((task) => task.id === activeId), - ); - const destinationColumn = canonical.columns.find( - (column) => - column.id === overId || column.tasks.some((task) => task.id === overId), - ); - const isCrossColumnMove = - sourceColumn && - destinationColumn && - sourceColumn.id !== destinationColumn.id; - const usesAppendOnlySortedMove = - sortedByNumber || - (!shouldAllowSortedReorder && - (sortedByPriority || Boolean(isCrossColumnMove))); - const moved = moveBoardTask( - canonical, - activeId, - overId, - usesAppendOnlySortedMove, - shouldAllowSortedReorder, - shouldAllowSortedReorder && finalPlacement - ? finalPlacement.insertAfterTarget - : undefined, - ); + const moved = placement + ? moveBoardTask( + canonical, + activeId, + placement.overId, + false, + placement.insertAfterTarget, + ) + : moveBoardTask(canonical, activeId, overId, isAutomaticallySorted); if (!moved || !moved.tasks.length) return; - const activeTask = canonical.columns - .flatMap((column) => column.tasks) - .find((task) => task.id === activeId); - const priorityTarget = shouldAllowSortedReorder - ? canonical.columns - .flatMap((column) => column.tasks) - .find((task) => task.id === overId) - : null; - const movedProject = - activeTask && priorityTarget - ? produce(moved.project, (draft) => { - const task = draft.columns - .flatMap((column) => column.tasks) - .find((task) => task.id === activeId); - if (task) task.priority = priorityTarget.priority; - }) - : moved.project; - if (activeTask && priorityTarget) { - updatePriority({ - taskId: activeId, - task: { - ...activeTask, - priority: priorityTarget.priority, - }, - }); - } for (const task of moved.tasks) markBoardCacheChanged(queryClient, project.id, task.id); - setProject(movedProject); - queryClient.setQueryData(["tasks", project.id], movedProject); + setProject(moved.project); + queryClient.setQueryData(["tasks", project.id], moved.project); reorder({ projectId: project.id, tasks: moved.tasks, @@ -445,43 +267,6 @@ function KanbanBoard({ }); }; - useEffect(() => { - const stopSorting = () => { - setIsSortModifierHeld(false); - isSortedReorderActiveRef.current = false; - setIsSortedReorderActive(false); - dragPreviewProjectRef.current = null; - setDragPreviewProject(null); - }; - const handleKeyDown = (event: KeyboardEvent) => { - if (isAutomaticallySorted) return; - if ( - event.key !== "Meta" && - !event.metaKey && - (isMac || (event.key !== "Control" && !event.ctrlKey)) - ) - return; - if (!activeIdRef.current) return; - setIsSortModifierHeld(true); - isSortedReorderActiveRef.current = true; - setIsSortedReorderActive(true); - }; - const handleKeyUp = (event: KeyboardEvent) => { - if (!event.metaKey && (isMac || !event.ctrlKey)) stopSorting(); - }; - const handleBlur = stopSorting; - - window.addEventListener("keydown", handleKeyDown, { capture: true }); - window.addEventListener("keyup", handleKeyUp, { capture: true }); - window.addEventListener("blur", handleBlur); - - return () => { - window.removeEventListener("keydown", handleKeyDown, { capture: true }); - window.removeEventListener("keyup", handleKeyUp, { capture: true }); - window.removeEventListener("blur", handleBlur); - }; - }, [isAutomaticallySorted, isMac]); - if (!project?.columns) { return (
@@ -524,7 +309,6 @@ function KanbanBoard({ ); } - const displayedProject = dragPreviewProject ?? project; const activeTask = activeId ? project.columns .flatMap((col) => col.tasks) @@ -536,21 +320,9 @@ function KanbanBoard({ sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} - onDragMove={handleDragHover} - onDragOver={handleDragHover} + onDragOver={handleDragOver} onDragEnd={handleDragEnd} - onDragCancel={() => { - setIsSortModifierHeld(false); - activeIdRef.current = null; - setActiveId(null); - setOverColumnId(null); - dragPreviewProjectRef.current = null; - hoverPlacementRef.current = null; - setHoverPlacement(null); - setDragPreviewProject(null); - isSortedReorderActiveRef.current = false; - setIsSortedReorderActive(false); - }} + onDragCancel={resetDrag} >
- {displayedProject.columns?.map((column) => ( + {(dragPreview.preview ?? project).columns.map((column) => (
- column.tasks.some((task) => task.id === activeId), - )?.id - } - automaticSortLabel={ - sortedByNumber - ? t("tasks:sort.fields.number") - : sortedByPriority - ? t("tasks:sort.fields.priority") - : undefined + sortHint={ + column.id === sortHintColumnId + ? t("tasks:kanban.automaticallySortedHint", { + sort: t( + sortedByNumber + ? "tasks:sort.fields.number" + : "tasks:sort.fields.priority", + ), + }) + : undefined } - isSortOverlaySuppressed={isSortModifierHeld} - sortOverlayColumnId={overColumnId} disableDragDrop={disableDragDrop} disableCollectionActions={disableCollectionActions} /> diff --git a/apps/web/src/components/kanban-board/move-task.test.ts b/apps/web/src/components/kanban-board/move-task.test.ts index 67400e875b..656fe606c0 100644 --- a/apps/web/src/components/kanban-board/move-task.test.ts +++ b/apps/web/src/components/kanban-board/move-task.test.ts @@ -75,25 +75,8 @@ describe("board moves", () => { ]); }); - it("keeps number-sorted moves append-only when a sorted override is active", () => { - const original = board(); - original.columns[1].tasks.push({ - id: "d", - status: "doing", - position: 1, - } as (typeof original.columns)[number]["tasks"][number]); - const moved = moveBoardTask(original, "a", "c", true, true)!; - - expect(moved.project.columns[1].tasks.map((task) => task.id)).toEqual([ - "c", - "d", - "a", - ]); - expect(moved.tasks).toEqual([{ id: "a", position: 2, status: "doing" }]); - }); - it("can insert before a cross-column target for a visual drag preview", () => { - const moved = moveBoardTask(board(), "a", "c", false, true, false)!; + const moved = moveBoardTask(board(), "a", "c", false, false)!; expect(moved.project.columns[1].tasks.map((task) => task.id)).toEqual([ "a", @@ -102,13 +85,13 @@ describe("board moves", () => { }); it("derives the persisted position from the visual preview neighbors", () => { - const preview = moveBoardTask(board(), "a", "c", false, true, false)!; + const preview = moveBoardTask(board(), "a", "c", false, false)!; expect(getVisualTaskPlacement(preview.project, "a")).toEqual({ overId: "c", insertAfterTarget: false, }); - const bottomPreview = moveBoardTask(board(), "a", "c", false, true, true)!; + const bottomPreview = moveBoardTask(board(), "a", "c", false, true)!; expect(getVisualTaskPlacement(bottomPreview.project, "a")).toEqual({ overId: "c", insertAfterTarget: true, @@ -120,14 +103,7 @@ describe("board moves", () => { status: "doing", position: 1, } as (typeof middleBoard.columns)[number]["tasks"][number]); - const middlePreview = moveBoardTask( - middleBoard, - "a", - "d", - false, - true, - false, - )!; + const middlePreview = moveBoardTask(middleBoard, "a", "d", false, false)!; expect( middlePreview.project.columns[1].tasks.map((task) => task.id), ).toEqual(["c", "a", "d"]); diff --git a/apps/web/src/components/kanban-board/move-task.ts b/apps/web/src/components/kanban-board/move-task.ts index 516e717814..ad69e741c5 100644 --- a/apps/web/src/components/kanban-board/move-task.ts +++ b/apps/web/src/components/kanban-board/move-task.ts @@ -24,8 +24,7 @@ export function moveBoardTask( project: ProjectWithTasks, activeId: string, overId: string, - sortedByNumber = false, - _allowSortedReorder = false, + appendOnly = false, insertAfterTarget?: boolean, ) { const source = project.columns.find((column) => @@ -39,7 +38,7 @@ export function moveBoardTask( !source || !destination || activeId === overId || - (sortedByNumber && source.id === destination.id) + (appendOnly && source.id === destination.id) ) return null; const next = produce(project, (draft) => { @@ -48,7 +47,7 @@ export function moveBoardTask( const sourceIndex = from.tasks.findIndex((task) => task.id === activeId); const [task] = from.tasks.splice(sourceIndex, 1); task.status = to.slug; - if (sortedByNumber) { + if (appendOnly) { task.position = Math.max(-1, ...to.tasks.map((task) => task.position ?? -1)) + 1; to.tasks.push(task); diff --git a/apps/web/src/components/kanban-board/task-card.tsx b/apps/web/src/components/kanban-board/task-card.tsx index 1a25512266..2e9151dff0 100644 --- a/apps/web/src/components/kanban-board/task-card.tsx +++ b/apps/web/src/components/kanban-board/task-card.tsx @@ -59,7 +59,6 @@ const dueDateTextColors: Record = { type TaskCardProps = { task: Task; - isDragPreview?: boolean; disableDragDrop?: boolean; isFinalColumn?: boolean; }; @@ -67,7 +66,6 @@ type TaskCardProps = { function TaskCard({ task, disableDragDrop = false, - isDragPreview = false, isFinalColumn, }: TaskCardProps) { const { t } = useTranslation(); @@ -143,8 +141,7 @@ function TaskCard({ ); const style: CSSProperties = { - // The preview already reorders the DOM; sortable transforms would shift it twice. - transform: isDragPreview ? undefined : CSS.Transform.toString(transform), + transform: CSS.Transform.toString(transform), transition: transition || "transform 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94)", opacity: isDragging ? 0.6 : 1, diff --git a/apps/web/src/components/kanban-board/use-drag-preview.ts b/apps/web/src/components/kanban-board/use-drag-preview.ts new file mode 100644 index 0000000000..bd85e0466a --- /dev/null +++ b/apps/web/src/components/kanban-board/use-drag-preview.ts @@ -0,0 +1,32 @@ +import type { Active, Over } from "@dnd-kit/core"; +import { useRef, useState } from "react"; +import type { ProjectWithTasks } from "@/types/project"; +import { getPreviewDropPlacement, moveIntoHoveredColumn } from "./drag-preview"; + +export function useDragPreview(project: ProjectWithTasks) { + const [preview, setPreview] = useState(null); + // dnd-kit can fire dragOver and dragEnd before React re-renders. + const previewRef = useRef(null); + + const update = (next: ProjectWithTasks | null) => { + previewRef.current = next; + setPreview(next); + }; + + return { + preview, + hover: (active: Active, over: Over) => { + const next = moveIntoHoveredColumn( + previewRef.current ?? project, + active, + over, + ); + if (next) update(next); + }, + getDropPlacement: (active: Active, over: Over) => + previewRef.current + ? getPreviewDropPlacement(previewRef.current, active, over) + : null, + clear: () => update(null), + }; +} diff --git a/i18n/az-AZ.json b/i18n/az-AZ.json index 9c5114c63e..0c68ba1805 100644 --- a/i18n/az-AZ.json +++ b/i18n/az-AZ.json @@ -2610,8 +2610,7 @@ }, "kanban": { "addTask": "Tapşırıq əlavə et", - "automaticallySortedOverlayHint": "Avtomatik olaraq {{sort}} üzrə sıralanıb", - "priorityOrderedOverlayHint": "Kartları sıralamaq üçün {{key}} düyməsini basılı saxlayın" + "automaticallySortedHint": "Avtomatik olaraq {{sort}} üzrə sıralanıb" }, "pr": { "merged": "Birləşdirilib", diff --git a/i18n/de-DE.json b/i18n/de-DE.json index 9665bf4564..0c1f110e79 100644 --- a/i18n/de-DE.json +++ b/i18n/de-DE.json @@ -2602,8 +2602,7 @@ }, "kanban": { "addTask": "Aufgabe hinzufügen", - "automaticallySortedOverlayHint": "Automatisch nach {{sort}} sortiert", - "priorityOrderedOverlayHint": "Halte {{key}} gedrückt, um Karten zu sortieren" + "automaticallySortedHint": "Automatisch nach {{sort}} sortiert" }, "pr": { "merged": "Zusammengeführt", diff --git a/i18n/el-GR.json b/i18n/el-GR.json index ee4fe15edf..a5e364d09d 100644 --- a/i18n/el-GR.json +++ b/i18n/el-GR.json @@ -2602,8 +2602,7 @@ }, "kanban": { "addTask": "Προσθήκη εργασίας", - "automaticallySortedOverlayHint": "Αυτόματη ταξινόμηση κατά {{sort}}", - "priorityOrderedOverlayHint": "Κρατήστε πατημένο το {{key}} για να ταξινομήσετε τις κάρτες" + "automaticallySortedHint": "Αυτόματη ταξινόμηση κατά {{sort}}" }, "pr": { "merged": "Συγχωνευμένο", diff --git a/i18n/en-US.json b/i18n/en-US.json index e1331cf0e0..279af3ab0c 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -2616,8 +2616,7 @@ }, "kanban": { "addTask": "Add task", - "automaticallySortedOverlayHint": "Automatically sorted by {{sort}}", - "priorityOrderedOverlayHint": "Hold {{key}} to sort cards" + "automaticallySortedHint": "Automatically sorted by {{sort}}" }, "pr": { "merged": "Merged", diff --git a/i18n/es-ES.json b/i18n/es-ES.json index fd93cadb5a..cf38a5d177 100644 --- a/i18n/es-ES.json +++ b/i18n/es-ES.json @@ -2603,8 +2603,7 @@ }, "kanban": { "addTask": "Añadir tarea", - "automaticallySortedOverlayHint": "Ordenado automáticamente por {{sort}}", - "priorityOrderedOverlayHint": "Mantén pulsada {{key}} para ordenar las tarjetas" + "automaticallySortedHint": "Ordenado automáticamente por {{sort}}" }, "pr": { "merged": "Mergeada", diff --git a/i18n/fr-FR.json b/i18n/fr-FR.json index 6c291d22dd..0b68beb097 100644 --- a/i18n/fr-FR.json +++ b/i18n/fr-FR.json @@ -2614,8 +2614,7 @@ }, "kanban": { "addTask": "Ajouter une tâche", - "automaticallySortedOverlayHint": "Tri automatique par {{sort}}", - "priorityOrderedOverlayHint": "Maintenez {{key}} enfoncée pour trier les cartes" + "automaticallySortedHint": "Tri automatique par {{sort}}" }, "pr": { "merged": "Fusionné", diff --git a/i18n/hi-IN.json b/i18n/hi-IN.json index 3b584b510f..97da78c891 100644 --- a/i18n/hi-IN.json +++ b/i18n/hi-IN.json @@ -2602,8 +2602,7 @@ }, "kanban": { "addTask": "कार्य जोड़ें", - "automaticallySortedOverlayHint": "{{sort}} के अनुसार स्वचालित रूप से क्रमबद्ध", - "priorityOrderedOverlayHint": "कार्डों को क्रम में लगाने के लिए {{key}} दबाए रखें" + "automaticallySortedHint": "{{sort}} के अनुसार स्वचालित रूप से क्रमबद्ध" }, "pr": { "merged": "मर्ज किया", diff --git a/i18n/id-ID.json b/i18n/id-ID.json index d651154ff8..198f442b95 100644 --- a/i18n/id-ID.json +++ b/i18n/id-ID.json @@ -2590,8 +2590,7 @@ }, "kanban": { "addTask": "Tambah tugas", - "automaticallySortedOverlayHint": "Diurutkan otomatis berdasarkan {{sort}}", - "priorityOrderedOverlayHint": "Tahan {{key}} untuk mengurutkan kartu" + "automaticallySortedHint": "Diurutkan otomatis berdasarkan {{sort}}" }, "pr": { "merged": "Digabung", diff --git a/i18n/it-IT.json b/i18n/it-IT.json index 4445298072..4e420335d9 100644 --- a/i18n/it-IT.json +++ b/i18n/it-IT.json @@ -2614,8 +2614,7 @@ }, "kanban": { "addTask": "Aggiungi attività", - "automaticallySortedOverlayHint": "Ordinamento automatico per {{sort}}", - "priorityOrderedOverlayHint": "Tieni premuto {{key}} per ordinare le schede" + "automaticallySortedHint": "Ordinamento automatico per {{sort}}" }, "pr": { "merged": "Unito", diff --git a/i18n/ja-JP.json b/i18n/ja-JP.json index dd612a7a8b..054a02e465 100644 --- a/i18n/ja-JP.json +++ b/i18n/ja-JP.json @@ -2598,8 +2598,7 @@ }, "kanban": { "addTask": "タスクを追加", - "automaticallySortedOverlayHint": "{{sort}}で自動的に並べ替え", - "priorityOrderedOverlayHint": "{{key}}を押しながらカードを並べ替え" + "automaticallySortedHint": "{{sort}}で自動的に並べ替え" }, "pr": { "merged": "マージ済み", diff --git a/i18n/ko-KR.json b/i18n/ko-KR.json index b06bccb22a..d34c868226 100644 --- a/i18n/ko-KR.json +++ b/i18n/ko-KR.json @@ -2603,8 +2603,7 @@ }, "kanban": { "addTask": "작업 추가", - "automaticallySortedOverlayHint": "{{sort}} 기준으로 자동 정렬됨", - "priorityOrderedOverlayHint": "{{key}} 키를 누른 채로 카드를 정렬하세요" + "automaticallySortedHint": "{{sort}} 기준으로 자동 정렬됨" }, "pr": { "merged": "병합됨", diff --git a/i18n/mk-MK.json b/i18n/mk-MK.json index 4483e08ec5..0a36c83fdf 100644 --- a/i18n/mk-MK.json +++ b/i18n/mk-MK.json @@ -2602,8 +2602,7 @@ }, "kanban": { "addTask": "Додади задача", - "automaticallySortedOverlayHint": "Автоматски подредено според {{sort}}", - "priorityOrderedOverlayHint": "Држете го притиснато {{key}} за да ги подредите картичките" + "automaticallySortedHint": "Автоматски подредено според {{sort}}" }, "pr": { "merged": "Споен", diff --git a/i18n/nl-NL.json b/i18n/nl-NL.json index b7a597d0b9..0d6a23fa55 100644 --- a/i18n/nl-NL.json +++ b/i18n/nl-NL.json @@ -2591,8 +2591,7 @@ }, "kanban": { "addTask": "Taak toevoegen", - "automaticallySortedOverlayHint": "Automatisch gesorteerd op {{sort}}", - "priorityOrderedOverlayHint": "Houd {{key}} ingedrukt om kaarten te sorteren" + "automaticallySortedHint": "Automatisch gesorteerd op {{sort}}" }, "pr": { "merged": "Samengevoegd", diff --git a/i18n/pl-PL.json b/i18n/pl-PL.json index 6c6f9e0a4e..ae4a17ed5e 100644 --- a/i18n/pl-PL.json +++ b/i18n/pl-PL.json @@ -2803,8 +2803,7 @@ }, "kanban": { "addTask": "Dodaj zadanie", - "automaticallySortedOverlayHint": "Automatyczne sortowanie: {{sort}}", - "priorityOrderedOverlayHint": "Przytrzymaj {{key}}, aby uporządkować karty" + "automaticallySortedHint": "Automatyczne sortowanie: {{sort}}" }, "labels": { "empty": "Brak dostępnych etykiet", diff --git a/i18n/pt-BR.json b/i18n/pt-BR.json index ebc305172d..f0b73cfd62 100644 --- a/i18n/pt-BR.json +++ b/i18n/pt-BR.json @@ -2614,8 +2614,7 @@ }, "kanban": { "addTask": "Adicionar tarefa", - "automaticallySortedOverlayHint": "Ordenado automaticamente por {{sort}}", - "priorityOrderedOverlayHint": "Mantenha {{key}} pressionado para ordenar os cartões" + "automaticallySortedHint": "Ordenado automaticamente por {{sort}}" }, "pr": { "merged": "Mesclado", diff --git a/i18n/ru-RU.json b/i18n/ru-RU.json index 14744d6032..b447f650d5 100644 --- a/i18n/ru-RU.json +++ b/i18n/ru-RU.json @@ -2646,8 +2646,7 @@ }, "kanban": { "addTask": "Добавить задачу", - "automaticallySortedOverlayHint": "Автоматическая сортировка: {{sort}}", - "priorityOrderedOverlayHint": "Удерживайте {{key}}, чтобы изменить порядок карточек" + "automaticallySortedHint": "Автоматическая сортировка: {{sort}}" }, "pr": { "merged": "Влит", diff --git a/i18n/tr-TR.json b/i18n/tr-TR.json index e1980a2022..0e13dac006 100644 --- a/i18n/tr-TR.json +++ b/i18n/tr-TR.json @@ -2602,8 +2602,7 @@ }, "kanban": { "addTask": "Talep ekle", - "automaticallySortedOverlayHint": "{{sort}} ölçütüne göre otomatik olarak sıralandı", - "priorityOrderedOverlayHint": "Kartları sıralamak için {{key}} tuşunu basılı tutun" + "automaticallySortedHint": "{{sort}} ölçütüne göre otomatik olarak sıralandı" }, "pr": { "merged": "Birleştirildi", diff --git a/i18n/uk-UA.json b/i18n/uk-UA.json index 6012f31d88..539f03b773 100644 --- a/i18n/uk-UA.json +++ b/i18n/uk-UA.json @@ -2626,8 +2626,7 @@ }, "kanban": { "addTask": "Додати завдання", - "automaticallySortedOverlayHint": "Автоматично відсортовано за полем «{{sort}}»", - "priorityOrderedOverlayHint": "Утримуйте {{key}}, щоб змінити порядок карток" + "automaticallySortedHint": "Автоматично відсортовано за полем «{{sort}}»" }, "pr": { "merged": "Змерджено", diff --git a/i18n/vi-VN.json b/i18n/vi-VN.json index 269aaaa830..13f6e4c95c 100644 --- a/i18n/vi-VN.json +++ b/i18n/vi-VN.json @@ -2590,8 +2590,7 @@ }, "kanban": { "addTask": "Thêm công việc", - "automaticallySortedOverlayHint": "Tự động sắp xếp theo {{sort}}", - "priorityOrderedOverlayHint": "Giữ phím {{key}} để sắp xếp thẻ" + "automaticallySortedHint": "Tự động sắp xếp theo {{sort}}" }, "pr": { "merged": "Đã gộp", diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index 1b19008f9f..b1a068db24 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -2603,8 +2603,7 @@ }, "kanban": { "addTask": "添加任务", - "automaticallySortedOverlayHint": "按{{sort}}自动排序", - "priorityOrderedOverlayHint": "按住 {{key}} 键以调整卡片顺序" + "automaticallySortedHint": "按{{sort}}自动排序" }, "pr": { "merged": "已合并", diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index 78ba2a3760..dc3c8748cf 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -2603,8 +2603,7 @@ }, "kanban": { "addTask": "新增任務", - "automaticallySortedOverlayHint": "依{{sort}}自動排序", - "priorityOrderedOverlayHint": "按住 {{key}} 鍵以調整卡片順序" + "automaticallySortedHint": "依{{sort}}自動排序" }, "pr": { "merged": "已合併", From c3ffa5e9bd294fcc263a499fe411adc5b27d3c05 Mon Sep 17 00:00:00 2001 From: Andrej Acevski Date: Mon, 5 Oct 2026 20:13:09 +0200 Subject: [PATCH 06/13] fix(board): keep the dragged card from animating between columns While previewing a cross-column move, the dragged card left a fading copy behind in the column it moved out of, which overlapped the cards around it. The dragged card now skips its enter and exit animations. --- .../kanban-board/column/column-dropzone.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/kanban-board/column/column-dropzone.tsx b/apps/web/src/components/kanban-board/column/column-dropzone.tsx index be601b5d37..c8b43949da 100644 --- a/apps/web/src/components/kanban-board/column/column-dropzone.tsx +++ b/apps/web/src/components/kanban-board/column/column-dropzone.tsx @@ -1,4 +1,4 @@ -import { useDroppable } from "@dnd-kit/core"; +import { useDndContext, useDroppable } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, @@ -32,6 +32,8 @@ export function ColumnDropzone({ }, [isOver, onIsOverChange]); const reduceMotion = useReducedMotion(); + const { active } = useDndContext(); + const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }; return (
@@ -44,15 +46,13 @@ export function ColumnDropzone({ {column.tasks.map((task) => ( Date: Mon, 5 Oct 2026 20:38:44 +0200 Subject: [PATCH 07/13] fix(board): keep live updates during drags and fill empty drop zones Address review feedback on the cross-column preview: - Rebuild the preview from the current board and the last cross-column hover, so filters and live updates during a drag stay visible and the drop is placed against fresh data - Let each column's drop zone fill its body, so an empty column still wins collisions against cards in neighbouring columns - Split the drag preview into single-purpose modules with tests beside them, and drop the inline comments --- .../kanban-board/column/column-dropzone.tsx | 9 ++-- .../components/kanban-board/column/index.tsx | 7 +-- .../components/kanban-board/drag-preview.ts | 51 ------------------- .../kanban-board/drag-preview/drag-hover.ts | 1 + .../drag-preview/find-task-column.ts | 7 +++ .../get-hovered-other-column-id.test.ts | 16 ++++++ .../get-hovered-other-column-id.ts | 12 +++++ .../get-preview-drop-placement.test.ts | 29 +++++++++++ .../get-preview-drop-placement.ts | 17 +++++++ .../get-visual-task-placement.test.ts | 32 ++++++++++++ .../drag-preview/get-visual-task-placement.ts | 19 +++++++ .../move-into-hovered-column.test.ts | 23 +++++++++ .../drag-preview/move-into-hovered-column.ts | 12 +++++ .../kanban-board/drag-preview/test-board.ts | 34 +++++++++++++ .../drag-preview/use-drag-preview.ts | 44 ++++++++++++++++ .../kanban-board/filtered-drag.test.tsx | 22 ++++++++ .../web/src/components/kanban-board/index.tsx | 4 +- .../components/kanban-board/move-task.test.ts | 31 +---------- .../src/components/kanban-board/move-task.ts | 19 ------- .../kanban-board/use-drag-preview.ts | 32 ------------ 20 files changed, 280 insertions(+), 141 deletions(-) delete mode 100644 apps/web/src/components/kanban-board/drag-preview.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/drag-hover.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/find-task-column.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/get-hovered-other-column-id.test.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/get-hovered-other-column-id.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/get-preview-drop-placement.test.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/get-preview-drop-placement.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/get-visual-task-placement.test.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/get-visual-task-placement.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/move-into-hovered-column.test.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/move-into-hovered-column.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/test-board.ts create mode 100644 apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts delete mode 100644 apps/web/src/components/kanban-board/use-drag-preview.ts diff --git a/apps/web/src/components/kanban-board/column/column-dropzone.tsx b/apps/web/src/components/kanban-board/column/column-dropzone.tsx index c8b43949da..405ef89573 100644 --- a/apps/web/src/components/kanban-board/column/column-dropzone.tsx +++ b/apps/web/src/components/kanban-board/column/column-dropzone.tsx @@ -4,7 +4,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; -import { useEffect } from "react"; +import { type ReactNode, useEffect } from "react"; import type { ProjectWithTasks } from "@/types/project"; import TaskCard from "../task-card"; @@ -12,12 +12,14 @@ type ColumnDropzoneProps = { column: ProjectWithTasks["columns"][number]; disableDragDrop?: boolean; onIsOverChange?: (isOver: boolean) => void; + children?: ReactNode; }; export function ColumnDropzone({ column, disableDragDrop = false, onIsOverChange, + children, }: ColumnDropzoneProps) { const { setNodeRef, isOver } = useDroppable({ id: column.id, @@ -36,7 +38,7 @@ export function ColumnDropzone({ const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }; return ( -
+
(
+ {children}
); } diff --git a/apps/web/src/components/kanban-board/column/index.tsx b/apps/web/src/components/kanban-board/column/index.tsx index ecef154e96..b253dbe100 100644 --- a/apps/web/src/components/kanban-board/column/index.tsx +++ b/apps/web/src/components/kanban-board/column/index.tsx @@ -83,9 +83,10 @@ function Column({ column={column} disableDragDrop={disableDragDrop} onIsOverChange={setIsDropzoneOver} - /> - {/* New work starts in an open column; finished ones only collect. */} - {!column.isFinal && } + > + {/* New work starts in an open column; finished ones only collect. */} + {!column.isFinal && } +
{sortHint && }
diff --git a/apps/web/src/components/kanban-board/drag-preview.ts b/apps/web/src/components/kanban-board/drag-preview.ts deleted file mode 100644 index 4a17d66ffa..0000000000 --- a/apps/web/src/components/kanban-board/drag-preview.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { Active, Over } from "@dnd-kit/core"; -import type { ProjectWithTasks } from "@/types/project"; -import { getVisualTaskPlacement, moveBoardTask } from "./move-task"; - -type DragItem = Pick; - -function findColumn(board: ProjectWithTasks, id: string) { - return board.columns.find( - (column) => column.id === id || column.tasks.some((task) => task.id === id), - ); -} - -export function getHoveredOtherColumnId( - board: ProjectWithTasks, - activeId: string, - overId: string, -) { - const from = findColumn(board, activeId); - const to = findColumn(board, overId); - return from && to && from.id !== to.id ? to.id : null; -} - -// The card takes the hovered card's slot. Sortable transforms then move it -// above or below as the pointer travels, the same as within one column. -export function moveIntoHoveredColumn( - board: ProjectWithTasks, - active: DragItem, - over: DragItem, -) { - const activeId = active.id.toString(); - const overId = over.id.toString(); - if (!getHoveredOtherColumnId(board, activeId, overId)) return null; - return moveBoardTask(board, activeId, overId, false, false)?.project ?? null; -} - -// Applies the last sortable hover, which only exists as transforms, so the -// drop lands exactly where the card was shown. -export function getPreviewDropPlacement( - board: ProjectWithTasks, - active: DragItem, - over: DragItem, -) { - const activeId = active.id.toString(); - const overId = over.id.toString(); - const settled = - moveIntoHoveredColumn(board, active, over) ?? - (overId === activeId - ? board - : (moveBoardTask(board, activeId, overId)?.project ?? board)); - return getVisualTaskPlacement(settled, activeId); -} diff --git a/apps/web/src/components/kanban-board/drag-preview/drag-hover.ts b/apps/web/src/components/kanban-board/drag-preview/drag-hover.ts new file mode 100644 index 0000000000..0cdb19ccd1 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/drag-hover.ts @@ -0,0 +1 @@ +export type DragHover = { activeId: string; overId: string }; diff --git a/apps/web/src/components/kanban-board/drag-preview/find-task-column.ts b/apps/web/src/components/kanban-board/drag-preview/find-task-column.ts new file mode 100644 index 0000000000..710610ae78 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/find-task-column.ts @@ -0,0 +1,7 @@ +import type { ProjectWithTasks } from "@/types/project"; + +export function findTaskColumn(board: ProjectWithTasks, id: string) { + return board.columns.find( + (column) => column.id === id || column.tasks.some((task) => task.id === id), + ); +} diff --git a/apps/web/src/components/kanban-board/drag-preview/get-hovered-other-column-id.test.ts b/apps/web/src/components/kanban-board/drag-preview/get-hovered-other-column-id.test.ts new file mode 100644 index 0000000000..97a0ad2f78 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/get-hovered-other-column-id.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; +import { getHoveredOtherColumnId } from "./get-hovered-other-column-id"; +import { testBoard } from "./test-board"; + +describe("getHoveredOtherColumnId", () => { + it("returns the hovered column when it differs from the card's column", () => { + expect(getHoveredOtherColumnId(testBoard(), "a", "c")).toBe("doing"); + expect(getHoveredOtherColumnId(testBoard(), "a", "done")).toBe("done"); + }); + + it("returns null within the card's column or for unknown ids", () => { + expect(getHoveredOtherColumnId(testBoard(), "a", "b")).toBeNull(); + expect(getHoveredOtherColumnId(testBoard(), "a", "todo")).toBeNull(); + expect(getHoveredOtherColumnId(testBoard(), "a", "missing")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/kanban-board/drag-preview/get-hovered-other-column-id.ts b/apps/web/src/components/kanban-board/drag-preview/get-hovered-other-column-id.ts new file mode 100644 index 0000000000..c9559e74a0 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/get-hovered-other-column-id.ts @@ -0,0 +1,12 @@ +import type { ProjectWithTasks } from "@/types/project"; +import { findTaskColumn } from "./find-task-column"; + +export function getHoveredOtherColumnId( + board: ProjectWithTasks, + activeId: string, + overId: string, +) { + const from = findTaskColumn(board, activeId); + const to = findTaskColumn(board, overId); + return from && to && from.id !== to.id ? to.id : null; +} diff --git a/apps/web/src/components/kanban-board/drag-preview/get-preview-drop-placement.test.ts b/apps/web/src/components/kanban-board/drag-preview/get-preview-drop-placement.test.ts new file mode 100644 index 0000000000..0ac5d1eecf --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/get-preview-drop-placement.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vite-plus/test"; +import { getPreviewDropPlacement } from "./get-preview-drop-placement"; +import { moveIntoHoveredColumn } from "./move-into-hovered-column"; +import { testBoard } from "./test-board"; + +const preview = () => moveIntoHoveredColumn(testBoard(), "a", "c")!; + +describe("getPreviewDropPlacement", () => { + it("keeps the previewed slot when dropped on the card itself", () => { + expect(getPreviewDropPlacement(preview(), "a", "a")).toEqual({ + overId: "c", + insertAfterTarget: false, + }); + }); + + it("applies the final sortable hover within the column", () => { + expect(getPreviewDropPlacement(preview(), "a", "d")).toEqual({ + overId: "d", + insertAfterTarget: true, + }); + }); + + it("follows a final hover into another column", () => { + expect(getPreviewDropPlacement(preview(), "a", "done")).toEqual({ + overId: "done", + insertAfterTarget: undefined, + }); + }); +}); diff --git a/apps/web/src/components/kanban-board/drag-preview/get-preview-drop-placement.ts b/apps/web/src/components/kanban-board/drag-preview/get-preview-drop-placement.ts new file mode 100644 index 0000000000..23d032a632 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/get-preview-drop-placement.ts @@ -0,0 +1,17 @@ +import type { ProjectWithTasks } from "@/types/project"; +import { moveBoardTask } from "../move-task"; +import { getVisualTaskPlacement } from "./get-visual-task-placement"; +import { moveIntoHoveredColumn } from "./move-into-hovered-column"; + +export function getPreviewDropPlacement( + board: ProjectWithTasks, + activeId: string, + overId: string, +) { + const settled = + moveIntoHoveredColumn(board, activeId, overId) ?? + (overId === activeId + ? board + : (moveBoardTask(board, activeId, overId)?.project ?? board)); + return getVisualTaskPlacement(settled, activeId); +} diff --git a/apps/web/src/components/kanban-board/drag-preview/get-visual-task-placement.test.ts b/apps/web/src/components/kanban-board/drag-preview/get-visual-task-placement.test.ts new file mode 100644 index 0000000000..f405122791 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/get-visual-task-placement.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vite-plus/test"; +import { getVisualTaskPlacement } from "./get-visual-task-placement"; +import { testBoard } from "./test-board"; + +describe("getVisualTaskPlacement", () => { + it("places the card before its next neighbor", () => { + expect(getVisualTaskPlacement(testBoard(), "c")).toEqual({ + overId: "d", + insertAfterTarget: false, + }); + }); + + it("places the last card after its previous neighbor", () => { + expect(getVisualTaskPlacement(testBoard(), "d")).toEqual({ + overId: "c", + insertAfterTarget: true, + }); + }); + + it("targets the column for a card alone in it", () => { + const board = testBoard(); + board.columns[1].tasks = board.columns[1].tasks.slice(0, 1); + expect(getVisualTaskPlacement(board, "c")).toEqual({ + overId: "doing", + insertAfterTarget: undefined, + }); + }); + + it("returns null for an unknown card", () => { + expect(getVisualTaskPlacement(testBoard(), "missing")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/kanban-board/drag-preview/get-visual-task-placement.ts b/apps/web/src/components/kanban-board/drag-preview/get-visual-task-placement.ts new file mode 100644 index 0000000000..5f45fbe483 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/get-visual-task-placement.ts @@ -0,0 +1,19 @@ +import type { ProjectWithTasks } from "@/types/project"; +import { findTaskColumn } from "./find-task-column"; + +export function getVisualTaskPlacement( + project: ProjectWithTasks, + activeId: string, +) { + const column = findTaskColumn(project, activeId); + if (!column) return null; + + const index = column.tasks.findIndex((task) => task.id === activeId); + const nextTask = column.tasks[index + 1]; + if (nextTask) return { overId: nextTask.id, insertAfterTarget: false }; + + const previousTask = column.tasks[index - 1]; + if (previousTask) return { overId: previousTask.id, insertAfterTarget: true }; + + return { overId: column.id, insertAfterTarget: undefined }; +} diff --git a/apps/web/src/components/kanban-board/drag-preview/move-into-hovered-column.test.ts b/apps/web/src/components/kanban-board/drag-preview/move-into-hovered-column.test.ts new file mode 100644 index 0000000000..151a969aed --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/move-into-hovered-column.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vite-plus/test"; +import { moveIntoHoveredColumn } from "./move-into-hovered-column"; +import { columnIds, testBoard } from "./test-board"; + +describe("moveIntoHoveredColumn", () => { + it("puts the card in the hovered card's slot", () => { + const moved = moveIntoHoveredColumn(testBoard(), "a", "d"); + expect(columnIds(moved)).toEqual(["b", "c,a,d", ""]); + expect(moved?.columns[1].tasks[1].status).toBe("doing"); + }); + + it("appends the card when hovering the column itself", () => { + expect(columnIds(moveIntoHoveredColumn(testBoard(), "a", "done"))).toEqual([ + "b", + "c,d", + "a", + ]); + }); + + it("leaves same-column hovers to sortable", () => { + expect(moveIntoHoveredColumn(testBoard(), "a", "b")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/kanban-board/drag-preview/move-into-hovered-column.ts b/apps/web/src/components/kanban-board/drag-preview/move-into-hovered-column.ts new file mode 100644 index 0000000000..e65fddd25e --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/move-into-hovered-column.ts @@ -0,0 +1,12 @@ +import type { ProjectWithTasks } from "@/types/project"; +import { moveBoardTask } from "../move-task"; +import { getHoveredOtherColumnId } from "./get-hovered-other-column-id"; + +export function moveIntoHoveredColumn( + board: ProjectWithTasks, + activeId: string, + overId: string, +) { + if (!getHoveredOtherColumnId(board, activeId, overId)) return null; + return moveBoardTask(board, activeId, overId, false, false)?.project ?? null; +} diff --git a/apps/web/src/components/kanban-board/drag-preview/test-board.ts b/apps/web/src/components/kanban-board/drag-preview/test-board.ts new file mode 100644 index 0000000000..075d548176 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/test-board.ts @@ -0,0 +1,34 @@ +import type { ProjectWithTasks } from "@/types/project"; + +export function testBoard() { + return { + id: "project", + columns: [ + { + id: "todo", + slug: "todo", + tasks: [ + { id: "a", status: "todo", position: 0 }, + { id: "b", status: "todo", position: 1 }, + ], + }, + { + id: "doing", + slug: "doing", + tasks: [ + { id: "c", status: "doing", position: 0 }, + { id: "d", status: "doing", position: 1 }, + ], + }, + { id: "done", slug: "done", tasks: [] }, + ], + plannedTasks: [], + archivedTasks: [], + } as unknown as ProjectWithTasks; +} + +export function columnIds(board: ProjectWithTasks | null) { + return board?.columns.map((column) => + column.tasks.map((task) => task.id).join(","), + ); +} diff --git a/apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts b/apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts new file mode 100644 index 0000000000..c3b2d3bee7 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts @@ -0,0 +1,44 @@ +import type { Active, Over } from "@dnd-kit/core"; +import { useRef, useState } from "react"; +import type { ProjectWithTasks } from "@/types/project"; +import type { DragHover } from "./drag-hover"; +import { getHoveredOtherColumnId } from "./get-hovered-other-column-id"; +import { getPreviewDropPlacement } from "./get-preview-drop-placement"; +import { moveIntoHoveredColumn } from "./move-into-hovered-column"; + +export function useDragPreview(project: ProjectWithTasks) { + const [hover, setHover] = useState(null); + const hoverRef = useRef(null); + + const previewFor = (current: DragHover | null) => + current + ? moveIntoHoveredColumn(project, current.activeId, current.overId) + : null; + + const update = (next: DragHover | null) => { + hoverRef.current = next; + setHover(next); + }; + + return { + preview: previewFor(hover), + hover: (active: Active, over: Over) => { + const activeId = active.id.toString(); + const overId = over.id.toString(); + const shown = previewFor(hoverRef.current) ?? project; + if (getHoveredOtherColumnId(shown, activeId, overId)) + update({ activeId, overId }); + }, + getDropPlacement: (active: Active, over: Over) => { + const shown = previewFor(hoverRef.current); + return shown + ? getPreviewDropPlacement( + shown, + active.id.toString(), + over.id.toString(), + ) + : null; + }, + clear: () => update(null), + }; +} diff --git a/apps/web/src/components/kanban-board/filtered-drag.test.tsx b/apps/web/src/components/kanban-board/filtered-drag.test.tsx index 74b34b0122..1325cf6471 100644 --- a/apps/web/src/components/kanban-board/filtered-drag.test.tsx +++ b/apps/web/src/components/kanban-board/filtered-drag.test.tsx @@ -327,6 +327,28 @@ describe("cross-column dragging", () => { expect(shownIds(view, "doing")).toBe("c,d"); expect(mocks.reorder).not.toHaveBeenCalled(); }); + + it("keeps board updates that arrive during a drag", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); + + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + + const updated = crossColumnBoard(); + updated.columns[1].tasks.push({ + id: "e", + status: "doing", + position: 2, + } as (typeof updated.columns)[number]["tasks"][number]); + mocks.project = updated; + view.rerender(); + expect(shownIds(view, "doing")).toBe("a,c,d,e"); + + fireEvent.click(view.getByText("drop-on-card")); + expect(savedIds(1)).toEqual(["a", "c", "d", "e"]); + }); }); describe.each([ diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index c02f4f3163..3abad39658 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -24,9 +24,9 @@ import reorderTasks, { type TaskReorder } from "@/fetchers/task/reorder-tasks"; import { toast } from "@/lib/toast"; import { useTranslation } from "react-i18next"; import { rollbackBoardReorder } from "./apply-reorder"; -import { getHoveredOtherColumnId } from "./drag-preview"; +import { getHoveredOtherColumnId } from "./drag-preview/get-hovered-other-column-id"; import { moveBoardTask } from "./move-task"; -import { useDragPreview } from "./use-drag-preview"; +import { useDragPreview } from "./drag-preview/use-drag-preview"; import { useEffect, useState } from "react"; import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useProjectBackground } from "@/hooks/use-project-background"; diff --git a/apps/web/src/components/kanban-board/move-task.test.ts b/apps/web/src/components/kanban-board/move-task.test.ts index 656fe606c0..5b75b71678 100644 --- a/apps/web/src/components/kanban-board/move-task.test.ts +++ b/apps/web/src/components/kanban-board/move-task.test.ts @@ -1,7 +1,7 @@ import { applyBoardReorder, rollbackBoardReorder } from "./apply-reorder"; import { describe, expect, it } from "vite-plus/test"; import type { ProjectWithTasks } from "@/types/project"; -import { getVisualTaskPlacement, moveBoardTask } from "./move-task"; +import { moveBoardTask } from "./move-task"; function board() { return { @@ -83,35 +83,6 @@ describe("board moves", () => { "c", ]); }); - - it("derives the persisted position from the visual preview neighbors", () => { - const preview = moveBoardTask(board(), "a", "c", false, false)!; - expect(getVisualTaskPlacement(preview.project, "a")).toEqual({ - overId: "c", - insertAfterTarget: false, - }); - - const bottomPreview = moveBoardTask(board(), "a", "c", false, true)!; - expect(getVisualTaskPlacement(bottomPreview.project, "a")).toEqual({ - overId: "c", - insertAfterTarget: true, - }); - - const middleBoard = board(); - middleBoard.columns[1].tasks.push({ - id: "d", - status: "doing", - position: 1, - } as (typeof middleBoard.columns)[number]["tasks"][number]); - const middlePreview = moveBoardTask(middleBoard, "a", "d", false, false)!; - expect( - middlePreview.project.columns[1].tasks.map((task) => task.id), - ).toEqual(["c", "a", "d"]); - expect(getVisualTaskPlacement(middlePreview.project, "a")).toEqual({ - overId: "d", - insertAfterTarget: false, - }); - }); }); it("patches a remote reorder without removing hidden cards or replacing concurrent fields", () => { diff --git a/apps/web/src/components/kanban-board/move-task.ts b/apps/web/src/components/kanban-board/move-task.ts index ad69e741c5..9e30b4ee00 100644 --- a/apps/web/src/components/kanban-board/move-task.ts +++ b/apps/web/src/components/kanban-board/move-task.ts @@ -1,25 +1,6 @@ import { produce } from "immer"; import type { ProjectWithTasks } from "@/types/project"; -export function getVisualTaskPlacement( - project: ProjectWithTasks, - activeId: string, -) { - const column = project.columns.find((column) => - column.tasks.some((task) => task.id === activeId), - ); - if (!column) return null; - - const index = column.tasks.findIndex((task) => task.id === activeId); - const nextTask = column.tasks[index + 1]; - if (nextTask) return { overId: nextTask.id, insertAfterTarget: false }; - - const previousTask = column.tasks[index - 1]; - if (previousTask) return { overId: previousTask.id, insertAfterTarget: true }; - - return { overId: column.id, insertAfterTarget: undefined }; -} - export function moveBoardTask( project: ProjectWithTasks, activeId: string, diff --git a/apps/web/src/components/kanban-board/use-drag-preview.ts b/apps/web/src/components/kanban-board/use-drag-preview.ts deleted file mode 100644 index bd85e0466a..0000000000 --- a/apps/web/src/components/kanban-board/use-drag-preview.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Active, Over } from "@dnd-kit/core"; -import { useRef, useState } from "react"; -import type { ProjectWithTasks } from "@/types/project"; -import { getPreviewDropPlacement, moveIntoHoveredColumn } from "./drag-preview"; - -export function useDragPreview(project: ProjectWithTasks) { - const [preview, setPreview] = useState(null); - // dnd-kit can fire dragOver and dragEnd before React re-renders. - const previewRef = useRef(null); - - const update = (next: ProjectWithTasks | null) => { - previewRef.current = next; - setPreview(next); - }; - - return { - preview, - hover: (active: Active, over: Over) => { - const next = moveIntoHoveredColumn( - previewRef.current ?? project, - active, - over, - ); - if (next) update(next); - }, - getDropPlacement: (active: Active, over: Over) => - previewRef.current - ? getPreviewDropPlacement(previewRef.current, active, over) - : null, - clear: () => update(null), - }; -} From 7282c0916d53491c8c003a47949cb74773e622b0 Mon Sep 17 00:00:00 2001 From: Andrej Acevski Date: Mon, 5 Oct 2026 21:12:58 +0200 Subject: [PATCH 08/13] perf(board): stop re-rendering every card while dragging Dragging between columns re-rendered every card several times per pointer move, and an empty column could send the preview into an endless loop. - Split TaskCard into a thin sortable wrapper and memoized content, so dnd-kit's per-move updates no longer re-render the card body - Pass the dragged card's id to columns as a prop instead of reading the drag context, which changes on every pointer move - Pick drop targets by what is under the pointer instead of corner distance, which made the preview flip between an empty column and a neighbouring card until React gave up - Drop on the shown position when released between columns --- .../board-collision-detection.test.ts | 46 +++++++ .../kanban-board/board-collision-detection.ts | 8 ++ .../kanban-board/column/column-dropzone.tsx | 9 +- .../components/kanban-board/column/index.tsx | 7 +- .../drag-preview/use-drag-preview.ts | 26 ++-- .../kanban-board/filtered-drag.test.tsx | 25 ++++ .../web/src/components/kanban-board/index.tsx | 17 +-- .../kanban-board/task-card/drag-listeners.ts | 3 + .../kanban-board/task-card/index.tsx | 63 ++++++++++ .../task-card-content.tsx} | 117 +++--------------- .../task-card/use-task-card-click.ts | 68 ++++++++++ 11 files changed, 267 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/components/kanban-board/board-collision-detection.test.ts create mode 100644 apps/web/src/components/kanban-board/board-collision-detection.ts create mode 100644 apps/web/src/components/kanban-board/task-card/drag-listeners.ts create mode 100644 apps/web/src/components/kanban-board/task-card/index.tsx rename apps/web/src/components/kanban-board/{task-card.tsx => task-card/task-card-content.tsx} (83%) create mode 100644 apps/web/src/components/kanban-board/task-card/use-task-card-click.ts diff --git a/apps/web/src/components/kanban-board/board-collision-detection.test.ts b/apps/web/src/components/kanban-board/board-collision-detection.test.ts new file mode 100644 index 0000000000..ee68d801bc --- /dev/null +++ b/apps/web/src/components/kanban-board/board-collision-detection.test.ts @@ -0,0 +1,46 @@ +import type { ClientRect, DroppableContainer } from "@dnd-kit/core"; +import { describe, expect, it } from "vite-plus/test"; +import { boardCollisionDetection } from "./board-collision-detection"; + +function rect(left: number, top: number, width: number, height: number) { + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + } as ClientRect; +} + +const rects = new Map([ + ["empty-column", rect(0, 100, 300, 700)], + ["neighbor-card", rect(320, 100, 300, 80)], +]); + +function detect(pointer: { x: number; y: number } | null) { + const droppableContainers = [...rects.keys()].map( + (id) => ({ id, disabled: false }) as unknown as DroppableContainer, + ); + return boardCollisionDetection({ + active: { id: "card" } as never, + collisionRect: rect(100, 220, 300, 80), + droppableRects: rects, + droppableContainers, + pointerCoordinates: pointer, + }).map((collision) => collision.id); +} + +describe("boardCollisionDetection", () => { + it("targets the empty column under the pointer over a closer card", () => { + expect(detect({ x: 150, y: 260 })).toEqual(["empty-column"]); + }); + + it("finds nothing when the pointer is between columns", () => { + expect(detect({ x: 310, y: 260 })).toEqual([]); + }); + + it("falls back to corner distance without a pointer", () => { + expect(detect(null)[0]).toBe("neighbor-card"); + }); +}); diff --git a/apps/web/src/components/kanban-board/board-collision-detection.ts b/apps/web/src/components/kanban-board/board-collision-detection.ts new file mode 100644 index 0000000000..66e16f1bf9 --- /dev/null +++ b/apps/web/src/components/kanban-board/board-collision-detection.ts @@ -0,0 +1,8 @@ +import { + type CollisionDetection, + closestCorners, + pointerWithin, +} from "@dnd-kit/core"; + +export const boardCollisionDetection: CollisionDetection = (args) => + args.pointerCoordinates ? pointerWithin(args) : closestCorners(args); diff --git a/apps/web/src/components/kanban-board/column/column-dropzone.tsx b/apps/web/src/components/kanban-board/column/column-dropzone.tsx index 405ef89573..3ad13ae1d2 100644 --- a/apps/web/src/components/kanban-board/column/column-dropzone.tsx +++ b/apps/web/src/components/kanban-board/column/column-dropzone.tsx @@ -1,4 +1,4 @@ -import { useDndContext, useDroppable } from "@dnd-kit/core"; +import { useDroppable } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, @@ -10,6 +10,7 @@ import TaskCard from "../task-card"; type ColumnDropzoneProps = { column: ProjectWithTasks["columns"][number]; + activeTaskId: string | null; disableDragDrop?: boolean; onIsOverChange?: (isOver: boolean) => void; children?: ReactNode; @@ -17,6 +18,7 @@ type ColumnDropzoneProps = { export function ColumnDropzone({ column, + activeTaskId, disableDragDrop = false, onIsOverChange, children, @@ -34,7 +36,6 @@ export function ColumnDropzone({ }, [isOver, onIsOverChange]); const reduceMotion = useReducedMotion(); - const { active } = useDndContext(); const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }; return ( @@ -48,11 +49,11 @@ export function ColumnDropzone({ {column.tasks.map((task) => ( @@ -93,4 +96,4 @@ function Column({ ); } -export default Column; +export default memo(Column); diff --git a/apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts b/apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts index c3b2d3bee7..ec3fa5b063 100644 --- a/apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts +++ b/apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts @@ -1,5 +1,5 @@ import type { Active, Over } from "@dnd-kit/core"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import type { ProjectWithTasks } from "@/types/project"; import type { DragHover } from "./drag-hover"; import { getHoveredOtherColumnId } from "./get-hovered-other-column-id"; @@ -15,29 +15,33 @@ export function useDragPreview(project: ProjectWithTasks) { ? moveIntoHoveredColumn(project, current.activeId, current.overId) : null; + const preview = useMemo( + () => + hover + ? moveIntoHoveredColumn(project, hover.activeId, hover.overId) + : null, + [project, hover], + ); + const update = (next: DragHover | null) => { hoverRef.current = next; setHover(next); }; return { - preview: previewFor(hover), + preview, hover: (active: Active, over: Over) => { const activeId = active.id.toString(); const overId = over.id.toString(); - const shown = previewFor(hoverRef.current) ?? project; + const current = hoverRef.current; + if (current?.activeId === activeId && current?.overId === overId) return; + const shown = previewFor(current) ?? project; if (getHoveredOtherColumnId(shown, activeId, overId)) update({ activeId, overId }); }, - getDropPlacement: (active: Active, over: Over) => { + getDropPlacement: (activeId: string, overId: string) => { const shown = previewFor(hoverRef.current); - return shown - ? getPreviewDropPlacement( - shown, - active.id.toString(), - over.id.toString(), - ) - : null; + return shown ? getPreviewDropPlacement(shown, activeId, overId) : null; }, clear: () => update(null), }; diff --git a/apps/web/src/components/kanban-board/filtered-drag.test.tsx b/apps/web/src/components/kanban-board/filtered-drag.test.tsx index 1325cf6471..e91e71083e 100644 --- a/apps/web/src/components/kanban-board/filtered-drag.test.tsx +++ b/apps/web/src/components/kanban-board/filtered-drag.test.tsx @@ -103,6 +103,9 @@ vi.mock("@dnd-kit/core", () => ({ + ); @@ -112,6 +115,7 @@ vi.mock("@dnd-kit/core", () => ({ TouchSensor: {}, KeyboardSensor: {}, closestCorners: {}, + pointerWithin: {}, useSensor: vi.fn(), useSensors: vi.fn(), defaultDropAnimationSideEffects: vi.fn(), @@ -328,6 +332,27 @@ describe("cross-column dragging", () => { expect(mocks.reorder).not.toHaveBeenCalled(); }); + it("drops where the card is shown when released over a gap", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); + + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + fireEvent.click(view.getByText("drop-gap")); + expect(savedIds(1)).toEqual(["a", "c", "d"]); + }); + + it("ignores a release over a gap without a preview", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); + + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("drop-gap")); + expect(mocks.reorder).not.toHaveBeenCalled(); + }); + it("keeps board updates that arrive during a drag", () => { const project = crossColumnBoard(); mocks.project = project; diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index 3abad39658..4983779bb8 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -2,7 +2,6 @@ import { invalidateMyWork } from "@/lib/invalidate-my-work"; import { markBoardCacheChanged } from "@/lib/board-cache-version"; import { selectReorderBoard } from "./select-reorder-board"; import { - closestCorners, DndContext, type DragEndEvent, DragOverlay, @@ -24,6 +23,7 @@ import reorderTasks, { type TaskReorder } from "@/fetchers/task/reorder-tasks"; import { toast } from "@/lib/toast"; import { useTranslation } from "react-i18next"; import { rollbackBoardReorder } from "./apply-reorder"; +import { boardCollisionDetection } from "./board-collision-detection"; import { getHoveredOtherColumnId } from "./drag-preview/get-hovered-other-column-id"; import { moveBoardTask } from "./move-task"; import { useDragPreview } from "./drag-preview/use-drag-preview"; @@ -222,13 +222,15 @@ function KanbanBoard({ const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - const placement = over ? dragPreview.getDropPlacement(active, over) : null; + const activeId = active.id.toString(); + const overId = + over?.id.toString() ?? (dragPreview.preview ? activeId : null); + const placement = overId + ? dragPreview.getDropPlacement(activeId, overId) + : null; resetDrag(); - if (!over || !project?.columns) return; - - const activeId = active.id.toString(); - const overId = over.id.toString(); + if (!overId || !project?.columns) return; if ( disableDragDrop || @@ -318,7 +320,7 @@ function KanbanBoard({ return ( ["listeners"]; diff --git a/apps/web/src/components/kanban-board/task-card/index.tsx b/apps/web/src/components/kanban-board/task-card/index.tsx new file mode 100644 index 0000000000..f2bfd76b82 --- /dev/null +++ b/apps/web/src/components/kanban-board/task-card/index.tsx @@ -0,0 +1,63 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { CSSProperties } from "react"; +import type Task from "@/types/task"; +import TaskCardContent from "./task-card-content"; +import { useTaskCardClick } from "./use-task-card-click"; + +type TaskCardProps = { + task: Task; + disableDragDrop?: boolean; + isFinalColumn?: boolean; +}; + +function TaskCard({ + task, + disableDragDrop = false, + isFinalColumn, +}: TaskCardProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: task.id, + disabled: disableDragDrop, + data: { isFinalColumn }, + }); + const { handleKeyDown } = useTaskCardClick(task, listeners); + + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: + transition || "transform 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94)", + opacity: isDragging ? 0.6 : 1, + touchAction: isDragging ? "none" : "auto", + zIndex: isDragging ? 999 : "auto", + }; + + return ( +
+ +
+ ); +} + +export default TaskCard; diff --git a/apps/web/src/components/kanban-board/task-card.tsx b/apps/web/src/components/kanban-board/task-card/task-card-content.tsx similarity index 83% rename from apps/web/src/components/kanban-board/task-card.tsx rename to apps/web/src/components/kanban-board/task-card/task-card-content.tsx index 2e9151dff0..0ded3921b8 100644 --- a/apps/web/src/components/kanban-board/task-card.tsx +++ b/apps/web/src/components/kanban-board/task-card/task-card-content.tsx @@ -1,6 +1,3 @@ -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -import { useNavigate } from "@tanstack/react-router"; import { format } from "date-fns"; import { Calendar, @@ -8,7 +5,7 @@ import { CalendarX, SlidersHorizontal, } from "lucide-react"; -import { type CSSProperties, useMemo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { TaskProgressBadges } from "@/components/task/task-progress-badges"; import { TaskPullRequests } from "@/components/task/task-pull-requests"; @@ -47,8 +44,10 @@ import useBulkSelectionStore from "@/store/bulk-selection"; import useProjectStore from "@/store/project"; import { useUserPreferencesStore } from "@/store/user-preferences"; import type Task from "@/types/task"; -import TaskCardContextMenuContent from "./task-card-context-menu/task-card-context-menu-content"; -import { TaskLabels } from "./task-labels"; +import TaskCardContextMenuContent from "../task-card-context-menu/task-card-context-menu-content"; +import { TaskLabels } from "../task-labels"; +import type { DragListeners } from "./drag-listeners"; +import { useTaskCardClick } from "./use-task-card-click"; const dueDateTextColors: Record = { overdue: "text-destructive-foreground", @@ -57,30 +56,22 @@ const dueDateTextColors: Record = { "no-due-date": "text-muted-foreground", }; -type TaskCardProps = { +type TaskCardContentProps = { task: Task; - disableDragDrop?: boolean; + disableDragDrop: boolean; isFinalColumn?: boolean; + isDragging: boolean; + dragListeners: DragListeners; }; -function TaskCard({ +function TaskCardContent({ task, - disableDragDrop = false, + disableDragDrop, isFinalColumn, -}: TaskCardProps) { + isDragging, + dragListeners, +}: TaskCardContentProps) { const { t } = useTranslation(); - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: task.id, - disabled: disableDragDrop, - data: { isFinalColumn }, - }); const { project } = useProjectStore(); const taskIsCompleted = isFinalColumn ?? isTaskCompleted(task.status, project?.columns); @@ -89,7 +80,6 @@ function TaskCard({ const hasPriority = Boolean(task.priority) && task.priority !== "no-priority"; const { data: workspace } = useActiveWorkspace(); const { mutateAsync: deleteTask } = useDeleteTask(); - const navigate = useNavigate(); const { showAssignees, showPriority, @@ -98,13 +88,6 @@ function TaskCard({ showTaskNumbers, } = useUserPreferencesStore(); const [isDeleteTaskModalOpen, setIsDeleteTaskModalOpen] = useState(false); - const toggleSelection = useBulkSelectionStore( - (state) => state.toggleSelection, - ); - const selectRange = useBulkSelectionStore((state) => state.selectRange); - const setSelectionAnchor = useBulkSelectionStore( - (state) => state.setSelectionAnchor, - ); const isTaskSelected = useBulkSelectionStore((state) => state.selectedTaskIds.has(task.id), ); @@ -140,15 +123,6 @@ function TaskCard({ [customFieldValues], ); - const style: CSSProperties = { - transform: CSS.Transform.toString(transform), - transition: - transition || "transform 250ms cubic-bezier(0.25, 0.46, 0.45, 0.94)", - opacity: isDragging ? 0.6 : 1, - touchAction: isDragging ? "none" : "auto", - zIndex: isDragging ? 999 : "auto", - }; - const { data: workspaceUsers } = useGetActiveWorkspaceUsers( workspace?.id ?? "", ); @@ -159,52 +133,7 @@ function TaskCard({ ); }, [workspaceUsers, task.userId]); - function handleTaskCardClick( - e: React.MouseEvent | React.KeyboardEvent, - ) { - if (!project || !task || !workspace) return; - - if (e.shiftKey) { - e.preventDefault(); - selectRange(task.id); - return; - } - - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - toggleSelection(task.id); - return; - } - - setSelectionAnchor(task.id); - const currentParams = new URLSearchParams(window.location.search); - const currentTaskId = currentParams.get("taskId"); - - if (currentTaskId === task.id) { - navigate({ - to: ".", - search: {}, - }); - } else { - navigate({ - to: ".", - search: { taskId: task.id }, - }); - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.defaultPrevented || e.target !== e.currentTarget) return; - if (e.key === "Enter") { - handleTaskCardClick(e); - e.preventDefault(); - } else { - if (e.key === "Escape") { - toggleSelection(task.id); - } - listeners?.onKeyDown?.(e); - } - }; + const { handleClick, handleKeyDown } = useTaskCardClick(task, dragListeners); const handleDeleteTask = async () => { try { @@ -218,20 +147,12 @@ function TaskCard({ }; return ( -
+ <> {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- false positive for onClick and onKeyDown */}
-
+ ); } -export default TaskCard; +export default memo(TaskCardContent); diff --git a/apps/web/src/components/kanban-board/task-card/use-task-card-click.ts b/apps/web/src/components/kanban-board/task-card/use-task-card-click.ts new file mode 100644 index 0000000000..c33836d913 --- /dev/null +++ b/apps/web/src/components/kanban-board/task-card/use-task-card-click.ts @@ -0,0 +1,68 @@ +import { useNavigate } from "@tanstack/react-router"; +import useActiveWorkspace from "@/hooks/queries/workspace/use-active-workspace"; +import useBulkSelectionStore from "@/store/bulk-selection"; +import useProjectStore from "@/store/project"; +import type Task from "@/types/task"; +import type { DragListeners } from "./drag-listeners"; + +export function useTaskCardClick(task: Task, dragListeners: DragListeners) { + const { project } = useProjectStore(); + const { data: workspace } = useActiveWorkspace(); + const navigate = useNavigate(); + const toggleSelection = useBulkSelectionStore( + (state) => state.toggleSelection, + ); + const selectRange = useBulkSelectionStore((state) => state.selectRange); + const setSelectionAnchor = useBulkSelectionStore( + (state) => state.setSelectionAnchor, + ); + + function handleClick( + e: React.MouseEvent | React.KeyboardEvent, + ) { + if (!project || !task || !workspace) return; + + if (e.shiftKey) { + e.preventDefault(); + selectRange(task.id); + return; + } + + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + toggleSelection(task.id); + return; + } + + setSelectionAnchor(task.id); + const currentParams = new URLSearchParams(window.location.search); + const currentTaskId = currentParams.get("taskId"); + + if (currentTaskId === task.id) { + navigate({ + to: ".", + search: {}, + }); + } else { + navigate({ + to: ".", + search: { taskId: task.id }, + }); + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.defaultPrevented || e.target !== e.currentTarget) return; + if (e.key === "Enter") { + handleClick(e); + e.preventDefault(); + } else { + if (e.key === "Escape") { + toggleSelection(task.id); + } + dragListeners?.onKeyDown?.(e); + } + }; + + return { handleClick, handleKeyDown }; +} From f7e973898abdf01b76f593687ff2fbd575f4f474 Mon Sep 17 00:00:00 2001 From: Andrej Acevski Date: Mon, 5 Oct 2026 21:32:12 +0200 Subject: [PATCH 09/13] perf(board): mount task menus and delete dialogs on first use Every board card and list row rendered its full context menu, with eight mutation hooks and every submenu, plus a delete dialog, before anyone opened them. With 200 tasks that dominated switching to the board or list. Both now mount the first time they open and stay mounted afterwards. --- .../task-card/task-card-content.tsx | 73 ++++++++++-------- .../web/src/components/list-view/task-row.tsx | 77 +++++++++++-------- 2 files changed, 84 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/kanban-board/task-card/task-card-content.tsx b/apps/web/src/components/kanban-board/task-card/task-card-content.tsx index 0ded3921b8..30ba2bba6c 100644 --- a/apps/web/src/components/kanban-board/task-card/task-card-content.tsx +++ b/apps/web/src/components/kanban-board/task-card/task-card-content.tsx @@ -87,7 +87,10 @@ function TaskCardContent({ showLabels, showTaskNumbers, } = useUserPreferencesStore(); - const [isDeleteTaskModalOpen, setIsDeleteTaskModalOpen] = useState(false); + const [isDeleteTaskModalOpen, setIsDeleteTaskModalOpen] = useState< + boolean | null + >(null); + const [hasOpenedMenu, setHasOpenedMenu] = useState(false); const isTaskSelected = useBulkSelectionStore((state) => state.selectedTaskIds.has(task.id), ); @@ -148,7 +151,11 @@ function TaskCardContent({ return ( <> - + { + if (open) setHasOpenedMenu(true); + }} + > {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- false positive for onClick and onKeyDown */}
- {project && workspace && ( + {hasOpenedMenu && project && workspace && ( - - - - {t("tasks:delete.title")} - - {t("tasks:delete.description")} - - - - }> - {t("common:actions.cancel")} - - - } - > - {t("tasks:delete.action")} - - - - + {isDeleteTaskModalOpen !== null && ( + + + + {t("tasks:delete.title")} + + {t("tasks:delete.description")} + + + + }> + {t("common:actions.cancel")} + + + } + > + {t("tasks:delete.action")} + + + + + )} ); } diff --git a/apps/web/src/components/list-view/task-row.tsx b/apps/web/src/components/list-view/task-row.tsx index 5da882b0f4..855d202d2c 100644 --- a/apps/web/src/components/list-view/task-row.tsx +++ b/apps/web/src/components/list-view/task-row.tsx @@ -3,7 +3,7 @@ import { CSS } from "@dnd-kit/utilities"; import { useNavigate } from "@tanstack/react-router"; import { format } from "date-fns"; import { Calendar, CalendarClock, CalendarX } from "lucide-react"; -import { type CSSProperties, useMemo, useState } from "react"; +import { type CSSProperties, useMemo, useState, memo } from "react"; import { useTranslation } from "react-i18next"; import { TaskProgressBadges } from "@/components/task/task-progress-badges"; import { TaskPullRequests } from "@/components/task/task-pull-requests"; @@ -65,7 +65,10 @@ function TaskRow({ task, projectSlug }: TaskRowProps) { showLabels, showTaskNumbers, } = useUserPreferencesStore(); - const [isDeleteTaskModalOpen, setIsDeleteTaskModalOpen] = useState(false); + const [isDeleteTaskModalOpen, setIsDeleteTaskModalOpen] = useState< + boolean | null + >(null); + const [hasOpenedMenu, setHasOpenedMenu] = useState(false); const { mutateAsync: deleteTask } = useDeleteTask(); const toggleSelection = useBulkSelectionStore( (state) => state.toggleSelection, @@ -163,7 +166,11 @@ function TaskRow({ task, projectSlug }: TaskRowProps) { isTaskFocused && "ring-2 ring-inset ring-ring/50", )} > - + { + if (open) setHasOpenedMenu(true); + }} + > {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- false positive for onClick and onKeyDown */}
- {project && workspace && ( + {hasOpenedMenu && project && workspace && ( - - - - {t("tasks:delete.title")} - - {t("tasks:delete.description")} - - - - }> - {t("common:actions.cancel")} - - - } - > - {t("tasks:delete.action")} - - - - + {isDeleteTaskModalOpen !== null && ( + + + + {t("tasks:delete.title")} + + {t("tasks:delete.description")} + + + + }> + {t("common:actions.cancel")} + + + } + > + {t("tasks:delete.action")} + + + + + )}
); } -export default TaskRow; +export default memo(TaskRow); From 01e30659816704866b558c7319a52debcfb81a3b Mon Sep 17 00:00:00 2001 From: Andrej Acevski Date: Mon, 5 Oct 2026 21:46:04 +0200 Subject: [PATCH 10/13] fix(board): keep previews honest when a drop would be refused Address Qodo review findings: - Skip and clear the cross-column preview and sort hint while the board would refuse the drop (dragging disabled, a reorder in flight, or the board refetching), so a card no longer lands and then jumps back - On number- and priority-sorted boards, stop shifting cards during a drag within a column and show the sort hint on the hovered column, including the card's own, instead of animating a move that snaps back --- .../kanban-board/column/column-dropzone.tsx | 7 +++- .../components/kanban-board/column/index.tsx | 3 ++ .../kanban-board/filtered-drag.test.tsx | 27 ++++++++++++++++ .../web/src/components/kanban-board/index.tsx | 32 +++++++++---------- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/kanban-board/column/column-dropzone.tsx b/apps/web/src/components/kanban-board/column/column-dropzone.tsx index 3ad13ae1d2..b2c5b27711 100644 --- a/apps/web/src/components/kanban-board/column/column-dropzone.tsx +++ b/apps/web/src/components/kanban-board/column/column-dropzone.tsx @@ -1,6 +1,7 @@ import { useDroppable } from "@dnd-kit/core"; import { SortableContext, + type SortingStrategy, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; @@ -8,10 +9,13 @@ import { type ReactNode, useEffect } from "react"; import type { ProjectWithTasks } from "@/types/project"; import TaskCard from "../task-card"; +const keepOrder: SortingStrategy = () => null; + type ColumnDropzoneProps = { column: ProjectWithTasks["columns"][number]; activeTaskId: string | null; disableDragDrop?: boolean; + disableSorting?: boolean; onIsOverChange?: (isOver: boolean) => void; children?: ReactNode; }; @@ -20,6 +24,7 @@ export function ColumnDropzone({ column, activeTaskId, disableDragDrop = false, + disableSorting = false, onIsOverChange, children, }: ColumnDropzoneProps) { @@ -42,7 +47,7 @@ export function ColumnDropzone({
diff --git a/apps/web/src/components/kanban-board/column/index.tsx b/apps/web/src/components/kanban-board/column/index.tsx index 335c9ed034..413bba30e1 100644 --- a/apps/web/src/components/kanban-board/column/index.tsx +++ b/apps/web/src/components/kanban-board/column/index.tsx @@ -12,6 +12,7 @@ type ColumnProps = { activeTaskId: string | null; sortHint?: string; disableDragDrop?: boolean; + disableSorting?: boolean; disableCollectionActions?: boolean; }; @@ -62,6 +63,7 @@ function Column({ activeTaskId, sortHint, disableDragDrop = false, + disableSorting = false, disableCollectionActions = false, }: ColumnProps) { const [isDropzoneOver, setIsDropzoneOver] = useState(false); @@ -85,6 +87,7 @@ function Column({ column={column} activeTaskId={activeTaskId} disableDragDrop={disableDragDrop} + disableSorting={disableSorting} onIsOverChange={setIsDropzoneOver} > {/* New work starts in an open column; finished ones only collect. */} diff --git a/apps/web/src/components/kanban-board/filtered-drag.test.tsx b/apps/web/src/components/kanban-board/filtered-drag.test.tsx index e91e71083e..f136ddaeb4 100644 --- a/apps/web/src/components/kanban-board/filtered-drag.test.tsx +++ b/apps/web/src/components/kanban-board/filtered-drag.test.tsx @@ -353,6 +353,24 @@ describe("cross-column dragging", () => { expect(mocks.reorder).not.toHaveBeenCalled(); }); + it("drops the preview once dragging is disabled mid-drag", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); + + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + expect(shownIds(view, "doing")).toBe("a,c,d"); + + view.rerender(); + fireEvent.click(view.getByText("over-c")); + expect(shownIds(view, "todo")).toBe("a"); + expect(shownIds(view, "doing")).toBe("c,d"); + + fireEvent.click(view.getByText("drop-gap")); + expect(mocks.reorder).not.toHaveBeenCalled(); + }); + it("keeps board updates that arrive during a drag", () => { const project = crossColumnBoard(); mocks.project = project; @@ -398,6 +416,15 @@ describe.each([ "data-sort-hint", ); + fireEvent.click(view.getByText("over-todo")); + expect(view.getByTestId("column-todo")).toHaveAttribute( + "data-sort-hint", + `tasks:kanban.automaticallySortedHint:${sortLabel}`, + ); + expect(view.getByTestId("column-doing")).not.toHaveAttribute( + "data-sort-hint", + ); + fireEvent.click(view.getByText("drop-c")); expect(savedIds(1)).toEqual(["c", "d", "a"]); expect(mocks.reorder).toHaveBeenCalledOnce(); diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index 4983779bb8..4c63a95375 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -24,7 +24,7 @@ import { toast } from "@/lib/toast"; import { useTranslation } from "react-i18next"; import { rollbackBoardReorder } from "./apply-reorder"; import { boardCollisionDetection } from "./board-collision-detection"; -import { getHoveredOtherColumnId } from "./drag-preview/get-hovered-other-column-id"; +import { findTaskColumn } from "./drag-preview/find-task-column"; import { moveBoardTask } from "./move-task"; import { useDragPreview } from "./drag-preview/use-drag-preview"; import { useEffect, useState } from "react"; @@ -204,19 +204,24 @@ function KanbanBoard({ dragPreview.clear(); }; + const isDropBlocked = () => + disableDragDrop || + isReordering || + queryClient.getQueryState(["tasks", project.id])?.fetchStatus === + "fetching"; + const handleDragOver = ({ active, over }: DragOverEvent) => { + if (isDropBlocked()) { + setSortHintColumnId(null); + dragPreview.clear(); + return; + } if (!isAutomaticallySorted) { if (over) dragPreview.hover(active, over); return; } setSortHintColumnId( - over - ? getHoveredOtherColumnId( - project, - active.id.toString(), - over.id.toString(), - ) - : null, + over ? (findTaskColumn(project, over.id.toString())?.id ?? null) : null, ); }; @@ -230,15 +235,7 @@ function KanbanBoard({ : null; resetDrag(); - if (!overId || !project?.columns) return; - - if ( - disableDragDrop || - isReordering || - queryClient.getQueryState(["tasks", project.id])?.fetchStatus === - "fetching" - ) - return; + if (!overId || !project?.columns || isDropBlocked()) return; const canonical = selectReorderBoard( project.id, activeId, @@ -355,6 +352,7 @@ function KanbanBoard({ : undefined } disableDragDrop={disableDragDrop} + disableSorting={isAutomaticallySorted} disableCollectionActions={disableCollectionActions} />
From 1d40c641e660a01d9e580d07019f58232db06cc0 Mon Sep 17 00:00:00 2001 From: Andrej Acevski Date: Mon, 5 Oct 2026 22:03:49 +0200 Subject: [PATCH 11/13] fix(board): drop on blank column space where the card is shown Releasing over empty space in a column targeted the column itself, which the drop treated as "append" while the preview still showed the card in its earlier slot. Blank space in a column with cards now targets the nearest card, so the preview follows the pointer to the bottom and the drop saves what was shown. Empty columns still target the column. --- .../board-collision-detection.test.ts | 42 +++++++++++++++---- .../kanban-board/board-collision-detection.ts | 33 ++++++++++++++- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/kanban-board/board-collision-detection.test.ts b/apps/web/src/components/kanban-board/board-collision-detection.test.ts index ee68d801bc..5eb4f755ab 100644 --- a/apps/web/src/components/kanban-board/board-collision-detection.test.ts +++ b/apps/web/src/components/kanban-board/board-collision-detection.test.ts @@ -14,17 +14,32 @@ function rect(left: number, top: number, width: number, height: number) { } const rects = new Map([ - ["empty-column", rect(0, 100, 300, 700)], - ["neighbor-card", rect(320, 100, 300, 80)], + ["empty", rect(0, 100, 300, 700)], + ["doing", rect(320, 100, 300, 700)], + ["c", rect(330, 110, 280, 80)], + ["d", rect(330, 200, 280, 80)], ]); +const columnData = { + empty: { type: "column", column: { id: "empty", tasks: [] } }, + doing: { + type: "column", + column: { id: "doing", tasks: [{ id: "c" }, { id: "d" }] }, + }, +} as Record; + function detect(pointer: { x: number; y: number } | null) { const droppableContainers = [...rects.keys()].map( - (id) => ({ id, disabled: false }) as unknown as DroppableContainer, + (id) => + ({ + id, + disabled: false, + data: { current: columnData[id] }, + }) as unknown as DroppableContainer, ); return boardCollisionDetection({ active: { id: "card" } as never, - collisionRect: rect(100, 220, 300, 80), + collisionRect: rect(100, 120, 300, 80), droppableRects: rects, droppableContainers, pointerCoordinates: pointer, @@ -33,14 +48,27 @@ function detect(pointer: { x: number; y: number } | null) { describe("boardCollisionDetection", () => { it("targets the empty column under the pointer over a closer card", () => { - expect(detect({ x: 150, y: 260 })).toEqual(["empty-column"]); + expect(detect({ x: 150, y: 160 })).toEqual(["empty"]); + }); + + it("targets the card under the pointer", () => { + expect(detect({ x: 400, y: 150 })).toEqual(["c"]); + }); + + it("targets the last card below a column's cards", () => { + expect(detect({ x: 400, y: 600 })).toEqual(["d"]); + }); + + it("targets the nearest card in the gap between cards", () => { + expect(detect({ x: 400, y: 194 })).toEqual(["c"]); + expect(detect({ x: 400, y: 197 })).toEqual(["d"]); }); it("finds nothing when the pointer is between columns", () => { - expect(detect({ x: 310, y: 260 })).toEqual([]); + expect(detect({ x: 310, y: 160 })).toEqual([]); }); it("falls back to corner distance without a pointer", () => { - expect(detect(null)[0]).toBe("neighbor-card"); + expect(detect(null)[0]).toBe("c"); }); }); diff --git a/apps/web/src/components/kanban-board/board-collision-detection.ts b/apps/web/src/components/kanban-board/board-collision-detection.ts index 66e16f1bf9..896f35e0fa 100644 --- a/apps/web/src/components/kanban-board/board-collision-detection.ts +++ b/apps/web/src/components/kanban-board/board-collision-detection.ts @@ -3,6 +3,35 @@ import { closestCorners, pointerWithin, } from "@dnd-kit/core"; +import type { ProjectWithTasks } from "@/types/project"; -export const boardCollisionDetection: CollisionDetection = (args) => - args.pointerCoordinates ? pointerWithin(args) : closestCorners(args); +type ColumnData = { + type?: string; + column?: ProjectWithTasks["columns"][number]; +}; + +export const boardCollisionDetection: CollisionDetection = (args) => { + const { pointerCoordinates, droppableContainers, droppableRects } = args; + if (!pointerCoordinates) return closestCorners(args); + + const [hit] = pointerWithin(args); + const data = droppableContainers.find((container) => container.id === hit?.id) + ?.data.current as ColumnData | undefined; + if (data?.type !== "column" || !data.column?.tasks.length) + return hit ? [hit] : []; + + let closest = hit; + let closestDistance = Number.POSITIVE_INFINITY; + for (const task of data.column.tasks) { + const rect = droppableRects.get(task.id); + if (!rect) continue; + const distance = Math.abs( + rect.top + rect.height / 2 - pointerCoordinates.y, + ); + if (distance < closestDistance) { + closest = { id: task.id }; + closestDistance = distance; + } + } + return [closest]; +}; From c905658d8de726954c762a1d6ca3e42743c14d49 Mon Sep 17 00:00:00 2001 From: Andrej Acevski Date: Mon, 5 Oct 2026 22:20:04 +0200 Subject: [PATCH 12/13] fix(board): treat column headers as drop targets and cancel off-board drops Address Qodo review findings: - Make the whole column, header included, the drop target, so releasing over a header lands at the top of that column instead of being ignored or committed to a previously previewed column - Snap the pointer to a column across the gap between columns - Clear the preview when the pointer leaves every column and cancel a release there, instead of saving the last previewed position --- .../board-collision-detection.test.ts | 13 +++- .../kanban-board/board-collision-detection.ts | 37 +++++++--- .../kanban-board/column/column-dropzone.tsx | 71 +++++++------------ .../components/kanban-board/column/index.tsx | 19 ++--- .../kanban-board/filtered-drag.test.tsx | 13 +++- .../web/src/components/kanban-board/index.tsx | 9 ++- 6 files changed, 88 insertions(+), 74 deletions(-) diff --git a/apps/web/src/components/kanban-board/board-collision-detection.test.ts b/apps/web/src/components/kanban-board/board-collision-detection.test.ts index 5eb4f755ab..081c5e6373 100644 --- a/apps/web/src/components/kanban-board/board-collision-detection.test.ts +++ b/apps/web/src/components/kanban-board/board-collision-detection.test.ts @@ -64,8 +64,17 @@ describe("boardCollisionDetection", () => { expect(detect({ x: 400, y: 197 })).toEqual(["d"]); }); - it("finds nothing when the pointer is between columns", () => { - expect(detect({ x: 310, y: 160 })).toEqual([]); + it("targets the first card when the pointer is over a column header", () => { + expect(detect({ x: 400, y: 104 })).toEqual(["c"]); + }); + + it("snaps to a column across the gap between columns", () => { + expect(detect({ x: 310, y: 160 })).toEqual(["empty"]); + }); + + it("finds nothing outside every column", () => { + expect(detect({ x: 700, y: 160 })).toEqual([]); + expect(detect({ x: 400, y: 900 })).toEqual([]); }); it("falls back to corner distance without a pointer", () => { diff --git a/apps/web/src/components/kanban-board/board-collision-detection.ts b/apps/web/src/components/kanban-board/board-collision-detection.ts index 896f35e0fa..1735e1cc39 100644 --- a/apps/web/src/components/kanban-board/board-collision-detection.ts +++ b/apps/web/src/components/kanban-board/board-collision-detection.ts @@ -5,29 +5,48 @@ import { } from "@dnd-kit/core"; import type { ProjectWithTasks } from "@/types/project"; +const COLUMN_GAP = 16; + type ColumnData = { type?: string; column?: ProjectWithTasks["columns"][number]; }; export const boardCollisionDetection: CollisionDetection = (args) => { - const { pointerCoordinates, droppableContainers, droppableRects } = args; - if (!pointerCoordinates) return closestCorners(args); + const { + pointerCoordinates: pointer, + droppableContainers, + droppableRects, + } = args; + if (!pointer) return closestCorners(args); const [hit] = pointerWithin(args); - const data = droppableContainers.find((container) => container.id === hit?.id) - ?.data.current as ColumnData | undefined; + const target = hit + ? droppableContainers.find((container) => container.id === hit.id) + : droppableContainers.find((container) => { + const rect = droppableRects.get(container.id); + return ( + (container.data.current as ColumnData | undefined)?.type === + "column" && + rect !== undefined && + pointer.y >= rect.top && + pointer.y <= rect.bottom && + pointer.x >= rect.left - COLUMN_GAP && + pointer.x <= rect.right + COLUMN_GAP + ); + }); + if (!target) return []; + + const data = target.data.current as ColumnData | undefined; if (data?.type !== "column" || !data.column?.tasks.length) - return hit ? [hit] : []; + return [{ id: target.id }]; - let closest = hit; + let closest: { id: string | number } = { id: target.id }; let closestDistance = Number.POSITIVE_INFINITY; for (const task of data.column.tasks) { const rect = droppableRects.get(task.id); if (!rect) continue; - const distance = Math.abs( - rect.top + rect.height / 2 - pointerCoordinates.y, - ); + const distance = Math.abs(rect.top + rect.height / 2 - pointer.y); if (distance < closestDistance) { closest = { id: task.id }; closestDistance = distance; diff --git a/apps/web/src/components/kanban-board/column/column-dropzone.tsx b/apps/web/src/components/kanban-board/column/column-dropzone.tsx index b2c5b27711..e5a9324894 100644 --- a/apps/web/src/components/kanban-board/column/column-dropzone.tsx +++ b/apps/web/src/components/kanban-board/column/column-dropzone.tsx @@ -1,11 +1,9 @@ -import { useDroppable } from "@dnd-kit/core"; import { SortableContext, type SortingStrategy, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; -import { type ReactNode, useEffect } from "react"; import type { ProjectWithTasks } from "@/types/project"; import TaskCard from "../task-card"; @@ -16,8 +14,6 @@ type ColumnDropzoneProps = { activeTaskId: string | null; disableDragDrop?: boolean; disableSorting?: boolean; - onIsOverChange?: (isOver: boolean) => void; - children?: ReactNode; }; export function ColumnDropzone({ @@ -25,53 +21,34 @@ export function ColumnDropzone({ activeTaskId, disableDragDrop = false, disableSorting = false, - onIsOverChange, - children, }: ColumnDropzoneProps) { - const { setNodeRef, isOver } = useDroppable({ - id: column.id, - data: { - type: "column", - column, - }, - }); - - useEffect(() => { - onIsOverChange?.(isOver); - }, [isOver, onIsOverChange]); - const reduceMotion = useReducedMotion(); const hidden = reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.98 }; return ( -
- -
- - {column.tasks.map((task) => ( - - - - ))} - -
-
- {children} -
+ +
+ + {column.tasks.map((task) => ( + + + + ))} + +
+
); } diff --git a/apps/web/src/components/kanban-board/column/index.tsx b/apps/web/src/components/kanban-board/column/index.tsx index 413bba30e1..3404918d97 100644 --- a/apps/web/src/components/kanban-board/column/index.tsx +++ b/apps/web/src/components/kanban-board/column/index.tsx @@ -1,5 +1,6 @@ +import { useDroppable } from "@dnd-kit/core"; import { cva } from "class-variance-authority"; -import { memo, useState } from "react"; +import { memo } from "react"; import { useBackgroundStore } from "@/store/background"; import type { ProjectWithTasks } from "@/types/project"; import { ColumnAddTask } from "./column-add-task"; @@ -66,13 +67,17 @@ function Column({ disableSorting = false, disableCollectionActions = false, }: ColumnProps) { - const [isDropzoneOver, setIsDropzoneOver] = useState(false); + const { setNodeRef, isOver } = useDroppable({ + id: column.id, + data: { type: "column", column }, + }); const { background } = useBackgroundStore(); return (
@@ -88,11 +93,9 @@ function Column({ activeTaskId={activeTaskId} disableDragDrop={disableDragDrop} disableSorting={disableSorting} - onIsOverChange={setIsDropzoneOver} - > - {/* New work starts in an open column; finished ones only collect. */} - {!column.isFinal && } - + /> + {/* New work starts in an open column; finished ones only collect. */} + {!column.isFinal && }
{sortHint && }
diff --git a/apps/web/src/components/kanban-board/filtered-drag.test.tsx b/apps/web/src/components/kanban-board/filtered-drag.test.tsx index f136ddaeb4..768ebc0854 100644 --- a/apps/web/src/components/kanban-board/filtered-drag.test.tsx +++ b/apps/web/src/components/kanban-board/filtered-drag.test.tsx @@ -100,6 +100,9 @@ vi.mock("@dnd-kit/core", () => ({ + @@ -332,18 +335,22 @@ describe("cross-column dragging", () => { expect(mocks.reorder).not.toHaveBeenCalled(); }); - it("drops where the card is shown when released over a gap", () => { + it("cancels a previewed move released outside every column", () => { const project = crossColumnBoard(); mocks.project = project; const view = render(); fireEvent.click(view.getByText("start")); fireEvent.click(view.getByText("over-c")); + fireEvent.click(view.getByText("over-none")); + expect(shownIds(view, "todo")).toBe("a"); + expect(shownIds(view, "doing")).toBe("c,d"); + fireEvent.click(view.getByText("drop-gap")); - expect(savedIds(1)).toEqual(["a", "c", "d"]); + expect(mocks.reorder).not.toHaveBeenCalled(); }); - it("ignores a release over a gap without a preview", () => { + it("ignores a release outside every column without a preview", () => { const project = crossColumnBoard(); mocks.project = project; const view = render(); diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index 4c63a95375..89a41da053 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -211,25 +211,24 @@ function KanbanBoard({ "fetching"; const handleDragOver = ({ active, over }: DragOverEvent) => { - if (isDropBlocked()) { + if (!over || isDropBlocked()) { setSortHintColumnId(null); dragPreview.clear(); return; } if (!isAutomaticallySorted) { - if (over) dragPreview.hover(active, over); + dragPreview.hover(active, over); return; } setSortHintColumnId( - over ? (findTaskColumn(project, over.id.toString())?.id ?? null) : null, + findTaskColumn(project, over.id.toString())?.id ?? null, ); }; const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; const activeId = active.id.toString(); - const overId = - over?.id.toString() ?? (dragPreview.preview ? activeId : null); + const overId = over?.id.toString(); const placement = overId ? dragPreview.getDropPlacement(activeId, overId) : null; From 62d172ddf1ec70e1a919283f5275307d045a18e8 Mon Sep 17 00:00:00 2001 From: Andrej Acevski Date: Mon, 5 Oct 2026 22:34:52 +0200 Subject: [PATCH 13/13] fix(board): pick drop targets from visible cards in the nearest column Address Qodo review findings: - Choose the column whose visible area holds the pointer, or the nearer column across a gap, instead of the first match, so the right half of a gap no longer drops into the left column - Only consider cards visible inside that column, so cards scrolled out of view no longer accept drops released above or below the board --- .../board-collision-detection.test.ts | 16 ++++-- .../kanban-board/board-collision-detection.ts | 51 ++++++++++--------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/kanban-board/board-collision-detection.test.ts b/apps/web/src/components/kanban-board/board-collision-detection.test.ts index 081c5e6373..58d8852fd5 100644 --- a/apps/web/src/components/kanban-board/board-collision-detection.test.ts +++ b/apps/web/src/components/kanban-board/board-collision-detection.test.ts @@ -18,13 +18,17 @@ const rects = new Map([ ["doing", rect(320, 100, 300, 700)], ["c", rect(330, 110, 280, 80)], ["d", rect(330, 200, 280, 80)], + ["scrolled", rect(330, 10, 280, 80)], ]); const columnData = { empty: { type: "column", column: { id: "empty", tasks: [] } }, doing: { type: "column", - column: { id: "doing", tasks: [{ id: "c" }, { id: "d" }] }, + column: { + id: "doing", + tasks: [{ id: "scrolled" }, { id: "c" }, { id: "d" }], + }, }, } as Record; @@ -68,8 +72,14 @@ describe("boardCollisionDetection", () => { expect(detect({ x: 400, y: 104 })).toEqual(["c"]); }); - it("snaps to a column across the gap between columns", () => { - expect(detect({ x: 310, y: 160 })).toEqual(["empty"]); + it("snaps to the nearer column across the gap between columns", () => { + expect(detect({ x: 305, y: 160 })).toEqual(["empty"]); + expect(detect({ x: 315, y: 160 })).toEqual(["c"]); + }); + + it("ignores cards scrolled out of their column", () => { + expect(detect({ x: 400, y: 50 })).toEqual([]); + expect(detect({ x: 400, y: 101 })).toEqual(["c"]); }); it("finds nothing outside every column", () => { diff --git a/apps/web/src/components/kanban-board/board-collision-detection.ts b/apps/web/src/components/kanban-board/board-collision-detection.ts index 1735e1cc39..b603027252 100644 --- a/apps/web/src/components/kanban-board/board-collision-detection.ts +++ b/apps/web/src/components/kanban-board/board-collision-detection.ts @@ -1,7 +1,7 @@ import { type CollisionDetection, closestCorners, - pointerWithin, + type DroppableContainer, } from "@dnd-kit/core"; import type { ProjectWithTasks } from "@/types/project"; @@ -20,33 +20,34 @@ export const boardCollisionDetection: CollisionDetection = (args) => { } = args; if (!pointer) return closestCorners(args); - const [hit] = pointerWithin(args); - const target = hit - ? droppableContainers.find((container) => container.id === hit.id) - : droppableContainers.find((container) => { - const rect = droppableRects.get(container.id); - return ( - (container.data.current as ColumnData | undefined)?.type === - "column" && - rect !== undefined && - pointer.y >= rect.top && - pointer.y <= rect.bottom && - pointer.x >= rect.left - COLUMN_GAP && - pointer.x <= rect.right + COLUMN_GAP - ); - }); - if (!target) return []; - - const data = target.data.current as ColumnData | undefined; - if (data?.type !== "column" || !data.column?.tasks.length) - return [{ id: target.id }]; + let column: DroppableContainer | undefined; + let columnDistance = COLUMN_GAP; + for (const container of droppableContainers) { + if ((container.data.current as ColumnData | undefined)?.type !== "column") + continue; + const rect = droppableRects.get(container.id); + if (!rect || pointer.y < rect.top || pointer.y > rect.bottom) continue; + const distance = Math.max(rect.left - pointer.x, pointer.x - rect.right, 0); + if (distance < columnDistance) { + column = container; + columnDistance = distance; + } + } + const columnRect = column && droppableRects.get(column.id); + if (!column || !columnRect) return []; - let closest: { id: string | number } = { id: target.id }; + let closest: { id: string | number } = { id: column.id }; let closestDistance = Number.POSITIVE_INFINITY; - for (const task of data.column.tasks) { + const tasks = + (column.data.current as ColumnData | undefined)?.column?.tasks ?? []; + for (const task of tasks) { const rect = droppableRects.get(task.id); - if (!rect) continue; - const distance = Math.abs(rect.top + rect.height / 2 - pointer.y); + if (!rect || rect.bottom < columnRect.top || rect.top > columnRect.bottom) + continue; + const distance = + pointer.y >= rect.top && pointer.y <= rect.bottom + ? 0 + : Math.abs(rect.top + rect.height / 2 - pointer.y); if (distance < closestDistance) { closest = { id: task.id }; closestDistance = distance;