diff --git a/backend/app/services/TaskService.scala b/backend/app/services/TaskService.scala index df96981..479be45 100644 --- a/backend/app/services/TaskService.scala +++ b/backend/app/services/TaskService.scala @@ -321,7 +321,7 @@ class TaskService @Inject()(taskRepository: TaskRepository, def updatePosition(taskId: Int, req: UpdateTaskPositionRequest, userId: Int): Future[Int] = { val action = for { - (task, _) <- getTaskAndProjectByTaskId(taskId, userId) + (task, projectId) <- getTaskAndProjectByTaskId(taskId, userId) _ <- if (task.status == TaskStatus.active) { DBIO.successful(()) @@ -332,8 +332,19 @@ class TaskService @Inject()(taskRepository: TaskRepository, } updatedRows <- taskRepository.updatePosition(taskId, req.position, req.columnId) + _ <- updatedRows match { + case 1 => + broadcastService.broadcastToProject( + projectId, + TaskMoved(TaskMovedPayload(taskId, task.columnId, req.columnId, req.position))) + DBIO.successful(()) + case 0 => + DBIO.failed(AppException( + message = s"Failed to update task position", + statusCode = Status.BAD_REQUEST + )) + } } yield updatedRows - db.run(action.transactionally) } diff --git a/frontend/src/components/board/AddTask.tsx b/frontend/src/components/board/AddTask.tsx index 6d8b6ca..2b3cf59 100644 --- a/frontend/src/components/board/AddTask.tsx +++ b/frontend/src/components/board/AddTask.tsx @@ -18,6 +18,7 @@ const AddTask: React.FC = ({ columnId }) => { const [taskTitle, setTaskTitle] = useState(""); const [detectedUrl, setDetectedUrl] = useState(null); const inputRef = useRef(null); + const formRef = useRef(null); const { isAddingTask, columnId: currentToggleColumn } = useSelector(selectColumnToggleStates); const maxPosition = useSelector(selectMaxTaskPositionByColumn(columnId)); @@ -35,10 +36,11 @@ const AddTask: React.FC = ({ columnId }) => { const result = await taskService.createTask(columnId, taskTitle.trim(), newPosition); notify.success(result.message); setTaskTitle(""); + dispatch(resetcolumnToggleStates()); } catch (error: any) { notify.error(error.response?.data?.message || "Failed to create card"); } - }, [columnId, maxPosition, taskTitle]); + }, [columnId, maxPosition, taskTitle, dispatch]); const handleRemoveUrlPreview = useCallback(() => { if (detectedUrl) { @@ -70,9 +72,16 @@ const AddTask: React.FC = ({ columnId }) => { [handleSubmitAddTask, cancelAddingTask] ); - const handleBlur = useCallback(() => { - dispatch(resetcolumnToggleStates()); - }, []); + // Cancel adding task when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (formRef.current && !formRef.current.contains(e.target as Node)) { + dispatch(resetcolumnToggleStates()); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [dispatch]); useEffect(() => { if (isAddingTask) { @@ -94,7 +103,6 @@ const AddTask: React.FC = ({ columnId }) => { value={taskTitle} onChange={(e) => setTaskTitle(e.target.value)} onKeyDown={handleKeyDown} - onBlur={handleBlur} placeholder="Enter a title or paste a link" className="w-full p-2 text-sm bg-[#22272B] text-[#B6C2CF] border border-[#394B59] rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" rows={3} @@ -128,4 +136,4 @@ const AddTask: React.FC = ({ columnId }) => { ); }; -export default AddTask; +export default AddTask; \ No newline at end of file diff --git a/frontend/src/components/board/BoardContent.tsx b/frontend/src/components/board/BoardContent.tsx index cd14809..a0209a4 100644 --- a/frontend/src/components/board/BoardContent.tsx +++ b/frontend/src/components/board/BoardContent.tsx @@ -5,426 +5,391 @@ import LoadingContent from '@/components/ui/LoadingContent'; import { useBoardData } from '@/hooks/useBoardData'; import { useBoardOperations } from '@/hooks/useBoardOperations'; import { updateColumnPosititon } from '@/services/boardService'; +import taskService from '@/services/taskService'; import { useAppSelector } from '@/store'; import { selectActiveColumns } from '@/store/selectors/columnsSelector'; -import { selectTaskById, selectTasksByColumns } from '@/store/selectors/tasksSelectors'; -import { columnsReordered, setColumns } from '@/store/slices/columnsSlice'; -import type { Column } from '@/types'; +import { selectTasksByColumns } from '@/store/selectors/tasksSelectors'; +import { addTaskToColumn, removeTaskFromColumn, setColumns } from '@/store/slices/columnsSlice'; +import { taskReordered } from '@/store/slices/tasksSlice'; +import type { Column, Task } from '@/types'; import { - DndContext, - DragOverlay, - KeyboardCode, - KeyboardSensor, - PointerSensor, - pointerWithin, - useSensor, - useSensors, - type DragEndEvent, - type DragOverEvent, - type DragStartEvent, - type UniqueIdentifier, + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + pointerWithin, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, + type UniqueIdentifier, } from '@dnd-kit/core'; import { - arrayMove, - horizontalListSortingStrategy, - SortableContext, - sortableKeyboardCoordinates, + arrayMove, + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; import { GripVertical, Plus } from 'lucide-react'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; const WorkspaceBoard = () => { - - const { boardId } = useParams(); - const [activeId, setActiveId] = useState(null); - const containerRef = useRef(null); - const dispatch = useDispatch(); - const columns = useAppSelector(selectActiveColumns); - const tasksByColumn = useAppSelector(selectTasksByColumns); - - const { - boardDetail, - isLoading, - isBoardClosed, - setIsBoardClosed, - reopenBoard, - } = useBoardData(Number(boardId)); - - const { - addColumn, - } = useBoardOperations({ - boardId: Number(boardId), - isBoardClosed, - columns, - }); - - // OPTIMIZATION: Track dragging state separately from active elements - const [isDragging, setIsDragging] = useState(false); - const [dragType, setDragType] = useState<'column' | 'item' | null>(null); - - // OPTIMIZATION: Debounce drag operations - const dragTimeoutRef = useRef(null); - const lastDragOperationRef = useRef(''); - - // OPTIMIZATION: Store temporary drag state separately - const dragStateRef = useRef<{ - originalColumns: Column[]; - currentColumns: Column[]; - hasChanged: boolean; - }>({ - originalColumns: [], - currentColumns: [], - hasChanged: false, - }); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - useSensor(KeyboardSensor, { - keyboardCodes: { - start: [KeyboardCode.Enter], - cancel: [KeyboardCode.Esc], - end: [KeyboardCode.Enter], - }, - }) - ); - - // Memoize column IDs to prevent unnecessary SortableContext rerenders - const columnIds = useMemo(() => columns.map(col => col.id), [columns]); - - // OPTIMIZATION: Memoize active elements only when activeId changes - const activeTask = useAppSelector( - activeId ? selectTaskById(activeId as number) : () => null - ); - - const activeColumn = useMemo(() => { - if (!activeId) return null; - return columns.find(col => col.id === activeId) ?? null; - }, [activeId, columns]); - - const activeElements = { activeColumn, activeTask }; - - const handleDragStart = useCallback( - (event: DragStartEvent) => { - // Prevent drag operations when board is closed - if (isBoardClosed) return; - - // const activatorEvent = event.activatorEvent as KeyboardEvent | undefined; - // if (activatorEvent?.key === ' ') return; - - setActiveId(event.active.id); - setIsDragging(true); - - // Determine drag type - const type = event.active.data.current?.type as 'column' | 'item'; - setDragType(type); - - // Store initial state - dragStateRef.current = { - originalColumns: columns, - currentColumns: columns, - hasChanged: false, - }; - }, - [columns, isBoardClosed] - ); - - // OPTIMIZATION: Heavily optimized drag over with batching and debouncing - const handleDragOver = useCallback( - (event: DragOverEvent) => { - const { active, over } = event; - - if (!over || !isDragging || isBoardClosed) return; - - const activeId = active.id as number; - const overId = over.id as number; - - if (activeId === overId) return; - - // OPTIMIZATION: Create operation signature to prevent duplicate operations - const operationSignature = `${activeId}-${overId}`; - if (lastDragOperationRef.current === operationSignature) { - return; - } - lastDragOperationRef.current = operationSignature; - - // OPTIMIZATION: Clear previous timeout and batch operations - if (dragTimeoutRef.current) { - clearTimeout(dragTimeoutRef.current); - } - - // OPTIMIZATION 10: Batch drag operations with timeout - dragTimeoutRef.current = setTimeout(() => { - const isActiveAnItem = active.data.current?.type === 'item'; - const isOverAnItem = over.data.current?.type === 'item'; - const isOverAColumn = over.data.current?.type === 'column'; - - if (!isActiveAnItem) return; - - // Work with current drag state instead of component state - const newColumns = [...dragStateRef.current.currentColumns]; - let hasChanged = false; - - // Item over item (different column) - if (isActiveAnItem && isOverAnItem) { - const activeColumnIndex = newColumns.findIndex(col => - col.taskIds.includes(activeId) - ); - const overColumnIndex = newColumns.findIndex(col => - col.taskIds.includes(overId) - ); - - if ( - activeColumnIndex !== -1 && - overColumnIndex !== -1 && - activeColumnIndex !== overColumnIndex - ) { - const activeColumn = { - ...newColumns[activeColumnIndex], - }; - const overColumn = { ...newColumns[overColumnIndex] }; - - const activeItemIndex = activeColumn.taskIds.indexOf(activeId); - const overItemIndex = overColumn.taskIds.indexOf(overId); - - if (activeItemIndex !== -1 && overItemIndex !== -1) { - activeColumn.taskIds = [...activeColumn.taskIds]; - overColumn.taskIds = [...overColumn.taskIds]; - - activeColumn.taskIds.splice(activeItemIndex, 1); - overColumn.taskIds.splice(overItemIndex, 0, activeId); - - - newColumns[activeColumnIndex] = activeColumn; - newColumns[overColumnIndex] = overColumn; - hasChanged = true; - } - } - } - - // Item over column - if (isActiveAnItem && isOverAColumn) { - const activeColumnIndex = newColumns.findIndex(col => - col.taskIds.includes(activeId) - ); - const overColumnIndex = newColumns.findIndex( - col => col.id === overId - ); - - if ( - activeColumnIndex !== -1 && - overColumnIndex !== -1 && - activeColumnIndex !== overColumnIndex - ) { - const activeColumn = { - ...newColumns[activeColumnIndex], - }; - const overColumn = { ...newColumns[overColumnIndex] }; - - const activeItemIndex = activeColumn.taskIds.indexOf(activeId); - - if (activeItemIndex !== -1) { - activeColumn.taskIds = [...activeColumn.taskIds]; - overColumn.taskIds = [...overColumn.taskIds]; - - activeColumn.taskIds.splice(activeItemIndex, 1); - overColumn.taskIds.push(activeId); - - newColumns[activeColumnIndex] = activeColumn; - newColumns[overColumnIndex] = overColumn; - hasChanged = true; - } - } - } - - // OPTIMIZATION: Only update state if something actually changed - if (hasChanged) { - dragStateRef.current.currentColumns = newColumns; - dragStateRef.current.hasChanged = true; - - // Update component state less frequently - dispatch(columnsReordered(newColumns)); - } - }, 16); // OPTIMIZATION: 16ms delay = ~60fps batching - }, - [isDragging, isBoardClosed] - ); - - const handleDragEnd = useCallback((event: DragEndEvent) => { - const { active, over } = event; - - // OPTIMIZATION: Clear timeout and reset refs - if (dragTimeoutRef.current) { - clearTimeout(dragTimeoutRef.current); - dragTimeoutRef.current = null; + const { boardId } = useParams(); + const dispatch = useDispatch(); + + const columns = useAppSelector(selectActiveColumns); // Column[] + const tasks = useAppSelector(state => state.tasks); + const { + boardDetail, + isLoading, + isBoardClosed, + setIsBoardClosed, + reopenBoard, + setIsLoading + } = useBoardData(Number(boardId)); + + const { addColumn } = useBoardOperations({ + boardId: Number(boardId), + isBoardClosed, + columns, + }); + + const [dragData, setDragData] = useState< + { + id: UniqueIdentifier | null; + type: 'column' | 'item' | null; + data: Column | Task | null; + } + | null + >(); + + // const [overData, setOverData] = useRef(null); + + console.log("Rendering BoardContent: ", boardId); + console.log("DragData: ", dragData) + + // Drag state for overlay & quick lookup (local, detached from Redux) + + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + // columnIds memo to avoid unnecessary SortableContext re-renders + const columnIds = useMemo(() => columns.map((c) => c.id), [columns]); + + // ---------- Handlers ---------- + const handleDragStart = useCallback( + (event: DragStartEvent) => { + if (isBoardClosed) return; + + console.log("drag start ", event); + + setDragData({ + id: event.active.id, + type: event.active.data?.current?.type as 'column' | 'item', + data: event.active.data?.current?.data + }); + + console.log("end of drag data") + }, + [isBoardClosed] + ); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + if (isBoardClosed) return; + const { active, over } = event; + if (!over) return; + if (active.id === over.id && active.data?.current?.type === over.data?.current?.type) return; + + console.log("drag over ", event) + + // If dragging columns (reordering columns), we don't need onDragOver for visual move because sortable handles it. + + // If dragging an item, and over is a column or item in another column -> move item optimistically + if (active.data?.current?.type === 'item') { + const activeItemId = Number(active.id); + const overIdNum = Number(over.id); + const fromColumn = columns.find((c) => c.taskIds.includes(activeItemId)); + + if (!fromColumn) return; + + if (over.data?.current?.type === 'item') { + // If still same column and over item same, do nothing + const toColumn = columns.find((c) => c.taskIds.includes(overIdNum)); + if (!toColumn || fromColumn.id === toColumn.id) return; + + // Move item from source column -> target column + console.log("drag item in different column") + const toIndex = toColumn.taskIds.indexOf(overIdNum); + const prevItem = tasks.byId[toColumn.taskIds[toIndex - 1]] ?? null; + const nextItem = tasks.byId[toColumn.taskIds[toIndex]] ?? null; + + let newPosition: number; + if (prevItem && nextItem) { + newPosition = Math.floor((prevItem.position + nextItem.position) / 2); + } else if (!prevItem && nextItem) { + newPosition = Math.floor(nextItem.position / 2); + } else if (prevItem && !nextItem) { + newPosition = prevItem.position + 1000; + } else { + newPosition = 1000; + } + + dispatch(removeTaskFromColumn({ taskId: activeItemId, columnId: fromColumn.id })); + dispatch(taskReordered({ taskId: activeItemId, newPosition })); + dispatch(addTaskToColumn({ columnId: toColumn.id, taskId: activeItemId, index: toIndex })) } - - setActiveId(null); - setIsDragging(false); - setDragType(null); - lastDragOperationRef.current = ''; - - // If board is closed, restore original state and return - if (isBoardClosed) { - dispatch(setColumns(dragStateRef.current.originalColumns)); - return; + // drag item into empty column + else if (over.data?.current?.type === 'column' && fromColumn.id !== overIdNum && over.data.current.data.taskIds.length === 0) { + console.log("drag item into empty column") + dispatch(removeTaskFromColumn({ taskId: activeItemId, columnId: fromColumn.id })); + dispatch(addTaskToColumn({ + columnId: Number(over.id), + taskId: Number(activeItemId), + index: -1 + })); } - - if (!over) { - // Restore original state if cancelled - dispatch(setColumns(dragStateRef.current.originalColumns)); - return; + } + + // For column dragging we rely on SortableContext default behavior — no custom onDragOver required + }, + [isBoardClosed, columns, dispatch] + ); + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + console.log("drag end ", event); + setDragData(null); + + if (!over || isBoardClosed) return; + + const activeIdNum = Number(active.id); + const overIdNum = Number(over.id); + + const activeIsColumn = active.data?.current?.type === 'column'; + const overIsColumn = over.data?.current?.type === 'column'; + + // ============================== + // 1️⃣ COLUMN REORDER + // ============================== + if (activeIsColumn && overIsColumn && activeIdNum !== overIdNum) { + const oldIndex = columns.findIndex(c => c.id === activeIdNum); + const newIndex = columns.findIndex(c => c.id === overIdNum); + const newColumns = arrayMove(columns, oldIndex, newIndex); + + const movedIndex = newIndex; + const preCol = newColumns[movedIndex - 1] ?? null; + const nextCol = newColumns[movedIndex + 1] ?? null; + + let newPosition: number; + if (preCol && nextCol) newPosition = Math.floor((preCol.position + nextCol.position) / 2); + else if (!preCol && nextCol) newPosition = Math.floor(nextCol.position / 2); + else if (preCol && !nextCol) newPosition = preCol.position + 1000; + else newPosition = 1000; + + try { + setIsLoading(true); + await updateColumnPosititon(Number(boardId), activeIdNum, newPosition); + } catch (err) { + console.error('updateColumnPosititon failed', err); + } finally { + setIsLoading(false); + } + return; + } + + // ============================== + // 2️⃣ TASK REORDER + // ============================== + if (!activeIsColumn && !overIsColumn) { + const fromCol = columns.find(c => c.taskIds.includes(activeIdNum)); + const toCol = columns.find(c => c.taskIds.includes(overIdNum)); + if (!fromCol || !toCol) return; + + // ===== SAME COLUMN ===== + if (fromCol.id === toCol.id) { + const tasksInCol = [...fromCol.taskIds]; + const oldIndex = tasksInCol.indexOf(activeIdNum); + const newIndex = tasksInCol.indexOf(overIdNum); + + // ✅ find direction (before / after) + const activeRect = active.rect.current.translated; + const overRect = over.rect; + const isAfter = activeRect && overRect + ? activeRect.top > overRect.top + overRect.height / 2 + : false; + + // move task into temp array + tasksInCol.splice(oldIndex, 1); + const targetIndex = isAfter ? newIndex + 1 : newIndex; + tasksInCol.splice(targetIndex, 0, activeIdNum); + + // ✅ find prev/next to calculate position + const movedIndex = tasksInCol.indexOf(activeIdNum); + const prevTaskId = tasksInCol[movedIndex - 1]; + const nextTaskId = tasksInCol[movedIndex + 1]; + + const prevItem = prevTaskId ? tasks.byId[prevTaskId] : null; + const nextItem = nextTaskId ? tasks.byId[nextTaskId] : null; + + let newPosition: number; + if (prevItem && nextItem) + newPosition = Math.floor((prevItem.position + nextItem.position) / 2); + else if (!prevItem && nextItem) + newPosition = Math.floor(nextItem.position / 2); + else if (prevItem && !nextItem) + newPosition = prevItem.position + 1000; + else newPosition = 1000; + + // Persist + update UI + try { + setIsLoading(true); + await taskService.updateTaskPosition(activeIdNum, fromCol.id, newPosition); + dispatch(taskReordered({ taskId: activeIdNum, newPosition })); + dispatch(setColumns( + columns.map(c => + c.id === fromCol.id ? { ...c, taskIds: tasksInCol } : c + ) + )); + } catch (err) { + console.error('updateTaskPosititon failed', err); + } finally { + setIsLoading(false); } - - const activeId = active.id as number; - const overId = over.id as number; - - const isActiveAColumn = active.data.current?.type === 'column'; - const isOverAColumn = over.data.current?.type === 'column'; - const isActiveAnItem = active.data.current?.type === 'item'; - const isOverAnItem = over.data.current?.type === 'item'; - - // Handle column reordering - if (isActiveAColumn && isOverAColumn) { - const activeColumnIndex = columns.findIndex(col => col.id === activeId); - const overColumnIndex = columns.findIndex(col => col.id === overId); - - if (activeColumnIndex !== -1 && overColumnIndex !== -1) { - const newColumns = arrayMove(columns, activeColumnIndex, overColumnIndex); - const movedIndex = overColumnIndex; - - const preCol = newColumns[movedIndex - 1] || null; - const nextCol = newColumns[movedIndex + 1] || null; - - let newPosition: number; - if (preCol && nextCol) { - newPosition = (preCol.position + nextCol.position) / 2; - } else if (!preCol && nextCol) { - newPosition = nextCol.position - 1000; - } else if (preCol && !nextCol) { - newPosition = preCol.position + 1000; - } else { - newPosition = 1000; - } - - const movedColumn = { ...newColumns[movedIndex], position: Math.floor(newPosition) }; - newColumns[movedIndex] = movedColumn; - dispatch(columnsReordered(newColumns)); - updateColumnPosititon(Number(boardId), movedColumn.id, movedColumn.position); - } + return; + } + + // ===== DIFFERENT COLUMN ===== + if (fromCol.id !== toCol.id) { + const newColumns = columns.map(c => ({ ...c, taskIds: [...c.taskIds] })); + const from = newColumns.find(c => c.id === fromCol.id)!; + const to = newColumns.find(c => c.id === toCol.id)!; + + from.taskIds = from.taskIds.filter(id => id !== activeIdNum); + + // ✅ xác định hướng before/after để insert đúng chỗ + const overIndex = to.taskIds.indexOf(overIdNum); + const activeRect = active.rect.current.translated; + const overRect = over.rect; + const isAfter = activeRect && overRect + ? activeRect.top > overRect.top + overRect.height / 2 + : false; + + const insertAt = isAfter ? overIndex + 1 : overIndex; + to.taskIds.splice(insertAt, 0, activeIdNum); + + // ✅ prev/next để tính position chính xác + const movedIndex = to.taskIds.indexOf(activeIdNum); + const prevTaskId = to.taskIds[movedIndex - 1]; + const nextTaskId = to.taskIds[movedIndex + 1]; + const prevItem = prevTaskId ? tasks.byId[prevTaskId] : null; + const nextItem = nextTaskId ? tasks.byId[nextTaskId] : null; + + let newPosition: number; + if (prevItem && nextItem) + newPosition = Math.floor((prevItem.position + nextItem.position) / 2); + else if (!prevItem && nextItem) + newPosition = Math.floor(nextItem.position / 2); + else if (prevItem && !nextItem) + newPosition = prevItem.position + 1000; + else newPosition = 1000; + + try { + setIsLoading(true); + await taskService.updateTaskPosition(activeIdNum, to.id, newPosition); + dispatch(removeTaskFromColumn({ taskId: activeIdNum, columnId: from.id })); + dispatch(addTaskToColumn({ columnId: to.id, taskId: activeIdNum, index: insertAt })); + dispatch(taskReordered({ taskId: activeIdNum, newPosition })); + } catch (err) { + console.error('updateTaskPosititon failed', err); + } finally { + setIsLoading(false); } + } + } + }; - // Handle item reordering within same column - if (isActiveAnItem && isOverAnItem) { - const activeColumnIndex = columns.findIndex(col => - col.taskIds.includes(activeId) - ); - const overColumnIndex = columns.findIndex(col => - col.taskIds.includes(overId) - ); - if (activeColumnIndex === overColumnIndex && activeColumnIndex !== -1) { - const newColumns = [...columns]; - const column = { ...newColumns[activeColumnIndex] }; - const activeItemIndex = column.taskIds.indexOf(activeId); - const overItemIndex = column.taskIds.indexOf(overId); - - if (activeItemIndex !== -1 && overItemIndex !== -1) { - column.taskIds = arrayMove(column.taskIds, activeItemIndex, overItemIndex); - - newColumns[activeColumnIndex] = column; - - dispatch(setColumns(newColumns)); - } - } - } - }, [isBoardClosed]); - - console.log("worspace board: " + boardId); - return ( -
- { - isLoading ? -
- -
: - <> - ' - - -
- -
- - {columns.map(col => ( - - ))} - - - -
- - {!isBoardClosed && activeElements.activeColumn ? ( -
-
- -

- {activeElements.activeColumn.name} -

- - { - activeElements.activeColumn.taskIds - .length - } - -
-
- ) : !isBoardClosed && activeElements.activeTask ? ( -
- - {activeElements.activeTask.name} - -
- ) : null} -
-
-
- - } + // ---------- Render ---------- + return ( +
+ {isLoading ? ( +
+
- ); + ) : ( + <> + + + +
+ +
+ + {columns.map((col) => ( + + ))} + + + {!isBoardClosed && ( + + )} +
+ + {/* Drag overlay uses local activeData to avoid disappearing during redux rerender */} + + {!isBoardClosed && dragData?.type === 'column' ? ( +
+
+ +

{(dragData.data as Column).name}

+ + {(dragData.data as Column).taskIds?.length ?? 0} + +
+
+ ) : !isBoardClosed && dragData?.type === 'item' ? ( +
+ + {(dragData.data as Task).name} + +
+ ) : null} +
+
+
+ + )} +
+ ); }; -export default memo(WorkspaceBoard); \ No newline at end of file +export default memo(WorkspaceBoard); diff --git a/frontend/src/components/board/DraggableItem.tsx b/frontend/src/components/board/DraggableItem.tsx index eeb035b..93e5913 100644 --- a/frontend/src/components/board/DraggableItem.tsx +++ b/frontend/src/components/board/DraggableItem.tsx @@ -31,7 +31,7 @@ const DraggableItem: React.FC = ({ id: item.id, data: { type: 'item', - item, + data: item, }, }); diff --git a/frontend/src/components/board/DroppableColumn.tsx b/frontend/src/components/board/DroppableColumn.tsx index 60a0b77..6374e93 100644 --- a/frontend/src/components/board/DroppableColumn.tsx +++ b/frontend/src/components/board/DroppableColumn.tsx @@ -12,15 +12,16 @@ import React, { import DraggableItem from './DraggableItem'; import ColumnHeader from './ColumnHeader'; import AddTask from './AddTask'; +import type { UniqueIdentifier } from '@dnd-kit/core'; interface DroppableColumnProps { column: Column; - items: Task[]; + itemIds: UniqueIdentifier[]; } const DroppableColumnComponent: React.FC = ({ column, - items, + itemIds, }) => { const columnRef = useRef(null); @@ -35,7 +36,7 @@ const DroppableColumnComponent: React.FC = ({ id: column.id, data: { type: 'column', - column, + data: column, }, }); @@ -44,7 +45,7 @@ const DroppableColumnComponent: React.FC = ({ transition, }; - const itemIds = useMemo(() => items.map(item => item.id), [items]); + // const itemIds = useMemo(() => items.map(item => item.id), [items]); console.log('Rendering DroppableColumn:', column.id, column.name); @@ -69,10 +70,10 @@ const DroppableColumnComponent: React.FC = ({ >
- {items?.map(item => ( + {itemIds?.map(id => ( ))}
@@ -98,18 +99,18 @@ const arePropsEqual = ( } // Check items array - if (prevProps.items.length !== nextProps.items.length) { + if (prevProps.itemIds.length !== nextProps.itemIds.length) { return false; } // Compare each item - for (let i = 0; i < prevProps.items.length; i++) { - const prevItem = prevProps.items[i]; - const nextItem = nextProps.items[i]; + for (let i = 0; i < prevProps.itemIds.length; i++) { + const prevItem = prevProps.itemIds[i]; + const nextItem = nextProps.itemIds[i]; if ( - prevItem.id !== nextItem.id || - prevItem.name !== nextItem.name + prevItem !== nextItem + // prevItem.name !== nextItem.name ) { return false; } diff --git a/frontend/src/hooks/useBoardData.ts b/frontend/src/hooks/useBoardData.ts index 12b608f..0dccd02 100644 --- a/frontend/src/hooks/useBoardData.ts +++ b/frontend/src/hooks/useBoardData.ts @@ -18,6 +18,7 @@ interface UseBoardDataReturn { setIsBoardClosed: (closed: boolean) => void; refetch: () => Promise; reopenBoard: () => Promise; + setIsLoading: (loading: boolean) => void; } /** @@ -87,7 +88,7 @@ export const useBoardData = (boardId: number): UseBoardDataReturn => { // Initial data fetch fetchBoardData(); - + // Setup WebSocket connection connectToProjectWS(boardId, (message) => { handleBoardWSMessage(message, dispatch, store.getState); @@ -133,5 +134,6 @@ export const useBoardData = (boardId: number): UseBoardDataReturn => { setIsBoardClosed, refetch: fetchBoardData, reopenBoard, + setIsLoading }; }; \ No newline at end of file diff --git a/frontend/src/services/taskService.ts b/frontend/src/services/taskService.ts index fdf6c76..66f85ac 100644 --- a/frontend/src/services/taskService.ts +++ b/frontend/src/services/taskService.ts @@ -48,6 +48,7 @@ const taskService = { removeMember(projectId: number, taskId: number, memberId: number): Promise> { return axiosClients.delete(`/${projectUrl}/${projectId}/${taskUrl}/${taskId}/members/${memberId}`); }, + searchTasks(keyword: string, projectIds?: number[], page: number = 1, size: number = 10): Promise> { return axiosClients.get("/tasks", { params: { @@ -59,6 +60,13 @@ const taskService = { paramsSerializer: (params) => qs.stringify(params, { arrayFormat: "repeat" }), }); + }, + + updateTaskPosition(taskId: number, columnId: number, position: number): Promise> { + return axiosClients.patch(`/${taskUrl}/${taskId}/position`, { + columnId, + position + }) } } diff --git a/frontend/src/store/selectors/tasksSelectors.ts b/frontend/src/store/selectors/tasksSelectors.ts index ba44726..aafaeee 100644 --- a/frontend/src/store/selectors/tasksSelectors.ts +++ b/frontend/src/store/selectors/tasksSelectors.ts @@ -24,7 +24,7 @@ export const selectTasksByColumns = createSelector( Object.fromEntries( allIds.map(id => [ id, - (columnsById[id].taskIds ?? []).map(tid => tasksById[tid]).filter(Boolean) + (columnsById[id].taskIds ?? []).map(tid => tasksById[tid]).filter(Boolean).sort((a, b) => a.position - b.position) ]) ) ); diff --git a/frontend/src/store/slices/columnsSlice.ts b/frontend/src/store/slices/columnsSlice.ts index f8bae76..1ebe2b1 100644 --- a/frontend/src/store/slices/columnsSlice.ts +++ b/frontend/src/store/slices/columnsSlice.ts @@ -24,7 +24,7 @@ const columnsSlice = createSlice({ const column = state.byId[columnId]; if (column) { - column.name = name; + column.name = name; } }, columnRemoved: (state, action: PayloadAction) => { @@ -43,11 +43,9 @@ const columnsSlice = createSlice({ state.allIds.splice(index, 0, column.id); } }, - columnsReordered: (state, action: PayloadAction) => { - action.payload.forEach(col => { - state.byId[col.id] = col; - }); - state.allIds = action.payload.map(col => col.id); + columnsReordered: (state, action: PayloadAction<{ columnId: number, newPosition: number }>) => { + state.byId[action.payload.columnId].position = action.payload.newPosition; + state.allIds.sort((a, b) => state.byId[a].position - state.byId[b].position); }, columnReplaced: ( state, @@ -62,13 +60,9 @@ const columnsSlice = createSlice({ state.byId[realColumn.id] = realColumn; state.allIds.push(realColumn.id); }, - removeTaskFromColumn: (state, action: PayloadAction) => { - const itemId = action.payload; - state.allIds = state.allIds.filter((tid) => tid !== itemId); - - Object.values(state.byId).forEach((column) => { - column.taskIds = column.taskIds.filter((id) => id !== itemId); - }); + removeTaskFromColumn: (state, action: PayloadAction<{ taskId: number, columnId: number }>) => { + const { taskId, columnId } = action.payload; + state.byId[columnId].taskIds = state.byId[columnId].taskIds.filter(id => id !== taskId); }, addTaskToColumn: (state, action: PayloadAction<{ columnId: number; taskId: number, index: number }>) => { const { columnId, taskId, index } = action.payload; diff --git a/frontend/src/store/slices/tasksSlice.ts b/frontend/src/store/slices/tasksSlice.ts index 479199f..f55277d 100644 --- a/frontend/src/store/slices/tasksSlice.ts +++ b/frontend/src/store/slices/tasksSlice.ts @@ -77,7 +77,15 @@ const tasksSlice = createSlice({ task.memberIds.splice(index, 1); } } - } + }, + + taskReordered: (state, action: PayloadAction<{ taskId: number, newPosition: number }>) => { + const { taskId, newPosition } = action.payload; + const task = state.byId[taskId]; + if (task) { + task.position = newPosition; + } + }, } }); @@ -89,6 +97,7 @@ export const { taskRemoved, taskRestored, assignedMemberToTask, - removeMemberFromTask + removeMemberFromTask, + taskReordered } = tasksSlice.actions; export default tasksSlice.reducer; diff --git a/frontend/src/utils/normalize.ts b/frontend/src/utils/normalize.ts index 043b58e..5f2b81f 100644 --- a/frontend/src/utils/normalize.ts +++ b/frontend/src/utils/normalize.ts @@ -6,7 +6,7 @@ export const normalizeColumns = (columns: Column[]): ColumnsState => { acc[col.id] = col; return acc; }, {} as Record), - allIds: columns.map((col) => col.id), + allIds: columns.map((col) => col.id) }; }; diff --git a/frontend/src/websocket/boardHandler.ts b/frontend/src/websocket/boardHandler.ts index b9d5d08..dc234b6 100644 --- a/frontend/src/websocket/boardHandler.ts +++ b/frontend/src/websocket/boardHandler.ts @@ -2,8 +2,8 @@ import type { RootState, AppDispatch } from "@/store"; import { columnArchived, archivedColumnRestored, columnDeleted } from "@/store/slices/archiveColumnsSlice"; import { taskArchived, taskDeleted, archivedTaskRestored } from "@/store/slices/archiveTasksSlice"; -import { addTaskToColumn, columnCreated, columnRemoved, columnReplaced, columnRestored, columnUpdated, removeTaskFromColumn } from "@/store/slices/columnsSlice"; -import { assignedMemberToTask, removeMemberFromTask, taskCreated, taskRemoved, taskReplaced, taskRestored, taskUpdated } from "@/store/slices/tasksSlice"; +import { addTaskToColumn, columnCreated, columnRemoved, columnReplaced, columnRestored, columnsReordered, columnUpdated, removeTaskFromColumn } from "@/store/slices/columnsSlice"; +import { assignedMemberToTask, removeMemberFromTask, taskCreated, taskRemoved, taskReordered, taskReplaced, taskRestored, taskUpdated } from "@/store/slices/tasksSlice"; export const handleBoardWSMessage = ( message: any, @@ -13,6 +13,11 @@ export const handleBoardWSMessage = ( switch (message.type) { case "COLUMN_MOVED": console.log("Column moved", message); + const { columnId, newPosition } = message.payload; + const column = getState().columns.byId[columnId]; + if (column) { + dispatch(columnsReordered({ columnId, newPosition })); + } break; case "COLUMN_CREATED": { @@ -92,7 +97,7 @@ export const handleBoardWSMessage = ( if (updatedStatus === "archived") { const task = getState().tasks.byId[taskId]; if (task) { - dispatch(removeTaskFromColumn(taskId)); + dispatch(removeTaskFromColumn({ taskId, columnId })); dispatch(taskRemoved(taskId)); dispatch(taskArchived(task)); } @@ -126,6 +131,19 @@ export const handleBoardWSMessage = ( break; } + case "TASK_MOVED": { + console.log("Task moved", message); + const { taskId, fromColumnId, toColumnId, newPosition } = message.payload; + // if (!getState().columns.byId[toColumnId].taskIds.includes(taskId)) { + dispatch(taskReordered({ taskId, newPosition })); + dispatch(removeTaskFromColumn({ taskId, columnId: fromColumnId })); + const column = getState().columns.byId[toColumnId]; + const index = column.taskIds.findIndex(id => getState().tasks.byId[id].position > newPosition); + dispatch(addTaskToColumn({ columnId: toColumnId, taskId, index: index === -1 ? -1 : index })); + // } + break; + } + default: console.warn("Unknown WS event", message); }