From 520888988ef70c04a7dfe754da0bb6ba57a3345e Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 9 Jun 2025 09:58:28 +0530 Subject: [PATCH] feat(task-list): implement drag-and-drop functionality for task reordering - Integrated drag-and-drop capabilities in the task list using `@dnd-kit` for improved user experience. - Created a `DraggableRow` component to handle individual task dragging and dropping. - Updated task list rendering to support dynamic reordering and socket integration for backend persistence. - Enhanced task selection and hover effects for better visual feedback during drag operations. - Refactored task list components to streamline rendering and improve performance. --- .../shared/info-tab/info-tab-footer.tsx | 36 ++- .../home/task-list/add-task-inline-form.tsx | 29 +- .../project-view-1/task-list/table-v2.tsx | 298 +++++++++++++++--- .../task-list/task-list-custom.tsx | 158 ++++------ .../task-list-table-wrapper.tsx | 38 ++- .../project-view-1/task-list/task-list.tsx | 17 +- 6 files changed, 406 insertions(+), 170 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx index 2e79c42c..fbd3ba43 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx @@ -97,30 +97,28 @@ const InfoTabFooter = () => { // mentions options const mentionsOptions = members?.map(member => ({ - value: member.id, + value: member.name, label: member.name, + key: member.id, })) ?? []; const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => { console.log('member', member); if (!member?.value || !member?.label) return; + + // Find the member ID from the members list using the name + const selectedMember = members.find(m => m.name === member.value); + if (!selectedMember) return; + + // Add to selected members if not already present setSelectedMembers(prev => - prev.some(mention => mention.team_member_id === member.value) + prev.some(mention => mention.team_member_id === selectedMember.id) ? prev - : [...prev, { team_member_id: member.value, name: member.label }] + : [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }] ); - - setCommentValue(prev => { - const parts = prev.split('@'); - const lastPart = parts[parts.length - 1]; - const mentionText = member.label; - // Keep only the part before the @ and add the new mention - return prev.slice(0, prev.length - lastPart.length) + mentionText; - }); - }, []); + }, [members]); const handleCommentChange = useCallback((value: string) => { - // Only update the value without trying to replace mentions setCommentValue(value); setCharacterLength(value.trim().length); }, []); @@ -275,6 +273,12 @@ const InfoTabFooter = () => { maxLength={5000} onClick={() => setIsCommentBoxExpand(true)} onChange={e => setCharacterLength(e.length)} + prefix="@" + filterOption={(input, option) => { + if (!input) return true; + const optionLabel = (option as any)?.label || ''; + return optionLabel.toLowerCase().includes(input.toLowerCase()); + }} style={{ minHeight: 60, resize: 'none', @@ -371,7 +375,11 @@ const InfoTabFooter = () => { onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)} onChange={handleCommentChange} prefix="@" - split="" + filterOption={(input, option) => { + if (!input) return true; + const optionLabel = (option as any)?.label || ''; + return optionLabel.toLowerCase().includes(input.toLowerCase()); + }} style={{ minHeight: 100, maxHeight: 200, diff --git a/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx b/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx index 2c80df82..878f7c90 100644 --- a/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx +++ b/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx @@ -57,20 +57,31 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => { }, ]; - const calculateEndDate = (dueDate: string): Date | undefined => { + const calculateEndDate = (dueDate: string): string | undefined => { const today = new Date(); + let targetDate: Date; + switch (dueDate) { case 'Today': - return today; + targetDate = new Date(today); + break; case 'Tomorrow': - return new Date(today.setDate(today.getDate() + 1)); + targetDate = new Date(today); + targetDate.setDate(today.getDate() + 1); + break; case 'Next Week': - return new Date(today.setDate(today.getDate() + 7)); + targetDate = new Date(today); + targetDate.setDate(today.getDate() + 7); + break; case 'Next Month': - return new Date(today.setMonth(today.getMonth() + 1)); + targetDate = new Date(today); + targetDate.setMonth(today.getMonth() + 1); + break; default: return undefined; } + + return targetDate.toISOString().split('T')[0]; // Returns YYYY-MM-DD format }; const projectOptions = [ @@ -82,12 +93,16 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => { ]; const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => { - const newTask: IHomeTaskCreateRequest = { + const endDate = calendarView + ? homeTasksConfig.selected_date?.format('YYYY-MM-DD') + : calculateEndDate(values.dueDate); + + const newTask = { name: values.name, project_id: values.project, reporter_id: currentSession?.id, team_id: currentSession?.team_id, - end_date: (calendarView ? homeTasksConfig.selected_date?.format('YYYY-MM-DD') : calculateEndDate(values.dueDate)), + end_date: endDate || new Date().toISOString().split('T')[0], // Fallback to today if undefined }; socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask)); diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/table-v2.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/table-v2.tsx index a613b25e..9d6b836c 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/table-v2.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/table-v2.tsx @@ -1,12 +1,110 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { Checkbox, Flex, Tag, Tooltip } from 'antd'; -import { useVirtualizer } from '@tanstack/react-virtual'; +import { HolderOutlined } from '@ant-design/icons'; +import { + DndContext, + DragEndEvent, + DragStartEvent, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + KeyboardSensor, + TouchSensor, + UniqueIdentifier, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useSocket } from '@/socket/socketContext'; +import { useAuthService } from '@/hooks/useAuth'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { SocketEvents } from '@/shared/socket-events'; +import { reorderTasks } from '@/features/tasks/tasks.slice'; +import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events'; + +// Draggable Row Component +interface DraggableRowProps { + task: IProjectTask; + visibleColumns: Array<{ key: string; width: number }>; + renderCell: (columnKey: string | number, task: IProjectTask, isSubtask?: boolean) => React.ReactNode; + hoverRow: string | null; + onRowHover: (taskId: string | null) => void; + isSubtask?: boolean; +} + +const DraggableRow = ({ + task, + visibleColumns, + renderCell, + hoverRow, + onRowHover, + isSubtask = false +}: DraggableRowProps) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: task.id as UniqueIdentifier, + data: { + type: 'task', + task, + }, + disabled: isSubtask, // Disable drag for subtasks + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : 'auto', + }; + + return ( +
onRowHover(task.id)} + onMouseLeave={() => onRowHover(null)} + > +
+ {!isSubtask && ( +
+ +
+ )} +
+ {visibleColumns.map(column => ( +
+ {renderCell(column.key, task, isSubtask)} +
+ ))} +
+ ); +}; const TaskListTable = ({ taskListGroup, + tableId, visibleColumns, onTaskSelect, onTaskExpand, @@ -18,11 +116,38 @@ const TaskListTable = ({ onTaskExpand?: (taskId: string) => void; }) => { const [hoverRow, setHoverRow] = useState(null); + const [activeId, setActiveId] = useState(null); const tableRef = useRef(null); const parentRef = useRef(null); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { projectId } = useAppSelector(state => state.projectReducer); + const groupBy = useAppSelector(state => state.taskReducer.groupBy); + + const dispatch = useAppDispatch(); + const { socket } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + const { trackMixpanelEvent } = useMixpanelTracking(); - // Memoize all tasks including subtasks for virtualization + // Configure sensors for drag and drop + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }) + ); + + // Memoize all tasks including subtasks const flattenedTasks = useMemo(() => { return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => { acc.push(task); @@ -33,13 +158,10 @@ const TaskListTable = ({ }, []); }, [taskListGroup.tasks]); - // Virtual row renderer - const rowVirtualizer = useVirtualizer({ - count: flattenedTasks.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 42, // row height - overscan: 5, - }); + // Get only main tasks for sortable context (exclude subtasks) + const mainTasks = useMemo(() => { + return taskListGroup.tasks.filter(task => !task.isSubtask); + }, [taskListGroup.tasks]); // Memoize cell render functions const renderCell = useCallback( @@ -54,7 +176,7 @@ const TaskListTable = ({ ); }, task: () => ( - + {task.name} ), @@ -66,6 +188,77 @@ const TaskListTable = ({ [] ); + // Handle drag start + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(event.active.id); + document.body.style.cursor = 'grabbing'; + }, []); + + // Handle drag end with socket integration + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + + setActiveId(null); + document.body.style.cursor = ''; + + if (!over || active.id === over.id) { + return; + } + + const activeIndex = mainTasks.findIndex(task => task.id === active.id); + const overIndex = mainTasks.findIndex(task => task.id === over.id); + + if (activeIndex !== -1 && overIndex !== -1) { + const activeTask = mainTasks[activeIndex]; + const overTask = mainTasks[overIndex]; + + // Create updated task arrays + const updatedTasks = [...mainTasks]; + updatedTasks.splice(activeIndex, 1); + updatedTasks.splice(overIndex, 0, activeTask); + + // Dispatch Redux action for optimistic update + dispatch(reorderTasks({ + activeGroupId: tableId, + overGroupId: tableId, + fromIndex: activeIndex, + toIndex: overIndex, + task: activeTask, + updatedSourceTasks: updatedTasks, + updatedTargetTasks: updatedTasks, + })); + + // Emit socket event for backend persistence + if (socket && projectId && currentSession?.team_id) { + const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1; + + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + project_id: projectId, + from_index: activeTask.sort_order, + to_index: toPos, + to_last_index: overIndex === mainTasks.length - 1, + from_group: tableId, + to_group: tableId, + group_by: groupBy, + task: activeTask, + team_id: currentSession.team_id, + }); + + // Track analytics event + trackMixpanelEvent(evt_project_task_list_drag_and_move); + } + } + }, [ + mainTasks, + tableId, + dispatch, + socket, + projectId, + currentSession?.team_id, + groupBy, + trackMixpanelEvent + ]); + // Memoize header rendering const TableHeader = useMemo( () => ( @@ -94,48 +287,55 @@ const TaskListTable = ({ target.classList.toggle('show-shadow', hasHorizontalShadow); }, []); - return ( -
- {TableHeader} + // Find active task for drag overlay + const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null; -
+
+ {TableHeader} + + task.id)} strategy={verticalListSortingStrategy}> +
+ {flattenedTasks.map((task, index) => ( + + ))} +
+
+
+ + - {rowVirtualizer.getVirtualItems().map(virtualRow => { - const task = flattenedTasks[virtualRow.index]; - return ( -
-
- {/* */} -
- {visibleColumns.map(column => ( -
- {renderCell(column.key, task, task.is_sub_task)} -
- ))} -
- ); - })} -
-
+ {activeTask && ( +
+ {}} + isSubtask={activeTask.isSubtask} + /> +
+ )} + + ); }; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx index e2ca07f2..da30ca2f 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx @@ -10,7 +10,6 @@ import { Row, Column, } from '@tanstack/react-table'; -import { useVirtualizer } from '@tanstack/react-virtual'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { useAppSelector } from '@/hooks/useAppSelector'; import React from 'react'; @@ -78,19 +77,6 @@ const TaskListCustom: React.FC = ({ tasks, color, groupId, const { rows } = table.getRowModel(); - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => tableContainerRef.current, - estimateSize: () => 50, - overscan: 20, - }); - - const virtualRows = rowVirtualizer.getVirtualItems(); - const totalSize = rowVirtualizer.getTotalSize(); - const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; - const paddingBottom = - virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0; - const columnToggleItems = columns.map(column => ({ key: column.id as string, label: ( @@ -125,6 +111,7 @@ const TaskListCustom: React.FC = ({ tasks, color, groupId, flex: 1, minHeight: 0, overflowX: 'auto', + overflowY: 'auto', maxHeight: '100%', }} > @@ -161,80 +148,75 @@ const TaskListCustom: React.FC = ({ tasks, color, groupId, ))}
- {paddingTop > 0 &&
} - {virtualRows.map(virtualRow => { - const row = rows[virtualRow.index]; - return ( - -
- {row.getVisibleCells().map((cell, index) => ( -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
- ))} -
- {expandedRows[row.id] && - row.original.sub_tasks?.map(subTask => ( -
- {columns.map((col, index) => ( -
- {flexRender(col.cell, { - getValue: () => subTask[col.id as keyof typeof subTask] ?? null, - row: { original: subTask } as Row, - column: col as Column, - table, - })} -
- ))} -
- ))} -
- ); - })} - {paddingBottom > 0 &&
} + {rows.map(row => ( + +
+ {row.getVisibleCells().map((cell, index) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ))} +
+ {expandedRows[row.id] && + row.original.sub_tasks?.map(subTask => ( +
+ {columns.map((col, index) => ( +
+ {flexRender(col.cell, { + getValue: () => subTask[col.id as keyof typeof subTask] ?? null, + row: { original: subTask } as Row, + column: col as Column, + table, + })} +
+ ))} +
+ ))} +
+ ))}
diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx index f3149767..26f01be7 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx @@ -4,12 +4,12 @@ import { TaskType } from '@/types/task.types'; import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons'; import { colors } from '@/styles/colors'; import './task-list-table-wrapper.css'; -import TaskListTable from '../task-list-table-old/task-list-table-old'; +import TaskListTable from '../table-v2'; import { MenuProps } from 'antd/lib'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useTranslation } from 'react-i18next'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import TaskListCustom from '../task-list-custom'; +import { columnList as defaultColumnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList'; type TaskListTableWrapperProps = { taskList: ITaskListGroup; @@ -37,6 +37,22 @@ const TaskListTableWrapper = ({ // localization const { t } = useTranslation('task-list-table'); + // Get column visibility from Redux + const columnVisibilityList = useAppSelector( + state => state.projectViewTaskListColumnsReducer.columnList + ); + + // Filter visible columns and format them for table-v2 + const visibleColumns = defaultColumnList + .filter(column => { + const visibilityConfig = columnVisibilityList.find(col => col.key === column.key); + return visibilityConfig?.isVisible ?? false; + }) + .map(column => ({ + key: column.key, + width: column.width, + })); + // function to handle toggle expand const handlToggleExpand = () => { setIsExpanded(!isExpanded); @@ -98,6 +114,14 @@ const TaskListTableWrapper = ({ }, ]; + const handleTaskSelect = (taskId: string) => { + console.log('Task selected:', taskId); + }; + + const handleTaskExpand = (taskId: string) => { + console.log('Task expanded:', taskId); + }; + return ( ), }, diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx index 727a510b..a5ad9f85 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx @@ -6,9 +6,7 @@ import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types' import { useAppDispatch } from '@/hooks/useAppDispatch'; import { fetchTaskGroups } from '@/features/tasks/taskSlice'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; - -import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList'; -import StatusGroupTables from '../taskList/statusTables/StatusGroupTables'; +import TaskListTableWrapper from './task-list-table-wrapper/task-list-table-wrapper'; const TaskList = () => { const dispatch = useAppDispatch(); @@ -31,6 +29,7 @@ const TaskList = () => { const onTaskExpand = (taskId: string) => { console.log('taskId:', taskId); }; + useEffect(() => { if (projectId) { const config: ITaskListConfigV2 = { @@ -54,9 +53,15 @@ const TaskList = () => { - {/* {taskGroups.map((group: ITaskListGroup) => ( - - ))} */} + {taskGroups.map((group: ITaskListGroup) => ( + + ))} );