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..58d8852fd5 --- /dev/null +++ b/apps/web/src/components/kanban-board/board-collision-detection.test.ts @@ -0,0 +1,93 @@ +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", rect(0, 100, 300, 700)], + ["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: "scrolled" }, { id: "c" }, { id: "d" }], + }, + }, +} as Record; + +function detect(pointer: { x: number; y: number } | null) { + const droppableContainers = [...rects.keys()].map( + (id) => + ({ + id, + disabled: false, + data: { current: columnData[id] }, + }) as unknown as DroppableContainer, + ); + return boardCollisionDetection({ + active: { id: "card" } as never, + collisionRect: rect(100, 120, 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: 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("targets the first card when the pointer is over a column header", () => { + expect(detect({ x: 400, y: 104 })).toEqual(["c"]); + }); + + 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", () => { + expect(detect({ x: 700, y: 160 })).toEqual([]); + expect(detect({ x: 400, y: 900 })).toEqual([]); + }); + + it("falls back to corner distance without a pointer", () => { + 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 new file mode 100644 index 0000000000..b603027252 --- /dev/null +++ b/apps/web/src/components/kanban-board/board-collision-detection.ts @@ -0,0 +1,57 @@ +import { + type CollisionDetection, + closestCorners, + type DroppableContainer, +} 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: pointer, + droppableContainers, + droppableRects, + } = args; + if (!pointer) return closestCorners(args); + + 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: column.id }; + let closestDistance = Number.POSITIVE_INFINITY; + const tasks = + (column.data.current as ColumnData | undefined)?.column?.tasks ?? []; + for (const task of tasks) { + const rect = droppableRects.get(task.id); + 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; + } + } + return [closest]; +}; 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..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,70 +1,54 @@ -import { useDroppable } from "@dnd-kit/core"; import { SortableContext, + type SortingStrategy, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; -import { 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; - onIsOverChange?: (isOver: boolean) => void; + disableSorting?: boolean; }; export function ColumnDropzone({ column, + activeTaskId, disableDragDrop = false, - onIsOverChange, + disableSorting = false, }: 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) => ( - - - - ))} - -
-
-
+ +
+ + {column.tasks.map((task) => ( + + + + ))} + +
+
); } diff --git a/apps/web/src/components/kanban-board/column/column-sort-hint.tsx b/apps/web/src/components/kanban-board/column/column-sort-hint.tsx new file mode 100644 index 0000000000..20ac970533 --- /dev/null +++ b/apps/web/src/components/kanban-board/column/column-sort-hint.tsx @@ -0,0 +1,10 @@ +export function ColumnSortHint({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} diff --git a/apps/web/src/components/kanban-board/column/index.tsx b/apps/web/src/components/kanban-board/column/index.tsx index ef2ba710a4..3404918d97 100644 --- a/apps/web/src/components/kanban-board/column/index.tsx +++ b/apps/web/src/components/kanban-board/column/index.tsx @@ -1,14 +1,19 @@ +import { useDroppable } from "@dnd-kit/core"; import { cva } from "class-variance-authority"; -import { useState } from "react"; +import { memo } from "react"; import { useBackgroundStore } from "@/store/background"; import type { ProjectWithTasks } from "@/types/project"; import { ColumnAddTask } from "./column-add-task"; import { ColumnDropzone } from "./column-dropzone"; import { ColumnHeader } from "./column-header"; +import { ColumnSortHint } from "./column-sort-hint"; type ColumnProps = { column: ProjectWithTasks["columns"][number]; + activeTaskId: string | null; + sortHint?: string; disableDragDrop?: boolean; + disableSorting?: boolean; disableCollectionActions?: boolean; }; @@ -56,16 +61,23 @@ export const columnVariants = cva( function Column({ column, + activeTaskId, + sortHint, disableDragDrop = false, + 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 (
@@ -78,14 +90,16 @@ function Column({
{/* New work starts in an open column; finished ones only collect. */} {!column.isFinal && }
+ {sortHint && }
); } -export default Column; +export default memo(Column); 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..ec3fa5b063 --- /dev/null +++ b/apps/web/src/components/kanban-board/drag-preview/use-drag-preview.ts @@ -0,0 +1,48 @@ +import type { Active, Over } from "@dnd-kit/core"; +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"; +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 preview = useMemo( + () => + hover + ? moveIntoHoveredColumn(project, hover.activeId, hover.overId) + : null, + [project, hover], + ); + + const update = (next: DragHover | null) => { + hoverRef.current = next; + setHover(next); + }; + + return { + preview, + hover: (active: Active, over: Over) => { + const activeId = active.id.toString(); + const overId = over.id.toString(); + 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: (activeId: string, overId: string) => { + const shown = previewFor(hoverRef.current); + 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 f542492a8f..768ebc0854 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 { TaskReorder } from "@/fetchers/task/reorder-tasks"; import type { ProjectWithTasks } from "@/types/project"; import KanbanBoard from "./index"; @@ -39,7 +40,10 @@ vi.mock("@tanstack/react-query", () => ({ vi.mock("@kaneo/libs", () => ({ client: {} })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); vi.mock("react-i18next", () => ({ - useTranslation: () => ({ t: (key: string) => key }), + useTranslation: () => ({ + t: (key: string, options?: { sort?: string }) => + options?.sort ? `${key}:${options.sort}` : key, + }), })); vi.mock("@/store/project", () => ({ default: () => ({ project: mocks.project, setProject: mocks.setProject }), @@ -54,32 +58,67 @@ 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, + sortHint, + }: { + column: { id: string; tasks: { id: string }[] }; + sortHint?: string; + }) => ( +
task.id).join(",")} + data-sort-hint={sortHint} + /> + ), +})); vi.mock("./task-card", () => ({ default: () => null })); vi.mock("@dnd-kit/core", () => ({ DndContext: ({ + children, onDragStart, + onDragOver, onDragEnd, + onDragCancel, }: { + children: ReactNode; onDragStart: (event: unknown) => void; + onDragOver: (event: unknown) => void; onDragEnd: (event: unknown) => void; - }) => ( - <> - - - - ), + 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: {}, KeyboardSensor: {}, closestCorners: {}, + pointerWithin: {}, useSensor: vi.fn(), useSensors: vi.fn(), defaultDropAnimationSideEffects: vi.fn(), @@ -197,3 +236,207 @@ it("refreshes personal work after a cross-column status change", () => { queryKey: ["projects"], }); }); + +function crossColumnBoard() { + return { + 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; +} + +function shownIds(view: ReturnType, columnId: string) { + return view.getByTestId(`column-${columnId}`).getAttribute("data-task-ids"); +} + +function savedIds(columnIndex: number) { + return mocks.setProject.mock.calls[0][0].columns[columnIndex].tasks.map( + (task: { id: string }) => task.id, + ); +} + +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(); + + fireEvent.click(view.getByText("start")); + fireEvent.click(view.getByText("over-c")); + expect(shownIds(view, "todo")).toBe(""); + expect(shownIds(view, "doing")).toBe("a,c,d"); + + 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"); + }); + + it("applies a final sortable hover inside the destination 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("drop-c")); + expect(savedIds(1)).toEqual(["c", "a", "d"]); + }); + + it("leaves the board unchanged when the card returns to its 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-todo")); + expect(shownIds(view, "todo")).toBe("a"); + expect(shownIds(view, "doing")).toBe("c,d"); + + fireEvent.click(view.getByText("drop-on-card")); + expect(mocks.setProject).not.toHaveBeenCalled(); + expect(mocks.reorder).not.toHaveBeenCalled(); + }); + + it("restores the board when the drag is cancelled", () => { + const project = crossColumnBoard(); + mocks.project = project; + const view = render(); + + 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(); + }); + + 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(mocks.reorder).not.toHaveBeenCalled(); + }); + + it("ignores a release outside every column 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("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; + 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([ + ["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( + , + ); + + 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", + ); + + 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(); + 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 73fe7a844f..89a41da053 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -2,10 +2,10 @@ 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, + type DragOverEvent, type DragStartEvent, type DropAnimation, defaultDropAnimationSideEffects, @@ -23,7 +23,10 @@ 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 { 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"; import { useRegisterShortcuts } from "@/hooks/use-keyboard-shortcuts"; import { useProjectBackground } from "@/hooks/use-project-background"; @@ -41,6 +44,7 @@ type KanbanBoardProps = { disableDragDrop?: boolean; disableCollectionActions?: boolean; sortedByNumber?: boolean; + sortedByPriority?: boolean; }; function KanbanBoard({ @@ -48,7 +52,9 @@ function KanbanBoard({ disableDragDrop = false, disableCollectionActions = false, sortedByNumber = false, + sortedByPriority = false, }: KanbanBoardProps) { + const isAutomaticallySorted = sortedByNumber || sortedByPriority; const queryClient = useQueryClient(); const { project: storedProject, setProject } = useProjectStore(); const setAvailableTasks = useBulkSelectionStore( @@ -60,6 +66,8 @@ function KanbanBoard({ const clearFocus = useBulkSelectionStore((state) => state.clearFocus); const [activeIsFinal, setActiveIsFinal] = useState(); const [activeId, setActiveId] = useState(null); + const [sortHintColumnId, setSortHintColumnId] = useState(null); + const dragPreview = useDragPreview(project); const { t } = useTranslation(); const { mutate: reorder, isPending: isReordering } = useMutation({ mutationFn: ({ @@ -190,22 +198,43 @@ function KanbanBoard({ setActiveIsFinal(typeof isFinal === "boolean" ? isFinal : undefined); }; - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; + const resetDrag = () => { setActiveId(null); + setSortHintColumnId(null); + dragPreview.clear(); + }; - if (!over || !project?.columns) return; + const isDropBlocked = () => + disableDragDrop || + isReordering || + queryClient.getQueryState(["tasks", project.id])?.fetchStatus === + "fetching"; + const handleDragOver = ({ active, over }: DragOverEvent) => { + if (!over || isDropBlocked()) { + setSortHintColumnId(null); + dragPreview.clear(); + return; + } + if (!isAutomaticallySorted) { + dragPreview.hover(active, over); + return; + } + setSortHintColumnId( + 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(); + const overId = over?.id.toString(); + const placement = overId + ? dragPreview.getDropPlacement(activeId, overId) + : null; + resetDrag(); - if ( - disableDragDrop || - isReordering || - queryClient.getQueryState(["tasks", project.id])?.fetchStatus === - "fetching" - ) - return; + if (!overId || !project?.columns || isDropBlocked()) return; const canonical = selectReorderBoard( project.id, activeId, @@ -214,7 +243,15 @@ function KanbanBoard({ ); if (!canonical) return; - const moved = moveBoardTask(canonical, activeId, overId, sortedByNumber); + const moved = placement + ? moveBoardTask( + canonical, + activeId, + placement.overId, + false, + placement.insertAfterTarget, + ) + : moveBoardTask(canonical, activeId, overId, isAutomaticallySorted); if (!moved || !moved.tasks.length) return; for (const task of moved.tasks) markBoardCacheChanged(queryClient, project.id, task.id); @@ -279,9 +316,11 @@ function KanbanBoard({ return (
- {project.columns?.map((column) => ( + {(dragPreview.preview ?? project).columns.map((column) => (
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..5b75b71678 100644 --- a/apps/web/src/components/kanban-board/move-task.test.ts +++ b/apps/web/src/components/kanban-board/move-task.test.ts @@ -74,6 +74,15 @@ describe("board moves", () => { { 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, false)!; + + expect(moved.project.columns[1].tasks.map((task) => task.id)).toEqual([ + "a", + "c", + ]); + }); }); 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..9e30b4ee00 100644 --- a/apps/web/src/components/kanban-board/move-task.ts +++ b/apps/web/src/components/kanban-board/move-task.ts @@ -5,7 +5,8 @@ export function moveBoardTask( project: ProjectWithTasks, activeId: string, overId: string, - sortedByNumber = false, + appendOnly = false, + insertAfterTarget?: boolean, ) { const source = project.columns.find((column) => column.tasks.some((task) => task.id === activeId), @@ -18,7 +19,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) => { @@ -27,7 +28,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); @@ -37,8 +38,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/drag-listeners.ts b/apps/web/src/components/kanban-board/task-card/drag-listeners.ts new file mode 100644 index 0000000000..6c529d08e0 --- /dev/null +++ b/apps/web/src/components/kanban-board/task-card/drag-listeners.ts @@ -0,0 +1,3 @@ +import type { useSortable } from "@dnd-kit/sortable"; + +export type DragListeners = ReturnType["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 77% 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..30ba2bba6c 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, @@ -97,14 +87,10 @@ function TaskCard({ showLabels, 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 [isDeleteTaskModalOpen, setIsDeleteTaskModalOpen] = useState< + boolean | null + >(null); + const [hasOpenedMenu, setHasOpenedMenu] = useState(false); const isTaskSelected = useBulkSelectionStore((state) => state.selectedTaskIds.has(task.id), ); @@ -140,15 +126,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 +136,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 +150,16 @@ function TaskCard({ }; 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")} + + + + + )} + ); } -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 }; +} 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); 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"} /> ) : (