From 02d814b935d24be4253e5d194708cdc4abf7ea17 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 14 Jul 2025 12:04:31 +0530 Subject: [PATCH 1/3] refactor(task-list): enhance task row components with depth handling - Added depth and maxDepth props to TaskRow, TaskRowWithSubtasks, and TitleColumn components to manage nested subtasks more effectively. - Updated AddSubtaskRow to support depth for proper indentation and visual hierarchy. - Improved styling for subtasks based on their depth level, ensuring better visual distinction. - Adjusted task management slice to utilize actual subtask counts from the backend for accurate display. --- .../task-drawer-phase-selector.tsx | 14 +-- .../src/components/task-list-v2/TaskRow.tsx | 5 +- .../task-list-v2/TaskRowWithSubtasks.tsx | 97 +++++++++++++------ .../task-list-v2/components/TitleColumn.tsx | 19 ++-- .../task-list-v2/hooks/useTaskRowColumns.tsx | 5 + .../task-management/task-management.slice.ts | 2 +- 6 files changed, 96 insertions(+), 46 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx index b336f091..e7504761 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx @@ -1,6 +1,5 @@ import { useSocket } from '@/socket/socketContext'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { Select } from 'antd'; import { Form } from 'antd'; @@ -27,12 +26,6 @@ const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps) phase_id: value, parent_task: task.parent_task_id || null, }); - - // socket?.once(SocketEvents.TASK_PHASE_CHANGE.toString(), () => { - // if(list.getCurrentGroup().value === this.list.GROUP_BY_PHASE_VALUE && this.list.isSubtasksIncluded) { - // this.list.emitRefreshSubtasksIncluded(); - // } - // }); }; return ( @@ -41,8 +34,11 @@ const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps) allowClear placeholder="Select Phase" options={phaseMenuItems} - style={{ width: 'fit-content' }} - dropdownStyle={{ width: 'fit-content' }} + styles={{ + root: { + width: 'fit-content', + }, + }} onChange={handlePhaseChange} /> diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index 24571b8b..e1dd657f 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -24,6 +24,7 @@ interface TaskRowProps { isSubtask?: boolean; isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; + depth?: number; } const TaskRow: React.FC = memo(({ @@ -32,7 +33,8 @@ const TaskRow: React.FC = memo(({ visibleColumns, isSubtask = false, isFirstInGroup = false, - updateTaskCustomColumnValue + updateTaskCustomColumnValue, + depth = 0 }) => { // Get task data and selection state from Redux const task = useAppSelector(state => selectTaskById(state, taskId)); @@ -107,6 +109,7 @@ const TaskRow: React.FC = memo(({ handleTaskNameEdit, attributes, listeners, + depth, }); // Memoize style object to prevent unnecessary re-renders diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index 10226d03..9e450c60 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -22,6 +22,8 @@ interface TaskRowWithSubtasksProps { }>; isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; + depth?: number; // Add depth prop to track nesting level + maxDepth?: number; // Add maxDepth prop to limit nesting } interface AddSubtaskRowProps { @@ -32,14 +34,15 @@ interface AddSubtaskRowProps { width: string; isSticky?: boolean; }>; - onSubtaskAdded: () => void; // Simplified - no rowId needed - rowId: string; // Unique identifier for this add subtask row - autoFocus?: boolean; // Whether this row should auto-focus on mount - isActive?: boolean; // Whether this row should show the input/button - onActivate?: () => void; // Simplified - no rowId needed + onSubtaskAdded: () => void; + rowId: string; + autoFocus?: boolean; + isActive?: boolean; + onActivate?: () => void; + depth?: number; // Add depth prop for proper indentation } -const AddSubtaskRow: React.FC = memo(({ +const AddSubtaskRow: React.FC = memo(({ parentTaskId, projectId, visibleColumns, @@ -47,25 +50,20 @@ const AddSubtaskRow: React.FC = memo(({ rowId, autoFocus = false, isActive = true, - onActivate + onActivate, + depth = 0 }) => { - const [isAdding, setIsAdding] = useState(autoFocus); + const { t } = useTranslation('task-list-table'); + const [isAdding, setIsAdding] = useState(false); const [subtaskName, setSubtaskName] = useState(''); const inputRef = useRef(null); - const { socket, connected } = useSocket(); - const { t } = useTranslation('task-list-table'); const dispatch = useAppDispatch(); - - // Get session data for reporter_id and team_id + const { socket, connected } = useSocket(); const currentSession = useAuthService().getCurrentSession(); - // Auto-focus when autoFocus prop is true useEffect(() => { if (autoFocus && inputRef.current) { - setIsAdding(true); - setTimeout(() => { - inputRef.current?.focus(); - }, 100); + inputRef.current.focus(); } }, [autoFocus]); @@ -142,10 +140,14 @@ const AddSubtaskRow: React.FC = memo(({
{/* Match subtask indentation pattern - tighter spacing */} -
+
+ {/* Add additional indentation for deeper levels - 16px per level */} + {Array.from({ length: depth }).map((_, i) => ( +
+ ))}
- {isActive ? ( + {isActive ? ( !isAdding ? (
); }; diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx index 335ad133..de80fb4f 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx @@ -3,7 +3,7 @@ import Drawer from 'antd/es/drawer'; import { InputRef } from 'antd/es/input'; import { useTranslation } from 'react-i18next'; import { useEffect, useRef, useState } from 'react'; -import { PlusOutlined } from '@ant-design/icons'; +import { PlusOutlined, CloseOutlined, ArrowLeftOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; @@ -13,6 +13,7 @@ import { setTaskFormViewModel, setTaskSubscribers, setTimeLogEditing, + fetchTask, } from '@/features/task-drawer/task-drawer.slice'; import './task-drawer.css'; @@ -33,6 +34,7 @@ const TaskDrawer = () => { const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer); const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer); + const { projectId } = useAppSelector(state => state.projectReducer); const taskNameInputRef = useRef(null); const isClosingManually = useRef(false); @@ -54,6 +56,17 @@ const TaskDrawer = () => { dispatch(setTaskSubscribers([])); }; + const handleBackToParent = () => { + if (taskFormViewModel?.task?.parent_task_id && projectId) { + // Navigate to parent task + dispatch(setSelectedTaskId(taskFormViewModel.task.parent_task_id)); + dispatch(fetchTask({ + taskId: taskFormViewModel.task.parent_task_id, + projectId + })); + } + }; + const handleOnClose = ( e?: React.MouseEvent | React.KeyboardEvent ) => { @@ -68,10 +81,8 @@ const TaskDrawer = () => { if (isClickOutsideDrawer || !taskFormViewModel?.task?.is_sub_task) { resetTaskState(); } else { - dispatch(setSelectedTaskId(null)); - dispatch(setTaskFormViewModel({})); - dispatch(setTaskSubscribers([])); - dispatch(setSelectedTaskId(taskFormViewModel?.task?.parent_task_id || null)); + // For sub-tasks, navigate to parent instead of closing + handleBackToParent(); } // Reset the flag after a short delay setTimeout(() => { @@ -205,6 +216,17 @@ const TaskDrawer = () => { }; }; + // Check if current task is a sub-task + const isSubTask = taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id; + + // Custom close icon based on whether it's a sub-task + const getCloseIcon = () => { + if (isSubTask) { + return ; + } + return ; + }; + const drawerProps = { open: showTaskDrawer, onClose: handleOnClose, @@ -215,6 +237,7 @@ const TaskDrawer = () => { footer: renderFooter(), bodyStyle: getBodyStyle(), footerStyle: getFooterStyle(), + closeIcon: getCloseIcon(), }; return ( diff --git a/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css new file mode 100644 index 00000000..58f85ab6 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css @@ -0,0 +1,88 @@ +.task-hierarchy-breadcrumb { + margin-bottom: 4px; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb { + font-size: 14px; + line-height: 1; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb-link { + color: inherit; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb-separator { + color: #8c8c8c; + margin: 0 4px; +} + +/* Dark mode styles */ +[data-theme='dark'] .task-hierarchy-breadcrumb .ant-breadcrumb-separator { + color: #595959; +} + +/* Back button styles */ +.task-hierarchy-breadcrumb .ant-btn-link { + padding: 0; + height: auto; + font-size: 14px; + line-height: 1.3; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; +} + +.task-hierarchy-breadcrumb .ant-btn-link .anticon { + margin-right: 0; /* Remove default margin */ +} + +.task-hierarchy-breadcrumb .ant-btn-link:hover { + color: #40a9ff; +} + +[data-theme='dark'] .task-hierarchy-breadcrumb .ant-btn-link:hover { + color: #40a9ff; +} + +/* Current task name styles */ +.task-hierarchy-breadcrumb .current-task-name { + font-size: 14px; + color: #000000d9; + font-weight: 500; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + line-height: 1.3; +} + +[data-theme='dark'] .task-hierarchy-breadcrumb .current-task-name { + color: #ffffffd9; +} + +/* Breadcrumb item container */ +.task-hierarchy-breadcrumb .ant-breadcrumb-item { + max-width: 220px; + overflow: hidden; + display: flex; + align-items: center; +} + +/* Ensure breadcrumb items don't break the layout */ +.task-hierarchy-breadcrumb .ant-breadcrumb ol { + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 0; + padding: 0; +} + +/* Better alignment for breadcrumb items */ +.task-hierarchy-breadcrumb .ant-breadcrumb-item .ant-breadcrumb-link { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx new file mode 100644 index 00000000..792b4347 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from 'react'; +import { Breadcrumb, Button, Typography, Tooltip } from 'antd'; +import { HomeOutlined } from '@ant-design/icons'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { fetchTask, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice'; +import { tasksApiService } from '@/api/tasks/tasks.api.service'; +import { TFunction } from 'i18next'; +import './task-hierarchy-breadcrumb.css'; + +interface TaskHierarchyBreadcrumbProps { + t: TFunction; + onBackClick?: () => void; +} + +interface TaskHierarchyItem { + id: string; + name: string; + parent_task_id?: string; +} + +// Utility function to truncate text +const truncateText = (text: string, maxLength: number = 25): string => { + if (!text || text.length <= maxLength) return text; + return `${text.substring(0, maxLength)}...`; +}; + +const TaskHierarchyBreadcrumb: React.FC = ({ t, onBackClick }) => { + const dispatch = useAppDispatch(); + const { taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer); + const { projectId } = useAppSelector(state => state.projectReducer); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const [hierarchyPath, setHierarchyPath] = useState([]); + const [loading, setLoading] = useState(false); + + const task = taskFormViewModel?.task; + const isSubTask = task?.is_sub_task || !!task?.parent_task_id; + + // Recursively fetch the complete hierarchy path + const fetchHierarchyPath = async (currentTaskId: string): Promise => { + if (!projectId) return []; + + const path: TaskHierarchyItem[] = []; + let taskId = currentTaskId; + + // Traverse up the hierarchy until we reach the root + while (taskId) { + try { + const response = await tasksApiService.getFormViewModel(taskId, projectId); + if (response.done && response.body.task) { + const taskData = response.body.task; + path.unshift({ + id: taskData.id, + name: taskData.name || '', + parent_task_id: taskData.parent_task_id || undefined + }); + + // Move to parent task + taskId = taskData.parent_task_id || ''; + } else { + break; + } + } catch (error) { + console.error('Error fetching task in hierarchy:', error); + break; + } + } + + return path; + }; + + // Fetch the complete hierarchy when component mounts or task changes + useEffect(() => { + const loadHierarchy = async () => { + if (!isSubTask || !task?.parent_task_id || !projectId) { + setHierarchyPath([]); + return; + } + + setLoading(true); + try { + const path = await fetchHierarchyPath(task.parent_task_id); + setHierarchyPath(path); + } catch (error) { + console.error('Error loading task hierarchy:', error); + setHierarchyPath([]); + } finally { + setLoading(false); + } + }; + + loadHierarchy(); + }, [task?.parent_task_id, projectId, isSubTask]); + + const handleNavigateToTask = (taskId: string) => { + if (projectId) { + if (onBackClick) { + onBackClick(); + } + + // Navigate to the selected task + dispatch(setSelectedTaskId(taskId)); + dispatch(fetchTask({ taskId, projectId })); + } + }; + + if (!isSubTask || hierarchyPath.length === 0) { + return null; + } + + // Create breadcrumb items from the hierarchy path + const breadcrumbItems = [ + // Add all parent tasks in the hierarchy + ...hierarchyPath.map((hierarchyTask, index) => { + const truncatedName = truncateText(hierarchyTask.name, 25); + const shouldShowTooltip = hierarchyTask.name.length > 25; + + return { + title: ( + + + + ), + }; + }), + // Add the current task as the last item (non-clickable) + { + title: (() => { + const currentTaskName = task?.name || t('taskHeader.currentTask', 'Current Task'); + const truncatedCurrentName = truncateText(currentTaskName, 25); + const shouldShowCurrentTooltip = currentTaskName.length > 25; + + return ( + + + {truncatedCurrentName} + + + ); + })(), + }, + ]; + + return ( +
+ {loading ? ( + + {t('taskHeader.loadingHierarchy', 'Loading hierarchy...')} + + ) : ( + + )} +
+ ); +}; + +export default TaskHierarchyBreadcrumb; \ No newline at end of file From 3d67145af7832d8c279d76a642541f04d574a7b6 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 14 Jul 2025 12:37:54 +0530 Subject: [PATCH 3/3] refactor(task-list): adjust subtask indentation for improved visual hierarchy - Reduced spacing for level 1 subtasks and increased spacing for deeper levels in TaskRowWithSubtasks and TitleColumn components. - Enhanced comments to clarify indentation logic for better maintainability. --- .../components/task-list-v2/TaskRowWithSubtasks.tsx | 12 ++++++------ .../task-list-v2/components/TitleColumn.tsx | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index 9e450c60..f6c35cfc 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -139,13 +139,13 @@ const AddSubtaskRow: React.FC = memo(({ return (
- {/* Match subtask indentation pattern - tighter spacing */} -
- {/* Add additional indentation for deeper levels - 16px per level */} - {Array.from({ length: depth }).map((_, i) => ( -
- ))} + {/* Match subtask indentation pattern - reduced spacing for level 1 */}
+ {/* Add additional indentation for deeper levels - increased spacing for level 2+ */} + {Array.from({ length: depth }).map((_, i) => ( +
+ ))} +
{isActive ? ( !isAdding ? ( diff --git a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx index 55381c6a..990b4a16 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx @@ -152,12 +152,12 @@ export const TitleColumn: React.FC = memo(({ /* Normal layout when not editing */ <>
- {/* Indentation for subtasks - tighter spacing */} - {isSubtask &&
} + {/* Indentation for subtasks - reduced spacing for level 1 */} + {isSubtask &&
} - {/* Additional indentation for deeper levels - 16px per level */} + {/* Additional indentation for deeper levels - increased spacing for level 2+ */} {Array.from({ length: depth }).map((_, i) => ( -
+
))} {/* Expand/Collapse button - show for any task that can have sub-tasks */} @@ -182,8 +182,8 @@ export const TitleColumn: React.FC = memo(({ )} - {/* Additional indentation for subtasks after the expand button space */} - {isSubtask &&
} + {/* Additional indentation for subtasks after the expand button space - reduced for level 1 */} + {isSubtask &&
}
{/* Task name with dynamic width */}