diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 2683c7a6..fd8dea2f 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[]; isDarkMode?: boolean }> = ({ + isVisible, + visibleColumns, + isDarkMode = false +}) => { if (!isVisible) return null; - + return ( -
{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: 10, + backgroundColor: 'inherit', // Inherit from parent spacer + }), }; + + 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[]; isDarkMode?: boolean }> = ({ + visibleColumns, + isDarkMode = false +}) => { + return ( +
+ {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: 10, + backgroundColor: 'inherit', // Inherit from parent container + }), + }; + + // 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'; @@ -211,7 +235,7 @@ const TaskListV2Section: React.FC = () => { // State hooks const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); - const [addTaskRows, setAddTaskRows] = useState<{ [groupId: string]: string[] }>({}); + const [addTaskRows, setAddTaskRows] = useState<{[groupId: string]: string[]}>({}); // Configure sensors for drag and drop const sensors = useSensors( @@ -232,7 +256,7 @@ const TaskListV2Section: React.FC = () => { ); // Custom hooks - const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( + const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( allTasks, groups ); @@ -453,17 +477,17 @@ const TaskListV2Section: React.FC = () => { const handleTaskAdded = useCallback((rowId: string) => { // Task is now added in real-time via socket, no need to refetch // The global socket handler will handle the real-time update - + // Find the group this row belongs to const groupId = rowId.split('-')[2]; // Extract from rowId format: add-task-{groupId}-{index} - + // Add a new add task row to this group setAddTaskRows(prev => { const currentRows = prev[groupId] || []; const newRowId = `add-task-${groupId}-${currentRows.length + 1}`; return { ...prev, - [groupId]: [...currentRows, newRowId], + [groupId]: [...currentRows, newRowId] }; }); }, []); @@ -493,7 +517,7 @@ const TaskListV2Section: React.FC = () => { // Get add task rows for this group const groupAddRows = addTaskRows[group.id] || []; - const addTaskItems = !isCurrentGroupCollapsed + const addTaskItems = !isCurrentGroupCollapsed ? [ // Default add task row { @@ -516,7 +540,7 @@ const TaskListV2Section: React.FC = () => { projectId: urlProjectId, rowId: rowId, autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row - })), + })) ] : []; @@ -545,6 +569,7 @@ const TaskListV2Section: React.FC = () => { return virtuosoGroups.flatMap(group => group.tasks); }, [virtuosoGroups]); + // Render functions const renderGroup = useCallback( (groupIndex: number) => { @@ -566,12 +591,12 @@ const TaskListV2Section: React.FC = () => { projectId={urlProjectId || ''} /> {isGroupEmpty && !isGroupCollapsed && ( - + )}
); }, - [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] + [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode] ); const renderTask = useCallback( @@ -612,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: 15, + backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50 + }), }; return ( @@ -701,9 +747,9 @@ const TaskListV2Section: React.FC = () => { color: '#fbc84c69', actualCount: 0, count: 1, // For the add task row - startIndex: 0, + startIndex: 0 }; - + return ( { > {renderColumnHeaders()}
- +
{ ); } - + // For other groupings, show the empty state message return (
@@ -789,13 +835,25 @@ const TaskListV2Section: React.FC = () => { } return ( - + <> + {/* CSS for sticky column hover effects */} + + +
{/* Table Container */}
{ {renderGroup(groupIndex)} {/* Group Tasks */} - {!collapsedGroups.has(group.id) && - (group.tasks.length > 0 - ? group.tasks.map((task, taskIndex) => { - const globalTaskIndex = - virtuosoGroups - .slice(0, groupIndex) - .reduce((sum, g) => sum + g.count, 0) + taskIndex; + {!collapsedGroups.has(group.id) && ( + group.tasks.length > 0 ? ( + group.tasks.map((task, taskIndex) => { + const globalTaskIndex = + virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) + + taskIndex; - // Check if this is the first actual task in the group (not AddTaskRow) - const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); + // 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 spacer + const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task); + const showDropSpacerBefore = isOverThisTask && dropPosition === 'before'; + const showDropSpacerAfter = isOverThisTask && dropPosition === 'after'; - // Check if we should show drop indicators - const isTaskBeingDraggedOver = overId === task.id; - const isGroupBeingDraggedOver = overId === group.id; - const isFirstTaskInGroupBeingDraggedOver = - isFirstTaskInGroup && isTaskBeingDraggedOver; - - return ( -
- {/* Placeholder drop indicator before first task in group */} - {isFirstTaskInGroupBeingDraggedOver && ( - - )} - - {/* Placeholder drop indicator between tasks */} - {isTaskBeingDraggedOver && !isFirstTaskInGroup && ( - - )} - - {renderTask(globalTaskIndex, isFirstTaskInGroup)} - - {/* Placeholder drop indicator at end of group when dragging over group */} - {isGroupBeingDraggedOver && - taskIndex === group.tasks.length - 1 && ( - - )} -
- ); - }) - : // Handle empty groups with placeholder drop indicator - overId === group.id && ( -
- -
- ))} + return ( +
+ {showDropSpacerBefore && } + {renderTask(globalTaskIndex, isFirstTaskInGroup)} + {showDropSpacerAfter && } +
+ ); + }) + ) : null + )}
))}
@@ -904,15 +930,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 || @@ -959,12 +985,13 @@ const TaskListV2Section: React.FC = () => { {/* Custom Column Modal */} {createPortal(, document.body, 'custom-column-modal')} - + {/* Convert To Subtask Drawer */} {createPortal(, document.body, 'convert-to-subtask-drawer')}
+ ); }; -export default TaskListV2Section; +export default TaskListV2Section; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index 26398f14..7375d905 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -27,116 +27,134 @@ interface TaskRowProps { depth?: number; } -const TaskRow: React.FC = memo( - ({ - taskId, - projectId, - visibleColumns, - isSubtask = false, - isFirstInGroup = false, - updateTaskCustomColumnValue, - depth = 0, - }) => { - // Get task data and selection state from Redux - const task = useAppSelector(state => selectTaskById(state, taskId)); - const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId)); - const themeMode = useAppSelector(state => state.themeReducer.mode); - const isDarkMode = themeMode === 'dark'; +const TaskRow: React.FC = memo(({ + taskId, + projectId, + visibleColumns, + isSubtask = false, + isFirstInGroup = false, + updateTaskCustomColumnValue, + depth = 0 +}) => { + // Get task data and selection state from Redux + const task = useAppSelector(state => selectTaskById(state, taskId)); + const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId)); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const isDarkMode = themeMode === 'dark'; - // Early return if task is not found - if (!task) { - return null; - } - - // Use extracted hooks for state management - const { - activeDatePicker, - setActiveDatePicker, - editTaskName, - setEditTaskName, - taskName, - setTaskName, - taskDisplayName, - convertedTask, - formattedDates, - dateValues, - labelsAdapter, - } = useTaskRowState(task); - - const { handleCheckboxChange, handleTaskNameSave, handleTaskNameEdit } = useTaskRowActions({ - task, - taskId, - taskName, - editTaskName, - setEditTaskName, - }); - - // Drag and drop functionality - only enable for parent tasks - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id: task.id, - data: { - type: 'task', - task, - }, - disabled: isSubtask, // Disable drag and drop for subtasks - }); - - // Use extracted column renderer hook - const { renderColumn } = useTaskRowColumns({ - task, - projectId, - isSubtask, - isSelected, - isDarkMode, - visibleColumns, - updateTaskCustomColumnValue, - taskDisplayName, - convertedTask, - formattedDates, - dateValues, - labelsAdapter, - activeDatePicker, - setActiveDatePicker, - editTaskName, - taskName, - setEditTaskName, - setTaskName, - handleCheckboxChange, - handleTaskNameSave, - handleTaskNameEdit, - attributes, - listeners, - depth, - }); - - // Memoize style object to prevent unnecessary re-renders - const style = useMemo( - () => ({ - transform: CSS.Transform.toString(transform), - transition, - opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging - }), - [transform, transition, isDragging] - ); - - return ( -
- {visibleColumns.map((column, index) => ( - - {renderColumn(column.id, column.width, column.isSticky, index)} - - ))} -
- ); + // Early return if task is not found + if (!task) { + return null; } -); + + // Use extracted hooks for state management + const { + activeDatePicker, + setActiveDatePicker, + editTaskName, + setEditTaskName, + taskName, + setTaskName, + taskDisplayName, + convertedTask, + formattedDates, + dateValues, + labelsAdapter, + } = useTaskRowState(task); + + const { + handleCheckboxChange, + handleTaskNameSave, + handleTaskNameEdit, + } = useTaskRowActions({ + task, + taskId, + taskName, + editTaskName, + setEditTaskName, + }); + + // Drag and drop functionality - only enable for parent tasks + const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({ + id: task.id, + data: { + type: 'task', + task, + }, + disabled: isSubtask, // Disable drag and drop for subtasks + }); + + // Use extracted column renderer hook + const { renderColumn } = useTaskRowColumns({ + task, + projectId, + isSubtask, + isSelected, + isDarkMode, + visibleColumns, + updateTaskCustomColumnValue, + taskDisplayName, + convertedTask, + formattedDates, + dateValues, + labelsAdapter, + activeDatePicker, + setActiveDatePicker, + editTaskName, + taskName, + setEditTaskName, + setTaskName, + handleCheckboxChange, + handleTaskNameSave, + handleTaskNameEdit, + attributes, + listeners, + depth, + }); + + // Memoize style object to prevent unnecessary re-renders + const style = useMemo(() => ({ + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging + }), [transform, transition, isDragging]); + + return ( +
+ {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)} + + ); + })} +
+ ); +}); TaskRow.displayName = 'TaskRow'; -export default TaskRow; +export default TaskRow; \ No newline at end of file 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 89f24ed0..04f9e42d 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; @@ -39,55 +40,31 @@ 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)!; - - 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); - } - + + // Find the group in our copy + const groupCopy = updatedGroups.find(g => g.id === group.id)!; + + // 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); + sort_order: currentSortOrder + }); 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,38 +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] ); @@ -154,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] ); @@ -189,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; @@ -204,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] @@ -292,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { return { activeId, overId, + dropPosition, handleDragStart, handleDragOver, handleDragEnd, 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 8d51f303..38855613 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useTaskRowColumns.tsx @@ -89,8 +89,30 @@ export const useTaskRowColumns = ({ listeners, depth = 0, }: UseTaskRowColumnsProps) => { - const renderColumn = useCallback( - (columnId: string, width: string, isSticky?: boolean, index?: number) => { + + 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 ( @@ -102,172 +124,245 @@ export const useTaskRowColumns = ({ /> ); - case 'checkbox': - return ( - - ); + case 'checkbox': + return ( + + ); - case 'taskKey': - return ; + case 'taskKey': + return ( + + ); - case 'title': + case 'title': + return ( + + ); + + case 'description': + return ( + + ); + + case 'status': + return ( + + ); + + case 'assignees': + return ( + + ); + + case 'priority': + return ( + + ); + + case 'dueDate': + return ( + + ); + + case 'startDate': + return ( + + ); + + case 'progress': + return ( + + ); + + case 'labels': + return ( + + ); + + case 'phase': + return ( + + ); + + case 'timeTracking': + return ( + + ); + + case 'estimation': + return ( + + ); + + case 'completedDate': + return ( + + ); + + case 'createdDate': + return ( + + ); + + case 'lastUpdated': + return ( + + ); + + case 'reporter': + return ( + + ); + + default: + // Handle custom columns + const column = visibleColumns.find(col => col.id === columnId); + if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) { return ( - ); - - case 'description': - return ; - - case 'status': - return ( - - ); - - case 'assignees': - return ( - - ); - - case 'priority': - return ( - - ); - - case 'dueDate': - return ( - - ); - - case 'startDate': - return ( - - ); - - case 'progress': - return ; - - case 'labels': - return ( - - ); - - case 'phase': - return ( - - ); - - case 'timeTracking': - return ( - - ); - - case 'estimation': - return ; - - case 'completedDate': - return ; - - case 'createdDate': - return ; - - case 'lastUpdated': - return ; - - case 'reporter': - return ; - - default: - // Handle custom columns - const column = visibleColumns.find(col => col.id === columnId); - if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) { - return ( - - ); - } - return null; + } + return null; } - }, - [ - task, - projectId, - isSubtask, - isSelected, - isDarkMode, - visibleColumns, - updateTaskCustomColumnValue, - taskDisplayName, - convertedTask, - formattedDates, - dateValues, - labelsAdapter, - activeDatePicker, - setActiveDatePicker, - editTaskName, - taskName, - setEditTaskName, - setTaskName, - handleCheckboxChange, - handleTaskNameSave, - handleTaskNameEdit, - attributes, - listeners, - ] - ); + }; + + // 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, + isSubtask, + isSelected, + isDarkMode, + visibleColumns, + updateTaskCustomColumnValue, + taskDisplayName, + convertedTask, + formattedDates, + dateValues, + labelsAdapter, + activeDatePicker, + setActiveDatePicker, + editTaskName, + taskName, + setEditTaskName, + setTaskName, + handleCheckboxChange, + handleTaskNameSave, + handleTaskNameEdit, + attributes, + listeners, + depth, + ]); return { renderColumn }; }; 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 be586cdf..400b68d7 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/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index c4233f16..9aeae348 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -706,8 +706,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 diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index a8b047d9..3bec983c 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -244,47 +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); - - // If not found, try case-insensitive matching + // Find target group based on the actual status ID from response + let targetGroup = groups.find(group => group.id === response.status_id); + + // 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) { - 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') + + // If still not found, try matching by status name (fallback) + if (!targetGroup && response.status) { + targetGroup = groups.find(group => + group.title?.toLowerCase() === response.status.toLowerCase() ); } @@ -298,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'); } } }, diff --git a/worklenz-frontend/src/layouts/MainLayout.tsx b/worklenz-frontend/src/layouts/MainLayout.tsx index 73c007fc..0361ed79 100644 --- a/worklenz-frontend/src/layouts/MainLayout.tsx +++ b/worklenz-frontend/src/layouts/MainLayout.tsx @@ -1,60 +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); + const location = useLocation(); + + const isProjectView = location.pathname.includes('/projects/') && + !location.pathname.endsWith('/projects'); - // 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', - }); - } - }, []); - - // 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: { @@ -67,27 +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={ - -