From 4e1c6fb3336d66b43fb734c4f651d0d0f5c8011d Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Fri, 20 Jun 2025 06:44:47 +0530 Subject: [PATCH] refactor(task-management): enhance TaskGroup and TaskRow components with column visibility and improved layout - Integrated Redux for column visibility management in TaskGroup and TaskRow components. - Simplified the rendering of task details based on column visibility settings. - Updated styling for better consistency and responsiveness across task rows and groups. - Removed unused imports and components to streamline the codebase. --- .../components/task-management/TaskGroup.tsx | 224 +++++++------- .../task-management/TaskListBoard.tsx | 70 +---- .../components/task-management/TaskRow.tsx | 251 +++++++++------- .../src/lib/project/project-view-constants.ts | 8 +- .../add-task-list-row.tsx | 279 ++++++++++++++++-- 5 files changed, 508 insertions(+), 324 deletions(-) diff --git a/worklenz-frontend/src/components/task-management/TaskGroup.tsx b/worklenz-frontend/src/components/task-management/TaskGroup.tsx index 1231464e..b7688c03 100644 --- a/worklenz-frontend/src/components/task-management/TaskGroup.tsx +++ b/worklenz-frontend/src/components/task-management/TaskGroup.tsx @@ -1,17 +1,15 @@ import React, { useState } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { Button, Typography, Badge, Space, Tooltip } from 'antd'; -import { - CaretRightOutlined, - CaretDownOutlined, - PlusOutlined, - MoreOutlined, -} from '@ant-design/icons'; +import { useSelector } from 'react-redux'; +import { Button, Typography } from 'antd'; +import { PlusOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { IGroupBy } from '@/features/tasks/tasks.slice'; +import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; +import { RootState } from '@/app/store'; import TaskRow from './TaskRow'; +import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; const { Text } = Typography; @@ -46,12 +44,21 @@ const TaskGroup: React.FC = ({ }, }); + // Get column visibility from Redux store + const columns = useSelector((state: RootState) => state.taskReducer.columns); + + // Helper function to check if a column is visible + const isColumnVisible = (columnKey: string) => { + const column = columns.find(col => col.key === columnKey); + return column ? column.pinned : true; // Default to visible if column not found + }; + // Get task IDs for sortable context const taskIds = group.tasks.map(task => task.id!); // Calculate group statistics - const completedTasks = group.tasks.filter(task => - task.status_category?.is_done || task.complete_ratio === 100 + const completedTasks = group.tasks.filter( + task => task.status_category?.is_done || task.complete_ratio === 100 ).length; const totalTasks = group.tasks.length; const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; @@ -59,16 +66,19 @@ const TaskGroup: React.FC = ({ // Get group color based on grouping type const getGroupColor = () => { if (group.color_code) return group.color_code; - + // Fallback colors based on group value switch (currentGrouping) { case 'status': - return group.id === 'todo' ? '#faad14' : - group.id === 'doing' ? '#1890ff' : '#52c41a'; + return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a'; case 'priority': - return group.id === 'critical' ? '#ff4d4f' : - group.id === 'high' ? '#fa8c16' : - group.id === 'medium' ? '#faad14' : '#52c41a'; + return group.id === 'critical' + ? '#ff4d4f' + : group.id === 'high' + ? '#fa8c16' + : group.id === 'medium' + ? '#faad14' + : '#52c41a'; case 'phase': return '#722ed1'; default: @@ -86,7 +96,7 @@ const TaskGroup: React.FC = ({ }; return ( -
= ({ {/* Group Header Row */}
-
-
-
-
-
-
-
-
-
- - {group.name} - - - {completionRate > 0 && ( - - {completionRate}% complete - - )} -
-
-
-
-
-
-
-
-
-
- - -
+
+
- - {/* Progress Bar */} - {totalTasks > 0 && !isCollapsed && ( -
-
-
-
-
-
-
-
-
- )}
{/* Column Headers */} @@ -180,8 +126,14 @@ const TaskGroup: React.FC = ({
-
-
+
+
Key
@@ -190,24 +142,36 @@ const TaskGroup: React.FC = ({
-
- Progress -
-
- Members -
-
- Labels -
-
- Status -
-
- Priority -
-
- Time Tracking -
+ {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( +
+ Progress +
+ )} + {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( +
+ Members +
+ )} + {isColumnVisible(COLUMN_KEYS.LABELS) && ( +
+ Labels +
+ )} + {isColumnVisible(COLUMN_KEYS.STATUS) && ( +
+ Status +
+ )} + {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( +
+ Priority +
+ )} + {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( +
+ Time Tracking +
+ )}
@@ -254,6 +218,11 @@ const TaskGroup: React.FC = ({
)} + + {/* Add Task Row - Always show when not collapsed */} +
+ +
)} @@ -280,8 +249,8 @@ const TaskGroup: React.FC = ({ .task-group-header-row { display: flex; - height: 42px; - max-height: 42px; + height: 40px; + max-height: 40px; overflow: hidden; } @@ -302,8 +271,8 @@ const TaskGroup: React.FC = ({ .task-group-column-headers-row { display: flex; - height: 32px; - max-height: 32px; + height: 40px; + max-height: 40px; overflow: hidden; } @@ -347,6 +316,21 @@ const TaskGroup: React.FC = ({ transition: background-color 0.3s ease; } + .task-group-add-task { + background: var(--task-bg-primary, white); + border-top: 1px solid var(--task-border-secondary, #f0f0f0); + transition: all 0.3s ease; + padding: 0 12px; + width: 100%; + min-height: 40px; + display: flex; + align-items: center; + } + + .task-group-add-task:hover { + background: var(--task-hover-bg, #fafafa); + } + .task-table-fixed-columns { display: flex; background: inherit; @@ -369,9 +353,9 @@ const TaskGroup: React.FC = ({ border-right: 1px solid var(--task-border-secondary, #f0f0f0); font-size: 12px; white-space: nowrap; - height: 42px; - max-height: 42px; - min-height: 42px; + height: 40px; + max-height: 40px; + min-height: 40px; overflow: hidden; color: var(--task-text-primary, #262626); transition: all 0.3s ease; @@ -408,4 +392,4 @@ const TaskGroup: React.FC = ({ ); }; -export default TaskGroup; \ No newline at end of file +export default TaskGroup; diff --git a/worklenz-frontend/src/components/task-management/TaskListBoard.tsx b/worklenz-frontend/src/components/task-management/TaskListBoard.tsx index 2621ea91..62e48820 100644 --- a/worklenz-frontend/src/components/task-management/TaskListBoard.tsx +++ b/worklenz-frontend/src/components/task-management/TaskListBoard.tsx @@ -13,30 +13,24 @@ import { useSensors, } from '@dnd-kit/core'; import { - SortableContext, - verticalListSortingStrategy, sortableKeyboardCoordinates, } from '@dnd-kit/sortable'; -import { Card, Button, Select, Space, Typography, Spin, Empty } from 'antd'; -import { ExpandOutlined, CompressOutlined, PlusOutlined } from '@ant-design/icons'; +import { Card, Spin, Empty } from 'antd'; import { RootState } from '@/app/store'; import { IGroupBy, - GROUP_BY_OPTIONS, setGroup, fetchTaskGroups, reorderTasks, } from '@/features/tasks/tasks.slice'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; import TaskGroup from './TaskGroup'; import TaskRow from './TaskRow'; import BulkActionBar from './BulkActionBar'; -import GroupingSelector from './GroupingSelector'; import { AppDispatch } from '@/app/store'; -const { Title } = Typography; -const { Option } = Select; +// Import the TaskListFilters component +const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')); interface TaskListBoardProps { projectId: string; @@ -200,21 +194,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' })); }; - const handleCollapseAll = () => { - // This would need to be implemented in the tasks slice - console.log('Collapse all groups'); - }; - const handleExpandAll = () => { - // This would need to be implemented in the tasks slice - console.log('Expand all groups'); - }; - - const handleRefresh = () => { - if (projectId) { - dispatch(fetchTaskGroups(projectId)); - } - }; const handleSelectTask = (taskId: string, selected: boolean) => { setSelectedTaskIds(prev => { @@ -244,48 +224,15 @@ const TaskListBoard: React.FC = ({ projectId, className = '' return (
- {/* Header Controls */} + {/* Task Filters */} -
-
- - Tasks ({totalTasksCount}) - - - -
- - - - -
+ Loading filters...
}> + + {/* Bulk Action Bar */} @@ -356,8 +303,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' overflow-x: auto; max-height: calc(100vh - 300px); overflow-y: auto; - background: var(--task-bg-secondary, #f5f5f5); - padding: 16px; + padding: 8px 8px 8px 0; border-radius: 8px; transition: background-color 0.3s ease; } diff --git a/worklenz-frontend/src/components/task-management/TaskRow.tsx b/worklenz-frontend/src/components/task-management/TaskRow.tsx index f20188b9..e689a70d 100644 --- a/worklenz-frontend/src/components/task-management/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-management/TaskRow.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { useSelector } from 'react-redux'; import { Checkbox, Avatar, Tag, Progress, Typography, Space, Button, Tooltip } from 'antd'; import { HolderOutlined, @@ -10,7 +11,8 @@ import { ClockCircleOutlined, } from '@ant-design/icons'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { IGroupBy } from '@/features/tasks/tasks.slice'; +import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; +import { RootState } from '@/app/store'; const { Text } = Typography; @@ -54,6 +56,15 @@ const TaskRow: React.FC = ({ disabled: isDragOverlay, }); + // Get column visibility from Redux store + const columns = useSelector((state: RootState) => state.taskReducer.columns); + + // Helper function to check if a column is visible + const isColumnVisible = (columnKey: string) => { + const column = columns.find(col => col.key === columnKey); + return column ? column.pinned : true; // Default to visible if column not found + }; + const style = { transform: CSS.Transform.toString(transform), transition, @@ -156,117 +167,131 @@ const TaskRow: React.FC = ({ {/* Scrollable Columns */}
{/* Progress */} -
- {task.complete_ratio !== undefined && task.complete_ratio >= 0 && ( -
- - {task.complete_ratio}% -
- )} -
- - {/* Members */} -
- {task.assignees && task.assignees.length > 0 && ( - - {task.assignees.map((assignee) => ( - - - {assignee.name?.charAt(0)?.toUpperCase()} - - - ))} - - )} -
- - {/* Labels */} -
- {task.labels && task.labels.length > 0 && ( -
- {task.labels.slice(0, 3).map((label) => ( - - {label.name} - - ))} - {task.labels.length > 3 && ( - - +{task.labels.length - 3} - - )} -
- )} -
- - {/* Status */} -
- {task.status_name && ( -
- {task.status_name} -
- )} -
- - {/* Priority */} -
- {task.priority_name && ( -
-
- {task.priority_name} -
- )} -
- - {/* Time Tracking */} -
-
- {task.time_spent_string && ( -
- - {task.time_spent_string} + {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( +
+ {task.complete_ratio !== undefined && task.complete_ratio >= 0 && ( +
+ {percent}%} + />
)} - {/* Task Indicators */} -
- {task.comments_count && task.comments_count > 0 && ( -
- - {task.comments_count} -
- )} - {task.attachments_count && task.attachments_count > 0 && ( -
- - {task.attachments_count} +
+ )} + + {/* Members */} + {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( +
+ {task.assignees && task.assignees.length > 0 && ( + + {task.assignees.map((assignee) => ( + + + {assignee.name?.charAt(0)?.toUpperCase()} + + + ))} + + )} +
+ )} + + {/* Labels */} + {isColumnVisible(COLUMN_KEYS.LABELS) && ( +
+ {task.labels && task.labels.length > 0 && ( +
+ {task.labels.slice(0, 3).map((label) => ( + + {label.name} + + ))} + {task.labels.length > 3 && ( + + +{task.labels.length - 3} + + )} +
+ )} +
+ )} + + {/* Status */} + {isColumnVisible(COLUMN_KEYS.STATUS) && ( +
+ {task.status_name && ( +
+ {task.status_name} +
+ )} +
+ )} + + {/* Priority */} + {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( +
+ {task.priority_name && ( +
+
+ {task.priority_name} +
+ )} +
+ )} + + {/* Time Tracking */} + {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( +
+
+ {task.time_spent_string && ( +
+ + {task.time_spent_string}
)} + {/* Task Indicators */} +
+ {task.comments_count && task.comments_count > 0 && ( +
+ + {task.comments_count} +
+ )} + {task.attachments_count && task.attachments_count > 0 && ( +
+ + {task.attachments_count} +
+ )} +
-
+ )}
@@ -313,8 +338,8 @@ const TaskRow: React.FC = ({ .task-row-content { display: flex; - height: 42px; - max-height: 42px; + height: 40px; + max-height: 40px; overflow: hidden; } @@ -340,9 +365,9 @@ const TaskRow: React.FC = ({ border-right: 1px solid var(--task-border-secondary, #f0f0f0); font-size: 12px; white-space: nowrap; - height: 42px; - max-height: 42px; - min-height: 42px; + height: 40px; + max-height: 40px; + min-height: 40px; overflow: hidden; color: var(--task-text-primary, #262626); transition: all 0.3s ease; @@ -441,13 +466,13 @@ const TaskRow: React.FC = ({ .task-progress { display: flex; align-items: center; - gap: 6px; + justify-content: center; width: 100%; height: 100%; } .task-progress .ant-progress { - flex: 1; + flex: 0 0 auto; } .task-progress-text { diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index d0ade74d..3957b42e 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -23,14 +23,14 @@ export const tabItems: TabItems[] = [ key: 'tasks-list', label: 'Task List', isPinned: true, - element: React.createElement(ProjectViewTaskList), + element: React.createElement(ProjectViewEnhancedTasks), }, { index: 1, - key: 'enhanced-tasks', - label: 'Enhanced Tasks', + key: 'task-list-v1', + label: 'Task List v1', isPinned: true, - element: React.createElement(ProjectViewEnhancedTasks), + element: React.createElement(ProjectViewTaskList), }, { index: 2, diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row.tsx index 3d8f33d0..b0232907 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row.tsx @@ -1,5 +1,7 @@ import Input, { InputRef } from 'antd/es/input'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState, useEffect } from 'react'; +import { Spin } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { colors } from '@/styles/colors'; import { useTranslation } from 'react-i18next'; @@ -31,7 +33,10 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr const [isEdit, setIsEdit] = useState(false); const [taskName, setTaskName] = useState(''); const [creatingTask, setCreatingTask] = useState(false); + const [error, setError] = useState(''); + const [taskCreationTimeout, setTaskCreationTimeout] = useState(null); const taskInputRef = useRef(null); + const containerRef = useRef(null); const dispatch = useAppDispatch(); const currentSession = useAuthService().getCurrentSession(); @@ -43,13 +48,62 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr const customBorderColor = useMemo(() => themeMode === 'dark' && ' border-[#303030]', [themeMode]); const projectId = useAppSelector(state => state.projectReducer.projectId); + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (taskCreationTimeout) { + clearTimeout(taskCreationTimeout); + } + }; + }, [taskCreationTimeout]); + + // Handle click outside to cancel edit mode + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + isEdit && + !creatingTask && + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + cancelEdit(); + } + }; + + if (isEdit) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [isEdit, creatingTask]); + const createRequestBody = (): ITaskCreateRequest | null => { if (!projectId || !currentSession) return null; const body: ITaskCreateRequest = { - project_id: projectId, + id: '', name: taskName, - reporter_id: currentSession.id, + description: '', + status_id: '', + priority: '', + start_date: '', + end_date: '', + total_hours: 0, + total_minutes: 0, + billable: false, + phase_id: '', + parent_task_id: undefined, + project_id: projectId, team_id: currentSession.team_id, + task_key: '', + labels: [], + assignees: [], + names: [], + sub_tasks_count: 0, + manual_progress: false, + progress_value: null, + weight: null, + reporter_id: currentSession.id, }; const groupBy = getCurrentGroup(); @@ -69,10 +123,14 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr const reset = (scroll = true) => { setIsEdit(false); - setCreatingTask(false); - setTaskName(''); + setError(''); + if (taskCreationTimeout) { + clearTimeout(taskCreationTimeout); + setTaskCreationTimeout(null); + } + setIsEdit(true); setTimeout(() => { @@ -81,6 +139,16 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr }, DRAWER_ANIMATION_INTERVAL); }; + const cancelEdit = () => { + setIsEdit(false); + setTaskName(''); + setError(''); + if (taskCreationTimeout) { + clearTimeout(taskCreationTimeout); + setTaskCreationTimeout(null); + } + }; + const onNewTaskReceived = (task: IAddNewTask) => { if (!groupId) return; @@ -106,49 +174,210 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr }; const addInstantTask = async () => { - if (creatingTask || !projectId || !currentSession || taskName.trim() === '') return; + // Validation + if (creatingTask || !projectId || !currentSession) return; + + const trimmedTaskName = taskName.trim(); + if (trimmedTaskName === '') { + setError('Task name cannot be empty'); + taskInputRef.current?.focus(); + return; + } try { setCreatingTask(true); + setError(''); + const body = createRequestBody(); - if (!body) return; + if (!body) { + setError('Failed to create task. Please try again.'); + setCreatingTask(false); + return; + } + + // Set timeout for task creation (10 seconds) + const timeout = setTimeout(() => { + setCreatingTask(false); + setError('Task creation timed out. Please try again.'); + }, 10000); + + setTaskCreationTimeout(timeout); socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); + + // Handle success response socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => { + clearTimeout(timeout); + setTaskCreationTimeout(null); setCreatingTask(false); - onNewTaskReceived(task as IAddNewTask); + + if (task && task.id) { + onNewTaskReceived(task as IAddNewTask); + } else { + setError('Failed to create task. Please try again.'); + } }); + + // Handle error response + socket?.once('error', (errorData: any) => { + clearTimeout(timeout); + setTaskCreationTimeout(null); + setCreatingTask(false); + const errorMessage = errorData?.message || 'Failed to create task'; + setError(errorMessage); + }); + } catch (error) { console.error('Error adding task:', error); setCreatingTask(false); + setError('An unexpected error occurred. Please try again.'); } }; const handleAddTask = () => { - setIsEdit(false); + if (creatingTask) return; // Prevent multiple submissions addInstantTask(); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + cancelEdit(); + } else if (e.key === 'Enter' && !creatingTask) { + e.preventDefault(); + handleAddTask(); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setTaskName(e.target.value); + if (error) setError(''); // Clear error when user starts typing + }; + return ( -
+
{isEdit ? ( - setTaskName(e.target.value)} - onBlur={handleAddTask} - onPressEnter={handleAddTask} - ref={taskInputRef} - /> +
+ + {creatingTask && ( +
+ } + /> +
+ )} + {error && ( +
+ {error} +
+ )} +
) : ( - setIsEdit(true)} - className="w-[300px] border-none" - value={parentTask ? t('addSubTaskText') : t('addTaskText')} - ref={taskInputRef} - /> +
setIsEdit(true)} + > + + {parentTask ? t('addSubTaskText') : t('addTaskText')} + +
)} + +
); };