import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react'; import { GroupedVirtuoso } from 'react-virtuoso'; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, KeyboardSensor, TouchSensor, closestCenter, useDroppable, } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { createPortal } from 'react-dom'; import { Skeleton } from 'antd'; import { HolderOutlined } from '@ant-design/icons'; // Redux hooks and selectors import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { selectAllTasksArray, selectGroups, selectGrouping, selectLoading, selectError, fetchTasksV3, fetchTaskListColumns, selectColumns, selectCustomColumns, selectLoadingColumns, updateColumnVisibility, } from '@/features/task-management/task-management.slice'; import { selectCurrentGrouping, selectCollapsedGroups, toggleGroupCollapsed, } from '@/features/task-management/grouping.slice'; import { selectSelectedTaskIds, selectLastSelectedTaskId, selectTask, toggleTaskSelection, selectRange, clearSelection, } from '@/features/task-management/selection.slice'; import { setCustomColumnModalAttributes, toggleCustomColumnModalOpen, } from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; // Components import TaskRowWithSubtasks from './TaskRowWithSubtasks'; import TaskGroupHeader from './TaskGroupHeader'; import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; import AddTaskRow from './components/AddTaskRow'; import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents'; import TaskListSkeleton from './components/TaskListSkeleton'; import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer'; import EmptyListPlaceholder from '@/components/EmptyListPlaceholder'; // Empty Group Drop Zone Component const EmptyGroupDropZone: React.FC<{ groupId: string; visibleColumns: any[]; t: (key: string) => 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 }) => { if (!isVisible) return null; return (
{visibleColumns.map((column, index) => { const columnStyle = { width: column.width, flexShrink: 0, }; 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' && (
)}
); })}
); }; // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useSocket } from '@/socket/socketContext'; import { useDragAndDrop } from './hooks/useDragAndDrop'; import { useBulkActions } from './hooks/useBulkActions'; // Constants and types import { BASE_COLUMNS, ColumnStyle } from './constants/columns'; import { Task } from '@/types/task-management.types'; import { SocketEvents } from '@/shared/socket-events'; const TaskListV2Section: React.FC = () => { const dispatch = useAppDispatch(); const { projectId: urlProjectId } = useParams(); const { t } = useTranslation('task-list-table'); const { socket, connected } = useSocket(); const themeMode = useAppSelector(state => state.themeReducer.mode); const isDarkMode = themeMode === 'dark'; // Redux state selectors const allTasks = useAppSelector(selectAllTasksArray); const groups = useAppSelector(selectGroups); const grouping = useAppSelector(selectGrouping); const loading = useAppSelector(selectLoading); const error = useAppSelector(selectError); const currentGrouping = useAppSelector(selectCurrentGrouping); const selectedTaskIds = useAppSelector(selectSelectedTaskIds); const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId); const collapsedGroups = useAppSelector(selectCollapsedGroups); const fields = useAppSelector(state => state.taskManagementFields) || []; const columns = useAppSelector(selectColumns); const customColumns = useAppSelector(selectCustomColumns); const loadingColumns = useAppSelector(selectLoadingColumns); // Refs for scroll synchronization const headerScrollRef = useRef(null); const contentScrollRef = useRef(null); // State hooks const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); const [addTaskRows, setAddTaskRows] = useState<{[groupId: string]: string[]}>({}); // Configure sensors for drag and drop const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), useSensor(TouchSensor, { activationConstraint: { delay: 250, tolerance: 5, }, }) ); // Custom hooks const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( allTasks, groups ); const bulkActions = useBulkActions(); // Enable real-time updates via socket handlers useTaskSocketHandlers(); // Filter visible columns based on local fields (primary) and backend columns (fallback) const visibleColumns = useMemo(() => { // Start with base columns const baseVisibleColumns = BASE_COLUMNS.filter(column => { // Always show drag handle and title (sticky columns) if (column.isSticky) return true; // Primary: Check local fields configuration const field = fields.find(f => f.key === column.key); if (field) { return field.visible; } // Fallback: Check backend column configuration if local field not found const backendColumn = columns.find(c => c.key === column.key); if (backendColumn) { return backendColumn.pinned ?? false; } // Default: hide if neither local field nor backend column found return false; }); // Add visible custom columns const visibleCustomColumns = customColumns ?.filter(column => column.pinned) ?.map(column => { // Give selection columns more width for dropdown content const fieldType = column.custom_column_obj?.fieldType; let defaultWidth = 160; if (fieldType === 'selection') { defaultWidth = 150; // Reduced width for selection dropdowns } else if (fieldType === 'people') { defaultWidth = 170; // Extra width for people with avatars } // Map the configuration data structure to the expected format const customColumnObj = column.custom_column_obj || (column as any).configuration; // Transform configuration format to custom_column_obj format if needed let transformedColumnObj = customColumnObj; if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) { transformedColumnObj = { ...customColumnObj, fieldType: customColumnObj.field_type, numberType: customColumnObj.number_type, labelPosition: customColumnObj.label_position, previewValue: customColumnObj.preview_value, firstNumericColumn: customColumnObj.first_numeric_column_key, secondNumericColumn: customColumnObj.second_numeric_column_key, selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [], labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [], }; } return { id: column.key || column.id || 'unknown', label: column.name || t('customColumns.customColumnHeader'), width: `${(column as any).width || defaultWidth}px`, key: column.key || column.id || 'unknown', custom_column: true, custom_column_obj: transformedColumnObj, isCustom: true, name: column.name, uuid: column.id, }; }) || []; return [...baseVisibleColumns, ...visibleCustomColumns]; }, [fields, columns, customColumns, t]); // Effects useEffect(() => { if (urlProjectId) { dispatch(fetchTasksV3(urlProjectId)); dispatch(fetchTaskListColumns(urlProjectId)); dispatch(fetchPhasesByProjectId(urlProjectId)); } }, [dispatch, urlProjectId]); // Initialize field visibility from database when columns are loaded (only once) useEffect(() => { if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) { // Update local fields to match database state only on initial load import('@/features/task-management/taskListFields.slice').then(({ setFields }) => { // Create updated fields based on database column state const updatedFields = fields.map(field => { const backendColumn = columns.find(c => c.key === field.key); if (backendColumn) { return { ...field, visible: backendColumn.pinned ?? field.visible, }; } return field; }); // Only update if there are actual changes const hasChanges = updatedFields.some( (field, index) => field.visible !== fields[index].visible ); if (hasChanges) { dispatch(setFields(updatedFields)); } setInitializedFromDatabase(true); }); } }, [columns, fields, dispatch, initializedFromDatabase]); // Event handlers const handleTaskSelect = useCallback( (taskId: string, event: React.MouseEvent) => { if (event.ctrlKey || event.metaKey) { dispatch(toggleTaskSelection(taskId)); } else if (event.shiftKey && lastSelectedTaskId) { const taskIds = allTasks.map(t => t.id); const startIdx = taskIds.indexOf(lastSelectedTaskId); const endIdx = taskIds.indexOf(taskId); const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1); dispatch(selectRange(rangeIds)); } else { dispatch(clearSelection()); dispatch(selectTask(taskId)); } }, [dispatch, lastSelectedTaskId, allTasks] ); const handleGroupCollapse = useCallback( (groupId: string) => { dispatch(toggleGroupCollapsed(groupId)); }, [dispatch] ); // Function to update custom column values const updateTaskCustomColumnValue = useCallback( (taskId: string, columnKey: string, value: string) => { try { if (!urlProjectId) { console.error('Project ID is missing'); return; } const body = { task_id: taskId, column_key: columnKey, value: value, project_id: urlProjectId, }; // Update the Redux store immediately for optimistic updates const currentTask = allTasks.find(task => task.id === taskId); if (currentTask) { const updatedTask = { ...currentTask, custom_column_values: { ...currentTask.custom_column_values, [columnKey]: value, }, updated_at: new Date().toISOString(), }; // Import and dispatch the updateTask action import('@/features/task-management/task-management.slice').then(({ updateTask }) => { dispatch(updateTask(updatedTask)); }); } if (socket && connected) { socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); } else { console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event'); } } catch (error) { console.error('Error updating custom column value:', error); } }, [urlProjectId, socket, connected, allTasks, dispatch] ); // Custom column settings handler const handleCustomColumnSettings = useCallback( (columnKey: string) => { if (!columnKey) return; const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); // Use the UUID for API calls, not the key (nanoid) // For custom columns, prioritize the uuid field over id field const columnId = (columnData as any)?.uuid || columnData?.id || columnKey; dispatch( setCustomColumnModalAttributes({ modalType: 'edit', columnId: columnId, columnData: columnData, }) ); dispatch(toggleCustomColumnModalOpen(true)); }, [dispatch, visibleColumns] ); // Add callback for task added 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] }; }); }, []); // Handle scroll synchronization - disabled since header is now sticky inside content const handleContentScroll = useCallback(() => { // No longer needed since header scrolls naturally with content }, []); // Memoized values for GroupedVirtuoso const virtuosoGroups = useMemo(() => { let currentTaskIndex = 0; return groups.map(group => { const isCurrentGroupCollapsed = collapsedGroups.has(group.id); const visibleTasksInGroup = isCurrentGroupCollapsed ? [] : group.taskIds .map(taskId => allTasks.find(task => task.id === taskId)) .filter((task): task is Task => task !== undefined); const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ ...task, originalIndex: allTasks.indexOf(task), })); // Get add task rows for this group const groupAddRows = addTaskRows[group.id] || []; const addTaskItems = !isCurrentGroupCollapsed ? [ // Default add task row { id: `add-task-${group.id}-0`, isAddTaskRow: true, groupId: group.id, groupType: currentGrouping || 'status', groupValue: group.id, // Send the UUID that backend expects projectId: urlProjectId, rowId: `add-task-${group.id}-0`, autoFocus: false, }, // Additional add task rows ...groupAddRows.map((rowId, index) => ({ id: rowId, isAddTaskRow: true, groupId: group.id, groupType: currentGrouping || 'status', groupValue: group.id, // Send the UUID that backend expects projectId: urlProjectId, rowId: rowId, autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row })) ] : []; const itemsWithAddTask = !isCurrentGroupCollapsed ? [...tasksForVirtuoso, ...addTaskItems] : tasksForVirtuoso; const groupData = { ...group, tasks: itemsWithAddTask, startIndex: currentTaskIndex, count: itemsWithAddTask.length, actualCount: group.taskIds.length, groupValue: group.groupValue || group.title, }; currentTaskIndex += itemsWithAddTask.length; return groupData; }); }, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId, addTaskRows]); const virtuosoGroupCounts = useMemo(() => { return virtuosoGroups.map(group => group.count); }, [virtuosoGroups]); const virtuosoItems = useMemo(() => { return virtuosoGroups.flatMap(group => group.tasks); }, [virtuosoGroups]); // Render functions const renderGroup = useCallback( (groupIndex: number) => { const group = virtuosoGroups[groupIndex]; const isGroupCollapsed = collapsedGroups.has(group.id); const isGroupEmpty = group.actualCount === 0; return (
0 ? 'mt-2' : ''}> handleGroupCollapse(group.id)} projectId={urlProjectId || ''} /> {isGroupEmpty && !isGroupCollapsed && ( )}
); }, [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] ); const renderTask = useCallback( (taskIndex: number, isFirstInGroup: boolean = false) => { const item = virtuosoItems[taskIndex]; if (!item || !urlProjectId) return null; if ('isAddTaskRow' in item && item.isAddTaskRow) { return ( ); } return ( ); }, [virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue] ); // Render column headers const renderColumnHeaders = useCallback( () => (
{visibleColumns.map((column, index) => { 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 }), }; return (
{column.id === 'dragHandle' || column.id === 'checkbox' ? ( ) : (column as any).isCustom ? ( ) : ( t(column.label || '') )}
); })} {/* Add Custom Column Button - positioned at the end and scrolls with content */}
), [visibleColumns, t, handleCustomColumnSettings] ); // Loading and error states if (loading || loadingColumns) { return ; } if (error) return (
{t('emptyStates.errorPrefix')} {error}
); // Show message when no data - but for phase grouping, create an unmapped group if (groups.length === 0 && !loading) { // If grouped by phase, show an unmapped group to allow task creation if (currentGrouping === 'phase') { const unmappedGroup = { id: 'Unmapped', title: 'Unmapped', groupType: 'phase', groupValue: 'Unmapped', // Use same ID as groupValue for consistency collapsed: false, tasks: [], taskIds: [], color: '#fbc84c69', actualCount: 0, count: 1, // For the add task row startIndex: 0 }; return (
{/* Sticky Column Headers */}
{renderColumnHeaders()}
{}} projectId={urlProjectId || ''} />
); } // For other groupings, show the empty state message return (
{t('emptyStates.noTaskGroups')}
{t('emptyStates.noTaskGroupsDescription')}
); } return (
{/* Table Container */}
{/* Task List Content with Sticky Header */}
{/* Sticky Column Headers */}
{renderColumnHeaders()}
!('isAddTaskRow' in item) && !item.parent_task_id) .map(item => item.id) .filter((id): id is string => id !== undefined)} strategy={verticalListSortingStrategy} >
{/* Render groups manually for debugging */} {virtuosoGroups.map((group, groupIndex) => (
{/* Group Header */} {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; // 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; 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 && (
) ) )}
))}
{/* Drag Overlay */} {activeId ? (
col.id === 'title')?.width || '300px' }} >
{allTasks.find(task => task.id === activeId)?.name || allTasks.find(task => task.id === activeId)?.title || t('emptyStates.dragTaskFallback')}
) : null}
{/* Bulk Action Bar */} {selectedTaskIds.length > 0 && urlProjectId && (
bulkActions.handleBulkStatusChange(statusId, selectedTaskIds) } onBulkPriorityChange={priorityId => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds) } onBulkPhaseChange={phaseId => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds) } onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)} onBulkAssignMembers={memberIds => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds) } onBulkAddLabels={labelIds => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds) } onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)} onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)} onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)} onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)} onBulkSetDueDate={date => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)} />
)} {/* Custom Column Modal */} {createPortal(, document.body, 'custom-column-modal')} {/* Convert To Subtask Drawer */} {createPortal(, document.body, 'convert-to-subtask-drawer')}
); }; export default TaskListV2Section;