From 10d64c88e3f809bdb3b20745fc2e750a3a4a4565 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 1 Jul 2025 12:04:30 +0530 Subject: [PATCH] feat(enhanced-kanban): implement real-time updates and task expansion handling - Integrated socket event handlers for real-time updates in the enhanced Kanban board, improving task management responsiveness. - Added functionality to toggle task expansion for subtasks, enhancing user interaction with task details. - Updated state management to handle subtasks more effectively, including loading states and counts. - Refactored subtask fetching logic to utilize a dedicated API endpoint, streamlining data retrieval. --- .../enhanced-kanban/EnhancedKanbanBoard.tsx | 4 + .../EnhancedKanbanCreateSubtaskCard.tsx | 173 ++++++++++++++++++ .../EnhancedKanbanTaskCard.tsx | 37 ++-- .../shared/info-tab/subtask-table.tsx | 7 + .../enhanced-kanban/enhanced-kanban.slice.ts | 110 +++++++---- .../src/hooks/useTaskSocketHandlers.ts | 52 ++++++ 6 files changed, 333 insertions(+), 50 deletions(-) create mode 100644 worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSubtaskCard.tsx diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx index 28a70475..dc59b83d 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx @@ -49,6 +49,7 @@ import EnhancedKanbanCreateSection from './EnhancedKanbanCreateSection'; import ImprovedTaskFilters from '../task-management/improved-task-filters'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; import { useFilterDataLoader } from '@/hooks/useFilterDataLoader'; +import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; // Import the TaskListFilters component const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')); @@ -74,6 +75,9 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl // Load filter data useFilterDataLoader(); + + // Set up socket event handlers for real-time updates + useTaskSocketHandlers(); // Local state for drag overlay const [activeTask, setActiveTask] = useState(null); diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSubtaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSubtaskCard.tsx new file mode 100644 index 00000000..5cd0dabc --- /dev/null +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSubtaskCard.tsx @@ -0,0 +1,173 @@ +import { Flex, Input, InputRef } from 'antd'; +import React, { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { updateEnhancedKanbanTaskProgress } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { getCurrentGroup } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { useAuthService } from '@/hooks/useAuth'; +import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types'; +import { useParams } from 'react-router-dom'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import logger from '@/utils/errorLogger'; + +type EnhancedKanbanCreateSubtaskCardProps = { + sectionId: string; + parentTaskId: string; + setShowNewSubtaskCard: (x: boolean) => void; +}; + +const EnhancedKanbanCreateSubtaskCard = ({ + sectionId, + parentTaskId, + setShowNewSubtaskCard, +}: EnhancedKanbanCreateSubtaskCardProps) => { + const { socket, connected } = useSocket(); + const dispatch = useAppDispatch(); + + const [creatingTask, setCreatingTask] = useState(false); + const [newSubtaskName, setNewSubtaskName] = useState(''); + const [isEnterKeyPressed, setIsEnterKeyPressed] = useState(false); + + const cardRef = useRef(null); + const inputRef = useRef(null); + + const { t } = useTranslation('kanban-board'); + + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { projectId } = useParams(); + const currentSession = useAuthService().getCurrentSession(); + + const createRequestBody = (): ITaskCreateRequest | null => { + if (!projectId || !currentSession) return null; + const body: ITaskCreateRequest = { + project_id: projectId, + name: newSubtaskName, + reporter_id: currentSession.id, + team_id: currentSession.team_id, + }; + + const groupBy = getCurrentGroup(); + if (groupBy === 'status') { + body.status_id = sectionId || undefined; + } else if (groupBy === 'priority') { + body.priority_id = sectionId || undefined; + } else if (groupBy === 'phase') { + body.phase_id = sectionId || undefined; + } + + if (parentTaskId) { + body.parent_task_id = parentTaskId; + } + return body; + }; + + const handleAddSubtask = () => { + if (creatingTask || !projectId || !currentSession || newSubtaskName.trim() === '' || !connected) + return; + + try { + setCreatingTask(true); + const body = createRequestBody(); + if (!body) return; + + socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); + socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => { + if (!task) return; + setCreatingTask(false); + setNewSubtaskName(''); + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + if (task.parent_task_id) { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: { + id: string; + complete_ratio: number; + completed_count: number; + total_tasks_count: number; + parent_task: string; + }) => { + if (!data.parent_task) data.parent_task = task.parent_task_id || ''; + dispatch(updateEnhancedKanbanTaskProgress({ + id: task.id || '', + complete_ratio: data.complete_ratio, + completed_count: data.completed_count, + total_tasks_count: data.total_tasks_count, + parent_task: data.parent_task, + })); + }); + } + }); + } catch (error) { + logger.error('Error adding task:', error); + setCreatingTask(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + setIsEnterKeyPressed(true); + handleAddSubtask(); + } + }; + + const handleInputBlur = () => { + if (!isEnterKeyPressed && newSubtaskName.length > 0) { + handleAddSubtask(); + } + setIsEnterKeyPressed(false); + }; + + const handleCancelNewCard = (e: React.FocusEvent) => { + if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) { + setNewSubtaskName(''); + setShowNewSubtaskCard(false); + } + }; + + return ( + + e.stopPropagation()} + onChange={e => setNewSubtaskName(e.target.value)} + onKeyDown={e => { + e.stopPropagation(); + if (e.key === 'Enter') { + setIsEnterKeyPressed(true); + handleAddSubtask(); + } + }} + onKeyUp={e => e.stopPropagation()} + onKeyPress={e => e.stopPropagation()} + onBlur={handleInputBlur} + placeholder={t('kanbanBoard.addSubTaskPlaceholder')} + className={`enhanced-kanban-create-subtask-input ${themeMode === 'dark' ? 'dark' : ''}`} + disabled={creatingTask} + autoFocus + /> + + ); +}; + +export default EnhancedKanbanCreateSubtaskCard; \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx index 37b9b535..e4f84142 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx @@ -20,7 +20,7 @@ import { ForkOutlined } from '@ant-design/icons'; import { Dayjs } from 'dayjs'; import dayjs from 'dayjs'; import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons'; -import { fetchBoardSubTasks } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { fetchBoardSubTasks, toggleTaskExpansion } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { Divider } from 'antd'; import { List } from 'antd'; import { Skeleton } from 'antd'; @@ -28,6 +28,7 @@ import { PlusOutlined } from '@ant-design/icons'; import BoardSubTaskCard from '@/pages/projects/projectView/board/board-section/board-sub-task-card/board-sub-task-card'; import BoardCreateSubtaskCard from '@/pages/projects/projectView/board/board-section/board-sub-task-card/board-create-sub-task-card'; import { useTranslation } from 'react-i18next'; +import EnhancedKanbanCreateSubtaskCard from './EnhancedKanbanCreateSubtaskCard'; interface EnhancedKanbanTaskCardProps { task: IProjectTask; @@ -58,7 +59,7 @@ const EnhancedKanbanTaskCard: React.FC = React.memo const [dueDate, setDueDate] = useState( task?.end_date ? dayjs(task?.end_date) : null ); - const [isSubTaskShow, setIsSubTaskShow] = useState(false); + const projectId = useAppSelector(state => state.projectReducer.projectId); const { attributes, @@ -115,13 +116,17 @@ const EnhancedKanbanTaskCard: React.FC = React.memo const handleSubTaskExpand = useCallback(() => { if (task && task.id && projectId) { - if (task.show_sub_tasks) { + // Check if subtasks are already loaded and we have subtask data + if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count > 0) { // If subtasks are already loaded, just toggle visibility - setIsSubTaskShow(prev => !prev); - } else { - // If subtasks need to be fetched, show the section first with loading state - setIsSubTaskShow(true); + dispatch(toggleTaskExpansion(task.id)); + } else if (task.sub_tasks_count > 0) { + // If we have a subtask count but no loaded subtasks, fetch them + dispatch(toggleTaskExpansion(task.id)); dispatch(fetchBoardSubTasks({ taskId: task.id, projectId })); + } else { + // If no subtasks exist, just toggle visibility (will show empty state) + dispatch(toggleTaskExpansion(task.id)); } } }, [task, projectId, dispatch]); @@ -199,13 +204,13 @@ const EnhancedKanbanTaskCard: React.FC = React.memo > {task.sub_tasks_count} - {isSubTaskShow ? : } + {task.show_sub_tasks ? : } - {isSubTaskShow && ( + {task.show_sub_tasks && ( @@ -215,13 +220,21 @@ const EnhancedKanbanTaskCard: React.FC = React.memo )} - {!task.sub_tasks_loading && task?.sub_tasks && - task?.sub_tasks.map((subtask: any) => ( + {!task.sub_tasks_loading && task?.sub_tasks && task.sub_tasks.length > 0 && + task.sub_tasks.map((subtask: any) => ( ))} + {!task.sub_tasks_loading && (!task?.sub_tasks || task.sub_tasks.length === 0) && task.sub_tasks_count === 0 && ( + +
+ {t('noSubtasks', 'No subtasks')} +
+
+ )} + {showNewSubtaskCard && ( - { try { - const state = getState() as { enhancedKanbanReducer: EnhancedKanbanState }; - const { enhancedKanbanReducer } = state; - - // Check if the task is already expanded (optional, can be enhanced later) - // const task = enhancedKanbanReducer.taskGroups.flatMap(group => group.tasks).find(t => t.id === taskId); - // if (task?.show_sub_tasks) { - // return []; - // } - - const selectedMembers = enhancedKanbanReducer.taskAssignees - .filter(member => member.selected) - .map(member => member.id) - .join(' '); - - const selectedLabels = enhancedKanbanReducer.labels - .filter(label => label.selected) - .map(label => label.id) - .join(' '); - - const config: ITaskListConfigV2 = { - id: projectId, - archived: enhancedKanbanReducer.archived, - group: enhancedKanbanReducer.groupBy, - field: enhancedKanbanReducer.fields.map(field => `${field.key} ${field.sort_order}`).join(','), - order: '', - search: enhancedKanbanReducer.search || '', - statuses: '', - members: selectedMembers, - projects: '', - isSubtasksInclude: false, - labels: selectedLabels, - priorities: enhancedKanbanReducer.priorities.join(' '), - parent_task: taskId, - }; - - const response = await tasksApiService.getTaskList(config); - return response.body; + // Use the dedicated subtasks API endpoint + const response = await subTasksApiService.getSubTasks(taskId); + return response.body || []; } catch (error) { logger.error('Fetch Enhanced Board Sub Tasks', error); if (error instanceof Error) { @@ -831,6 +798,18 @@ const enhancedKanbanSlice = createSlice({ } }, + // Enhanced Kanban task expansion toggle (for subtask expand/collapse) + toggleTaskExpansion: (state, action: PayloadAction) => { + const taskId = action.payload; + const result = findTaskInAllGroups(state.taskGroups, taskId); + + if (result) { + result.task.show_sub_tasks = !result.task.show_sub_tasks; + // Update cache + state.taskCache[taskId] = result.task; + } + }, + // Enhanced Kanban subtask update (for use in task drawer and socket events) updateEnhancedKanbanSubtask: ( state, @@ -906,10 +885,24 @@ const enhancedKanbanSlice = createSlice({ // Update performance metrics state.performanceMetrics = calculatePerformanceMetrics(action.payload); - // Update caches + // Update caches and initialize subtask properties action.payload.forEach(group => { state.groupCache[group.id] = group; group.tasks.forEach(task => { + // Initialize subtask-related properties if they don't exist + if (task.sub_tasks === undefined) { + task.sub_tasks = []; + } + if (task.sub_tasks_loading === undefined) { + task.sub_tasks_loading = false; + } + if (task.show_sub_tasks === undefined) { + task.show_sub_tasks = false; + } + if (task.sub_tasks_count === undefined) { + task.sub_tasks_count = 0; + } + state.taskCache[task.id!] = task; }); }); @@ -984,6 +977,46 @@ const enhancedKanbanSlice = createSlice({ .addCase(fetchEnhancedKanbanLabels.rejected, (state, action) => { state.loadingLabels = false; state.error = action.payload as string; + }) + // Fetch Board Sub Tasks + .addCase(fetchBoardSubTasks.pending, (state, action) => { + state.error = null; + // Find the task and set sub_tasks_loading to true + const taskId = action.meta.arg.taskId; + const result = findTaskInAllGroups(state.taskGroups, taskId); + if (result) { + result.task.sub_tasks_loading = true; + } + }) + .addCase(fetchBoardSubTasks.fulfilled, (state, action: PayloadAction) => { + const taskId = (action as any).meta?.arg?.taskId; + const result = findTaskInAllGroups(state.taskGroups, taskId); + + if (result) { + result.task.sub_tasks_loading = false; + result.task.sub_tasks = action.payload; + result.task.show_sub_tasks = true; + + // Only update the count if we don't have a count yet or if the API returned a different count + // This preserves the original count from the initial data load + if (!result.task.sub_tasks_count || result.task.sub_tasks_count === 0) { + result.task.sub_tasks_count = action.payload.length; + } + + // Update cache + state.taskCache[taskId] = result.task; + } + }) + .addCase(fetchBoardSubTasks.rejected, (state, action) => { + state.error = action.error.message || 'Failed to fetch sub tasks'; + // Set loading to false on rejection + const taskId = action.meta.arg.taskId; + const result = findTaskInAllGroups(state.taskGroups, taskId); + if (result) { + result.task.sub_tasks_loading = false; + // Update cache + state.taskCache[taskId] = result.task; + } }); }, }); @@ -1027,6 +1060,7 @@ export const { updateEnhancedKanbanTaskEndDate, updateEnhancedKanbanTaskStartDate, updateEnhancedKanbanSubtask, + toggleTaskExpansion, } = enhancedKanbanSlice.actions; export default enhancedKanbanSlice.reducer; \ No newline at end of file diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 3a0c0807..bdf9480b 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -42,6 +42,18 @@ import { selectCurrentGroupingV3, fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { + updateEnhancedKanbanSubtask, + addTaskToGroup as addEnhancedKanbanTaskToGroup, + updateEnhancedKanbanTaskStatus, + updateEnhancedKanbanTaskPriority, + updateEnhancedKanbanTaskAssignees, + updateEnhancedKanbanTaskLabels, + updateEnhancedKanbanTaskProgress, + updateEnhancedKanbanTaskName, + updateEnhancedKanbanTaskEndDate, + updateEnhancedKanbanTaskStartDate +} from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { selectCurrentGrouping } from '@/features/task-management/grouping.slice'; import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import { @@ -110,6 +122,9 @@ export const useTaskSocketHandlers = () => { manual_progress: false, } as IProjectTask) ); + + // Update enhanced kanban slice + dispatch(updateEnhancedKanbanTaskAssignees(data)); // Remove unnecessary refetch - real-time updates handle this // if (currentSession?.team_id && !loadingAssignees) { @@ -150,6 +165,9 @@ export const useTaskSocketHandlers = () => { // dispatch(fetchLabels()), // projectId && dispatch(fetchLabelsByProject(projectId)), ]); + + // Update enhanced kanban slice + dispatch(updateEnhancedKanbanTaskLabels(labels)); }, [dispatch, projectId] ); @@ -171,6 +189,9 @@ export const useTaskSocketHandlers = () => { // Update the old task slice (for backward compatibility) dispatch(updateTaskStatus(response)); dispatch(deselectAll()); + + // Update enhanced kanban slice + dispatch(updateEnhancedKanbanTaskStatus(response)); // For the task management slice, move task between groups without resetting const state = store.getState(); @@ -267,6 +288,15 @@ export const useTaskSocketHandlers = () => { } })); } + + // Update enhanced kanban slice + dispatch(updateEnhancedKanbanTaskProgress({ + id: data.id, + complete_ratio: data.complete_ratio, + completed_count: data.completed_count, + total_tasks_count: data.total_tasks_count, + parent_task: data.parent_task, + })); }, [dispatch] ); @@ -281,6 +311,9 @@ export const useTaskSocketHandlers = () => { dispatch(updateTaskPriority(response)); dispatch(setTaskPriority(response)); dispatch(deselectAll()); + + // Update enhanced kanban slice + dispatch(updateEnhancedKanbanTaskPriority(response)); // For the task management slice, always update the task entity first const state = store.getState(); @@ -371,6 +404,9 @@ export const useTaskSocketHandlers = () => { dispatch(updateTaskEndDate({ task: taskWithProgress })); dispatch(setTaskEndDate(taskWithProgress)); + + // Update enhanced kanban slice + dispatch(updateEnhancedKanbanTaskEndDate({ task: taskWithProgress })); }, [dispatch] ); @@ -392,6 +428,9 @@ export const useTaskSocketHandlers = () => { } })); } + + // Update enhanced kanban slice + dispatch(updateEnhancedKanbanTaskName({ task: data })); }, [dispatch] ); @@ -558,6 +597,13 @@ export const useTaskSocketHandlers = () => { if (data.parent_task_id) { // Handle subtask creation dispatch(updateSubTasks(data)); + + // Also update enhanced kanban slice for subtask creation + dispatch(updateEnhancedKanbanSubtask({ + sectionId: '', + subtask: data, + mode: 'add' + })); } else { // Handle regular task creation - transform to Task format and add const task = { @@ -613,6 +659,12 @@ export const useTaskSocketHandlers = () => { // Use addTaskToGroup with the actual group UUID dispatch(addTaskToGroup({ task, groupId })); + + // Also update enhanced kanban slice for regular task creation + dispatch(addEnhancedKanbanTaskToGroup({ + sectionId: groupId || '', + task: data + })); } }, [dispatch]