From 0452dbd1799ee25ad2b9a3104f878fb7e5b3171d Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 2 Jul 2025 15:17:21 +0530 Subject: [PATCH] feat(task-management): implement task reordering and group updates via API - Added API methods for reordering tasks and updating task groups (status, priority, phase). - Enhanced task management slice with async thunks for handling task reordering and group movements. - Updated task list board to support real-time updates during drag-and-drop operations, emitting socket events for task sort order changes. - Refactored task-related components to utilize shared Ant Design imports for consistency and maintainability. - Removed unused Ant Design imports and optimized drag-and-drop CSS for improved performance. --- .../commands/on-task-sort-order-change.ts | 242 ++++++++---------- worklenz-backend/src/socket.io/index.ts | 2 +- .../src/api/tasks/tasks.api.service.ts | 20 ++ .../task-management/antd-imports.ts | 237 ----------------- .../task-management/drag-drop-optimized.css | 155 ++--------- .../task-management/improved-task-filters.tsx | 2 +- .../components/task-management/task-group.tsx | 2 +- .../task-management/task-list-board.tsx | 183 ++++++------- .../task-management/task-row-utils.ts | 2 +- .../components/task-management/task-row.tsx | 105 +++----- .../task-management/virtualized-task-list.tsx | 14 +- .../task-management/task-management.slice.ts | 143 +++++++++-- worklenz-frontend/src/shared/antd-imports.ts | 58 ++++- 13 files changed, 462 insertions(+), 703 deletions(-) delete mode 100644 worklenz-frontend/src/components/task-management/antd-imports.ts diff --git a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts index 83b4a70e..79abae7a 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts @@ -12,160 +12,130 @@ import { assignMemberIfNot } from "./on-quick-assign-or-remove"; interface ChangeRequest { from_index: number; // from sort_order to_index: number; // to sort_order - to_last_index: boolean; + project_id: string; from_group: string; to_group: string; group_by: string; - project_id: string; - task: any; + to_last_index: boolean; + task: { + id: string; + project_id: string; + status: string; + priority: string; + }; team_id: string; } -// PERFORMANCE OPTIMIZATION: Connection pooling for better database performance -const dbPool = { - query: async (text: string, params?: any[]) => { - return await db.query(text, params); +interface Config { + from_index: number; + to_index: number; + task_id: string; + from_group: string | null; + to_group: string | null; + project_id: string; + group_by: string; + to_last_index: boolean; +} + +function notifyStatusChange(socket: Socket, config: Config) { + const userId = getLoggedInUserIdFromSocket(socket); + if (userId && config.to_group) { + void TasksController.notifyStatusChange(userId, config.task_id, config.to_group); } -}; +} -// PERFORMANCE OPTIMIZATION: Cache for dependency checks to reduce database queries -const dependencyCache = new Map(); -const CACHE_TTL = 5000; // 5 seconds cache +async function emitSortOrderChange(data: ChangeRequest, socket: Socket) { + const q = ` + SELECT id, sort_order, completed_at + FROM tasks + WHERE project_id = $1 + ORDER BY sort_order; + `; + const tasks = await db.query(q, [data.project_id]); + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), tasks.rows); +} -const clearExpiredCache = () => { - const now = Date.now(); - for (const [key, value] of dependencyCache.entries()) { - if (now - value.timestamp > CACHE_TTL) { - dependencyCache.delete(key); - } - } -}; +function updateUnmappedStatus(config: Config) { + if (config.to_group === UNMAPPED) + config.to_group = null; + if (config.from_group === UNMAPPED) + config.from_group = null; +} -// Clear expired cache entries every 10 seconds -setInterval(clearExpiredCache, 10000); - -const onTaskSortOrderChange = async (io: Server, socket: Socket, data: ChangeRequest) => { +export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) { try { - const userId = getLoggedInUserIdFromSocket(socket); - if (!userId) { - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "User not authenticated" }); - return; - } + const q = `SELECT handle_task_list_sort_order_change($1);`; - const { - from_index, - to_index, - to_last_index, - from_group, - to_group, - group_by, - project_id, - task, - team_id - } = data; - - // PERFORMANCE OPTIMIZATION: Validate input data early to avoid expensive operations - if (!project_id || !task?.id || !team_id) { - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "Missing required data" }); - return; - } - - // PERFORMANCE OPTIMIZATION: Use cached dependency check if available - const cacheKey = `${project_id}-${userId}-${team_id}`; - const cachedDependency = dependencyCache.get(cacheKey); - - let hasAccess = false; - if (cachedDependency && (Date.now() - cachedDependency.timestamp) < CACHE_TTL) { - hasAccess = cachedDependency.result; - } else { - // PERFORMANCE OPTIMIZATION: Optimized dependency check query - const dependencyResult = await dbPool.query(` - SELECT EXISTS( - SELECT 1 FROM project_members pm - INNER JOIN projects p ON p.id = pm.project_id - INNER JOIN team_members tm ON pm.team_member_id = tm.id -WHERE pm.project_id = $1 - AND tm.user_id = $2 - AND p.team_id = $3 - ) as has_access - `, [project_id, userId, team_id]); - - hasAccess = dependencyResult.rows[0]?.has_access || false; - - // Cache the result - dependencyCache.set(cacheKey, { result: hasAccess, timestamp: Date.now() }); - } - - if (!hasAccess) { - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "Access denied" }); - return; - } - - // PERFORMANCE OPTIMIZATION: Execute database operation directly - await dbPool.query(`SELECT handle_task_list_sort_order_change($1)`, [JSON.stringify({ - project_id, - task_id: task.id, - from_index, - to_index, - to_last_index, - from_group, - to_group, - group_by - })]); - - // PERFORMANCE OPTIMIZATION: Optimized project updates notification - const projectUpdateData = { - project_id, - team_id, - user_id: userId, - update_type: "task_sort_order_change", - task_id: task.id, - from_group, - to_group, - group_by + const config: Config = { + from_index: data.from_index, + to_index: data.to_index, + task_id: data.task.id, + from_group: data.from_group, + to_group: data.to_group, + project_id: data.project_id, + group_by: data.group_by, + to_last_index: Boolean(data.to_last_index) }; - // Emit to all users in the project room - io.to(`project_${project_id}`).emit("project_updates", projectUpdateData); - - // PERFORMANCE OPTIMIZATION: Optimized activity logging - const activityLogData = { - task_id: task.id, - socket, - new_value: to_group, - old_value: from_group - }; - - // Log activity asynchronously to avoid blocking the response - setImmediate(async () => { - try { - if (group_by === "phase") { - await logPhaseChange(activityLogData); - } else if (group_by === "status") { - await logStatusChange(activityLogData); - } else if (group_by === "priority") { - await logPriorityChange(activityLogData); - } - } catch (error) { - log_error(error); + if ((config.group_by === GroupBy.STATUS) && config.to_group) { + const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group); + if (!canContinue) { + return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + completed_deps: canContinue + }); } - }); - // Send success response - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { - success: true, - task_id: task.id, - from_group, - to_group, - group_by - }); + notifyStatusChange(socket, config); + } + if (config.group_by === GroupBy.PHASE) { + updateUnmappedStatus(config); + } + + await db.query(q, [JSON.stringify(config)]); + await emitSortOrderChange(data, socket); + + if (config.group_by === GroupBy.STATUS) { + const userId = getLoggedInUserIdFromSocket(socket); + const isAlreadyAssigned = await TasksControllerV2.checkUserAssignedToTask(data.task.id, userId as string, data.team_id); + + if (!isAlreadyAssigned) { + await assignMemberIfNot(data.task.id, userId as string, data.team_id, _io, socket); + } + } + + if (config.group_by === GroupBy.PHASE) { + void logPhaseChange({ + task_id: data.task.id, + socket, + new_value: data.to_group, + old_value: data.from_group + }); + } + + if (config.group_by === GroupBy.STATUS) { + void logStatusChange({ + task_id: data.task.id, + socket, + new_value: data.to_group, + old_value: data.from_group + }); + } + + if (config.group_by === GroupBy.PRIORITY) { + void logPriorityChange({ + task_id: data.task.id, + socket, + new_value: data.to_group, + old_value: data.from_group + }); + } + + void notifyProjectUpdates(socket, config.task_id); + return; } catch (error) { log_error(error); - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { - error: "Internal server error" - }); } -}; -export default onTaskSortOrderChange; + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), []); +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index 26ad2bf2..04927214 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -18,7 +18,7 @@ import { on_task_description_change } from "./commands/on-task-description-chang import { on_get_task_progress } from "./commands/on-get-task-progress"; import { on_task_timer_start } from "./commands/on-task-timer-start"; import { on_task_timer_stop } from "./commands/on-task-timer-stop"; -import on_task_sort_order_change from "./commands/on-task-sort-order-change"; +import { on_task_sort_order_change } from "./commands/on-task-sort-order-change"; import { on_join_project_room as on_join_or_leave_project_room } from "./commands/on-join-or-leave-project-room"; import { on_task_subscriber_change } from "./commands/on-task-subscriber-change"; import { on_project_subscriber_change } from "./commands/on-project-subscriber-change"; diff --git a/worklenz-frontend/src/api/tasks/tasks.api.service.ts b/worklenz-frontend/src/api/tasks/tasks.api.service.ts index 862b0af9..460983d1 100644 --- a/worklenz-frontend/src/api/tasks/tasks.api.service.ts +++ b/worklenz-frontend/src/api/tasks/tasks.api.service.ts @@ -159,4 +159,24 @@ export const tasksApiService = { const response = await apiClient.get(`${rootUrl}/progress-status/${projectId}`); return response.data; }, + + // API method to reorder tasks + reorderTasks: async (params: { taskIds: string[]; newOrder: number[]; projectId: string }): Promise> => { + const response = await apiClient.post(`${rootUrl}/reorder`, { + task_ids: params.taskIds, + new_order: params.newOrder, + project_id: params.projectId, + }); + return response.data; + }, + + // API method to update task group (status, priority, phase) + updateTaskGroup: async (params: { taskId: string; groupType: 'status' | 'priority' | 'phase'; groupValue: string; projectId: string }): Promise> => { + const response = await apiClient.put(`${rootUrl}/${params.taskId}/group`, { + group_type: params.groupType, + group_value: params.groupValue, + project_id: params.projectId, + }); + return response.data; + }, }; diff --git a/worklenz-frontend/src/components/task-management/antd-imports.ts b/worklenz-frontend/src/components/task-management/antd-imports.ts deleted file mode 100644 index 16991148..00000000 --- a/worklenz-frontend/src/components/task-management/antd-imports.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Centralized Ant Design imports for Task Management components - * - * This file provides: - * - Tree-shaking optimization by importing only used components - * - Type safety with proper TypeScript types - * - Performance optimization through selective imports - * - Consistent component versions across task management - * - Easy maintenance and updates - */ - -// Core Components -export { - Button, - Input, - Select, - Typography, - Card, - Spin, - Empty, - Space, - Tooltip, - Badge, - Popconfirm, - message, - Checkbox, - Dropdown, - Menu -} from 'antd/es'; - -// Date & Time Components -export { - DatePicker, - TimePicker -} from 'antd/es'; - -// Form Components (if needed for task management) -export { - Form, - InputNumber -} from 'antd/es'; - -// Layout Components -export { - Row, - Col, - Divider, - Flex -} from 'antd/es'; - -// Icon Components (commonly used in task management) -export { - EditOutlined, - DeleteOutlined, - PlusOutlined, - MoreOutlined, - CheckOutlined, - CloseOutlined, - CalendarOutlined, - ClockCircleOutlined, - UserOutlined, - TeamOutlined, - TagOutlined, - FlagOutlined, - BarsOutlined, - TableOutlined, - AppstoreOutlined, - FilterOutlined, - SortAscendingOutlined, - SortDescendingOutlined, - SearchOutlined, - ReloadOutlined, - SettingOutlined, - EyeOutlined, - EyeInvisibleOutlined, - CopyOutlined, - ExportOutlined, - ImportOutlined, - DownOutlined, - RightOutlined, - LeftOutlined, - UpOutlined, - DragOutlined, - HolderOutlined, - MessageOutlined, - PaperClipOutlined, - GroupOutlined, - InboxOutlined, - TagsOutlined, - UsergroupAddOutlined, - UserAddOutlined, - RetweetOutlined -} from '@ant-design/icons'; - -// TypeScript Types -export type { - ButtonProps, - InputProps, - InputRef, - SelectProps, - TypographyProps, - CardProps, - SpinProps, - EmptyProps, - SpaceProps, - TooltipProps, - BadgeProps, - PopconfirmProps, - CheckboxProps, - CheckboxChangeEvent, - DropdownProps, - MenuProps, - DatePickerProps, - TimePickerProps, - FormProps, - FormInstance, - InputNumberProps, - RowProps, - ColProps, - DividerProps, - FlexProps -} from 'antd/es'; - -// Dayjs (used with DatePicker) -export { default as dayjs } from 'dayjs'; -export type { Dayjs } from 'dayjs'; - -// Re-export commonly used Ant Design utilities -export { - ConfigProvider, - theme -} from 'antd'; - -// Custom hooks for task management (if any Ant Design specific hooks are needed) -export const useAntdBreakpoint = () => { - // You can add custom breakpoint logic here if needed - return { - xs: window.innerWidth < 576, - sm: window.innerWidth >= 576 && window.innerWidth < 768, - md: window.innerWidth >= 768 && window.innerWidth < 992, - lg: window.innerWidth >= 992 && window.innerWidth < 1200, - xl: window.innerWidth >= 1200 && window.innerWidth < 1600, - xxl: window.innerWidth >= 1600, - }; -}; - -// Import message separately to avoid circular dependency -import { message as antdMessage } from 'antd'; - -// Performance optimized message utility -export const taskMessage = { - success: (content: string) => antdMessage.success(content), - error: (content: string) => antdMessage.error(content), - warning: (content: string) => antdMessage.warning(content), - info: (content: string) => antdMessage.info(content), - loading: (content: string) => antdMessage.loading(content), -}; - -// Commonly used Ant Design configurations for task management -export const taskManagementAntdConfig = { - // DatePicker default props for consistency - datePickerDefaults: { - format: 'MMM DD, YYYY', - placeholder: 'Set Date', - suffixIcon: null, - size: 'small' as const, - }, - - // Button default props for task actions - taskButtonDefaults: { - size: 'small' as const, - type: 'text' as const, - }, - - // Input default props for task editing - taskInputDefaults: { - size: 'small' as const, - variant: 'borderless' as const, - }, - - // Select default props for dropdowns - taskSelectDefaults: { - size: 'small' as const, - variant: 'borderless' as const, - showSearch: true, - optionFilterProp: 'label' as const, - }, - - // Tooltip default props - tooltipDefaults: { - placement: 'top' as const, - mouseEnterDelay: 0.5, - mouseLeaveDelay: 0.1, - }, - - // Dropdown default props - dropdownDefaults: { - trigger: ['click'] as const, - placement: 'bottomLeft' as const, - }, -}; - -// Theme tokens specifically for task management -export const taskManagementTheme = { - light: { - colorBgContainer: '#ffffff', - colorBorder: '#e5e7eb', - colorText: '#374151', - colorTextSecondary: '#6b7280', - colorPrimary: '#3b82f6', - colorSuccess: '#10b981', - colorWarning: '#f59e0b', - colorError: '#ef4444', - colorBgHover: '#f9fafb', - colorBgSelected: '#eff6ff', - }, - dark: { - colorBgContainer: '#1f2937', - colorBorder: '#374151', - colorText: '#f9fafb', - colorTextSecondary: '#d1d5db', - colorPrimary: '#60a5fa', - colorSuccess: '#34d399', - colorWarning: '#fbbf24', - colorError: '#f87171', - colorBgHover: '#374151', - colorBgSelected: '#1e40af', - }, -}; - -// Export default configuration object -export default { - config: taskManagementAntdConfig, - theme: taskManagementTheme, - message: taskMessage, - useBreakpoint: useAntdBreakpoint, -}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css index c604cbcb..5b70bc21 100644 --- a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css +++ b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css @@ -1,149 +1,40 @@ -/* DRAG AND DROP PERFORMANCE OPTIMIZATIONS */ +/* MINIMAL DRAG AND DROP CSS - SHOW ONLY TASK NAME */ -/* Force GPU acceleration for all drag operations */ -[data-dnd-draggable], -[data-dnd-drag-handle], -[data-dnd-overlay] { - transform: translateZ(0); - will-change: transform; - backface-visibility: hidden; - perspective: 1000px; -} - -/* Optimize drag handle for instant response */ +/* Basic drag handle styling */ .drag-handle-optimized { cursor: grab; - user-select: none; - touch-action: none; - -webkit-user-select: none; - -webkit-touch-callout: none; - -webkit-tap-highlight-color: transparent; + opacity: 0.6; + transition: opacity 0.2s ease; +} + +.drag-handle-optimized:hover { + opacity: 1; } .drag-handle-optimized:active { cursor: grabbing; } -/* Disable all transitions during drag for instant response */ -[data-dnd-dragging="true"] *, -[data-dnd-dragging="true"] { - transition: none !important; - animation: none !important; -} - -/* Optimize drag overlay for smooth movement */ +/* Simple drag overlay - just show task name */ [data-dnd-overlay] { + background: white; + border: 1px solid #d9d9d9; + border-radius: 4px; + padding: 8px 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); pointer-events: none; - position: fixed !important; z-index: 9999; - transform: translateZ(0); - will-change: transform; - backface-visibility: hidden; } -/* Reduce layout thrashing during drag */ -.task-row-dragging { - contain: layout style paint; - will-change: transform; - transform: translateZ(0); +/* Dark mode support for drag overlay */ +.dark [data-dnd-overlay], +[data-theme="dark"] [data-dnd-overlay] { + background: #1f1f1f; + border-color: #404040; + color: white; } -/* Optimize virtualized lists during drag */ -.react-window-list { - contain: layout style; - will-change: scroll-position; -} - -.react-window-list-item { - contain: layout style; - will-change: transform; -} - -/* Disable hover effects during drag */ -[data-dnd-dragging="true"] .task-row:hover { - background-color: inherit !important; -} - -/* Optimize cursor changes */ -.task-row { - cursor: default; -} - -.task-row[data-dnd-dragging="true"] { - cursor: grabbing; -} - -/* Performance optimizations for large lists */ -.virtualized-task-container { - contain: layout style paint; - will-change: scroll-position; - transform: translateZ(0); -} - -/* Reduce repaints during scroll */ -.task-groups-container { - contain: layout style; - will-change: scroll-position; -} - -/* Optimize sortable context */ -[data-dnd-sortable-context] { - contain: layout style; -} - -/* Disable animations during drag operations */ -[data-dnd-context] [data-dnd-dragging="true"] * { - transition: none !important; - animation: none !important; -} - -/* Optimize drop indicators */ -.drop-indicator { - contain: layout style; - will-change: opacity; - transition: opacity 0.1s ease; -} - -/* Performance optimizations for touch devices */ -@media (pointer: coarse) { - .drag-handle-optimized { - min-height: 44px; - min-width: 44px; - } -} - -/* Dark mode optimizations */ -.dark [data-dnd-dragging="true"], -[data-theme="dark"] [data-dnd-dragging="true"] { - background-color: rgba(255, 255, 255, 0.05) !important; -} - -/* Reduce memory usage during drag */ -[data-dnd-dragging="true"] img, -[data-dnd-dragging="true"] svg { - contain: layout style paint; -} - -/* Optimize for high DPI displays */ -@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { - [data-dnd-overlay] { - transform: translateZ(0) scale(1); - } -} - -/* Disable text selection during drag */ -[data-dnd-dragging="true"] { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -/* Optimize for reduced motion preferences */ -@media (prefers-reduced-motion: reduce) { - [data-dnd-overlay], - [data-dnd-dragging="true"] { - transition: none !important; - animation: none !important; - } +/* Hide drag handle during drag */ +[data-dnd-dragging="true"] .drag-handle-optimized { + opacity: 0; } \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index c32ca90f..dd777df4 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -14,7 +14,7 @@ import { EyeOutlined, InboxOutlined, CheckOutlined, -} from './antd-imports'; +} from '@/shared/antd-imports'; import { RootState } from '@/app/store'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index a6fce7f9..51f9b7d8 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -9,7 +9,7 @@ import { PlusOutlined, RightOutlined, DownOutlined -} from './antd-imports'; +} from '@/shared/antd-imports'; import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types'; import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; import { RootState } from '@/app/store'; diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index 2c759513..668dc961 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -21,6 +21,7 @@ import { reorderTasks, moveTaskToGroup, optimisticTaskMove, + reorderTasksInGroup, setLoading, fetchTasks, fetchTasksV3, @@ -39,6 +40,8 @@ import { } from '@/features/task-management/selection.slice'; import { Task } from '@/types/task-management.types'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; import TaskRow from './task-row'; // import BulkActionBar from './bulk-action-bar'; import OptimizedBulkActionBar from './optimized-bulk-action-bar'; @@ -136,7 +139,6 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const renderCountRef = useRef(0); const [shouldThrottle, setShouldThrottle] = useState(false); - // Refs for performance optimization const dragOverTimeoutRef = useRef(null); const containerRef = useRef(null); @@ -144,6 +146,9 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Enable real-time socket updates for task changes useTaskSocketHandlers(); + // Socket connection for drag and drop + const { socket, connected } = useSocket(); + // Redux selectors using V3 API (pre-processed data, minimal loops) const tasks = useSelector(taskManagementSelectors.selectAll); const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual); @@ -151,7 +156,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const selectedTaskIds = useSelector(selectSelectedTaskIds); const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual); const error = useSelector((state: RootState) => state.taskManagement.error); - + // Bulk action selectors const statusList = useSelector((state: RootState) => state.taskStatusReducer.status); const priorityList = useSelector((state: RootState) => state.priorityReducer.priorities); @@ -234,8 +239,12 @@ const TaskListBoard: React.FC = ({ projectId, className = '' [dispatch] ); + // Add isDragging state + const [isDragging, setIsDragging] = useState(false); + const handleDragStart = useCallback( (event: DragStartEvent) => { + setIsDragging(true); const { active } = event; const taskId = active.id as string; @@ -244,13 +253,12 @@ const TaskListBoard: React.FC = ({ projectId, className = '' let activeGroupId: string | null = null; if (activeTask) { - // Determine group ID based on current grouping - if (currentGrouping === 'status') { - activeGroupId = `status-${activeTask.status}`; - } else if (currentGrouping === 'priority') { - activeGroupId = `priority-${activeTask.priority}`; - } else if (currentGrouping === 'phase') { - activeGroupId = `phase-${activeTask.phase}`; + // Find which group contains this task by looking through all groups + for (const group of taskGroups) { + if (group.taskIds.includes(taskId)) { + activeGroupId = group.id; + break; + } } } @@ -259,7 +267,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' activeGroupId, }); }, - [tasks, currentGrouping] + [tasks, currentGrouping, taskGroups] ); // Throttled drag over handler for smoother performance @@ -270,15 +278,14 @@ const TaskListBoard: React.FC = ({ projectId, className = '' if (!over || !dragState.activeTask) return; const activeTaskId = active.id as string; - const overContainer = over.id as string; + const overId = over.id as string; - // PERFORMANCE OPTIMIZATION: Immediate response for instant UX - // Only update if we're hovering over a different container - const targetTask = tasks.find(t => t.id === overContainer); - let targetGroupId = overContainer; + // Check if we're hovering over a task or a group container + const targetTask = tasks.find(t => t.id === overId); + let targetGroupId = overId; if (targetTask) { - // PERFORMANCE OPTIMIZATION: Use switch instead of multiple if statements + // We're hovering over a task, determine its group switch (currentGrouping) { case 'status': targetGroupId = `status-${targetTask.status}`; @@ -291,29 +298,13 @@ const TaskListBoard: React.FC = ({ projectId, className = '' break; } } - - if (targetGroupId !== dragState.activeGroupId) { - // PERFORMANCE OPTIMIZATION: Use findIndex for better performance - const targetGroupIndex = taskGroups.findIndex(g => g.id === targetGroupId); - if (targetGroupIndex !== -1) { - const targetGroup = taskGroups[targetGroupIndex]; - dispatch( - optimisticTaskMove({ - taskId: activeTaskId, - newGroupId: targetGroupId, - newIndex: targetGroup.taskIds.length, - }) - ); - } - } }, 16), // 60fps throttling for smooth performance - [dragState, tasks, taskGroups, currentGrouping, dispatch] + [dragState, tasks, taskGroups, currentGrouping] ); const handleDragEnd = useCallback( (event: DragEndEvent) => { - const { active, over } = event; - + setIsDragging(false); // Clear any pending drag over timeouts if (dragOverTimeoutRef.current) { clearTimeout(dragOverTimeoutRef.current); @@ -327,36 +318,27 @@ const TaskListBoard: React.FC = ({ projectId, className = '' activeGroupId: null, }); - if (!over || !currentDragState.activeTask || !currentDragState.activeGroupId) { + if (!event.over || !currentDragState.activeTask || !currentDragState.activeGroupId) { return; } + const { active, over } = event; const activeTaskId = active.id as string; - const overContainer = over.id as string; + const overId = over.id as string; - // Parse the group ID to get group type and value - optimized - const parseGroupId = (groupId: string) => { - const [groupType, ...groupValueParts] = groupId.split('-'); - return { - groupType: groupType as 'status' | 'priority' | 'phase', - groupValue: groupValueParts.join('-'), - }; - }; - - // Determine target group - let targetGroupId = overContainer; + // Determine target group and position + let targetGroupId = overId; let targetIndex = -1; // Check if dropping on a task or a group - const targetTask = tasks.find(t => t.id === overContainer); + const targetTask = tasks.find(t => t.id === overId); if (targetTask) { - // Dropping on a task, determine its group - if (currentGrouping === 'status') { - targetGroupId = `status-${targetTask.status}`; - } else if (currentGrouping === 'priority') { - targetGroupId = `priority-${targetTask.priority}`; - } else if (currentGrouping === 'phase') { - targetGroupId = `phase-${targetTask.phase}`; + // Dropping on a task, find which group contains this task + for (const group of taskGroups) { + if (group.taskIds.includes(targetTask.id)) { + targetGroupId = group.id; + break; + } } // Find the index of the target task within its group @@ -364,23 +346,15 @@ const TaskListBoard: React.FC = ({ projectId, className = '' if (targetGroup) { targetIndex = targetGroup.taskIds.indexOf(targetTask.id); } + } else { + // Dropping on a group container, add to the end + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + if (targetGroup) { + targetIndex = targetGroup.taskIds.length; + } } - const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId); - const targetGroupInfo = parseGroupId(targetGroupId); - - // If moving between different groups, update the task's group property - if (currentDragState.activeGroupId !== targetGroupId) { - dispatch( - moveTaskToGroup({ - taskId: activeTaskId, - groupType: targetGroupInfo.groupType, - groupValue: targetGroupInfo.groupValue, - }) - ); - } - - // Handle reordering within the same group or between groups + // Find source and target groups const sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId); const targetGroup = taskGroups.find(g => g.id === targetGroupId); @@ -390,27 +364,41 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Only reorder if actually moving to a different position if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) { - // Calculate new order values - simplified - const allTasksInTargetGroup = targetGroup.taskIds.map( - (id: string) => tasks.find((t: any) => t.id === id)! - ); - const newOrder = allTasksInTargetGroup.map((task, index) => { - if (index < finalTargetIndex) return task.order; - if (index === finalTargetIndex) return currentDragState.activeTask!.order; - return task.order + 1; - }); - - // Dispatch reorder action + // Use the new reorderTasksInGroup action that properly handles group arrays dispatch( - reorderTasks({ - taskIds: [activeTaskId, ...allTasksInTargetGroup.map((t: any) => t.id)], - newOrder: [currentDragState.activeTask!.order, ...newOrder], + reorderTasksInGroup({ + taskId: activeTaskId, + fromGroupId: currentDragState.activeGroupId, + toGroupId: targetGroupId, + fromIndex: sourceIndex, + toIndex: finalTargetIndex, + groupType: targetGroup.groupType, + groupValue: targetGroup.groupValue, }) ); + + // Emit socket event to backend + if (connected && socket && currentDragState.activeTask) { + const currentSession = JSON.parse(localStorage.getItem('session') || '{}'); + + const socketData = { + from_index: sourceIndex, + to_index: finalTargetIndex, + to_last_index: finalTargetIndex >= targetGroup.taskIds.length, + from_group: currentDragState.activeGroupId, + to_group: targetGroupId, + group_by: currentGrouping, + project_id: projectId, + task: currentDragState.activeTask, + team_id: currentSession.team_id, + }; + + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); + } } } }, - [dragState, tasks, taskGroups, currentGrouping, dispatch] + [dragState, tasks, taskGroups, currentGrouping, dispatch, connected, socket, projectId] ); const handleSelectTask = useCallback( @@ -651,17 +639,14 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Additional handlers for new actions const handleBulkDuplicate = useCallback(async () => { // This would need to be implemented in the API service - console.log('Bulk duplicate not yet implemented in API:', selectedTaskIds); }, [selectedTaskIds]); const handleBulkExport = useCallback(async () => { // This would need to be implemented in the API service - console.log('Bulk export not yet implemented in API:', selectedTaskIds); }, [selectedTaskIds]); const handleBulkSetDueDate = useCallback(async (date: string) => { // This would need to be implemented in the API service - console.log('Bulk set due date not yet implemented in API:', date, selectedTaskIds); }, [selectedTaskIds]); // Cleanup effect @@ -689,24 +674,19 @@ const TaskListBoard: React.FC = ({ projectId, className = '' onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} + autoScroll={false} > - - - - {/* Task Filters */}
- {/* Performance Analysis - Only show in development */} {/* {process.env.NODE_ENV === 'development' && ( )} */} - {/* Fixed Height Task Groups Container - Asana Style */}
-
+
{loading ? (
@@ -775,14 +755,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' {dragOverlayContent} @@ -1277,6 +1250,10 @@ const TaskListBoard: React.FC = ({ projectId, className = '' .react-window-list-item { contain: layout style; } + + .task-groups-scrollable.lock-scroll { + overflow: hidden !important; + } `}
); diff --git a/worklenz-frontend/src/components/task-management/task-row-utils.ts b/worklenz-frontend/src/components/task-management/task-row-utils.ts index f51b1c59..dcfa05db 100644 --- a/worklenz-frontend/src/components/task-management/task-row-utils.ts +++ b/worklenz-frontend/src/components/task-management/task-row-utils.ts @@ -1,5 +1,5 @@ import { Task } from '@/types/task-management.types'; -import { dayjs } from './antd-imports'; +import { dayjs } from '@/shared/antd-imports'; // Performance constants export const PERFORMANCE_CONSTANTS = { diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 635f4675..3d4e8b91 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -15,8 +15,15 @@ import { UserOutlined, type InputRef, Tooltip -} from './antd-imports'; -import { DownOutlined, RightOutlined, ExpandAltOutlined, CheckCircleOutlined, MinusCircleOutlined, EyeOutlined, RetweetOutlined } from '@ant-design/icons'; +} from '@/shared/antd-imports'; +import { + RightOutlined, + ExpandAltOutlined, + CheckCircleOutlined, + MinusCircleOutlined, + EyeOutlined, + RetweetOutlined, +} from '@/shared/antd-imports'; import { useTranslation } from 'react-i18next'; import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; @@ -68,22 +75,25 @@ const STATUS_COLORS = { } as const; // Memoized sub-components for maximum performance -const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => ( -
- -
-)); +const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => { + return ( +
+ +
+ ); +}); const TaskKey = React.memo<{ taskKey: string; isDarkMode: boolean }>(({ taskKey, isDarkMode }) => ( = React.memo(({ return { transform: CSS.Transform.toString(transform), - transition: isDragging ? 'opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1)' : 'none', - opacity: isDragging ? 0.3 : 1, + opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 1000 : 'auto', - // PERFORMANCE OPTIMIZATION: Force GPU acceleration - willChange: 'transform, opacity', - filter: isDragging ? 'blur(0.5px)' : 'none', }; }, [transform, isDragging]); @@ -1223,58 +1229,27 @@ const TaskRow: React.FC = React.memo(({ // Compute theme class const themeClass = isDarkMode ? 'dark' : ''; - if (isDragging) { - console.log('TaskRow isDragging:', task.id); - } - // DRAG OVERLAY: Render simplified version when dragging if (isDragOverlay) { return (
-
-
- -
- - {task.title} - -
+ {task.title}
); } diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx index 34e3fb75..46a64073 100644 --- a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useCallback, useEffect, useRef } from 'react'; -import { FixedSizeList as List } from 'react-window'; +import { FixedSizeList as List, FixedSizeList } from 'react-window'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSelector, useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; @@ -205,7 +205,7 @@ const VirtualizedTaskList: React.FC = React.memo(({ return group.taskIds .map((taskId: string) => tasksById[taskId]) .filter((task: Task | undefined): task is Task => task !== undefined); - }, [group.taskIds, tasksById]); + }, [group.taskIds, tasksById, group.id]); // Calculate selection state for the group checkbox const selectionState = useMemo(() => { @@ -329,7 +329,7 @@ const VirtualizedTaskList: React.FC = React.memo(({ }, [groupTasks.length]); // Build displayRows array - const displayRows = []; + const displayRows: Array<{ type: 'task'; task: Task } | { type: 'add-subtask'; parentTask: Task }> = []; for (let i = 0; i < groupTasks.length; i++) { const task = groupTasks[i]; displayRows.push({ type: 'task', task }); @@ -340,6 +340,7 @@ const VirtualizedTaskList: React.FC = React.memo(({ const scrollContainerRef = useRef(null); const headerScrollRef = useRef(null); + const listRef = useRef(null); // PERFORMANCE OPTIMIZATION: Throttled scroll handler const handleScroll = useCallback(() => { @@ -551,14 +552,17 @@ const VirtualizedTaskList: React.FC = React.memo(({ contain: 'layout style', // CSS containment for better performance }} > - + {shouldVirtualize ? ( {({ index, style }) => { diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 644b0b46..b73b4ccf 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -208,6 +208,60 @@ export const refreshTaskProgress = createAsyncThunk( } ); +// Async thunk to reorder tasks with API call +export const reorderTasksWithAPI = createAsyncThunk( + 'taskManagement/reorderTasksWithAPI', + async ({ taskIds, newOrder, projectId }: { taskIds: string[]; newOrder: number[]; projectId: string }, { rejectWithValue }) => { + try { + // Make API call to update task order + const response = await tasksApiService.reorderTasks({ + taskIds, + newOrder, + projectId, + }); + + if (response.done) { + return { taskIds, newOrder }; + } else { + return rejectWithValue('Failed to reorder tasks'); + } + } catch (error) { + logger.error('Reorder Tasks API Error:', error); + return rejectWithValue('Failed to reorder tasks'); + } + } +); + +// Async thunk to move task between groups with API call +export const moveTaskToGroupWithAPI = createAsyncThunk( + 'taskManagement/moveTaskToGroupWithAPI', + async ({ taskId, groupType, groupValue, projectId }: { + taskId: string; + groupType: 'status' | 'priority' | 'phase'; + groupValue: string; + projectId: string; + }, { rejectWithValue }) => { + try { + // Make API call to update task group + const response = await tasksApiService.updateTaskGroup({ + taskId, + groupType, + groupValue, + projectId, + }); + + if (response.done) { + return { taskId, groupType, groupValue }; + } else { + return rejectWithValue('Failed to move task'); + } + } catch (error) { + logger.error('Move Task API Error:', error); + return rejectWithValue('Failed to move task'); + } + } +); + const taskManagementSlice = createSlice({ name: 'taskManagement', initialState: tasksAdapter.getInitialState(initialState), @@ -328,15 +382,6 @@ const taskManagementSlice = createSlice({ }>) => { const { taskId, fromGroupId, toGroupId, taskUpdate } = action.payload; - console.log('🔧 moveTaskBetweenGroups action:', { - taskId, - fromGroupId, - toGroupId, - taskUpdate, - hasGroups: !!state.groups, - groupsCount: state.groups?.length || 0 - }); - // Update the task entity with new values tasksAdapter.updateOne(state, { id: taskId, @@ -351,25 +396,15 @@ const taskManagementSlice = createSlice({ // Remove task from old group const fromGroup = state.groups.find(group => group.id === fromGroupId); if (fromGroup) { - const beforeCount = fromGroup.taskIds.length; fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId); - console.log(`🔧 Removed task from ${fromGroup.title}: ${beforeCount} -> ${fromGroup.taskIds.length}`); - } else { - console.warn('🚨 From group not found:', fromGroupId); } // Add task to new group const toGroup = state.groups.find(group => group.id === toGroupId); if (toGroup) { - const beforeCount = toGroup.taskIds.length; // Add to the end of the group (newest last) toGroup.taskIds.push(taskId); - console.log(`🔧 Added task to ${toGroup.title}: ${beforeCount} -> ${toGroup.taskIds.length}`); - } else { - console.warn('🚨 To group not found:', toGroupId); } - } else { - console.warn('🚨 No groups available for task movement'); } }, @@ -397,7 +432,76 @@ const taskManagementSlice = createSlice({ changes.phase = groupValue; } + // Update the task entity tasksAdapter.updateOne(state, { id: taskId, changes }); + + // Update groups if they exist + if (state.groups && state.groups.length > 0) { + // Find the target group + const targetGroup = state.groups.find(group => group.id === newGroupId); + if (targetGroup) { + // Remove task from all groups first + state.groups.forEach(group => { + group.taskIds = group.taskIds.filter(id => id !== taskId); + }); + + // Add task to target group at the specified index + if (newIndex >= targetGroup.taskIds.length) { + targetGroup.taskIds.push(taskId); + } else { + targetGroup.taskIds.splice(newIndex, 0, taskId); + } + } + } + } + }, + + // Proper reorder action that handles both task entities and group arrays + reorderTasksInGroup: (state, action: PayloadAction<{ + taskId: string; + fromGroupId: string; + toGroupId: string; + fromIndex: number; + toIndex: number; + groupType: 'status' | 'priority' | 'phase'; + groupValue: string; + }>) => { + const { taskId, fromGroupId, toGroupId, fromIndex, toIndex, groupType, groupValue } = action.payload; + + // Update the task entity + const changes: Partial = { + order: toIndex, + updatedAt: new Date().toISOString(), + }; + + // Update group-specific field + if (groupType === 'status') { + changes.status = groupValue as Task['status']; + } else if (groupType === 'priority') { + changes.priority = groupValue as Task['priority']; + } else if (groupType === 'phase') { + changes.phase = groupValue; + } + + tasksAdapter.updateOne(state, { id: taskId, changes }); + + // Update groups if they exist + if (state.groups && state.groups.length > 0) { + // Remove task from source group + const fromGroup = state.groups.find(group => group.id === fromGroupId); + if (fromGroup) { + fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId); + } + + // Add task to target group + const toGroup = state.groups.find(group => group.id === toGroupId); + if (toGroup) { + if (toIndex >= toGroup.taskIds.length) { + toGroup.taskIds.push(taskId); + } else { + toGroup.taskIds.splice(toIndex, 0, taskId); + } + } } }, @@ -483,6 +587,7 @@ export const { moveTaskToGroup, moveTaskBetweenGroups, optimisticTaskMove, + reorderTasksInGroup, setLoading, setError, setSelectedPriorities, diff --git a/worklenz-frontend/src/shared/antd-imports.ts b/worklenz-frontend/src/shared/antd-imports.ts index b5cd5e9e..f6181b82 100644 --- a/worklenz-frontend/src/shared/antd-imports.ts +++ b/worklenz-frontend/src/shared/antd-imports.ts @@ -87,7 +87,17 @@ export { TableOutlined, BarChartOutlined, FileOutlined, - MessageOutlined + MessageOutlined, + FlagOutlined, + GroupOutlined, + EyeOutlined, + InboxOutlined, + PaperClipOutlined, + HolderOutlined, + ExpandAltOutlined, + CheckCircleOutlined, + MinusCircleOutlined, + RetweetOutlined, } from '@ant-design/icons'; // Re-export all components with React @@ -196,4 +206,48 @@ export default { config: antdConfig, message: appMessage, notification: appNotification, -}; \ No newline at end of file +}; + +// Commonly used Ant Design configurations for task management +export const taskManagementAntdConfig = { + // DatePicker default props for consistency + datePickerDefaults: { + format: 'MMM DD, YYYY', + placeholder: 'Set Date', + suffixIcon: null, + size: 'small' as const, + }, + + // Button default props for task actions + taskButtonDefaults: { + size: 'small' as const, + type: 'text' as const, + }, + + // Input default props for task editing + taskInputDefaults: { + size: 'small' as const, + variant: 'borderless' as const, + }, + + // Select default props for dropdowns + taskSelectDefaults: { + size: 'small' as const, + variant: 'borderless' as const, + showSearch: true, + optionFilterProp: 'label' as const, + }, + + // Tooltip default props + tooltipDefaults: { + placement: 'top' as const, + mouseEnterDelay: 0.5, + mouseLeaveDelay: 0.1, + }, + + // Dropdown default props + dropdownDefaults: { + trigger: ['click'] as const, + placement: 'bottomLeft' as const, + }, +};