diff --git a/worklenz-backend/src/socket.io/commands/on-quick-task.ts b/worklenz-backend/src/socket.io/commands/on-quick-task.ts index 859cbf58..066b52d0 100644 --- a/worklenz-backend/src/socket.io/commands/on-quick-task.ts +++ b/worklenz-backend/src/socket.io/commands/on-quick-task.ts @@ -56,6 +56,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string) const q = `SELECT create_quick_task($1) AS task;`; const body = JSON.parse(data as string); + + body.name = (body.name || "").trim(); body.priority_id = body.priority_id?.trim() || null; body.status_id = body.status_id?.trim() || null; @@ -111,10 +113,12 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string) notifyProjectUpdates(socket, d.task.id); } + } else { + // Empty task name, emit null to indicate no task was created + socket.emit(SocketEvents.QUICK_TASK.toString(), null); } } catch (error) { log_error(error); + socket.emit(SocketEvents.QUICK_TASK.toString(), null); } - - socket.emit(SocketEvents.QUICK_TASK.toString(), null); } diff --git a/worklenz-frontend/src/components/task-list-v2/SubtaskLoadingSkeleton.tsx b/worklenz-frontend/src/components/task-list-v2/SubtaskLoadingSkeleton.tsx index 66df7e5e..1f67a048 100644 --- a/worklenz-frontend/src/components/task-list-v2/SubtaskLoadingSkeleton.tsx +++ b/worklenz-frontend/src/components/task-list-v2/SubtaskLoadingSkeleton.tsx @@ -19,16 +19,16 @@ const SubtaskLoadingSkeleton: React.FC = ({ visible return
; case 'taskKey': return ( -
-
+
+
); case 'title': return (
- {/* Subtask indentation */} -
-
+ {/* Subtask indentation - tighter spacing */} +
+
); diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index e4dacb86..c545ce5f 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -79,7 +79,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o return (
= ({ group, isCollapsed, o }} onClick={onToggle} > - {/* Drag Handle Space */} -
+ {/* Drag Handle Space - ultra minimal width */} +
{/* Chevron button */}
- {/* Select All Checkbox Space */} -
+ {/* Select All Checkbox Space - ultra minimal width */} +
= ({ group, isCollapsed, o />
- {/* Group indicator and name */} -
- {/* Color indicator (removed as full header is colored) */} - + {/* Group indicator and name - no gap at all */} +
{/* Group name and count */}
diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 943c9ad3..3fe947e7 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -1,11 +1,8 @@ -import React, { useState, useCallback, useMemo, useEffect, memo } from 'react'; +import React, { useCallback, useMemo, useEffect } from 'react'; import { GroupedVirtuoso } from 'react-virtuoso'; import { DndContext, - DragEndEvent, - DragOverEvent, DragOverlay, - DragStartEvent, PointerSensor, useSensor, useSensors, @@ -19,6 +16,12 @@ import { 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 { @@ -27,17 +30,12 @@ import { selectGrouping, selectLoading, selectError, - selectSelectedPriorities, - selectSearch, fetchTasksV3, - reorderTasksInGroup, - moveTaskBetweenGroups, fetchTaskListColumns, selectColumns, selectCustomColumns, selectLoadingColumns, updateColumnVisibility, - addTaskToGroup, } from '@/features/task-management/task-management.slice'; import { selectCurrentGrouping, @@ -47,648 +45,38 @@ import { import { selectSelectedTaskIds, selectLastSelectedTaskId, - selectIsTaskSelected, selectTask, - deselectTask, toggleTaskSelection, selectRange, clearSelection, } from '@/features/task-management/selection.slice'; -import TaskRowWithSubtasks from './TaskRowWithSubtasks'; -import TaskGroupHeader from './TaskGroupHeader'; -import { Task, TaskGroup } from '@/types/task-management.types'; -import { RootState } from '@/app/store'; -import { TaskListField } from '@/types/task-list-field.types'; -import { useParams } from 'react-router-dom'; -import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; -import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar'; -import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; -import { HolderOutlined, PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons'; -import { COLUMN_KEYS } from '@/features/tasks/tasks.slice'; -import { Skeleton, Input, Button, Tooltip, Flex, Dropdown, DatePicker } from 'antd'; -import dayjs from 'dayjs'; -import { useSocket } from '@/socket/socketContext'; -import { SocketEvents } from '@/shared/socket-events'; -import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal'; import { setCustomColumnModalAttributes, toggleCustomColumnModalOpen, - CustomFieldsTypes, } from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; -import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; -import { createPortal } from 'react-dom'; -// Base column configuration -const BASE_COLUMNS = [ - { id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' }, - { id: 'checkbox', label: '', width: '40px', isSticky: true, key: 'checkbox' }, - { id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY }, - { id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME }, - { id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS }, - { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, - { id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, - { id: 'dueDate', label: 'dueDateColumn', width: '120px', key: COLUMN_KEYS.DUE_DATE }, - { id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS }, - { id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS }, - { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, - { id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING }, - { id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION }, - { id: 'startDate', label: 'startDateColumn', width: '120px', key: COLUMN_KEYS.START_DATE }, - { id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME }, - { id: 'completedDate', label: 'completedDateColumn', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE }, - { id: 'createdDate', label: 'createdDateColumn', width: '120px', key: COLUMN_KEYS.CREATED_DATE }, - { id: 'lastUpdated', label: 'lastUpdatedColumn', width: '120px', key: COLUMN_KEYS.LAST_UPDATED }, - { id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER }, -]; +// Components +import TaskRowWithSubtasks from './TaskRowWithSubtasks'; +import TaskGroupHeader from './TaskGroupHeader'; +import ImprovedTaskFilters from '@/components/task-management/improved-task-filters'; +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'; -type ColumnStyle = { - width: string; - position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed'; - left?: number; - backgroundColor?: string; - zIndex?: number; - flexShrink?: number; -}; +// Hooks and utilities +import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { useSocket } from '@/socket/socketContext'; +import { useDragAndDrop } from './hooks/useDragAndDrop'; +import { useBulkActions } from './hooks/useBulkActions'; -// Add Task Row Component - similar to AddSubtaskRow -interface AddTaskRowProps { - groupId: string; - groupType: string; - groupValue: string; - projectId: string; - visibleColumns: Array<{ - id: string; - width: string; - isSticky?: boolean; - }>; - onTaskAdded: () => void; -} - -const AddTaskRow: React.FC = memo(({ - groupId, - groupType, - groupValue, - projectId, - visibleColumns, - onTaskAdded -}) => { - const [isAdding, setIsAdding] = useState(false); - const [taskName, setTaskName] = useState(''); - const { socket, connected } = useSocket(); - const { t } = useTranslation('task-list-table'); - const dispatch = useAppDispatch(); - - const handleAddTask = useCallback(() => { - if (!taskName.trim()) return; - - // Prepare task data based on group type - const taskData: any = { - name: taskName.trim(), - project_id: projectId, - }; - - // Set the appropriate field based on group type - // Note: groupValue comes from backend and might be lowercase with underscores for phases - if (groupType === 'status') { - taskData.status_id = groupValue === 'Unmapped' ? null : groupValue; - } else if (groupType === 'priority') { - taskData.priority_id = groupValue === 'Unmapped' ? null : groupValue; - } else if (groupType === 'phase') { - // For phase, we need to handle the case where groupValue might be - // the actual phase name or 'Unmapped' - if (groupValue === 'Unmapped' || groupValue === 'unmapped') { - taskData.phase_id = null; - } else { - // Use the original group title for phase_id since backend expects phase names - taskData.phase_id = groupValue; - } - } - - // Emit socket event for server-side creation - if (connected && socket) { - socket.emit( - SocketEvents.QUICK_TASK.toString(), - JSON.stringify(taskData) - ); - } - - setTaskName(''); - setIsAdding(false); - onTaskAdded(); - }, [taskName, groupType, groupValue, projectId, connected, socket, onTaskAdded]); - - const handleCancel = useCallback(() => { - setTaskName(''); - setIsAdding(false); - }, []); - - const renderColumn = useCallback((columnId: string, width: string) => { - const baseStyle = { width }; - - switch (columnId) { - case 'dragHandle': - return
; - case 'checkbox': - return
; - case 'taskKey': - return
; - case 'title': - return ( -
-
- {!isAdding ? ( - - ) : ( - setTaskName(e.target.value)} - onPressEnter={handleAddTask} - onBlur={handleCancel} - placeholder="Type task name and press Enter to save" - className="w-full h-full border-none shadow-none bg-transparent" - style={{ - height: '100%', - minHeight: '32px', - padding: '0', - fontSize: '14px' - }} - autoFocus - /> - )} -
-
- ); - default: - return
; - } - }, [isAdding, taskName, handleAddTask, handleCancel, t]); - - return ( -
- {visibleColumns.map((column) => - renderColumn(column.id, column.width) - )} -
- ); -}); - -AddTaskRow.displayName = 'AddTaskRow'; - -// Add Custom Column Button Component -const AddCustomColumnButton: React.FC = memo(() => { - const dispatch = useAppDispatch(); - - const handleModalOpen = useCallback(() => { - dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null })); - dispatch(toggleCustomColumnModalOpen(true)); - }, [dispatch]); - - const { t } = useTranslation('task-list-table'); - - return ( - - -
-
-
- ); - - return ( -
- {selectedMembers.length > 0 && ( -
- {selectedMembers.slice(0, 3).map((member) => ( -
- {member.avatar_url ? ( - {member.name} - ) : ( - member.name?.charAt(0).toUpperCase() - )} -
- ))} - {selectedMembers.length > 3 && ( -
- +{selectedMembers.length - 3} -
- )} -
- )} - - dropdownContent} - trigger={['click']} - placement="bottomLeft" - > - - -
- ); -}); - -PeopleCustomColumnCell.displayName = 'PeopleCustomColumnCell'; - -// Date Field Cell Component -const DateCustomColumnCell: React.FC<{ - task: any; - columnKey: string; - customValue: any; - updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void; -}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => { - const dateValue = customValue ? dayjs(customValue) : null; - - return ( - { - if (task.id) { - updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : ''); - } - }} - placeholder="Set Date" - format="MMM DD, YYYY" - suffixIcon={null} - className="w-full border-none bg-transparent hover:bg-gray-50 dark:hover:bg-gray-700 text-sm" - inputReadOnly - /> - ); -}); - -DateCustomColumnCell.displayName = 'DateCustomColumnCell'; - -// Number Field Cell Component -const NumberCustomColumnCell: React.FC<{ - task: any; - columnKey: string; - customValue: any; - columnObj: any; - updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void; -}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => { - const [inputValue, setInputValue] = useState(customValue || ''); - const [isEditing, setIsEditing] = useState(false); - - const numberType = columnObj?.numberType || 'formatted'; - const decimals = columnObj?.decimals || 0; - const label = columnObj?.label || ''; - const labelPosition = columnObj?.labelPosition || 'left'; - - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value; - // Allow only numbers, decimal point, and minus sign - if (/^-?\d*\.?\d*$/.test(value) || value === '') { - setInputValue(value); - } - }; - - const handleBlur = () => { - setIsEditing(false); - if (task.id && inputValue !== customValue) { - updateTaskCustomColumnValue(task.id, columnKey, inputValue); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleBlur(); - } - if (e.key === 'Escape') { - setInputValue(customValue || ''); - setIsEditing(false); - } - }; - - const getDisplayValue = () => { - if (isEditing) return inputValue; - - if (!inputValue) return ''; - - const numValue = parseFloat(inputValue); - if (isNaN(numValue)) return inputValue; - - switch (numberType) { - case 'formatted': - return numValue.toFixed(decimals); - case 'percentage': - return `${numValue.toFixed(decimals)}%`; - case 'withLabel': - return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`; - default: - return inputValue; - } - }; - - return ( -
- {numberType === 'withLabel' && labelPosition === 'left' && ( - {label} - )} - setIsEditing(true)} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - className="w-full bg-transparent border-none text-sm text-right focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 px-1 py-0.5 rounded" - placeholder="0" - /> - {numberType === 'withLabel' && labelPosition === 'right' && ( - {label} - )} -
- ); -}); - -NumberCustomColumnCell.displayName = 'NumberCustomColumnCell'; - -// Selection Field Cell Component -const SelectionCustomColumnCell: React.FC<{ - task: any; - columnKey: string; - customValue: any; - columnObj: any; - updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void; -}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => { - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const selectionsList = columnObj?.selectionsList || []; - - const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue); - - const dropdownContent = ( -
- {selectionsList.map((option: any) => ( -
{ - if (task.id) { - updateTaskCustomColumnValue(task.id, columnKey, option.selection_name); - } - setIsDropdownOpen(false); - }} - className="flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer" - > -
- {option.selection_name} -
- ))} - {selectionsList.length === 0 && ( -
- No options available -
- )} -
- ); - - return ( - dropdownContent} - trigger={['click']} - placement="bottomLeft" - > -
- {selectedOption ? ( - <> -
- {selectedOption.selection_name} - - ) : ( - Select option - )} -
- - ); -}); - -SelectionCustomColumnCell.displayName = 'SelectionCustomColumnCell'; +// Constants and types +import { BASE_COLUMNS, ColumnStyle } from './constants/columns'; +import { Task } from '@/types/task-management.types'; +import { SocketEvents } from '@/shared/socket-events'; const TaskListV2: React.FC = () => { const dispatch = useAppDispatch(); @@ -696,8 +84,21 @@ const TaskListV2: React.FC = () => { const { t } = useTranslation('task-list-table'); const { socket, connected } = useSocket(); - // Drag and drop state - const [activeId, setActiveId] = useState(null); + // 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); // Configure sensors for drag and drop const sensors = useSensors( @@ -717,25 +118,9 @@ const TaskListV2: React.FC = () => { }) ); - // Using Redux state for collapsedGroups instead of local state - const collapsedGroups = useAppSelector(selectCollapsedGroups); - - // Selectors - const allTasks = useAppSelector(selectAllTasksArray); // Renamed to allTasks for clarity - const groups = useAppSelector(selectGroups); - const grouping = useAppSelector(selectGrouping); - const loading = useAppSelector(selectLoading); - const error = useAppSelector(selectError); - const selectedPriorities = useAppSelector(selectSelectedPriorities); - const searchQuery = useAppSelector(selectSearch); - const currentGrouping = useAppSelector(selectCurrentGrouping); - const selectedTaskIds = useAppSelector(selectSelectedTaskIds); - const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId); - - const fields = useAppSelector(state => state.taskManagementFields) || []; - const columns = useAppSelector(selectColumns); - const customColumns = useAppSelector(selectCustomColumns); - const loadingColumns = useAppSelector(selectLoadingColumns); + // Custom hooks + const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups); + const bulkActions = useBulkActions(); // Enable real-time updates via socket handlers useTaskSocketHandlers(); @@ -774,30 +159,26 @@ const TaskListV2: React.FC = () => { custom_column: true, custom_column_obj: column.custom_column_obj || (column as any).configuration, isCustom: true, - name: column.name, // Add the name property for proper display - uuid: column.id, // Preserve the actual UUID for delete operations + name: column.name, + uuid: column.id, })) || []; return [...baseVisibleColumns, ...visibleCustomColumns]; - }, [fields, columns, customColumns]); + }, [fields, columns, customColumns, t]); // Sync local field changes with backend column configuration (debounced) useEffect(() => { if (!urlProjectId || columns.length === 0 || fields.length === 0) return; - // Debounce the sync to avoid too many API calls const timeoutId = setTimeout(() => { - // Check if there are any differences between local fields and backend columns const changedFields = fields.filter(field => { const backendColumn = columns.find(c => c.key === field.key); if (backendColumn) { - // If backend column exists and visibility differs from local field return (backendColumn.pinned ?? false) !== field.visible; } return false; }); - // Update backend for any changed fields changedFields.forEach(field => { const backendColumn = columns.find(c => c.key === field.key); if (backendColumn) { @@ -810,7 +191,7 @@ const TaskListV2: React.FC = () => { })); } }); - }, 500); // 500ms debounce + }, 500); return () => clearTimeout(timeoutId); }, [fields, columns, urlProjectId, dispatch]); @@ -823,13 +204,13 @@ const TaskListV2: React.FC = () => { } }, [dispatch, urlProjectId]); - // Handlers + // 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); // Use allTasks here + 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); @@ -844,7 +225,7 @@ const TaskListV2: React.FC = () => { const handleGroupCollapse = useCallback( (groupId: string) => { - dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state + dispatch(toggleGroupCollapsed(groupId)); }, [dispatch] ); @@ -857,7 +238,6 @@ const TaskListV2: React.FC = () => { return; } - // Prepare the data to send via socket const body = { task_id: taskId, column_key: columnKey, @@ -865,7 +245,6 @@ const TaskListV2: React.FC = () => { project_id: urlProjectId, }; - // Emit socket event to update the custom column value if (socket && connected) { socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body)); } else { @@ -876,239 +255,12 @@ const TaskListV2: React.FC = () => { } }, [urlProjectId, socket, connected]); - // Drag and drop handlers - const handleDragStart = useCallback((event: DragStartEvent) => { - setActiveId(event.active.id as string); - }, []); - - const handleDragOver = useCallback( - (event: DragOverEvent) => { - const { active, over } = event; - - if (!over) return; - - const activeId = active.id; - const overId = over.id; - - // 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)); - } - - if (!activeGroup || !targetGroup) return; - - // If dragging to a different group, we need to handle cross-group movement - if (activeGroup.id !== targetGroup.id) { - console.log('Cross-group drag detected:', { - activeTask: activeTask.id, - fromGroup: activeGroup.id, - toGroup: targetGroup.id, - }); - } - }, - [allTasks, groups] - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - setActiveId(null); - - if (!over || active.id === over.id) { - return; - } - - const activeId = active.id; - const overId = over.id; - - // Find the active task - const activeTask = allTasks.find(task => task.id === activeId); - if (!activeTask) { - console.error('Active task not found:', activeId); - return; - } - - // Find the groups - const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); - if (!activeGroup) { - console.error('Could not find active group for task:', activeId); - return; - } - - // Check if we're dropping on a task or a group - const overTask = allTasks.find(task => task.id === overId); - const overGroup = groups.find(group => group.id === overId); - - let targetGroup = overGroup; - 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; - } - - if (!targetGroup) { - console.error('Could not find target group'); - return; - } - - const isCrossGroup = activeGroup.id !== targetGroup.id; - const activeIndex = activeGroup.taskIds.indexOf(activeTask.id); - - console.log('Drag operation:', { - activeId, - overId, - activeTask: activeTask.name || activeTask.title, - activeGroup: activeGroup.id, - targetGroup: targetGroup.id, - activeIndex, - insertIndex, - isCrossGroup, - }); - - 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, - }); - - // Move task to the target group - dispatch( - moveTaskBetweenGroups({ - taskId: activeId as string, - sourceGroupId: activeGroup.id, - targetGroupId: targetGroup.id, - }) - ); - - // Reorder task within target group at drop position - dispatch( - reorderTasksInGroup({ - sourceTaskId: activeId as string, - destinationTaskId: over.id as string, - sourceGroupId: activeGroup.id, - destinationGroupId: targetGroup.id, - }) - ); - } else { - // Reordering within the same group - console.log('Reordering task within same group:', { - task: activeTask.name || activeTask.title, - group: activeGroup.title, - from: activeIndex, - to: insertIndex, - }); - - 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, - }) - ); - } - } - }, - [allTasks, groups] - ); - - // Bulk action handlers - const handleClearSelection = useCallback(() => { - dispatch(clearSelection()); - }, [dispatch]); - - const handleBulkStatusChange = useCallback(async (statusId: string) => { - // TODO: Implement bulk status change - console.log('Bulk status change:', statusId); - }, []); - - const handleBulkPriorityChange = useCallback(async (priorityId: string) => { - // TODO: Implement bulk priority change - console.log('Bulk priority change:', priorityId); - }, []); - - const handleBulkPhaseChange = useCallback(async (phaseId: string) => { - // TODO: Implement bulk phase change - console.log('Bulk phase change:', phaseId); - }, []); - - const handleBulkAssignToMe = useCallback(async () => { - // TODO: Implement bulk assign to me - console.log('Bulk assign to me'); - }, []); - - const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => { - // TODO: Implement bulk assign members - console.log('Bulk assign members:', memberIds); - }, []); - - const handleBulkAddLabels = useCallback(async (labelIds: string[]) => { - // TODO: Implement bulk add labels - console.log('Bulk add labels:', labelIds); - }, []); - - const handleBulkArchive = useCallback(async () => { - // TODO: Implement bulk archive - console.log('Bulk archive'); - }, []); - - const handleBulkDelete = useCallback(async () => { - // TODO: Implement bulk delete - console.log('Bulk delete'); - }, []); - - const handleBulkDuplicate = useCallback(async () => { - // TODO: Implement bulk duplicate - console.log('Bulk duplicate'); - }, []); - - const handleBulkExport = useCallback(async () => { - // TODO: Implement bulk export - console.log('Bulk export'); - }, []); - - const handleBulkSetDueDate = useCallback(async (date: string) => { - // TODO: Implement bulk set due date - console.log('Bulk set due date:', date); - }, []); - // Custom column settings handler const handleCustomColumnSettings = useCallback((columnKey: string) => { if (!columnKey) return; - // Find the column data from visibleColumns const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey); - console.log('Opening modal with column data:', { - columnKey, - columnData, - visibleColumns - }); - dispatch(setCustomColumnModalAttributes({ modalType: 'edit', columnId: columnKey, @@ -1117,25 +269,31 @@ const TaskListV2: React.FC = () => { dispatch(toggleCustomColumnModalOpen(true)); }, [dispatch, visibleColumns]); + // Add callback for task added + const handleTaskAdded = useCallback(() => { + if (urlProjectId) { + dispatch(fetchTasksV3(urlProjectId)); + } + }, [dispatch, urlProjectId]); + // Memoized values for GroupedVirtuoso const virtuosoGroups = useMemo(() => { let currentTaskIndex = 0; + return groups.map(group => { const isCurrentGroupCollapsed = collapsedGroups.has(group.id); - // Order tasks according to group.taskIds array to maintain proper order const visibleTasksInGroup = isCurrentGroupCollapsed ? [] : group.taskIds .map(taskId => allTasks.find(task => task.id === taskId)) - .filter((task): task is Task => task !== undefined); // Type guard to filter out undefined tasks + .filter((task): task is Task => task !== undefined); const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ ...task, originalIndex: allTasks.indexOf(task), })); - // Add AddTaskRow as a virtual item at the end of each group (when not collapsed) const itemsWithAddTask = !isCurrentGroupCollapsed ? [ ...tasksForVirtuoso, { @@ -1143,7 +301,7 @@ const TaskListV2: React.FC = () => { isAddTaskRow: true, groupId: group.id, groupType: currentGrouping || 'status', - groupValue: group.groupValue || group.title, + groupValue: group.id, // Use the actual database ID from backend projectId: urlProjectId, } ] : tasksForVirtuoso; @@ -1153,9 +311,7 @@ const TaskListV2: React.FC = () => { tasks: itemsWithAddTask, startIndex: currentTaskIndex, count: itemsWithAddTask.length, - // Add actual task count for display purposes (regardless of collapsed state) actualCount: group.taskIds.length, - // Ensure groupValue is available for AddTaskRow groupValue: group.groupValue || group.title, }; currentTaskIndex += itemsWithAddTask.length; @@ -1171,69 +327,11 @@ const TaskListV2: React.FC = () => { return virtuosoGroups.flatMap(group => group.tasks); }, [virtuosoGroups]); - // Memoize column headers to prevent unnecessary re-renders - const columnHeaders = useMemo( - () => ( -
- {visibleColumns.map(column => { - const columnStyle: ColumnStyle = { - width: column.width, - flexShrink: 0, // Prevent columns from shrinking - // Add specific styling for labels column with auto width - ...(column.id === 'labels' && column.width === 'auto' - ? { - minWidth: '200px', // Ensure minimum width for labels - flexGrow: 1, // Allow it to grow - } - : {}), - }; - - return ( -
- {column.id === 'dragHandle' ? ( - // Empty space for drag handle column header - ) : column.id === 'checkbox' ? ( - // Empty for checkbox column header - ) : (column as any).isCustom ? ( - - ) : ( - t(column.label || '') - )} -
- ); - })} - {/* Add Custom Column Button */} -
- -
- {/* Filler div to extend background to full width */} -
-
- ), - [visibleColumns, t, handleCustomColumnSettings] - ); - - // Add callback for task added - const handleTaskAdded = useCallback(() => { - // Refresh tasks after adding a new one - if (urlProjectId) { - dispatch(fetchTasksV3(urlProjectId)); - } - }, [dispatch, urlProjectId]); - // Render functions const renderGroup = useCallback( (groupIndex: number) => { const group = virtuosoGroups[groupIndex]; const isGroupCollapsed = collapsedGroups.has(group.id); - // Check if group is empty (no actual tasks, only AddTaskRow) const isGroupEmpty = group.actualCount === 0; return ( @@ -1242,17 +340,15 @@ const TaskListV2: React.FC = () => { group={{ id: group.id, name: group.title, - count: group.actualCount, // Use actualCount instead of count for display + count: group.actualCount, color: group.color, }} isCollapsed={isGroupCollapsed} onToggle={() => handleGroupCollapse(group.id)} /> - {/* No tasks message when group is empty */} {isGroupEmpty && !isGroupCollapsed && (
-
- {/* Render invisible columns to maintain layout */} +
{visibleColumns.map((column) => (
{ /> ))}
- {/* Overlay the centered message */}
{t('noTasksInGroup')} @@ -1276,10 +371,9 @@ const TaskListV2: React.FC = () => { const renderTask = useCallback( (taskIndex: number) => { - const item = virtuosoItems[taskIndex]; // Get item from the flattened virtuosoItems - if (!item || !urlProjectId) return null; // Should not happen if logic is correct + const item = virtuosoItems[taskIndex]; + if (!item || !urlProjectId) return null; - // Check if this is an AddTaskRow virtual item if ('isAddTaskRow' in item && item.isAddTaskRow) { return ( { ); } - // Regular task row return ( { /> ); }, - [virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded] + [virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue] ); - if (loading || loadingColumns) return ; - if (error) return
Error: {error}
; - - return ( - -
- {/* Task Filters */} -
- -
- - {/* Table Container with fixed height and horizontal scroll */} -
-
- {/* Column Headers - Sticky at top */} + // Render column headers + const renderColumnHeaders = useCallback(() => (
-
+
{visibleColumns.map(column => { const columnStyle: ColumnStyle = { width: column.width, @@ -1345,17 +413,19 @@ const TaskListV2: React.FC = () => { flexGrow: 1, } : {}), + ...((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.id === 'dragHandle' || column.id === 'checkbox' ? ( ) : (column as any).isCustom ? ( {
+ ), [visibleColumns, t, handleCustomColumnSettings]); + + // Loading and error states + if (loading || loadingColumns) return ; + if (error) return
Error: {error}
; + + return ( + +
+ {/* Task Filters */} +
+ +
+ + {/* Table Container */} +
+
+ {/* Column Headers */} + {renderColumnHeaders()} {/* Task List Content */}
@@ -1427,25 +528,25 @@ const TaskListV2: React.FC = () => { ) : null} - {/* Bulk Action Bar - Positioned absolutely to not affect layout */} + {/* Bulk Action Bar */} {selectedTaskIds.length > 0 && urlProjectId && (
)} diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index d50f581e..38d20402 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -277,8 +277,8 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn case 'taskKey': return ( -
- +
+ {task.task_key || 'N/A'}
@@ -288,15 +288,15 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn return (
- {/* Indentation for subtasks - increased padding */} - {isSubtask &&
} + {/* Indentation for subtasks - tighter spacing */} + {isSubtask &&
} {/* Expand/Collapse button - only show for parent tasks */} {!isSubtask && ( + ) : ( + setTaskName(e.target.value)} + onPressEnter={handleAddTask} + onBlur={handleCancel} + placeholder="Type task name and press Enter to save" + className="w-full h-full border-none shadow-none bg-transparent" + style={{ + height: '100%', + minHeight: '32px', + padding: '0', + fontSize: '14px' + }} + autoFocus + /> + )} +
+
+ ); + default: + return
; + } + }, [isAdding, taskName, handleAddTask, handleCancel, t]); + + return ( +
+ {visibleColumns.map((column) => + renderColumn(column.id, column.width) + )} +
+ ); +}); + +AddTaskRow.displayName = 'AddTaskRow'; + +export default AddTaskRow; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/components/CustomColumnComponents.tsx b/worklenz-frontend/src/components/task-list-v2/components/CustomColumnComponents.tsx new file mode 100644 index 00000000..bc37ce9b --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/components/CustomColumnComponents.tsx @@ -0,0 +1,461 @@ +import React, { useState, useCallback, useMemo, memo } from 'react'; +import { Button, Tooltip, Flex, Dropdown, DatePicker } from 'antd'; +import { PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + setCustomColumnModalAttributes, + toggleCustomColumnModalOpen, +} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; +import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; +import dayjs from 'dayjs'; + +// Add Custom Column Button Component +export const AddCustomColumnButton: React.FC = memo(() => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('task-list-table'); + + const handleModalOpen = useCallback(() => { + dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null })); + dispatch(toggleCustomColumnModalOpen(true)); + }, [dispatch]); + + return ( + + +
+
+
+ ); + + return ( +
+ {selectedMembers.length > 0 && ( +
+ {selectedMembers.slice(0, 3).map((member) => ( +
+ {member.avatar_url ? ( + {member.name} + ) : ( + member.name?.charAt(0).toUpperCase() + )} +
+ ))} + {selectedMembers.length > 3 && ( +
+ +{selectedMembers.length - 3} +
+ )} +
+ )} + + dropdownContent} + trigger={['click']} + placement="bottomLeft" + > + + +
+ ); +}); + +PeopleCustomColumnCell.displayName = 'PeopleCustomColumnCell'; + +// Date Field Cell Component +export const DateCustomColumnCell: React.FC<{ + task: any; + columnKey: string; + customValue: any; + updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void; +}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => { + const dateValue = customValue ? dayjs(customValue) : null; + + return ( + { + if (task.id) { + updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : ''); + } + }} + placeholder="Set Date" + format="MMM DD, YYYY" + suffixIcon={null} + className="w-full border-none bg-transparent hover:bg-gray-50 dark:hover:bg-gray-700 text-sm" + inputReadOnly + /> + ); +}); + +DateCustomColumnCell.displayName = 'DateCustomColumnCell'; + +// Number Field Cell Component +export const NumberCustomColumnCell: React.FC<{ + task: any; + columnKey: string; + customValue: any; + columnObj: any; + updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void; +}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => { + const [inputValue, setInputValue] = useState(customValue || ''); + const [isEditing, setIsEditing] = useState(false); + + const numberType = columnObj?.numberType || 'formatted'; + const decimals = columnObj?.decimals || 0; + const label = columnObj?.label || ''; + const labelPosition = columnObj?.labelPosition || 'left'; + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + // Allow only numbers, decimal point, and minus sign + if (/^-?\d*\.?\d*$/.test(value) || value === '') { + setInputValue(value); + } + }; + + const handleBlur = () => { + setIsEditing(false); + if (task.id && inputValue !== customValue) { + updateTaskCustomColumnValue(task.id, columnKey, inputValue); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleBlur(); + } + if (e.key === 'Escape') { + setInputValue(customValue || ''); + setIsEditing(false); + } + }; + + const getDisplayValue = () => { + if (isEditing) return inputValue; + + if (!inputValue) return ''; + + const numValue = parseFloat(inputValue); + if (isNaN(numValue)) return inputValue; + + switch (numberType) { + case 'formatted': + return numValue.toFixed(decimals); + case 'percentage': + return `${numValue.toFixed(decimals)}%`; + case 'withLabel': + return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`; + default: + return inputValue; + } + }; + + return ( +
+ {numberType === 'withLabel' && labelPosition === 'left' && ( + {label} + )} + setIsEditing(true)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + className="w-full bg-transparent border-none text-sm text-right focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 px-1 py-0.5 rounded" + placeholder="0" + /> + {numberType === 'withLabel' && labelPosition === 'right' && ( + {label} + )} +
+ ); +}); + +NumberCustomColumnCell.displayName = 'NumberCustomColumnCell'; + +// Selection Field Cell Component +export const SelectionCustomColumnCell: React.FC<{ + task: any; + columnKey: string; + customValue: any; + columnObj: any; + updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void; +}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => { + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const selectionsList = columnObj?.selectionsList || []; + + const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue); + + const dropdownContent = ( +
+ {selectionsList.map((option: any) => ( +
{ + if (task.id) { + updateTaskCustomColumnValue(task.id, columnKey, option.selection_name); + } + setIsDropdownOpen(false); + }} + className="flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer" + > +
+ {option.selection_name} +
+ ))} + {selectionsList.length === 0 && ( +
+ No options available +
+ )} +
+ ); + + return ( + dropdownContent} + trigger={['click']} + placement="bottomLeft" + > +
+ {selectedOption ? ( + <> +
+ {selectedOption.selection_name} + + ) : ( + Select option + )} +
+ + ); +}); + +SelectionCustomColumnCell.displayName = 'SelectionCustomColumnCell'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts new file mode 100644 index 00000000..c2dd6e79 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts @@ -0,0 +1,35 @@ +import { COLUMN_KEYS } from '@/features/tasks/tasks.slice'; + +export type ColumnStyle = { + width: string; + position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed'; + left?: number; + backgroundColor?: string; + zIndex?: number; + flexShrink?: number; + minWidth?: string; + maxWidth?: string; +}; + +// Base column configuration +export const BASE_COLUMNS = [ + { id: 'dragHandle', label: '', width: '20px', isSticky: true, key: 'dragHandle' }, + { id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' }, + { id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' }, + { id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME }, + { id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS }, + { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, + { id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, + { id: 'dueDate', label: 'dueDateColumn', width: '120px', key: COLUMN_KEYS.DUE_DATE }, + { id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS }, + { id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS }, + { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, + { id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING }, + { id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION }, + { id: 'startDate', label: 'startDateColumn', width: '120px', key: COLUMN_KEYS.START_DATE }, + { id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME }, + { id: 'completedDate', label: 'completedDateColumn', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE }, + { id: 'createdDate', label: 'createdDateColumn', width: '120px', key: COLUMN_KEYS.CREATED_DATE }, + { id: 'lastUpdated', label: 'lastUpdatedColumn', width: '120px', key: COLUMN_KEYS.LAST_UPDATED }, + { id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER }, +]; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useBulkActions.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useBulkActions.ts new file mode 100644 index 00000000..8fde5d28 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useBulkActions.ts @@ -0,0 +1,81 @@ +import { useCallback } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { clearSelection } from '@/features/task-management/selection.slice'; + +export const useBulkActions = () => { + const dispatch = useAppDispatch(); + + const handleClearSelection = useCallback(() => { + dispatch(clearSelection()); + }, [dispatch]); + + const handleBulkStatusChange = useCallback(async (statusId: string) => { + // TODO: Implement bulk status change + console.log('Bulk status change:', statusId); + }, []); + + const handleBulkPriorityChange = useCallback(async (priorityId: string) => { + // TODO: Implement bulk priority change + console.log('Bulk priority change:', priorityId); + }, []); + + const handleBulkPhaseChange = useCallback(async (phaseId: string) => { + // TODO: Implement bulk phase change + console.log('Bulk phase change:', phaseId); + }, []); + + const handleBulkAssignToMe = useCallback(async () => { + // TODO: Implement bulk assign to me + console.log('Bulk assign to me'); + }, []); + + const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => { + // TODO: Implement bulk assign members + console.log('Bulk assign members:', memberIds); + }, []); + + const handleBulkAddLabels = useCallback(async (labelIds: string[]) => { + // TODO: Implement bulk add labels + console.log('Bulk add labels:', labelIds); + }, []); + + const handleBulkArchive = useCallback(async () => { + // TODO: Implement bulk archive + console.log('Bulk archive'); + }, []); + + const handleBulkDelete = useCallback(async () => { + // TODO: Implement bulk delete + console.log('Bulk delete'); + }, []); + + const handleBulkDuplicate = useCallback(async () => { + // TODO: Implement bulk duplicate + console.log('Bulk duplicate'); + }, []); + + const handleBulkExport = useCallback(async () => { + // TODO: Implement bulk export + console.log('Bulk export'); + }, []); + + const handleBulkSetDueDate = useCallback(async (date: string) => { + // TODO: Implement bulk set due date + console.log('Bulk set due date:', date); + }, []); + + return { + handleClearSelection, + handleBulkStatusChange, + handleBulkPriorityChange, + handleBulkPhaseChange, + handleBulkAssignToMe, + handleBulkAssignMembers, + handleBulkAddLabels, + handleBulkArchive, + handleBulkDelete, + handleBulkDuplicate, + handleBulkExport, + handleBulkSetDueDate, + }; +}; \ 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 new file mode 100644 index 00000000..4394bd34 --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -0,0 +1,176 @@ +import { useState, useCallback } from 'react'; +import { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { reorderTasksInGroup, moveTaskBetweenGroups } from '@/features/task-management/task-management.slice'; +import { Task, TaskGroup } from '@/types/task-management.types'; + +export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { + const dispatch = useAppDispatch(); + const [activeId, setActiveId] = useState(null); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveId(event.active.id as string); + }, []); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + const { active, over } = event; + + if (!over) return; + + const activeId = active.id; + const overId = over.id; + + // 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)); + } + + if (!activeGroup || !targetGroup) return; + + // If dragging to a different group, we need to handle cross-group movement + if (activeGroup.id !== targetGroup.id) { + console.log('Cross-group drag detected:', { + activeTask: activeTask.id, + fromGroup: activeGroup.id, + toGroup: targetGroup.id, + }); + } + }, + [allTasks, groups] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + if (!over || active.id === over.id) { + return; + } + + const activeId = active.id; + const overId = over.id; + + // Find the active task + const activeTask = allTasks.find(task => task.id === activeId); + if (!activeTask) { + console.error('Active task not found:', activeId); + return; + } + + // Find the groups + const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); + if (!activeGroup) { + console.error('Could not find active group for task:', activeId); + return; + } + + // Check if we're dropping on a task or a group + const overTask = allTasks.find(task => task.id === overId); + const overGroup = groups.find(group => group.id === overId); + + let targetGroup = overGroup; + 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; + } + + if (!targetGroup) { + console.error('Could not find target group'); + return; + } + + const isCrossGroup = activeGroup.id !== targetGroup.id; + const activeIndex = activeGroup.taskIds.indexOf(activeTask.id); + + console.log('Drag operation:', { + activeId, + overId, + activeTask: activeTask.name || activeTask.title, + activeGroup: activeGroup.id, + targetGroup: targetGroup.id, + activeIndex, + insertIndex, + isCrossGroup, + }); + + 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, + }); + + // Move task to the target group + dispatch( + moveTaskBetweenGroups({ + taskId: activeId as string, + sourceGroupId: activeGroup.id, + targetGroupId: targetGroup.id, + }) + ); + + // Reorder task within target group at drop position + dispatch( + reorderTasksInGroup({ + sourceTaskId: activeId as string, + destinationTaskId: over.id as string, + sourceGroupId: activeGroup.id, + destinationGroupId: targetGroup.id, + }) + ); + } else { + // Reordering within the same group + console.log('Reordering task within same group:', { + task: activeTask.name || activeTask.title, + group: activeGroup.title, + from: activeIndex, + to: insertIndex, + }); + + 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, + }) + ); + } + } + }, + [allTasks, groups, dispatch] + ); + + return { + activeId, + handleDragStart, + handleDragOver, + handleDragEnd, + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index d4fd98cc..02e4c758 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -18,43 +18,21 @@ import { Card, Spin, Empty, Alert } from 'antd'; import { RootState } from '@/app/store'; import { selectAllTasks, - selectGroups, - selectGrouping, selectLoading, selectError, - selectSelectedPriorities, - selectSearch, - reorderTasks, - moveTaskToGroup, - moveTaskBetweenGroups, - optimisticTaskMove, reorderTasksInGroup, - setLoading, - setError, - setSelectedPriorities, - setSearch, - resetTaskManagement, toggleTaskExpansion, - addSubtaskToParent, fetchTasksV3, + selectTaskGroupsV3, + fetchSubTasks, } from '@/features/task-management/task-management.slice'; import { selectCurrentGrouping, - selectCollapsedGroups, - selectIsGroupCollapsed, - toggleGroupCollapsed, - expandAllGroups, - collapseAllGroups, } from '@/features/task-management/grouping.slice'; import { selectSelectedTaskIds, - selectLastSelectedTaskId, - selectIsTaskSelected, - selectTask, - deselectTask, - toggleTaskSelection, - selectRange, clearSelection, + selectTask, } from '@/features/task-management/selection.slice'; import { selectTasks, @@ -89,18 +67,11 @@ import { IBulkTasksPriorityChangeRequest, IBulkTasksStatusChangeRequest, } from '@/types/tasks/bulk-action-bar.types'; -import { ITaskStatus } from '@/types/tasks/taskStatus.types'; -import { ITaskPriority } from '@/types/tasks/taskPriority.types'; -import { ITaskPhase } from '@/types/tasks/taskPhase.types'; -import { ITaskLabel } from '@/types/tasks/taskLabel.types'; -import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; import alertService from '@/services/alerts/alertService'; import logger from '@/utils/errorLogger'; import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; -import { performanceMonitor } from '@/utils/performance-monitor'; -import debugPerformance from '@/utils/debug-performance'; // Import the improved TaskListFilters component synchronously to avoid suspense import ImprovedTaskFilters from './improved-task-filters'; @@ -173,18 +144,11 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Redux selectors using V3 API (pre-processed data, minimal loops) const tasks = useSelector(selectAllTasks); - const groups = useSelector(selectGroups); - const grouping = useSelector(selectGrouping); const loading = useSelector(selectLoading); const error = useSelector(selectError); - const selectedPriorities = useSelector(selectSelectedPriorities); - const searchQuery = useSelector(selectSearch); const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual); const currentGrouping = useSelector(selectCurrentGrouping); - const collapsedGroups = useSelector(selectCollapsedGroups); const selectedTaskIds = useSelector(selectSelectedTaskIds); - const lastSelectedTaskId = useSelector(selectLastSelectedTaskId); - const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks); // Bulk action selectors const statusList = useSelector((state: RootState) => state.taskStatusReducer.status); @@ -202,9 +166,11 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const tasksById = useMemo(() => { const map: Record = {}; // Cache all tasks for full functionality - performance optimizations are handled at the virtualization level - tasks.forEach(task => { - map[task.id] = task; - }); + if (Array.isArray(tasks)) { + tasks.forEach((task: Task) => { + map[task.id] = task; + }); + } return map; }, [tasks]); @@ -262,14 +228,6 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const hasAnyTasks = useMemo(() => totalTasks > 0, [totalTasks]); - // Memoized handlers for better performance - const handleGroupingChange = useCallback( - (newGroupBy: 'status' | 'priority' | 'phase') => { - dispatch(setCurrentGrouping(newGroupBy)); - }, - [dispatch] - ); - // Add isDragging state const [isDragging, setIsDragging] = useState(false); @@ -280,7 +238,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const taskId = active.id as string; // Find the task and its group - const activeTask = tasks.find(t => t.id === taskId) || null; + const activeTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === taskId) || null : null; let activeGroupId: string | null = null; if (activeTask) { @@ -312,7 +270,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const overId = over.id as string; // Check if we're hovering over a task or a group container - const targetTask = tasks.find(t => t.id === overId); + const targetTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === overId) : undefined; let targetGroupId = overId; if (targetTask) { @@ -362,7 +320,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' let targetIndex = -1; // Check if dropping on a task or a group - const targetTask = tasks.find(t => t.id === overId); + const targetTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === overId) : undefined; if (targetTask) { // Dropping on a task, find which group contains this task for (const group of taskGroups) { @@ -398,13 +356,10 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Use the new reorderTasksInGroup action that properly handles group arrays dispatch( reorderTasksInGroup({ - taskId: activeTaskId, - fromGroupId: currentDragState.activeGroupId, - toGroupId: targetGroupId, - fromIndex: sourceIndex, - toIndex: finalTargetIndex, - groupType: targetGroup.groupType, - groupValue: targetGroup.groupValue, + sourceTaskId: activeTaskId, + destinationTaskId: targetTask?.id || '', + sourceGroupId: currentDragState.activeGroupId, + destinationGroupId: targetGroupId, }) ); @@ -448,10 +403,10 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const newSelectedIds = Array.from(currentSelectedIds); // Map selected tasks to the required format - const newSelectedTasks = tasks - .filter((t) => newSelectedIds.includes(t.id)) + const newSelectedTasks = Array.isArray(tasks) ? tasks + .filter((t: Task) => newSelectedIds.includes(t.id)) .map( - (task): IProjectTask => ({ + (task: Task): IProjectTask => ({ id: task.id, name: task.title, task_key: task.task_key, @@ -463,11 +418,11 @@ const TaskListBoard: React.FC = ({ projectId, className = '' description: task.description, start_date: task.startDate, end_date: task.dueDate, - total_hours: task.timeTracking.estimated || 0, - total_minutes: task.timeTracking.logged || 0, + total_hours: task.timeTracking?.estimated || 0, + total_minutes: task.timeTracking?.logged || 0, progress: task.progress, sub_tasks_count: task.sub_tasks_count || 0, - assignees: task.assignees.map((assigneeId) => ({ + assignees: task.assignees?.map((assigneeId: string) => ({ id: assigneeId, name: '', email: '', @@ -477,15 +432,16 @@ const TaskListBoard: React.FC = ({ projectId, className = '' })), labels: task.labels, manual_progress: false, - created_at: task.createdAt, - updated_at: task.updatedAt, + created_at: (task as any).createdAt || (task as any).created_at, + updated_at: (task as any).updatedAt || (task as any).updated_at, sort_order: task.order, }) - ); + ) : []; // Dispatch both actions to update the Redux state dispatch(selectTasks(newSelectedTasks)); - dispatch(selectTaskIds(newSelectedIds)); + // Update selection state with the new task IDs + newSelectedIds.forEach(taskId => dispatch(selectTask(taskId))); }, [dispatch, selectedTaskIds, tasks] ); diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index bb88ed33..d0ec6a15 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -615,9 +615,8 @@ export const useTaskSocketHandlers = () => { estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60, logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60, }, - customFields: {}, - createdAt: data.created_at || new Date().toISOString(), - updatedAt: data.updated_at || new Date().toISOString(), + created_at: data.created_at || new Date().toISOString(), + updated_at: data.updated_at || new Date().toISOString(), order: data.sort_order || 0, parent_task_id: data.parent_task_id, is_sub_task: true, @@ -634,7 +633,7 @@ export const useTaskSocketHandlers = () => { ); } else { // Handle regular task creation - transform to Task format and add - const task = { + const task: Task = { id: data.id || '', task_key: data.task_key || '', title: data.name || '', @@ -666,14 +665,17 @@ export const useTaskSocketHandlers = () => { names: l.names, })) || [], dueDate: data.end_date, + startDate: data.start_date, timeTracking: { estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60, logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60, }, - customFields: {}, - createdAt: data.created_at || new Date().toISOString(), - updatedAt: data.updated_at || new Date().toISOString(), + created_at: data.created_at || new Date().toISOString(), + updated_at: data.updated_at || new Date().toISOString(), order: data.sort_order || 0, + sub_tasks: [], + sub_tasks_count: 0, + show_sub_tasks: false, }; // Extract the group UUID from the backend response based on current grouping