From 81e1872c1f41bd34990f39522292b447a6215cf3 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 30 Jul 2025 15:08:28 +0530 Subject: [PATCH 1/4] refactor(task-list): simplify drag-and-drop functionality and enhance task rendering - Removed droppable functionality from TaskGroupHeader and replaced it with a more streamlined approach in TaskListV2Table. - Introduced DropSpacer component to improve visual feedback during task dragging. - Updated task rendering logic in TaskRow to enhance user experience with clearer drop indicators. - Refactored useDragAndDrop hook to manage drop positions more effectively, ensuring tasks can only be reordered within the same group. - Improved socket event handling for task sorting to ensure accurate updates during drag-and-drop operations. --- .../task-list-v2/TaskGroupHeader.tsx | 17 +- .../task-list-v2/TaskListV2Table.tsx | 209 +++++++--------- .../src/components/task-list-v2/TaskRow.tsx | 10 +- .../task-list-v2/hooks/useDragAndDrop.ts | 227 ++++++------------ .../task-management/task-management.slice.ts | 19 +- 5 files changed, 186 insertions(+), 296 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 32a19dd4..58066068 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useCallback, useState } from 'react'; -import { useDroppable } from '@dnd-kit/core'; // @ts-ignore: Heroicons module types import { ChevronDownIcon, @@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC = ({ t, ]); - // Make the group header droppable - const { isOver, setNodeRef } = useDroppable({ - id: group.id, - data: { - type: 'group', - group, - }, - }); - return (
string; -}> = ({ groupId, visibleColumns, t }) => { - const { setNodeRef, isOver, active } = useDroppable({ - id: `empty-group-${groupId}`, - data: { - type: 'group', - groupId: groupId, - isEmpty: true, - }, - }); - - return ( -
-
- {visibleColumns.map((column, index) => { - const emptyColumnStyle = { - width: column.width, - flexShrink: 0, - }; - - // Show text in the title column - if (column.id === 'title') { - return ( -
- - No tasks in this group - -
- ); - } - - return ( -
- ); - })} -
- {isOver && active && ( -
- )} -
- ); -}; - -// Placeholder Drop Indicator Component -const PlaceholderDropIndicator: React.FC<{ - isVisible: boolean; - visibleColumns: any[]; -}> = ({ isVisible, visibleColumns }) => { +// Drop Spacer Component - creates space between tasks when dragging +const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[] }> = ({ isVisible, visibleColumns }) => { if (!isVisible) return null; return (
- {visibleColumns.map((column, index) => { + {visibleColumns.map((column) => { const columnStyle = { width: column.width, flexShrink: 0, }; + + if (column.id === 'title') { + return ( +
+ + Drop here + +
+ ); + } + return (
- {/* Show "Drop task here" message in the title column */} - {column.id === 'title' && ( -
- Drop task here -
- )} - {/* Show subtle placeholder content in other columns */} - {column.id !== 'title' && column.id !== 'dragHandle' && ( -
- )} -
+ /> ); })}
); }; +// Empty Group Message Component +const EmptyGroupMessage: React.FC<{ visibleColumns: any[] }> = ({ visibleColumns }) => { + return ( +
+ {visibleColumns.map((column) => { + const emptyColumnStyle = { + width: column.width, + flexShrink: 0, + }; + + // Show text in the title column + if (column.id === 'title') { + return ( +
+ + No tasks in this group + +
+ ); + } + + return ( +
+ ); + })} +
+ ); +}; + + // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useSocket } from '@/socket/socketContext'; @@ -229,7 +215,7 @@ const TaskListV2Section: React.FC = () => { ); // Custom hooks - const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( + const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( allTasks, groups ); @@ -564,16 +550,12 @@ const TaskListV2Section: React.FC = () => { projectId={urlProjectId || ''} /> {isGroupEmpty && !isGroupCollapsed && ( - + )}
); }, - [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] + [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode] ); const renderTask = useCallback( @@ -797,6 +779,7 @@ const TaskListV2Section: React.FC = () => { onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} + modifiers={[restrictToVerticalAxis]} >
{/* Table Container */} @@ -850,41 +833,21 @@ const TaskListV2Section: React.FC = () => { // Check if this is the first actual task in the group (not AddTaskRow) const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); - - // Check if we should show drop indicators - const isTaskBeingDraggedOver = overId === task.id; - const isGroupBeingDraggedOver = overId === group.id; - const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver; + + // Check if we should show drop spacer + const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task); + const showDropSpacerBefore = isOverThisTask && dropPosition === 'before'; + const showDropSpacerAfter = isOverThisTask && dropPosition === 'after'; return (
- {/* Placeholder drop indicator before first task in group */} - {isFirstTaskInGroupBeingDraggedOver && ( - - )} - - {/* Placeholder drop indicator between tasks */} - {isTaskBeingDraggedOver && !isFirstTaskInGroup && ( - - )} - + {showDropSpacerBefore && } {renderTask(globalTaskIndex, isFirstTaskInGroup)} - - {/* Placeholder drop indicator at end of group when dragging over group */} - {isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && ( - - )} + {showDropSpacerAfter && }
); }) - ) : ( - // Handle empty groups with placeholder drop indicator - overId === group.id && ( -
- -
- ) - ) + ) : null )}
))} @@ -894,15 +857,15 @@ const TaskListV2Section: React.FC = () => {
{/* Drag Overlay */} - + {activeId ? (
col.id === 'title')?.width || '300px' }} > -
-
- +
+
+
{allTasks.find(task => task.id === activeId)?.name || allTasks.find(task => task.id === activeId)?.title || diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index d03082ef..c2cbdd96 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -75,7 +75,7 @@ const TaskRow: React.FC = memo(({ }); // Drag and drop functionality - only enable for parent tasks - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ id: task.id, data: { type: 'task', @@ -116,17 +116,19 @@ const TaskRow: React.FC = memo(({ const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging + opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging }), [transform, transition, isDragging]); return (
{visibleColumns.map((column, index) => ( diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts index 4a5b8848..f451931c 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const currentSession = useAuthService().getCurrentSession(); const [activeId, setActiveId] = useState(null); const [overId, setOverId] = useState(null); + const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null); - // Helper function to emit socket event for persistence + // Helper function to emit socket event for persistence (within-group only) const emitTaskSortChange = useCallback( - (taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => { + (taskId: string, group: TaskGroup, insertIndex: number) => { if (!socket || !connected || !projectId) { logger.warning('Socket not connected or missing project ID'); return; @@ -40,54 +41,30 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { // Use new bulk update approach - recalculate ALL task orders to prevent duplicates const taskUpdates: any[] = []; - // Create a copy of all groups and perform the move operation - const updatedGroups = groups.map(group => ({ - ...group, - taskIds: [...group.taskIds] + // Create a copy of all groups + const updatedGroups = groups.map(g => ({ + ...g, + taskIds: [...g.taskIds] })); - // Find the source and target groups in our copy - const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!; - const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!; + // Find the group in our copy + const groupCopy = updatedGroups.find(g => g.id === group.id)!; - if (sourceGroup.id === targetGroup.id) { - // Same group - reorder within the group - const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); - // Remove task from old position - sourceGroupCopy.taskIds.splice(sourceIndex, 1); - // Insert at new position - sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId); - } else { - // Different groups - move task between groups - // Remove from source group - const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); - sourceGroupCopy.taskIds.splice(sourceIndex, 1); - - // Add to target group - targetGroupCopy.taskIds.splice(insertIndex, 0, taskId); - } + // Reorder within the group + const sourceIndex = groupCopy.taskIds.indexOf(taskId); + // Remove task from old position + groupCopy.taskIds.splice(sourceIndex, 1); + // Insert at new position + groupCopy.taskIds.splice(insertIndex, 0, taskId); // Now assign sequential sort orders to ALL tasks across ALL groups let currentSortOrder = 0; - updatedGroups.forEach(group => { - group.taskIds.forEach(id => { - const update: any = { + updatedGroups.forEach(grp => { + grp.taskIds.forEach(id => { + taskUpdates.push({ task_id: id, sort_order: currentSortOrder - }; - - // Add group-specific fields for the moved task if it changed groups - if (id === taskId && sourceGroup.id !== targetGroup.id) { - if (currentGrouping === 'status') { - update.status_id = targetGroup.id; - } else if (currentGrouping === 'priority') { - update.priority_id = targetGroup.id; - } else if (currentGrouping === 'phase') { - update.phase_id = targetGroup.id; - } - } - - taskUpdates.push(update); + }); currentSortOrder++; }); }); @@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { project_id: projectId, group_by: currentGrouping || 'status', task_updates: taskUpdates, - from_group: sourceGroup.id, - to_group: targetGroup.id, + from_group: group.id, + to_group: group.id, task: { id: task.id, project_id: projectId, @@ -108,32 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { }; socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); - - // Also emit the specific grouping field change event for the moved task - if (sourceGroup.id !== targetGroup.id) { - if (currentGrouping === 'phase') { - // Emit phase change event - socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { - task_id: taskId, - phase_id: targetGroup.id, - parent_task: task.parent_task_id || null, - }); - } else if (currentGrouping === 'priority') { - // Emit priority change event - socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({ - task_id: taskId, - priority_id: targetGroup.id, - team_id: teamId, - })); - } else if (currentGrouping === 'status') { - // Emit status change event - socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({ - task_id: taskId, - status_id: targetGroup.id, - team_id: teamId, - })); - } - } }, [socket, connected, projectId, allTasks, groups, currentGrouping, currentSession] ); @@ -148,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { if (!over) { setOverId(null); + setDropPosition(null); return; } - const activeId = active.id; - const overId = over.id; - - // Set the overId for drop indicators - setOverId(overId as string); - - // Find the active task and the item being dragged over - const activeTask = allTasks.find(task => task.id === activeId); - if (!activeTask) return; - - // Check if we're dragging over a task or a group - const overTask = allTasks.find(task => task.id === overId); - const overGroup = groups.find(group => group.id === overId); - - // Find the groups - const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); - let targetGroup = overGroup; - - if (overTask) { - targetGroup = groups.find(group => group.taskIds.includes(overTask.id)); + const activeTask = allTasks.find(task => task.id === active.id); + const overTask = allTasks.find(task => task.id === over.id); + + if (activeTask && overTask) { + const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); + const overGroup = groups.find(group => group.taskIds.includes(overTask.id)); + + // Only set overId if both tasks are in the same group + if (activeGroup && overGroup && activeGroup.id === overGroup.id) { + setOverId(over.id as string); + + // Calculate drop position based on task indices + const activeIndex = activeGroup.taskIds.indexOf(activeTask.id); + const overIndex = activeGroup.taskIds.indexOf(overTask.id); + + if (activeIndex < overIndex) { + setDropPosition('after'); + } else { + setDropPosition('before'); + } + } else { + setOverId(null); + setDropPosition(null); + } + } else { + setOverId(null); + setDropPosition(null); } - - if (!activeGroup || !targetGroup) return; }, [allTasks, groups] ); @@ -183,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const { active, over } = event; setActiveId(null); setOverId(null); + setDropPosition(null); if (!over || active.id === over.id) { return; @@ -198,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { return; } - // Find the groups + // Find the active task's group const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); if (!activeGroup) { logger.error('Could not find active group for task:', activeId); return; } - // Check if we're dropping on a task, group, or empty group + // Only allow dropping on tasks in the same group const overTask = allTasks.find(task => task.id === overId); - const overGroup = groups.find(group => group.id === overId); - - // Check if dropping on empty group drop zone - const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-'); - const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null; - const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null; - - let targetGroup = overGroup || emptyGroup; - let insertIndex = 0; - - if (overTask) { - // Dropping on a task - targetGroup = groups.find(group => group.taskIds.includes(overTask.id)); - if (targetGroup) { - insertIndex = targetGroup.taskIds.indexOf(overTask.id); - } - } else if (overGroup) { - // Dropping on a group (at the end) - targetGroup = overGroup; - insertIndex = targetGroup.taskIds.length; - } else if (emptyGroup) { - // Dropping on an empty group - targetGroup = emptyGroup; - insertIndex = 0; // First position in empty group - } - - if (!targetGroup) { - logger.error('Could not find target group'); + if (!overTask) { + return; + } + + const overGroup = groups.find(group => group.taskIds.includes(overTask.id)); + if (!overGroup || overGroup.id !== activeGroup.id) { return; } - const isCrossGroup = activeGroup.id !== targetGroup.id; const activeIndex = activeGroup.taskIds.indexOf(activeTask.id); + const overIndex = activeGroup.taskIds.indexOf(overTask.id); - if (isCrossGroup) { - // Moving task between groups - console.log('Moving task between groups:', { - task: activeTask.name || activeTask.title, - from: activeGroup.title, - to: targetGroup.title, - newPosition: insertIndex, - }); - - // reorderTasksInGroup handles both same-group and cross-group moves - // No need for separate moveTaskBetweenGroups call + if (activeIndex !== overIndex) { + // Reorder task within same group dispatch( reorderTasksInGroup({ sourceTaskId: activeId as string, - destinationTaskId: over.id as string, + destinationTaskId: overId as string, sourceGroupId: activeGroup.id, - destinationGroupId: targetGroup.id, + destinationGroupId: activeGroup.id, }) ); - // Emit socket event for persistence - emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); - } else { - if (activeIndex !== insertIndex) { - // Reorder task within same group at drop position - dispatch( - reorderTasksInGroup({ - sourceTaskId: activeId as string, - destinationTaskId: over.id as string, - sourceGroupId: activeGroup.id, - destinationGroupId: activeGroup.id, - }) - ); - - // Emit socket event for persistence - emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); + // Calculate the final index after reordering for socket emission + let finalIndex = overIndex; + if (activeIndex < overIndex) { + // When dragging down, the task ends up just after the destination + finalIndex = overIndex; + } else { + // When dragging up, the task ends up at the destination position + finalIndex = overIndex; } + + // Emit socket event for persistence + emitTaskSortChange(activeId as string, activeGroup, finalIndex); } }, [allTasks, groups, dispatch, emitTaskSortChange] @@ -286,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { return { activeId, overId, + dropPosition, handleDragStart, handleDragOver, handleDragEnd, 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 c72dc392..1db46e4c 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -680,8 +680,23 @@ const taskManagementSlice = createSlice({ const group = state.groups.find(g => g.id === sourceGroupId); if (group) { const newTasks = Array.from(group.taskIds); - const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1); - newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); + const sourceIndex = newTasks.indexOf(sourceTaskId); + const destinationIndex = newTasks.indexOf(destinationTaskId); + + // Remove the task from its current position + const [removed] = newTasks.splice(sourceIndex, 1); + + // Calculate the insertion index + let insertIndex = destinationIndex; + if (sourceIndex < destinationIndex) { + // When dragging down, we need to insert after the destination + insertIndex = destinationIndex; + } else { + // When dragging up, we insert before the destination + insertIndex = destinationIndex; + } + + newTasks.splice(insertIndex, 0, removed); group.taskIds = newTasks; // Update order for affected tasks using the appropriate sort field From b6c056dd1adb199d3ce216b5e98727679ffd024a Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 30 Jul 2025 15:26:27 +0530 Subject: [PATCH 2/4] feat(task-status-dropdown): enhance task status update and group movement handling - Added logic to optimistically update task status in Redux for immediate feedback. - Implemented group movement handling when tasks are updated based on their status, ensuring tasks are moved between groups as needed. - Improved socket event emissions for real-time updates, including parent task handling. - Refactored group selection logic to streamline target group identification based on status ID and group value. --- .../task-management/task-status-dropdown.tsx | 43 ++++++++++++++-- .../src/hooks/useTaskSocketHandlers.ts | 51 ++++--------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/worklenz-frontend/src/components/task-management/task-status-dropdown.tsx b/worklenz-frontend/src/components/task-management/task-status-dropdown.tsx index 302367c0..bf298154 100644 --- a/worklenz-frontend/src/components/task-management/task-status-dropdown.tsx +++ b/worklenz-frontend/src/components/task-management/task-status-dropdown.tsx @@ -8,6 +8,8 @@ import { Task } from '@/types/task-management.types'; import { updateTask, selectCurrentGroupingV3, + selectGroups, + moveTaskBetweenGroups, } from '@/features/task-management/task-management.slice'; interface TaskStatusDropdownProps { @@ -30,6 +32,7 @@ const TaskStatusDropdown: React.FC = ({ const statusList = useAppSelector(state => state.taskStatusReducer.status); const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3); + const groups = useAppSelector(selectGroups); // Find current status details const currentStatus = useMemo(() => { @@ -44,21 +47,53 @@ const TaskStatusDropdown: React.FC = ({ (statusId: string, statusName: string) => { if (!task.id || !statusId || !connected) return; - console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName }); + // Optimistic update: immediately update the task status in Redux for instant feedback + const updatedTask = { + ...task, + status: statusId, + updatedAt: new Date().toISOString(), + }; + dispatch(updateTask(updatedTask)); + // Handle group movement if grouping by status + if (currentGroupingV3 === 'status' && groups && groups.length > 0) { + // Find current group containing the task + const currentGroup = groups.find(group => group.taskIds.includes(task.id)); + + // Find target group based on the new status ID + let targetGroup = groups.find(group => group.id === statusId); + + // If not found by status ID, try matching with group value + if (!targetGroup) { + targetGroup = groups.find(group => group.groupValue === statusId); + } + + if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { + // Move task between groups immediately for instant feedback + dispatch( + moveTaskBetweenGroups({ + taskId: task.id, + sourceGroupId: currentGroup.id, + targetGroupId: targetGroup.id, + }) + ); + } + } + + // Emit socket event for server-side update and real-time sync socket?.emit( SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({ task_id: task.id, status_id: statusId, - parent_task: null, // Assuming top-level tasks for now - team_id: projectId, // Using projectId as teamId + parent_task: task.parent_task_id || null, + team_id: projectId, }) ); socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); setIsOpen(false); }, - [task.id, connected, socket, projectId] + [task, connected, socket, projectId, dispatch, currentGroupingV3, groups] ); // Calculate dropdown position and handle outside clicks diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 42001f8a..72a1f71a 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -244,44 +244,18 @@ export const useTaskSocketHandlers = () => { // Find current group containing the task const currentGroup = groups.find(group => group.taskIds.includes(response.id)); - // Find target group based on new status value with multiple matching strategies - let targetGroup = groups.find(group => group.groupValue === newStatusValue); + // Find target group based on the actual status ID from response + let targetGroup = groups.find(group => group.id === response.status_id); - // If not found, try case-insensitive matching + // If not found by status ID, try matching with group value if (!targetGroup) { - targetGroup = groups.find(group => - group.groupValue?.toLowerCase() === newStatusValue.toLowerCase() - ); + targetGroup = groups.find(group => group.groupValue === response.status_id); } - // If still not found, try matching with title - if (!targetGroup) { + // If still not found, try matching by status name (fallback) + if (!targetGroup && response.status) { targetGroup = groups.find(group => - group.title?.toLowerCase() === newStatusValue.toLowerCase() - ); - } - - // If still not found, try matching common status patterns - if (!targetGroup && newStatusValue === 'todo') { - targetGroup = groups.find(group => - group.title?.toLowerCase().includes('todo') || - group.title?.toLowerCase().includes('to do') || - group.title?.toLowerCase().includes('pending') || - group.groupValue?.toLowerCase().includes('todo') - ); - } else if (!targetGroup && newStatusValue === 'doing') { - targetGroup = groups.find(group => - group.title?.toLowerCase().includes('doing') || - group.title?.toLowerCase().includes('progress') || - group.title?.toLowerCase().includes('active') || - group.groupValue?.toLowerCase().includes('doing') - ); - } else if (!targetGroup && newStatusValue === 'done') { - targetGroup = groups.find(group => - group.title?.toLowerCase().includes('done') || - group.title?.toLowerCase().includes('complete') || - group.title?.toLowerCase().includes('finish') || - group.groupValue?.toLowerCase().includes('done') + group.title?.toLowerCase() === response.status.toLowerCase() ); } @@ -295,14 +269,11 @@ export const useTaskSocketHandlers = () => { }) ); } else if (!targetGroup) { - console.log('❌ Target group not found for status:', newStatusValue); - } else if (!currentGroup) { - console.log('❌ Current group not found for task:', response.id); - } else { - console.log('🔧 No group movement needed - task already in correct group'); + // Fallback: refetch tasks to ensure consistency + if (projectId) { + dispatch(fetchTasksV3(projectId)); + } } - } else { - console.log('🔧 Not grouped by status, skipping group movement'); } } }, From 374595261f15b4a92df7fd0f485b4ddd85feb440 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 30 Jul 2025 16:25:29 +0530 Subject: [PATCH 3/4] feat(task-list-v2): enhance sticky column behavior and dark mode support - Updated DropSpacer and EmptyGroupMessage components to accept an optional isDarkMode prop for improved styling in dark mode. - Enhanced task rendering in TaskRow to dynamically adjust background colors based on dark mode and drag states. - Refactored useTaskRowColumns to support sticky column positioning and hover effects, ensuring a consistent user experience across different themes. - Improved overall visual feedback during task interactions, including drag-and-drop operations. --- .../task-list-v2/TaskListV2Table.tsx | 112 +++++++++++++++--- .../src/components/task-list-v2/TaskRow.tsx | 25 +++- .../task-list-v2/hooks/useTaskRowColumns.tsx | 65 ++++++++-- 3 files changed, 167 insertions(+), 35 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 07b25ba3..36b8182a 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -69,7 +69,11 @@ import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-sub import EmptyListPlaceholder from '@/components/EmptyListPlaceholder'; // Drop Spacer Component - creates space between tasks when dragging -const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[] }> = ({ isVisible, visibleColumns }) => { +const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[]; isDarkMode?: boolean }> = ({ + isVisible, + visibleColumns, + isDarkMode = false +}) => { if (!isVisible) return null; return ( @@ -83,17 +87,34 @@ const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[] }> = ({ i overflow: 'hidden', }} > - {visibleColumns.map((column) => { + {visibleColumns.map((column, index) => { + // Calculate left position for sticky columns + let leftPosition = 0; + if (column.isSticky) { + for (let i = 0; i < index; i++) { + const prevColumn = visibleColumns[i]; + if (prevColumn.isSticky) { + leftPosition += parseInt(prevColumn.width.replace('px', '')); + } + } + } + const columnStyle = { width: column.width, flexShrink: 0, + ...(column.isSticky && { + position: 'sticky' as const, + left: leftPosition, + zIndex: 5, + backgroundColor: 'inherit', // Inherit from parent spacer + }), }; if (column.id === 'title') { return (
@@ -116,13 +137,33 @@ const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[] }> = ({ i }; // Empty Group Message Component -const EmptyGroupMessage: React.FC<{ visibleColumns: any[] }> = ({ visibleColumns }) => { +const EmptyGroupMessage: React.FC<{ visibleColumns: any[]; isDarkMode?: boolean }> = ({ + visibleColumns, + isDarkMode = false +}) => { return (
- {visibleColumns.map((column) => { + {visibleColumns.map((column, index) => { + // Calculate left position for sticky columns + let leftPosition = 0; + if (column.isSticky) { + for (let i = 0; i < index; i++) { + const prevColumn = visibleColumns[i]; + if (prevColumn.isSticky) { + leftPosition += parseInt(prevColumn.width.replace('px', '')); + } + } + } + const emptyColumnStyle = { width: column.width, flexShrink: 0, + ...(column.isSticky && { + position: 'sticky' as const, + left: leftPosition, + zIndex: 5, + backgroundColor: 'inherit', // Inherit from parent container + }), }; // Show text in the title column @@ -130,7 +171,7 @@ const EmptyGroupMessage: React.FC<{ visibleColumns: any[] }> = ({ visibleColumns return (
@@ -550,7 +591,7 @@ const TaskListV2Section: React.FC = () => { projectId={urlProjectId || ''} /> {isGroupEmpty && !isGroupCollapsed && ( - + )}
); @@ -596,19 +637,40 @@ const TaskListV2Section: React.FC = () => { const renderColumnHeaders = useCallback( () => (
{visibleColumns.map((column, index) => { + // Calculate left position for sticky columns + let leftPosition = 0; + if (column.isSticky) { + for (let i = 0; i < index; i++) { + const prevColumn = visibleColumns[i]; + if (prevColumn.isSticky) { + leftPosition += parseInt(prevColumn.width.replace('px', '')); + } + } + } + const columnStyle: ColumnStyle = { width: column.width, flexShrink: 0, ...((column as any).minWidth && { minWidth: (column as any).minWidth }), ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), + ...(column.isSticky && { + position: 'sticky' as const, + left: leftPosition, + zIndex: 10, + backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50 + }), }; return ( @@ -773,14 +835,25 @@ const TaskListV2Section: React.FC = () => { } return ( - + <> + {/* CSS for sticky column hover effects */} + + +
{/* Table Container */}
{ return (
- {showDropSpacerBefore && } + {showDropSpacerBefore && } {renderTask(globalTaskIndex, isFirstTaskInGroup)} - {showDropSpacerAfter && } + {showDropSpacerAfter && }
); }) @@ -917,6 +990,7 @@ const TaskListV2Section: React.FC = () => { {createPortal(, document.body, 'convert-to-subtask-drawer')}
+ ); }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index c2cbdd96..be2cec2c 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -131,11 +131,26 @@ const TaskRow: React.FC = memo(({ isOver && !isDragging ? 'bg-blue-50 dark:bg-blue-900/20' : '' }`} > - {visibleColumns.map((column, index) => ( - - {renderColumn(column.id, column.width, column.isSticky, index)} - - ))} + {visibleColumns.map((column, index) => { + // Calculate background state for sticky columns - custom dark mode colors + const rowBackgrounds = { + normal: isDarkMode ? '#1e1e1e' : '#ffffff', // custom dark : bg-white + hover: isDarkMode ? '#1f2937' : '#f9fafb', // slightly lighter dark : bg-gray-50 + dragOver: isDarkMode ? '#1e3a8a33' : '#dbeafe', // bg-blue-900/20 : bg-blue-50 + }; + + let currentBg = rowBackgrounds.normal; + if (isOver && !isDragging) { + currentBg = rowBackgrounds.dragOver; + } + // Note: hover state is handled by CSS, so we'll use a CSS custom property + + return ( + + {renderColumn(column.id, column.width, column.isSticky, index, currentBg, rowBackgrounds)} + + ); + })}
); }); diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx index 6359deb3..38e61c19 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx @@ -90,17 +90,39 @@ export const useTaskRowColumns = ({ depth = 0, }: UseTaskRowColumnsProps) => { - const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => { - switch (columnId) { - case 'dragHandle': - return ( - - ); + const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number, currentBg?: string, rowBackgrounds?: any) => { + // Calculate left position for sticky columns + let leftPosition = 0; + if (isSticky && typeof index === 'number') { + for (let i = 0; i < index; i++) { + const prevColumn = visibleColumns[i]; + if (prevColumn.isSticky) { + leftPosition += parseInt(prevColumn.width.replace('px', '')); + } + } + } + + // Create wrapper style for sticky positioning + const wrapperStyle = isSticky ? { + position: 'sticky' as const, + left: leftPosition, + zIndex: 5, // Lower than header but above regular content + backgroundColor: currentBg || (isDarkMode ? '#1e1e1e' : '#ffffff'), // Use dynamic background or fallback + overflow: 'hidden', // Prevent content from spilling over + width: width, // Ensure the wrapper respects column width + } : {}; + + const renderColumnContent = () => { + switch (columnId) { + case 'dragHandle': + return ( + + ); case 'checkbox': return ( @@ -294,7 +316,27 @@ export const useTaskRowColumns = ({ ); } return null; + } + }; + + // Wrap content with sticky positioning if needed + const content = renderColumnContent(); + if (isSticky) { + const hoverBg = rowBackgrounds?.hover || (isDarkMode ? '#2a2a2a' : '#f9fafb'); + return ( +
+ {content} +
+ ); } + + return content; }, [ task, projectId, @@ -319,6 +361,7 @@ export const useTaskRowColumns = ({ handleTaskNameEdit, attributes, listeners, + depth, ]); return { renderColumn }; From 2bd6c19c1310b104ca14656bb7189ce0f82fbcdb Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 30 Jul 2025 17:20:20 +0530 Subject: [PATCH 4/4] refactor(layouts): simplify MainLayout and enhance styling - Removed unused imports and performance monitoring hooks from MainLayout. - Updated layout structure to improve responsiveness and styling, including sticky header and optimized content padding. - Adjusted home page layout to reduce margin and improve spacing for better visual consistency. - Enhanced TodoList component with collapsible sections for improved user interaction and task management. - Streamlined project and schedule pages by removing unnecessary margin adjustments, ensuring a cleaner layout. --- .../task-list-v2/TaskListV2Table.tsx | 6 +- worklenz-frontend/src/layouts/MainLayout.tsx | 76 ++------ .../src/pages/home/home-page.tsx | 6 +- .../src/pages/home/todo-list/todo-list.tsx | 170 +++++++++++------- .../src/pages/projects/project-list.tsx | 2 +- .../projects/projectView/project-view.tsx | 4 +- .../src/pages/schedule/schedule.tsx | 2 +- 7 files changed, 128 insertions(+), 138 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 36b8182a..95262a28 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -105,7 +105,7 @@ const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[]; isDarkMo ...(column.isSticky && { position: 'sticky' as const, left: leftPosition, - zIndex: 5, + zIndex: 10, backgroundColor: 'inherit', // Inherit from parent spacer }), }; @@ -161,7 +161,7 @@ const EmptyGroupMessage: React.FC<{ visibleColumns: any[]; isDarkMode?: boolean ...(column.isSticky && { position: 'sticky' as const, left: leftPosition, - zIndex: 5, + zIndex: 10, backgroundColor: 'inherit', // Inherit from parent container }), }; @@ -668,7 +668,7 @@ const TaskListV2Section: React.FC = () => { ...(column.isSticky && { position: 'sticky' as const, left: leftPosition, - zIndex: 10, + zIndex: 15, backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50 }), }; diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index b1c6a7d3..0361ed79 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -1,62 +1,18 @@ -import { Col, ConfigProvider, Layout } from '@/shared/antd-imports'; -import { Outlet } from 'react-router-dom'; -import { memo, useMemo, useEffect, useRef } from 'react'; -import { useMediaQuery } from 'react-responsive'; +import { ConfigProvider, Layout } from '@/shared/antd-imports'; +import { Outlet, useLocation } from 'react-router-dom'; +import { memo, useMemo } from 'react'; import Navbar from '../features/navbar/navbar'; import { useAppSelector } from '../hooks/useAppSelector'; import { colors } from '../styles/colors'; -import { useRenderPerformance } from '@/utils/performance'; -import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations'; - const MainLayout = memo(() => { const themeMode = useAppSelector(state => state.themeReducer.mode); - const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); - const layoutRef = useRef(null); - - // Performance monitoring in development - useRenderPerformance('MainLayout'); - - // Apply layout optimizations - useEffect(() => { - if (layoutRef.current) { - // Prevent layout shifts in main content area - LayoutStabilizer.applyContainment(layoutRef.current, 'layout'); - - // Load non-critical CSS dynamically - DynamicCSSLoader.loadCSS('/styles/non-critical.css', { - priority: 'low', - media: 'all' - }); - } - }, []); - + const location = useLocation(); + const isProjectView = location.pathname.includes('/projects/') && + !location.pathname.endsWith('/projects'); - // Memoize styles to prevent object recreation on every render - const headerStyles = useMemo( - () => ({ - zIndex: 999, - position: 'fixed' as const, - width: '100%', - display: 'flex', - alignItems: 'center', - padding: 0, - borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none', - }), - [themeMode] - ); - - const contentStyles = useMemo( - () => ({ - paddingInline: isDesktop ? 64 : 24, - overflowX: 'hidden' as const, - }), - [isDesktop] - ); - - // Memoize theme configuration const themeConfig = useMemo( () => ({ components: { @@ -69,23 +25,19 @@ const MainLayout = memo(() => { [themeMode] ); - // Memoize header className - const headerClassName = useMemo( - () => `shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`, - [themeMode] - ); - return ( - - + + - - - - + + diff --git a/worklenz-frontend/src/pages/home/home-page.tsx b/worklenz-frontend/src/pages/home/home-page.tsx index aba0890a..37b6b618 100644 --- a/worklenz-frontend/src/pages/home/home-page.tsx +++ b/worklenz-frontend/src/pages/home/home-page.tsx @@ -103,7 +103,7 @@ const HomePage = memo(() => { }, [isDesktop, isOwnerOrAdmin]); return ( -
+
{CreateProjectButtonComponent} @@ -113,13 +113,13 @@ const HomePage = memo(() => { - - + + diff --git a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx index d1ab980b..147b0208 100644 --- a/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx +++ b/worklenz-frontend/src/pages/home/todo-list/todo-list.tsx @@ -1,9 +1,10 @@ -import { CheckCircleOutlined, SyncOutlined } from '@/shared/antd-imports'; +import { CheckCircleOutlined, SyncOutlined, DownOutlined, RightOutlined } from '@/shared/antd-imports'; import { useRef, useState } from 'react'; import Form from 'antd/es/form'; import Input, { InputRef } from 'antd/es/input'; import Flex from 'antd/es/flex'; import Card from 'antd/es/card'; +import Collapse from 'antd/es/collapse'; import ConfigProvider from 'antd/es/config-provider'; import Table, { TableProps } from 'antd/es/table'; import Tooltip from 'antd/es/tooltip'; @@ -23,6 +24,7 @@ import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.ser const TodoList = () => { const [isAlertShowing, setIsAlertShowing] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(true); const [form] = Form.useForm(); const { t } = useTranslation('home'); @@ -97,73 +99,109 @@ const TodoList = () => { ]; return ( - - {t('home:todoList.title')} ({data?.body.length}) - - } - extra={ - -