From cf5f5c1449d9ddb99b8b5d5fc0e562eccab2cc7c Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 2 Jul 2025 09:27:36 +0530 Subject: [PATCH 1/6] feat(task-row): integrate task timer functionality into task management - Added TaskTimer component to manage task time tracking. - Updated TaskTimeTracking to utilize the new timer functionality, enhancing user interaction with task timing. - Refactored props to pass task ID for timer management, improving state handling. --- .../components/task-management/task-row.tsx | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index b3fb9cd3..917afb25 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -26,6 +26,8 @@ import { SocketEvents } from '@/shared/socket-events'; import TaskStatusDropdown from './task-status-dropdown'; import TaskPriorityDropdown from './task-priority-dropdown'; import TaskPhaseDropdown from './task-phase-dropdown'; +import TaskTimer from '@/components/taskListCommon/task-timer/task-timer'; +import { useTaskTimer } from '@/hooks/useTaskTimer'; import { formatDate as utilFormatDate, formatDateTime as utilFormatDateTime, @@ -145,19 +147,20 @@ const TaskPriority = React.memo<{ priority: string; isDarkMode: boolean }>(({ pr ); }); -const TaskTimeTracking = React.memo<{ timeTracking?: { logged?: number | string }; isDarkMode: boolean }>(({ timeTracking, isDarkMode }) => { - if (!timeTracking?.logged || timeTracking.logged === 0) return null; - +const TaskTimeTracking = React.memo<{ taskId: string; isDarkMode: boolean }>(({ taskId, isDarkMode }) => { + const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer( + taskId, + null // The hook will get the timer start time from Redux + ); + return ( -
- - - {typeof timeTracking.logged === 'number' - ? `${timeTracking.logged}h` - : timeTracking.logged - } - -
+ ); }); @@ -1117,7 +1120,10 @@ const TaskRow: React.FC = React.memo(({ case 'timeTracking': return (
- +
); From c29ba6ea69abedd585d565c844e48fec8b0824ab Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 2 Jul 2025 09:34:02 +0530 Subject: [PATCH 2/6] refactor(task-row): clean up imports and remove unused components - Removed unused imports and components from task-row.tsx --- .../components/task-management/task-row.tsx | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 917afb25..3873f49f 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useState, useRef, useEffect, lazy } from 'react'; +import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useSelector } from 'react-redux'; @@ -12,20 +12,17 @@ import { HolderOutlined, MessageOutlined, PaperClipOutlined, - ClockCircleOutlined, UserOutlined, - type InputRef + type InputRef, + Tooltip } from './antd-imports'; -import { DownOutlined, RightOutlined, ExpandAltOutlined, DoubleRightOutlined, CheckCircleOutlined } from '@ant-design/icons'; +import { DownOutlined, RightOutlined, ExpandAltOutlined, CheckCircleOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; -import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tooltip } from '@/components'; +import { AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, Progress } from '@/components'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; -import TaskStatusDropdown from './task-status-dropdown'; -import TaskPriorityDropdown from './task-priority-dropdown'; -import TaskPhaseDropdown from './task-phase-dropdown'; import TaskTimer from '@/components/taskListCommon/task-timer/task-timer'; import { useTaskTimer } from '@/hooks/useTaskTimer'; import { @@ -34,8 +31,6 @@ import { createLabelsAdapter, createAssigneeAdapter, PRIORITY_COLORS as UTIL_PRIORITY_COLORS, - performanceMonitor, - taskPropsEqual } from './task-row-utils'; import './task-row-optimized.css'; import { useAppDispatch } from '@/hooks/useAppDispatch'; @@ -132,21 +127,6 @@ const TaskProgress = React.memo<{ progress: number; isDarkMode: boolean }>(({ pr /> )); -const TaskPriority = React.memo<{ priority: string; isDarkMode: boolean }>(({ priority, isDarkMode }) => { - const color = PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9'; - return ( -
-
- - {priority} - -
- ); -}); - const TaskTimeTracking = React.memo<{ taskId: string; isDarkMode: boolean }>(({ taskId, isDarkMode }) => { const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer( taskId, From 2064c0833cf69c6d8a098741ceb69c68aa6e32d4 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 2 Jul 2025 12:38:24 +0530 Subject: [PATCH 3/6] feat(task-management): enhance task details and subtask handling - Added subtask-related properties to the Task interface for better management of subtasks. - Implemented functionality to show and add subtasks directly within the task list, improving user interaction. - Updated task rendering logic to accommodate new subtask features, enhancing overall task management experience. - Removed unused components and optimized imports across various task management files for cleaner code. --- .../src/controllers/tasks-controller-v2.ts | 18 +- worklenz-frontend/src/api/api-client.ts | 3 - .../task-management/asana-style-lazy-demo.tsx | 265 -------- .../assignee-dropdown-content.tsx | 270 -------- .../task-management/bulk-action-bar.tsx | 592 ------------------ .../task-management/grouping-selector.tsx | 43 -- .../task-management/lazy-date-picker.tsx | 101 --- .../optimized-bulk-action-bar.tsx | 1 - .../components/task-management/task-group.tsx | 2 +- .../task-management/task-list-board.tsx | 4 +- .../task-management/task-row-example.tsx | 283 --------- .../task-management/task-row-optimized.css | 81 +++ .../components/task-management/task-row.tsx | 371 ++++------- .../virtualized-task-group.tsx | 163 ----- .../task-management/virtualized-task-list.tsx | 256 ++++++-- .../task-management/task-management.slice.ts | 3 - .../task-management/taskListFields.slice.ts | 1 - .../add-sub-task-list-row.tsx | 92 ++- .../task-list-table/task-list-table.tsx | 37 +- .../src/types/task-management.types.ts | 4 + 20 files changed, 521 insertions(+), 2069 deletions(-) delete mode 100644 worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx delete mode 100644 worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx delete mode 100644 worklenz-frontend/src/components/task-management/bulk-action-bar.tsx delete mode 100644 worklenz-frontend/src/components/task-management/grouping-selector.tsx delete mode 100644 worklenz-frontend/src/components/task-management/lazy-date-picker.tsx delete mode 100644 worklenz-frontend/src/components/task-management/task-row-example.tsx delete mode 100644 worklenz-frontend/src/components/task-management/virtualized-task-group.tsx diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index f5800db1..536ee723 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -997,11 +997,9 @@ export default class TasksControllerV2 extends TasksControllerBase { const shouldRefreshProgress = req.query.refresh_progress === "true"; if (shouldRefreshProgress && req.params.id) { - console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id}`); const progressStartTime = performance.now(); await this.refreshProjectTaskProgressValues(req.params.id); const progressEndTime = performance.now(); - console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`); } const queryStartTime = performance.now(); @@ -1011,13 +1009,11 @@ export default class TasksControllerV2 extends TasksControllerBase { const result = await db.query(q, params); const tasks = [...result.rows]; const queryEndTime = performance.now(); - console.log(`[PERFORMANCE] Database query completed in ${(queryEndTime - queryStartTime).toFixed(2)}ms for ${tasks.length} tasks`); // Get groups metadata dynamically from database const groupsStartTime = performance.now(); const groups = await this.getGroups(groupBy, req.params.id); const groupsEndTime = performance.now(); - console.log(`[PERFORMANCE] Groups fetched in ${(groupsEndTime - groupsStartTime).toFixed(2)}ms`); // Create priority value to name mapping const priorityMap: Record = { @@ -1094,10 +1090,17 @@ export default class TasksControllerV2 extends TasksControllerBase { originalPriorityId: task.priority, statusColor: task.status_color, priorityColor: task.priority_color, + // Add subtask count + sub_tasks_count: task.sub_tasks_count || 0, + // Add indicator fields for frontend icons + comments_count: task.comments_count || 0, + has_subscribers: !!task.has_subscribers, + attachments_count: task.attachments_count || 0, + has_dependencies: !!task.has_dependencies, + schedule_id: task.schedule_id || null, }; }); const transformEndTime = performance.now(); - console.log(`[PERFORMANCE] Task transformation completed in ${(transformEndTime - transformStartTime).toFixed(2)}ms`); // Create groups based on dynamic data from database const groupingStartTime = performance.now(); @@ -1164,11 +1167,9 @@ export default class TasksControllerV2 extends TasksControllerBase { .filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true")); const groupingEndTime = performance.now(); - console.log(`[PERFORMANCE] Task grouping completed in ${(groupingEndTime - groupingStartTime).toFixed(2)}ms`); const endTime = performance.now(); const totalTime = endTime - startTime; - console.log(`[PERFORMANCE] Total getTasksV3 request completed in ${totalTime.toFixed(2)}ms for project ${req.params.id}`); // Log warning if request is taking too long if (totalTime > 1000) { @@ -1235,9 +1236,8 @@ export default class TasksControllerV2 extends TasksControllerBase { projectId: req.params.id } })); - } else { - return res.status(400).send(new ServerResponse(false, null, "Project ID is required")); } + return res.status(400).send(new ServerResponse(false, null, "Project ID is required")); } catch (error) { console.error("Error refreshing task progress:", error); return res.status(500).send(new ServerResponse(false, null, "Failed to refresh task progress")); diff --git a/worklenz-frontend/src/api/api-client.ts b/worklenz-frontend/src/api/api-client.ts index 14a4b0ea..353ebdf9 100644 --- a/worklenz-frontend/src/api/api-client.ts +++ b/worklenz-frontend/src/api/api-client.ts @@ -64,11 +64,9 @@ apiClient.interceptors.request.use( // Ensure we have a CSRF token before making requests if (!csrfToken) { - console.log('[API CLIENT] No CSRF token, fetching...'); const tokenStart = performance.now(); await refreshCsrfToken(); const tokenEnd = performance.now(); - console.log(`[API CLIENT] CSRF token fetch took ${(tokenEnd - tokenStart).toFixed(2)}ms`); } if (csrfToken) { @@ -78,7 +76,6 @@ apiClient.interceptors.request.use( } const requestEnd = performance.now(); - console.log(`[API CLIENT] Request interceptor took ${(requestEnd - requestStart).toFixed(2)}ms`); return config; }, diff --git a/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx b/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx deleted file mode 100644 index b1777192..00000000 --- a/worklenz-frontend/src/components/task-management/asana-style-lazy-demo.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import React, { useState, useCallback, Suspense } from 'react'; -import { Card, Typography, Space, Button, Divider } from 'antd'; -import { - UserAddOutlined, - CalendarOutlined, - FlagOutlined, - TagOutlined, - LoadingOutlined -} from '@ant-design/icons'; - -const { Title, Text } = Typography; - -// Simulate heavy components that would normally load immediately -const HeavyAssigneeSelector = React.lazy(() => - new Promise<{ default: React.ComponentType }>((resolve) => - setTimeout(() => resolve({ - default: () => ( -
- 🚀 Heavy Assignee Selector Loaded! -
- This component contains: -
    -
  • Team member search logic
  • -
  • Avatar rendering
  • -
  • Permission checking
  • -
  • Socket connections
  • -
  • Optimistic updates
  • -
-
- ) - }), 1000) // Simulate 1s load time - ) -); - -const HeavyDatePicker = React.lazy(() => - new Promise<{ default: React.ComponentType }>((resolve) => - setTimeout(() => resolve({ - default: () => ( -
- 📅 Heavy Date Picker Loaded! -
- This component contains: -
    -
  • Calendar rendering logic
  • -
  • Date validation
  • -
  • Timezone handling
  • -
  • Locale support
  • -
  • Accessibility features
  • -
-
- ) - }), 800) // Simulate 0.8s load time - ) -); - -const HeavyPrioritySelector = React.lazy(() => - new Promise<{ default: React.ComponentType }>((resolve) => - setTimeout(() => resolve({ - default: () => ( -
- 🔥 Heavy Priority Selector Loaded! -
- This component contains: -
    -
  • Priority level logic
  • -
  • Color calculations
  • -
  • Business rules
  • -
  • Validation
  • -
  • State management
  • -
-
- ) - }), 600) // Simulate 0.6s load time - ) -); - -const HeavyLabelsSelector = React.lazy(() => - new Promise<{ default: React.ComponentType }>((resolve) => - setTimeout(() => resolve({ - default: () => ( -
- 🏷️ Heavy Labels Selector Loaded! -
- This component contains: -
    -
  • Label management
  • -
  • Color picker
  • -
  • Search functionality
  • -
  • CRUD operations
  • -
  • Drag & drop
  • -
-
- ) - }), 700) // Simulate 0.7s load time - ) -); - -// Lightweight placeholder buttons (what loads immediately) -const PlaceholderButton: React.FC<{ - icon: React.ReactNode; - label: string; - onClick: () => void; - loaded?: boolean; -}> = ({ icon, label, onClick, loaded = false }) => ( - -); - -const AsanaStyleLazyDemo: React.FC = () => { - const [loadedComponents, setLoadedComponents] = useState<{ - assignee: boolean; - date: boolean; - priority: boolean; - labels: boolean; - }>({ - assignee: false, - date: false, - priority: false, - labels: false, - }); - - const [showComponents, setShowComponents] = useState<{ - assignee: boolean; - date: boolean; - priority: boolean; - labels: boolean; - }>({ - assignee: false, - date: false, - priority: false, - labels: false, - }); - - const handleLoad = useCallback((component: keyof typeof loadedComponents) => { - setLoadedComponents(prev => ({ ...prev, [component]: true })); - setTimeout(() => { - setShowComponents(prev => ({ ...prev, [component]: true })); - }, 100); - }, []); - - const resetDemo = useCallback(() => { - setLoadedComponents({ - assignee: false, - date: false, - priority: false, - labels: false, - }); - setShowComponents({ - assignee: false, - date: false, - priority: false, - labels: false, - }); - }, []); - - return ( - - 🎯 Asana-Style Lazy Loading Demo - -
- Performance Benefits: -
    -
  • Faster Initial Load: Only lightweight placeholders load initially
  • -
  • Reduced Bundle Size: Heavy components split into separate chunks
  • -
  • Better UX: Instant visual feedback, components load on demand
  • -
  • Memory Efficient: Components only consume memory when needed
  • -
  • Network Optimized: Parallel loading of components as user interacts
  • -
-
- - - -
-
- Task Management Components (Click to Load): -
- } - label="Add Assignee" - onClick={() => handleLoad('assignee')} - loaded={loadedComponents.assignee && !showComponents.assignee} - /> - } - label="Set Date" - onClick={() => handleLoad('date')} - loaded={loadedComponents.date && !showComponents.date} - /> - } - label="Set Priority" - onClick={() => handleLoad('priority')} - loaded={loadedComponents.priority && !showComponents.priority} - /> - } - label="Add Labels" - onClick={() => handleLoad('labels')} - loaded={loadedComponents.labels && !showComponents.labels} - /> -
-
- -
- - - Components loaded: {Object.values(showComponents).filter(Boolean).length}/4 - -
- - - -
- {showComponents.assignee && ( - Loading assignee selector...
}> - - - )} - - {showComponents.date && ( - Loading date picker...
}> - - - )} - - {showComponents.priority && ( - Loading priority selector...
}> - - - )} - - {showComponents.labels && ( - Loading labels selector...}> - - - )} - - - - - -
- How it works: -
    -
  1. 1. Page loads instantly with lightweight placeholder buttons
  2. -
  3. 2. User clicks a button to interact with a feature
  4. -
  5. 3. Heavy component starts loading in the background
  6. -
  7. 4. Loading state shows immediate feedback
  8. -
  9. 5. Full component renders when ready
  10. -
  11. 6. Subsequent interactions are instant (component cached)
  12. -
-
- - ); -}; - -export default AsanaStyleLazyDemo; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx b/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx deleted file mode 100644 index d07107b7..00000000 --- a/worklenz-frontend/src/components/task-management/assignee-dropdown-content.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { UserAddOutlined } from '@ant-design/icons'; -import { RootState } from '@/app/store'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types'; -import { useSocket } from '@/socket/socketContext'; -import { SocketEvents } from '@/shared/socket-events'; -import { useAuthService } from '@/hooks/useAuth'; -import { Avatar, Button, Checkbox } from '@/components'; -import { sortTeamMembers } from '@/utils/sort-team-members'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; -import { ILocalSession } from '@/types/auth/session.types'; -import { Socket } from 'socket.io-client'; -import { DefaultEventsMap } from '@socket.io/component-emitter'; -import { ThunkDispatch } from '@reduxjs/toolkit'; -import { Dispatch } from 'redux'; - -interface AssigneeDropdownContentProps { - task: IProjectTask; - groupId?: string | null; - isDarkMode?: boolean; - projectId: string | null; - currentSession: ILocalSession | null; - socket: Socket | null; - dispatch: ThunkDispatch & Dispatch; - isOpen: boolean; - onClose: () => void; - position: { top: number; left: number }; -} - -const AssigneeDropdownContent: React.FC = ({ - task, - groupId = null, - isDarkMode = false, - projectId, - currentSession, - socket, - dispatch, - isOpen, - onClose, - position, -}) => { - const [searchQuery, setSearchQuery] = useState(''); - const [teamMembers, setTeamMembers] = useState({ data: [], total: 0 }); - const [optimisticAssignees, setOptimisticAssignees] = useState([]); - const [pendingChanges, setPendingChanges] = useState>(new Set()); - const dropdownRef = useRef(null); - const searchInputRef = useRef(null); - - const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers); - - const filteredMembers = useMemo(() => { - return teamMembers?.data?.filter(member => - member.name?.toLowerCase().includes(searchQuery.toLowerCase()) - ); - }, [teamMembers, searchQuery]); - - // Initialize team members data when component mounts - useEffect(() => { - if (isOpen) { - const assignees = task?.assignees?.map(assignee => assignee.team_member_id); - const membersData = (members?.data || []).map(member => ({ - ...member, - selected: assignees?.includes(member.id), - })); - const sortedMembers = sortTeamMembers(membersData); - setTeamMembers({ data: sortedMembers }); - - // Focus search input after opening - setTimeout(() => { - searchInputRef.current?.focus(); - }, 0); - } - }, [isOpen, members, task]); - - const handleMemberToggle = useCallback((memberId: string, checked: boolean) => { - if (!memberId || !projectId || !task?.id || !currentSession?.id) return; - - // Add to pending changes for visual feedback - setPendingChanges(prev => new Set(prev).add(memberId)); - - // OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback - const currentAssignees = task?.assignees?.map(a => a.team_member_id) || []; - let newAssigneeIds: string[]; - - if (checked) { - // Adding assignee - newAssigneeIds = [...currentAssignees, memberId]; - } else { - // Removing assignee - newAssigneeIds = currentAssignees.filter(id => id !== memberId); - } - - // Update optimistic state for immediate UI feedback in dropdown - setOptimisticAssignees(newAssigneeIds); - - // Update local team members state for dropdown UI - setTeamMembers(prev => ({ - ...prev, - data: (prev.data || []).map(member => - member.id === memberId - ? { ...member, selected: checked } - : member - ) - })); - - const body = { - team_member_id: memberId, - project_id: projectId, - task_id: task.id, - reporter_id: currentSession.id, - mode: checked ? 0 : 1, - parent_task: task.parent_task_id, - }; - - // Emit socket event - the socket handler will update Redux with proper types - socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); - - // Remove from pending changes after a short delay (optimistic) - setTimeout(() => { - setPendingChanges(prev => { - const newSet = new Set(prev); - newSet.delete(memberId); - return newSet; - }); - }, 500); // Remove pending state after 500ms - }, [task, projectId, currentSession, socket]); - - const checkMemberSelected = useCallback((memberId: string) => { - if (!memberId) return false; - // Use optimistic assignees if available, otherwise fall back to task assignees - const assignees = optimisticAssignees.length > 0 - ? optimisticAssignees - : task?.assignees?.map(assignee => assignee.team_member_id) || []; - return assignees.includes(memberId); - }, [optimisticAssignees, task]); - - const handleInviteProjectMemberDrawer = useCallback(() => { - onClose(); // Close the assignee dropdown first - dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer - }, [onClose, dispatch]); - - return ( -
- {/* Header */} -
- setSearchQuery(e.target.value)} - placeholder="Search members..." - className={` - w-full px-2 py-1 text-xs rounded border - ${isDarkMode - ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' - : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' - } - focus:outline-none focus:ring-1 focus:ring-blue-500 - `} - /> -
- - {/* Members List */} -
- {filteredMembers && filteredMembers.length > 0 ? ( - filteredMembers.map((member) => ( -
{ - if (!member.pending_invitation) { - const isSelected = checkMemberSelected(member.id || ''); - handleMemberToggle(member.id || '', !isSelected); - } - }} - > -
- handleMemberToggle(member.id || '', checked)} - disabled={member.pending_invitation || pendingChanges.has(member.id || '')} - isDarkMode={isDarkMode} - /> - {pendingChanges.has(member.id || '') && ( -
-
-
- )} -
- - - -
-
- {member.name} -
-
- {member.email} - {member.pending_invitation && ( - (Pending) - )} -
-
-
- )) - ) : ( -
-
- No members found -
-
- )} -
- - {/* Footer - Invite button */} -
- -
-
- ); -}; - -export default AssigneeDropdownContent; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx deleted file mode 100644 index 2359a77f..00000000 --- a/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx +++ /dev/null @@ -1,592 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { - Button, - Typography, - Dropdown, - Menu, - Popconfirm, - Tooltip, - Badge, - DeleteOutlined, - CloseOutlined, - MoreOutlined, - RetweetOutlined, - UserAddOutlined, - InboxOutlined, - TagsOutlined, - UsergroupAddOutlined, - type CheckboxChangeEvent, - type InputRef -} from './antd-imports'; -import { useTranslation } from 'react-i18next'; -import { IGroupBy, fetchTaskGroups } from '@/features/tasks/tasks.slice'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; -import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; -import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service'; -import { - evt_project_task_list_bulk_archive, - evt_project_task_list_bulk_assign_me, - evt_project_task_list_bulk_assign_members, - evt_project_task_list_bulk_change_phase, - evt_project_task_list_bulk_change_priority, - evt_project_task_list_bulk_change_status, - evt_project_task_list_bulk_delete, - evt_project_task_list_bulk_update_labels, -} from '@/shared/worklenz-analytics-events'; -import { - IBulkTasksLabelsRequest, - IBulkTasksPhaseChangeRequest, - 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 { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types'; -import { ITaskAssignee } from '@/types/tasks/task.types'; -import { createPortal } from 'react-dom'; -import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; -import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown'; -import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown'; -import { sortTeamMembers } from '@/utils/sort-team-members'; -import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; -import { useAuthService } from '@/hooks/useAuth'; -import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; -import alertService from '@/services/alerts/alertService'; -import logger from '@/utils/errorLogger'; - -const { Text } = Typography; - -interface BulkActionBarProps { - selectedTaskIds: string[]; - totalSelected: number; - currentGrouping: IGroupBy; - projectId: string; - onClearSelection?: () => void; -} - -const BulkActionBarContent: React.FC = ({ - selectedTaskIds, - totalSelected, - currentGrouping, - projectId, - onClearSelection, -}) => { - const dispatch = useAppDispatch(); - const { t } = useTranslation('tasks/task-table-bulk-actions'); - const { trackMixpanelEvent } = useMixpanelTracking(); - - // Add permission hooks - const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); - - // loading state - const [loading, setLoading] = useState(false); - const [updatingLabels, setUpdatingLabels] = useState(false); - const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false); - const [updatingAssignees, setUpdatingAssignees] = useState(false); - const [updatingArchive, setUpdatingArchive] = useState(false); - const [updatingDelete, setUpdatingDelete] = useState(false); - - // Selectors - const { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer); - const statusList = useAppSelector(state => state.taskStatusReducer.status); - const priorityList = useAppSelector(state => state.priorityReducer.priorities); - const phaseList = useAppSelector(state => state.phaseReducer.phaseList); - const labelsList = useAppSelector(state => state.taskLabelsReducer.labels); - const members = useAppSelector(state => state.teamMembersReducer.teamMembers); - const archived = useAppSelector(state => state.taskReducer.archived); - const themeMode = useAppSelector(state => state.themeReducer.mode); - - const labelsInputRef = useRef(null); - const [createLabelText, setCreateLabelText] = useState(''); - const [teamMembersSorted, setTeamMembersSorted] = useState({ - data: [], - total: 0, - }); - const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false); - const [showDrawer, setShowDrawer] = useState(false); - const [selectedLabels, setSelectedLabels] = useState([]); - - // Handlers - const handleChangeStatus = async (status: ITaskStatus) => { - if (!status.id || !projectId) return; - try { - setLoading(true); - - const body: IBulkTasksStatusChangeRequest = { - tasks: selectedTaskIds, - status_id: status.id, - }; - const res = await taskListBulkActionsApiService.changeStatus(body, projectId); - if (res.done) { - trackMixpanelEvent(evt_project_task_list_bulk_change_status); - dispatch(deselectAll()); - dispatch(fetchTaskGroups(projectId)); - onClearSelection?.(); - } - for (const it of selectedTaskIds) { - const canContinue = await checkTaskDependencyStatus(it, status.id); - if (!canContinue) { - if (selectedTaskIds.length > 1) { - alertService.warning( - 'Incomplete Dependencies!', - 'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.' - ); - } else { - alertService.error( - 'Task is not completed', - 'Please complete the task dependencies before proceeding' - ); - } - return; - } - } - } catch (error) { - logger.error('Error changing status:', error); - } finally { - setLoading(false); - } - }; - - const handleChangePriority = async (priority: ITaskPriority) => { - if (!priority.id || !projectId) return; - try { - setLoading(true); - const body: IBulkTasksPriorityChangeRequest = { - tasks: selectedTaskIds, - priority_id: priority.id, - }; - const res = await taskListBulkActionsApiService.changePriority(body, projectId); - if (res.done) { - trackMixpanelEvent(evt_project_task_list_bulk_change_priority); - dispatch(deselectAll()); - dispatch(fetchTaskGroups(projectId)); - onClearSelection?.(); - } - } catch (error) { - logger.error('Error changing priority:', error); - } finally { - setLoading(false); - } - }; - - const handleChangePhase = async (phase: ITaskPhase) => { - if (!phase.id || !projectId) return; - try { - setLoading(true); - const body: IBulkTasksPhaseChangeRequest = { - tasks: selectedTaskIds, - phase_id: phase.id, - }; - const res = await taskListBulkActionsApiService.changePhase(body, projectId); - if (res.done) { - trackMixpanelEvent(evt_project_task_list_bulk_change_phase); - dispatch(deselectAll()); - dispatch(fetchTaskGroups(projectId)); - onClearSelection?.(); - } - } catch (error) { - logger.error('Error changing phase:', error); - } finally { - setLoading(false); - } - }; - - const handleAssignToMe = async () => { - if (!projectId) return; - try { - setUpdatingAssignToMe(true); - const body = { - tasks: selectedTaskIds, - project_id: projectId, - }; - const res = await taskListBulkActionsApiService.assignToMe(body); - if (res.done) { - trackMixpanelEvent(evt_project_task_list_bulk_assign_me); - dispatch(deselectAll()); - dispatch(fetchTaskGroups(projectId)); - onClearSelection?.(); - } - } catch (error) { - logger.error('Error assigning to me:', error); - } finally { - setUpdatingAssignToMe(false); - } - }; - - const handleArchive = async () => { - if (!projectId) return; - try { - setUpdatingArchive(true); - const body = { - tasks: selectedTaskIds, - project_id: projectId, - }; - const res = await taskListBulkActionsApiService.archiveTasks(body, archived); - if (res.done) { - trackMixpanelEvent(evt_project_task_list_bulk_archive); - dispatch(deselectAll()); - dispatch(fetchTaskGroups(projectId)); - onClearSelection?.(); - } - } catch (error) { - logger.error('Error archiving tasks:', error); - } finally { - setUpdatingArchive(false); - } - }; - - const handleChangeAssignees = async (selectedAssignees: ITeamMemberViewModel[]) => { - if (!projectId) return; - try { - setUpdatingAssignees(true); - const body = { - tasks: selectedTaskIds, - project_id: projectId, - members: selectedAssignees.map(member => ({ - id: member.id, - name: member.name || member.email || 'Unknown', // Fix: Ensure name is always a string - email: member.email || '', - avatar_url: member.avatar_url, - team_member_id: member.id, - project_member_id: member.id, - })) as ITaskAssignee[], - }; - const res = await taskListBulkActionsApiService.assignTasks(body); - if (res.done) { - trackMixpanelEvent(evt_project_task_list_bulk_assign_members); - dispatch(deselectAll()); - dispatch(fetchTaskGroups(projectId)); - onClearSelection?.(); - } - } catch (error) { - logger.error('Error assigning tasks:', error); - } finally { - setUpdatingAssignees(false); - } - }; - - const handleDelete = async () => { - if (!projectId) return; - try { - setUpdatingDelete(true); - const body = { - tasks: selectedTaskIds, - project_id: projectId, - }; - const res = await taskListBulkActionsApiService.deleteTasks(body, projectId); - if (res.done) { - trackMixpanelEvent(evt_project_task_list_bulk_delete); - dispatch(deselectAll()); - dispatch(fetchTaskGroups(projectId)); - onClearSelection?.(); - } - } catch (error) { - logger.error('Error deleting tasks:', error); - } finally { - setUpdatingDelete(false); - } - }; - - // Menu Generators - const getChangeOptionsMenu = () => [ - { - key: '1', - label: t('status'), - children: statusList.map(status => ({ - key: status.id, - onClick: () => handleChangeStatus(status), - label: , - })), - }, - { - key: '2', - label: t('priority'), - children: priorityList.map(priority => ({ - key: priority.id, - onClick: () => handleChangePriority(priority), - label: , - })), - }, - { - key: '3', - label: t('phase'), - children: phaseList.map(phase => ({ - key: phase.id, - onClick: () => handleChangePhase(phase), - label: , - })), - }, - ]; - - useEffect(() => { - if (members?.data && assigneeDropdownOpen) { - let sortedMembers = sortTeamMembers(members.data); - setTeamMembersSorted({ data: sortedMembers, total: members.total }); - } - }, [assigneeDropdownOpen, members?.data]); - - const getAssigneesMenu = () => { - return ( - setAssigneeDropdownOpen(false)} - t={t} - /> - ); - }; - - const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => { - if (e.target.checked) { - setSelectedLabels(prev => [...prev, label]); - } else { - setSelectedLabels(prev => prev.filter(l => l.id !== label.id)); - } - }; - - const applyLabels = async () => { - if (!projectId) return; - try { - setUpdatingLabels(true); - const body: IBulkTasksLabelsRequest = { - tasks: selectedTaskIds, - labels: selectedLabels, - text: - selectedLabels.length > 0 - ? null - : createLabelText.trim() !== '' - ? createLabelText.trim() - : null, - }; - const res = await taskListBulkActionsApiService.assignLabels(body, projectId); - if (res.done) { - trackMixpanelEvent(evt_project_task_list_bulk_update_labels); - dispatch(deselectAll()); - dispatch(fetchTaskGroups(projectId)); - dispatch(fetchLabels()); // Fallback: refetch all labels - setCreateLabelText(''); - setSelectedLabels([]); - onClearSelection?.(); - } - } catch (error) { - logger.error('Error updating labels:', error); - } finally { - setUpdatingLabels(false); - } - }; - - const labelsDropdownContent = ( - } - onLabelChange={handleLabelChange} - onCreateLabelTextChange={value => setCreateLabelText(value)} - onApply={applyLabels} - t={t} - loading={updatingLabels} - /> - ); - - const onAssigneeDropdownOpenChange = (open: boolean) => { - setAssigneeDropdownOpen(open); - }; - - const buttonStyle = { - background: 'transparent', - color: '#fff', - border: 'none', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: '4px 8px', - height: '32px', - fontSize: '16px', - }; - - return ( -
- - {totalSelected} task{totalSelected > 1 ? 's' : ''} selected - - - {/* Status/Priority/Phase Change */} - - -
- ); -}; - -const BulkActionBar: React.FC = (props) => { - // Render the bulk action bar through a portal to avoid suspense issues - return createPortal( - , - document.body, - 'bulk-action-bar' - ); -}; - -export default BulkActionBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/grouping-selector.tsx b/worklenz-frontend/src/components/task-management/grouping-selector.tsx deleted file mode 100644 index ffbc63b1..00000000 --- a/worklenz-frontend/src/components/task-management/grouping-selector.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react'; -import { Select, Typography } from 'antd'; -import { IGroupBy } from '@/features/tasks/tasks.slice'; -import { IGroupByOption } from '@/types/tasks/taskList.types'; - -const { Text } = Typography; -const { Option } = Select; - -interface GroupingSelectorProps { - currentGrouping: IGroupBy; - onChange: (groupBy: IGroupBy) => void; - options: IGroupByOption[]; - disabled?: boolean; -} - -const GroupingSelector: React.FC = ({ - currentGrouping, - onChange, - options, - disabled = false, -}) => { - return ( -
- Group by: - -
- ); -}; - -export default GroupingSelector; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/lazy-date-picker.tsx b/worklenz-frontend/src/components/task-management/lazy-date-picker.tsx deleted file mode 100644 index aae31c16..00000000 --- a/worklenz-frontend/src/components/task-management/lazy-date-picker.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React, { useState, useCallback, Suspense } from 'react'; -import { CalendarOutlined } from '@ant-design/icons'; -import { formatDate } from '@/utils/date-time'; - -// Lazy load the DatePicker component only when needed -const LazyDatePicker = React.lazy(() => - import('antd/es/date-picker').then(module => ({ default: module.default })) -); - -interface LazyDatePickerProps { - value?: string | null; - onChange?: (date: string | null) => void; - placeholder?: string; - isDarkMode?: boolean; - className?: string; -} - -// Lightweight loading placeholder -const DateLoadingPlaceholder: React.FC<{ isDarkMode: boolean; value?: string | null; placeholder?: string }> = ({ - isDarkMode, - value, - placeholder -}) => ( -
- - {value ? formatDate(value) : (placeholder || 'Select date')} -
-); - -const LazyDatePickerWrapper: React.FC = ({ - value, - onChange, - placeholder = 'Select date', - isDarkMode = false, - className = '' -}) => { - const [hasLoadedOnce, setHasLoadedOnce] = useState(false); - - const handleInteraction = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - if (!hasLoadedOnce) { - setHasLoadedOnce(true); - } - }, [hasLoadedOnce]); - - // If not loaded yet, show a simple placeholder - if (!hasLoadedOnce) { - return ( -
- - {value ? formatDate(value) : placeholder} -
- ); - } - - // Once loaded, show the full DatePicker - return ( - - } - > - onChange?.(date ? date.toISOString() : null)} - placeholder={placeholder} - className={className} - size="small" - /> - - ); -}; - -export default LazyDatePickerWrapper; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx index 8320c6a2..ea008987 100644 --- a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx +++ b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx @@ -540,7 +540,6 @@ OptimizedBulkActionBarContent.displayName = 'OptimizedBulkActionBarContent'; // Portal wrapper for performance isolation const OptimizedBulkActionBar: React.FC = React.memo((props) => { - console.log('BulkActionBar totalSelected:', props.totalSelected, typeof props.totalSelected); if (!props.totalSelected || Number(props.totalSelected) < 1) { return null; } diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index ea0ea3df..a6fce7f9 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -409,7 +409,7 @@ const TaskGroup: React.FC = React.memo(({ .task-group-header-text { color: white !important; - font-size: 13px !important; + font-size: 14px !important; font-weight: 600 !important; margin: 0 !important; } 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 0a1809f4..2c759513 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -221,7 +221,6 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Memoized calculations - optimized const totalTasks = useMemo(() => { const total = taskGroups.reduce((sum, g) => sum + g.taskIds.length, 0); - console.log(`[TASK-LIST-BOARD] Total tasks in groups: ${total}, Total tasks in store: ${tasks.length}, Groups: ${taskGroups.length}`); return total; }, [taskGroups, tasks.length]); @@ -815,7 +814,6 @@ const TaskListBoard: React.FC = ({ projectId, className = '' min-height: 400px; max-height: calc(100vh - 120px); position: relative; - border: 1px solid var(--task-border-primary, #e8e8e8); border-radius: 8px; background: var(--task-bg-primary, white); overflow: hidden; @@ -939,7 +937,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' .task-group-header-text { color: white !important; - font-size: 13px !important; + font-size: 14px !important; font-weight: 600 !important; margin: 0 !important; } diff --git a/worklenz-frontend/src/components/task-management/task-row-example.tsx b/worklenz-frontend/src/components/task-management/task-row-example.tsx deleted file mode 100644 index 9d671fa8..00000000 --- a/worklenz-frontend/src/components/task-management/task-row-example.tsx +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Example: Task Row Component using Centralized Ant Design Imports - * - * This file demonstrates how to migrate from direct antd imports to the centralized import system. - * - * BEFORE (Direct imports): - * import { Input, Typography, DatePicker } from 'antd'; - * import type { InputRef } from 'antd'; - * - * AFTER (Centralized imports): - * import { Input, Typography, DatePicker, type InputRef, dayjs, taskManagementAntdConfig } from './antd-imports'; - */ - -import React, { useState, useCallback, useMemo } from 'react'; -import { - Input, - Typography, - DatePicker, - Button, - Select, - Tooltip, - Badge, - Space, - Checkbox, - UserOutlined, - CalendarOutlined, - ClockCircleOutlined, - EditOutlined, - MoreOutlined, - dayjs, - taskManagementAntdConfig, - taskMessage, - type InputRef, - type DatePickerProps, - type Dayjs -} from './antd-imports'; - -// Your existing task type import -import { Task } from '@/types/task-management.types'; - -interface TaskRowExampleProps { - task: Task; - projectId: string; - isDarkMode?: boolean; - onTaskUpdate?: (taskId: string, updates: Partial) => void; -} - -const TaskRowExample: React.FC = ({ - task, - projectId, - isDarkMode = false, - onTaskUpdate -}) => { - const [isEditing, setIsEditing] = useState(false); - const [editingField, setEditingField] = useState(null); - - // Use centralized config for consistent DatePicker props - const datePickerProps = useMemo(() => ({ - ...taskManagementAntdConfig.datePickerDefaults, - className: "w-full bg-transparent border-none shadow-none" - }), []); - - // Use centralized config for consistent Button props - const buttonProps = useMemo(() => ({ - ...taskManagementAntdConfig.taskButtonDefaults, - icon: - }), []); - - // Handle date changes with centralized message system - const handleDateChange = useCallback((date: Dayjs | null, field: 'startDate' | 'dueDate') => { - if (onTaskUpdate) { - onTaskUpdate(task.id, { - [field]: date?.toISOString() || null - }); - taskMessage.success(`${field === 'startDate' ? 'Start' : 'Due'} date updated`); - } - }, [task.id, onTaskUpdate]); - - // Handle task title edit - const handleTitleEdit = useCallback((newTitle: string) => { - if (onTaskUpdate && newTitle.trim() !== task.title) { - onTaskUpdate(task.id, { title: newTitle.trim() }); - taskMessage.success('Task title updated'); - } - setIsEditing(false); - }, [task.id, task.title, onTaskUpdate]); - - // Memoized date values for performance - const startDateValue = useMemo(() => - task.startDate ? dayjs(task.startDate) : undefined, - [task.startDate] - ); - - const dueDateValue = useMemo(() => - task.dueDate ? dayjs(task.dueDate) : undefined, - [task.dueDate] - ); - - return ( -
-
- - {/* Task Selection Checkbox */} -
- { - // Handle selection logic here - console.log('Task selected:', e.target.checked); - }} - /> -
- - {/* Task Title */} -
- {isEditing ? ( - handleTitleEdit(e.currentTarget.value)} - onBlur={(e) => handleTitleEdit(e.currentTarget.value)} - /> - ) : ( - - setIsEditing(true)} - > - {task.title} - -
- - {/* Task Progress */} -
- -
- - {/* Task Assignees */} -
- - - - {task.assignee_names?.join(', ') || 'Unassigned'} - - -
- - {/* Start Date */} -
- - handleDateChange(date, 'startDate')} - placeholder="Start Date" - /> - -
- - {/* Due Date */} -
- - handleDateChange(date, 'dueDate')} - placeholder="Due Date" - disabledDate={(current) => - startDateValue ? current.isBefore(startDateValue, 'day') : false - } - /> - -
- - {/* Task Status */} -
- { - if (onTaskUpdate) { - onTaskUpdate(task.id, { priority: value }); - taskMessage.success('Priority updated'); - } - }} - options={[ - { label: 'Low', value: 'low' }, - { label: 'Medium', value: 'medium' }, - { label: 'High', value: 'high' }, - { label: 'Critical', value: 'critical' }, - ]} - /> -
- - {/* Time Tracking */} -
- - - - {task.timeTracking?.logged ? `${task.timeTracking.logged}h` : '0h'} - - -
- - {/* Actions */} -
-
- -
-
- ); -}; - -export default TaskRowExample; - -/** - * Migration Guide: - * - * 1. Replace direct antd imports with centralized imports: - * - Change: import { DatePicker } from 'antd'; - * - To: import { DatePicker } from './antd-imports'; - * - * 2. Use centralized configurations: - * - Apply taskManagementAntdConfig.datePickerDefaults to all DatePickers - * - Use taskMessage instead of direct message calls - * - Apply consistent styling with taskManagementTheme - * - * 3. Benefits: - * - Better tree-shaking (smaller bundle size) - * - Consistent component props across all task management components - * - Centralized theme management - * - Type safety with proper TypeScript types - * - Easy maintenance and updates - * - * 4. Performance optimizations included: - * - Memoized date values to prevent unnecessary dayjs parsing - * - Centralized configurations to prevent prop recreation - * - Optimized message utilities - */ \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-row-optimized.css b/worklenz-frontend/src/components/task-management/task-row-optimized.css index a5a4d5fb..abfaedbf 100644 --- a/worklenz-frontend/src/components/task-management/task-row-optimized.css +++ b/worklenz-frontend/src/components/task-management/task-row-optimized.css @@ -9,6 +9,87 @@ border-color: var(--task-border-primary, #e8e8e8); } +/* Horizontal Scrolling Optimizations */ +.task-table-fixed-columns { + flex-shrink: 0; + position: sticky; + left: 0; + z-index: 10; + background: var(--task-bg-primary, #fff); + border-right: 2px solid var(--task-border-primary, #e8e8e8); + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1); +} + +.task-table-scrollable-container { + flex: 1; + overflow: hidden; + min-width: 0; /* Allow flex item to shrink below content size */ +} + +.task-table-scrollable-columns { + display: flex; + width: max-content; /* Allow content to determine width */ + min-width: 100%; /* Ensure it takes at least full width */ +} + +/* Dark mode support for horizontal scrolling */ +.dark .task-table-fixed-columns { + background: var(--task-bg-primary, #1f1f1f); + border-right-color: var(--task-border-primary, #303030); + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.3); +} + +/* + * HORIZONTAL SCROLLING SETUP + * + * For proper horizontal scrolling, the parent container should have: + * - overflow-x: auto + * - width: 100% (or specific width) + * - min-width: fit-content (optional, for very wide content) + * + * Example parent container CSS: + * .task-list-container { + * overflow-x: auto; + * width: 100%; + * min-width: fit-content; + * } + */ + +/* Ensure task row works with horizontal scrolling containers */ +.task-row-optimized { + min-width: fit-content; + width: 100%; +} + +/* Container styles for horizontal scrolling */ +.task-row-container { + display: flex; + width: 100%; + min-width: fit-content; + overflow-x: auto; + overflow-y: hidden; +} + +/* All columns container - no fixed positioning */ +.task-table-all-columns { + display: flex; + min-width: fit-content; + width: 100%; +} + +/* Ensure columns maintain their widths */ +.task-table-all-columns > div { + flex-shrink: 0; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .task-row-container { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + /* SIMPLIFIED HOVER STATE: Remove complex containment and transforms */ .task-row-optimized:hover { /* Remove transform that was causing GPU conflicts */ diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 3873f49f..635f4675 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -16,7 +16,7 @@ import { type InputRef, Tooltip } from './antd-imports'; -import { DownOutlined, RightOutlined, ExpandAltOutlined, CheckCircleOutlined } from '@ant-design/icons'; +import { DownOutlined, RightOutlined, ExpandAltOutlined, CheckCircleOutlined, MinusCircleOutlined, EyeOutlined, RetweetOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; @@ -50,6 +50,7 @@ interface TaskRowProps { columns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>; fixedColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>; scrollableColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>; + onExpandSubtaskInput?: (taskId: string) => void; } // Priority and status colors - moved outside component to avoid recreation @@ -352,6 +353,7 @@ const TaskRow: React.FC = React.memo(({ columns, fixedColumns, scrollableColumns, + onExpandSubtaskInput, }) => { // PERFORMANCE OPTIMIZATION: Frame-rate aware loading const canRenderComplex = useFrameRateOptimizedLoading(index); @@ -494,23 +496,6 @@ const TaskRow: React.FC = React.memo(({ }; }, [editTaskName, shouldRenderFull, handleTaskNameSave]); - // Handle adding new subtask - const handleAddSubtask = useCallback(() => { - const subtaskName = newSubtaskName?.trim(); - if (subtaskName && connected) { - socket?.emit( - SocketEvents.TASK_NAME_CHANGE.toString(), // Using existing event for now - JSON.stringify({ - name: subtaskName, - parent_task_id: task.id, - project_id: projectId, - }) - ); - setNewSubtaskName(''); - setShowAddSubtask(false); - } - }, [newSubtaskName, connected, socket, task.id, projectId]); - // Handle canceling add subtask const handleCancelAddSubtask = useCallback(() => { setNewSubtaskName(''); @@ -541,9 +526,55 @@ const TaskRow: React.FC = React.memo(({ onToggleSubtasks?.(task.id); }, [onToggleSubtasks, task.id]); + // Handle successful subtask creation + const handleSubtaskCreated = useCallback((newTask: any) => { + if (newTask && newTask.id) { + // Update parent task progress + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); + + // Clear form and hide add subtask row + setNewSubtaskName(''); + setShowAddSubtask(false); + + // The global socket handler will automatically add the subtask to the parent task + // and update the UI through Redux + + // After creating the first subtask, the task now has subtasks + // so we should expand it to show the new subtask + if (task.sub_tasks_count === 0 || !task.sub_tasks_count) { + // Trigger expansion to show the newly created subtask + setTimeout(() => { + onToggleSubtasks?.(task.id); + }, 100); + } + } + }, [socket, task.id, task.sub_tasks_count, onToggleSubtasks]); + + // Handle adding new subtask + const handleAddSubtask = useCallback(() => { + const subtaskName = newSubtaskName?.trim(); + if (subtaskName && connected && projectId) { + // Get current session for reporter_id and team_id + const currentSession = JSON.parse(localStorage.getItem('session') || '{}'); + + const requestBody = { + project_id: projectId, + name: subtaskName, + reporter_id: currentSession.id, + team_id: currentSession.team_id, + parent_task_id: task.id, + }; + + socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody)); + + // Handle the response + socket?.once(SocketEvents.QUICK_TASK.toString(), handleSubtaskCreated); + } + }, [newSubtaskName, connected, socket, task.id, projectId, handleSubtaskCreated]); + // Handle expand/collapse or add subtask const handleExpandClick = useCallback(() => { - // For now, just toggle add subtask row for all tasks + // Always show add subtask row when clicking expand icon setShowAddSubtask(!showAddSubtask); if (!showAddSubtask) { // Focus the input after state update @@ -655,13 +686,22 @@ const TaskRow: React.FC = React.memo(({
{/* Always reserve space for expand icon */}
-
+
{task.title} + {(task as any).sub_tasks_count > 0 && ( +
+ {(task as any).sub_tasks_count} + {'»'} +
+ )}
@@ -707,12 +747,9 @@ const TaskRow: React.FC = React.memo(({ } // Full rendering logic (existing code) - // Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns - // If this is a scrollable column, use the normal logic - const isActuallyLast = isFixed - ? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0)) - : (index === totalColumns - 1); - const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + // Simplified border logic - no fixed columns + const isLast = index === totalColumns - 1; + const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; switch (col.key) { case 'drag': @@ -767,7 +804,7 @@ const TaskRow: React.FC = React.memo(({ onClick={(e) => { e.preventDefault(); e.stopPropagation(); - handleExpandClick(); + if (onExpandSubtaskInput) onExpandSubtaskInput(task.id); }} className={`expand-toggle-btn w-4 h-4 flex items-center justify-center border-none rounded text-xs cursor-pointer transition-all duration-200 ${ isDarkMode @@ -777,12 +814,12 @@ const TaskRow: React.FC = React.memo(({ style={{ backgroundColor: 'transparent' }} title="Add subtask" > - {showAddSubtask ? : } +
{/* Task name and input */} -
+
{editTaskName ? ( = React.memo(({ autoFocus /> ) : ( - setEditTaskName(true)} - className={styleClasses.taskName} - style={{ cursor: 'pointer' }} - > - {task.title} - + <> + setEditTaskName(true)} + className={styleClasses.taskName} + style={{ cursor: 'pointer' }} + > + {task.title} + + {(task as any).sub_tasks_count > 0 && ( +
+ {(task as any).sub_tasks_count} + {'»'} +
+ )} + )}
{/* Indicators section */} {!editTaskName && ( -
- {/* Subtasks count */} - {(task as any).subtasks_count && (task as any).subtasks_count > 0 && ( - -
{ - e.preventDefault(); - e.stopPropagation(); - handleToggleSubtasks?.(); - }} - > - {(task as any).subtasks_count} - -
-
- )} - +
{/* Comments indicator */} - {(task as any).comments_count && (task as any).comments_count > 0 && ( - -
- - {(task as any).comments_count} -
+ {(task as any).comments_count > 0 && ( + + )} - {/* Attachments indicator */} - {(task as any).attachments_count && (task as any).attachments_count > 0 && ( - -
- - {(task as any).attachments_count} -
+ {(task as any).attachments_count > 0 && ( + + + + )} + {/* Dependencies indicator */} + {(task as any).has_dependencies && ( + + + + )} + {/* Subscribers indicator */} + {(task as any).has_subscribers && ( + + + + )} + {/* Recurring indicator */} + {(task as any).schedule_id && ( + + )}
@@ -1191,7 +1214,7 @@ const TaskRow: React.FC = React.memo(({ }, [ shouldRenderFull, renderMinimalColumn, isDarkMode, task, isSelected, editTaskName, taskName, adapters, groupId, projectId, attributes, listeners, handleSelectChange, handleTaskNameSave, handleDateChange, - dateValues, styleClasses + dateValues, styleClasses, onExpandSubtaskInput ]); // Apply global cursor style when dragging @@ -1269,154 +1292,32 @@ const TaskRow: React.FC = React.memo(({ data-task-id={task.id} data-group-id={groupId} > -
- {/* Fixed Columns */} - {fixedColumns && fixedColumns.length > 0 && ( -
sum + col.width, 0), - position: 'sticky', - left: 0, - zIndex: 10, - background: isDarkMode ? 'var(--task-bg-primary, #1f1f1f)' : 'var(--task-bg-primary, white)', - borderRight: scrollableColumns && scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none', - boxShadow: scrollableColumns && scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none', - }} - > - {fixedColumns.map((col, index) => - shouldRenderMinimal - ? renderMinimalColumn(col, true, index, fixedColumns.length) - : renderColumn(col, true, index, fixedColumns.length) - )} -
- )} - - {/* Scrollable Columns */} - {scrollableColumns && scrollableColumns.length > 0 && ( -
sum + col.width, 0) - }} - > - {scrollableColumns.map((col, index) => - shouldRenderMinimal - ? renderMinimalColumn(col, false, index, scrollableColumns.length) - : renderColumn(col, false, index, scrollableColumns.length) - )} -
- )} -
- - {/* Add Subtask Row */} - {showAddSubtask && ( -
-
- {/* Fixed Columns for Add Subtask */} - {fixedColumns && fixedColumns.length > 0 && ( -
sum + col.width, 0), - position: 'sticky', - left: 0, - zIndex: 10, - background: isDarkMode ? 'var(--task-bg-primary, #1f1f1f)' : 'var(--task-bg-primary, white)', - borderRight: scrollableColumns && scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none', - boxShadow: scrollableColumns && scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none', - }} - > - {fixedColumns.map((col, index) => { - // Fix border logic for add subtask row: fixed columns should have right border if scrollable columns exist - const isActuallyLast = index === fixedColumns.length - 1 && (!scrollableColumns || scrollableColumns.length === 0); - const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; - - if (col.key === 'task') { - return ( -
-
- setNewSubtaskName(e.target.value)} - onPressEnter={handleAddSubtask} - onBlur={handleCancelAddSubtask} - className={`add-subtask-input flex-1 ${ - isDarkMode - ? 'bg-gray-700 border-gray-600 text-gray-200' - : 'bg-white border-gray-300 text-gray-900' - }`} - size="small" - autoFocus - /> -
- - -
-
-
- ); - } else { - return ( -
- ); - } - })} -
- )} - - {/* Scrollable Columns for Add Subtask */} - {scrollableColumns && scrollableColumns.length > 0 && ( -
sum + col.width, 0) - }} - > - {scrollableColumns.map((col, index) => { - const isLast = index === scrollableColumns.length - 1; - const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; - - return ( -
- ); - })} -
- )} -
+
+ {/* All Columns - No Fixed Positioning */} +
+ {/* Fixed Columns (now scrollable) */} + {(fixedColumns ?? []).length > 0 && ( + <> + {(fixedColumns ?? []).map((col, index) => + shouldRenderMinimal + ? renderMinimalColumn(col, false, index, (fixedColumns ?? []).length) + : renderColumn(col, false, index, (fixedColumns ?? []).length) + )} + + )} + + {/* Scrollable Columns */} + {(scrollableColumns ?? []).length > 0 && ( + <> + {(scrollableColumns ?? []).map((col, index) => + shouldRenderMinimal + ? renderMinimalColumn(col, false, index, (scrollableColumns ?? []).length) + : renderColumn(col, false, index, (scrollableColumns ?? []).length) + )} + + )}
- )} +
); }, (prevProps, nextProps) => { diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx deleted file mode 100644 index 8bf58b8e..00000000 --- a/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { useMemo, useCallback } from 'react'; -import { FixedSizeList as List } from 'react-window'; -import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { useSelector } from 'react-redux'; -import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; -import { Task } from '@/types/task-management.types'; -import TaskRow from './task-row'; - -interface VirtualizedTaskGroupProps { - group: any; - projectId: string; - currentGrouping: 'status' | 'priority' | 'phase'; - selectedTaskIds: string[]; - onSelectTask: (taskId: string, selected: boolean) => void; - onToggleSubtasks: (taskId: string) => void; - height: number; - width: number; -} - -const VirtualizedTaskGroup: React.FC = React.memo(({ - group, - projectId, - currentGrouping, - selectedTaskIds, - onSelectTask, - onToggleSubtasks, - height, - width -}) => { - const allTasks = useSelector(taskManagementSelectors.selectAll); - - // Get tasks for this group using memoization for performance - const groupTasks = useMemo(() => { - return group.taskIds - .map((taskId: string) => allTasks.find((task: Task) => task.id === taskId)) - .filter((task: Task | undefined): task is Task => task !== undefined); - }, [group.taskIds, allTasks]); - - const TASK_ROW_HEIGHT = 40; - const GROUP_HEADER_HEIGHT = 40; - const COLUMN_HEADER_HEIGHT = 40; - const ADD_TASK_ROW_HEIGHT = 40; - - // Calculate total height for the group - const totalHeight = GROUP_HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT) + ADD_TASK_ROW_HEIGHT; - - // Row renderer for virtualization - const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => { - // Header row - if (index === 0) { - return ( -
-
-
-
- - {group.title} ({groupTasks.length}) - -
-
-
-
- ); - } - - // Column headers row - if (index === 1) { - return ( -
-
-
-
-
-
-
- Key -
-
- Task -
-
-
-
- Progress -
-
- Status -
-
- Members -
-
- Labels -
-
- Priority -
-
- Time Tracking -
-
-
-
-
- ); - } - - // Task rows - const taskIndex = index - 2; - if (taskIndex >= 0 && taskIndex < groupTasks.length) { - const task = groupTasks[taskIndex]; - return ( -
- -
- ); - } - - // Add task row (last row) - if (taskIndex === groupTasks.length) { - return ( -
-
-
-
- + Add task -
-
-
-
- ); - } - - return null; - }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]); - - return ( -
- - - {Row} - - -
- ); -}); - -export default VirtualizedTaskGroup; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx index 7e113ebd..34e3fb75 100644 --- a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -3,7 +3,7 @@ import { FixedSizeList as List } from 'react-window'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSelector, useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import { Empty, Button } from 'antd'; +import { Empty, Button, Input } from 'antd'; import { RightOutlined, DownOutlined } from '@ant-design/icons'; import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice'; @@ -13,6 +13,8 @@ import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-tabl import { RootState } from '@/app/store'; import { TaskListField } from '@/features/task-management/taskListFields.slice'; import { Checkbox } from '@/components'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; interface VirtualizedTaskListProps { group: any; @@ -61,6 +63,35 @@ const VirtualizedTaskList: React.FC = React.memo(({ const RENDER_BATCH_SIZE = 5; // Render max 5 tasks per frame const FRAME_BUDGET_MS = 8; + const [showAddSubtaskForTaskId, setShowAddSubtaskForTaskId] = React.useState(null); + const [newSubtaskName, setNewSubtaskName] = React.useState(''); + const addSubtaskInputRef = React.useRef(null); + + const { socket, connected } = useSocket(); + + const handleAddSubtask = (parentTaskId: string) => { + if (!newSubtaskName.trim() || !connected || !socket) return; + const currentSession = JSON.parse(localStorage.getItem('session') || '{}'); + const requestBody = { + project_id: group.project_id || group.projectId || projectId, + name: newSubtaskName.trim(), + reporter_id: currentSession.id, + team_id: currentSession.team_id, + parent_task_id: parentTaskId, + }; + socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody)); + // Listen for the response and clear input/collapse row + socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => { + setNewSubtaskName(''); + setShowAddSubtaskForTaskId(null); + // Optionally: trigger a refresh or update tasks in parent + }); + }; + const handleCancelAddSubtask = () => { + setNewSubtaskName(''); + setShowAddSubtaskForTaskId(null); + }; + // Handle collapse/expand toggle const handleToggleCollapse = useCallback(() => { dispatch(toggleGroupCollapsed(group.id)); @@ -297,39 +328,15 @@ const VirtualizedTaskList: React.FC = React.memo(({ return 20; // Very large lists: 20 items overscan for smooth scrolling }, [groupTasks.length]); - // PERFORMANCE OPTIMIZATION: Memoize row renderer with better dependency management - const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => { - const task: Task | undefined = groupTasks[index]; - if (!task) return null; - - // PERFORMANCE OPTIMIZATION: Pre-calculate selection state - const isSelected = selectedTaskIds.includes(task.id); - - return ( -
- -
- ); - }, [group.id, group.color, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks, fixedColumns, scrollableColumns]); + // Build displayRows array + const displayRows = []; + for (let i = 0; i < groupTasks.length; i++) { + const task = groupTasks[i]; + displayRows.push({ type: 'task', task }); + if (showAddSubtaskForTaskId === task.id) { + displayRows.push({ type: 'add-subtask', parentTask: task }); + } + } const scrollContainerRef = useRef(null); const headerScrollRef = useRef(null); @@ -548,53 +555,170 @@ const VirtualizedTaskList: React.FC = React.memo(({ {shouldVirtualize ? ( {}} - onScroll={() => {}} > - {Row} + {({ index, style }) => { + const row = displayRows[index]; + if (row.type === 'task') { + return ( +
+ setShowAddSubtaskForTaskId(row.task.id)} + /> +
+ ); + } + if (row.type === 'add-subtask') { + return ( +
+
+
+ {(fixedColumns ?? []).map((col, index) => { + const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + if (col.key === 'task') { + return ( +
+
+ setNewSubtaskName(e.target.value)} + onPressEnter={() => handleAddSubtask(row.parentTask.id)} + onBlur={handleCancelAddSubtask} + className={`add-subtask-input flex-1 ${isDarkMode ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300 text-gray-900'}`} + size="small" + autoFocus + /> +
+
+ ); + } else { + return ( +
+ ); + } + })} + {(scrollableColumns ?? []).map((col, index) => { + const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + return ( +
+ ); + })} +
+
+
+ ); + } + return null; + }} ) : ( // PERFORMANCE OPTIMIZATION: Use React.Fragment to reduce DOM nodes - {groupTasks.map((task: Task, index: number) => { - // PERFORMANCE OPTIMIZATION: Pre-calculate selection state - const isSelected = selectedTaskIds.includes(task.id); - - return ( -
+ {displayRows.map((row, idx) => { + if (row.type === 'task') { + return ( setShowAddSubtaskForTaskId(row.task.id)} /> -
- ); + ); + } + if (row.type === 'add-subtask') { + return ( +
+
+
+ {(fixedColumns ?? []).map((col, index) => { + const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + if (col.key === 'task') { + return ( +
+
+ setNewSubtaskName(e.target.value)} + onPressEnter={() => handleAddSubtask(row.parentTask.id)} + onBlur={handleCancelAddSubtask} + className={`add-subtask-input flex-1 ${isDarkMode ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300 text-gray-900'}`} + size="small" + autoFocus + /> +
+
+ ); + } else { + return ( +
+ ); + } + })} + {(scrollableColumns ?? []).map((col, index) => { + const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + return ( +
+ ); + })} +
+
+
+ ); + } + return null; })} )} @@ -684,7 +808,7 @@ const VirtualizedTaskList: React.FC = React.memo(({ } .task-group-header-text { color: white !important; - font-size: 13px !important; + font-size: 14px !important; font-weight: 600 !important; margin: 0 !important; } diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 899dc00e..644b0b46 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -157,9 +157,6 @@ export const fetchTasksV3 = createAsyncThunk( // Get search value from taskReducer const searchValue = state.taskReducer.search || ''; - console.log('fetchTasksV3 - selectedPriorities:', selectedPriorities); - console.log('fetchTasksV3 - searchValue:', searchValue); - const config: ITaskListConfigV2 = { id: projectId, archived: false, diff --git a/worklenz-frontend/src/features/task-management/taskListFields.slice.ts b/worklenz-frontend/src/features/task-management/taskListFields.slice.ts index 7aab2485..c734fda6 100644 --- a/worklenz-frontend/src/features/task-management/taskListFields.slice.ts +++ b/worklenz-frontend/src/features/task-management/taskListFields.slice.ts @@ -50,7 +50,6 @@ function saveFields(fields: TaskListField[]) { } const initialState: TaskListField[] = loadFields(); -console.log('TaskListFields slice initial state:', initialState); const taskListFieldsSlice = createSlice({ name: 'taskManagementFields', diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-sub-task-list-row.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-sub-task-list-row.tsx index 4d189144..a22a81ad 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-sub-task-list-row.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-sub-task-list-row.tsx @@ -1,36 +1,80 @@ -import { Input } from 'antd'; -import React, { useState } from 'react'; +import { Input, Button } from 'antd'; +import React, { useRef, useEffect, useState } from 'react'; import { useAppSelector } from '../../../../../../hooks/useAppSelector'; import { colors } from '../../../../../../styles/colors'; import { useTranslation } from 'react-i18next'; -const AddSubTaskListRow = () => { - const [isEdit, setIsEdit] = useState(false); +interface AddSubTaskListRowProps { + visibleColumns: { key: string; label: string; width: number }[]; + taskColumnKey: string; + onAdd: (name: string) => void; + onCancel: () => void; + parentTaskId: string; +} - // localization +const AddSubTaskListRow: React.FC = ({ + visibleColumns, + taskColumnKey, + onAdd, + onCancel, +}) => { + const [subtaskName, setSubtaskName] = useState(''); + const inputRef = useRef(null); const { t } = useTranslation('task-list-table'); - - // get data theme data from redux const themeMode = useAppSelector(state => state.themeReducer.mode); - const customBorderColor = themeMode === 'dark' && ' border-[#303030]'; + const customBorderColor = themeMode === 'dark' ? ' border-[#303030]' : ''; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && subtaskName.trim()) { + onAdd(subtaskName.trim()); + setSubtaskName(''); + } else if (e.key === 'Escape') { + onCancel(); + } + }; return ( -
- {isEdit ? ( - setIsEdit(false)} - /> - ) : ( - setIsEdit(true)} - className="w-[300px] border-none" - value={t('addSubTaskText')} - /> - )} -
+ + {visibleColumns.map(col => ( + + {col.key === taskColumnKey ? ( +
+ setSubtaskName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={onCancel} + placeholder={t('enterSubtaskName')} + style={{ width: '100%' }} + autoFocus + /> + + +
+ ) : null} + + ))} + ); }; diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx index 29484bb2..ec85625f 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx @@ -1365,6 +1365,7 @@ const TaskListTable: React.FC = ({ taskList, tableId, active const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); const [editingTaskId, setEditingTaskId] = useState(null); const [editColumnKey, setEditColumnKey] = useState(null); + const [showAddSubtaskFor, setShowAddSubtaskFor] = useState(null); const toggleTaskExpansion = (taskId: string) => { const task = displayTasks.find(t => t.id === taskId); @@ -1857,14 +1858,38 @@ const TaskListTable: React.FC = ({ taskList, tableId, active {renderTaskRow(updatedTask)} {updatedTask.show_sub_tasks && ( <> - {updatedTask?.sub_tasks?.map(subtask => + {updatedTask?.sub_tasks?.map(subtask => subtask?.id ? renderTaskRow(subtask, true) : null )} - - - - - + {showAddSubtaskFor !== updatedTask.id && ( + + +
setShowAddSubtaskFor(updatedTask.id)} + > + + Add Sub Task +
+ + + )} + {showAddSubtaskFor === updatedTask.id && ( + + + setShowAddSubtaskFor(null)} + /> + + + )} )} diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 0d1a9d06..0e698fe9 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -24,6 +24,10 @@ export interface Task { createdAt: string; updatedAt: string; order: number; + // Subtask-related properties + sub_tasks_count?: number; + show_sub_tasks?: boolean; + sub_tasks?: Task[]; } export interface TaskGroup { From 0452dbd1799ee25ad2b9a3104f878fb7e5b3171d Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 2 Jul 2025 15:17:21 +0530 Subject: [PATCH 4/6] feat(task-management): implement task reordering and group updates via API - Added API methods for reordering tasks and updating task groups (status, priority, phase). - Enhanced task management slice with async thunks for handling task reordering and group movements. - Updated task list board to support real-time updates during drag-and-drop operations, emitting socket events for task sort order changes. - Refactored task-related components to utilize shared Ant Design imports for consistency and maintainability. - Removed unused Ant Design imports and optimized drag-and-drop CSS for improved performance. --- .../commands/on-task-sort-order-change.ts | 242 ++++++++---------- worklenz-backend/src/socket.io/index.ts | 2 +- .../src/api/tasks/tasks.api.service.ts | 20 ++ .../task-management/antd-imports.ts | 237 ----------------- .../task-management/drag-drop-optimized.css | 155 ++--------- .../task-management/improved-task-filters.tsx | 2 +- .../components/task-management/task-group.tsx | 2 +- .../task-management/task-list-board.tsx | 183 ++++++------- .../task-management/task-row-utils.ts | 2 +- .../components/task-management/task-row.tsx | 105 +++----- .../task-management/virtualized-task-list.tsx | 14 +- .../task-management/task-management.slice.ts | 143 +++++++++-- worklenz-frontend/src/shared/antd-imports.ts | 58 ++++- 13 files changed, 462 insertions(+), 703 deletions(-) delete mode 100644 worklenz-frontend/src/components/task-management/antd-imports.ts diff --git a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts index 83b4a70e..79abae7a 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts @@ -12,160 +12,130 @@ import { assignMemberIfNot } from "./on-quick-assign-or-remove"; interface ChangeRequest { from_index: number; // from sort_order to_index: number; // to sort_order - to_last_index: boolean; + project_id: string; from_group: string; to_group: string; group_by: string; - project_id: string; - task: any; + to_last_index: boolean; + task: { + id: string; + project_id: string; + status: string; + priority: string; + }; team_id: string; } -// PERFORMANCE OPTIMIZATION: Connection pooling for better database performance -const dbPool = { - query: async (text: string, params?: any[]) => { - return await db.query(text, params); +interface Config { + from_index: number; + to_index: number; + task_id: string; + from_group: string | null; + to_group: string | null; + project_id: string; + group_by: string; + to_last_index: boolean; +} + +function notifyStatusChange(socket: Socket, config: Config) { + const userId = getLoggedInUserIdFromSocket(socket); + if (userId && config.to_group) { + void TasksController.notifyStatusChange(userId, config.task_id, config.to_group); } -}; +} -// PERFORMANCE OPTIMIZATION: Cache for dependency checks to reduce database queries -const dependencyCache = new Map(); -const CACHE_TTL = 5000; // 5 seconds cache +async function emitSortOrderChange(data: ChangeRequest, socket: Socket) { + const q = ` + SELECT id, sort_order, completed_at + FROM tasks + WHERE project_id = $1 + ORDER BY sort_order; + `; + const tasks = await db.query(q, [data.project_id]); + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), tasks.rows); +} -const clearExpiredCache = () => { - const now = Date.now(); - for (const [key, value] of dependencyCache.entries()) { - if (now - value.timestamp > CACHE_TTL) { - dependencyCache.delete(key); - } - } -}; +function updateUnmappedStatus(config: Config) { + if (config.to_group === UNMAPPED) + config.to_group = null; + if (config.from_group === UNMAPPED) + config.from_group = null; +} -// Clear expired cache entries every 10 seconds -setInterval(clearExpiredCache, 10000); - -const onTaskSortOrderChange = async (io: Server, socket: Socket, data: ChangeRequest) => { +export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) { try { - const userId = getLoggedInUserIdFromSocket(socket); - if (!userId) { - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "User not authenticated" }); - return; - } + const q = `SELECT handle_task_list_sort_order_change($1);`; - const { - from_index, - to_index, - to_last_index, - from_group, - to_group, - group_by, - project_id, - task, - team_id - } = data; - - // PERFORMANCE OPTIMIZATION: Validate input data early to avoid expensive operations - if (!project_id || !task?.id || !team_id) { - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "Missing required data" }); - return; - } - - // PERFORMANCE OPTIMIZATION: Use cached dependency check if available - const cacheKey = `${project_id}-${userId}-${team_id}`; - const cachedDependency = dependencyCache.get(cacheKey); - - let hasAccess = false; - if (cachedDependency && (Date.now() - cachedDependency.timestamp) < CACHE_TTL) { - hasAccess = cachedDependency.result; - } else { - // PERFORMANCE OPTIMIZATION: Optimized dependency check query - const dependencyResult = await dbPool.query(` - SELECT EXISTS( - SELECT 1 FROM project_members pm - INNER JOIN projects p ON p.id = pm.project_id - INNER JOIN team_members tm ON pm.team_member_id = tm.id -WHERE pm.project_id = $1 - AND tm.user_id = $2 - AND p.team_id = $3 - ) as has_access - `, [project_id, userId, team_id]); - - hasAccess = dependencyResult.rows[0]?.has_access || false; - - // Cache the result - dependencyCache.set(cacheKey, { result: hasAccess, timestamp: Date.now() }); - } - - if (!hasAccess) { - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "Access denied" }); - return; - } - - // PERFORMANCE OPTIMIZATION: Execute database operation directly - await dbPool.query(`SELECT handle_task_list_sort_order_change($1)`, [JSON.stringify({ - project_id, - task_id: task.id, - from_index, - to_index, - to_last_index, - from_group, - to_group, - group_by - })]); - - // PERFORMANCE OPTIMIZATION: Optimized project updates notification - const projectUpdateData = { - project_id, - team_id, - user_id: userId, - update_type: "task_sort_order_change", - task_id: task.id, - from_group, - to_group, - group_by + const config: Config = { + from_index: data.from_index, + to_index: data.to_index, + task_id: data.task.id, + from_group: data.from_group, + to_group: data.to_group, + project_id: data.project_id, + group_by: data.group_by, + to_last_index: Boolean(data.to_last_index) }; - // Emit to all users in the project room - io.to(`project_${project_id}`).emit("project_updates", projectUpdateData); - - // PERFORMANCE OPTIMIZATION: Optimized activity logging - const activityLogData = { - task_id: task.id, - socket, - new_value: to_group, - old_value: from_group - }; - - // Log activity asynchronously to avoid blocking the response - setImmediate(async () => { - try { - if (group_by === "phase") { - await logPhaseChange(activityLogData); - } else if (group_by === "status") { - await logStatusChange(activityLogData); - } else if (group_by === "priority") { - await logPriorityChange(activityLogData); - } - } catch (error) { - log_error(error); + if ((config.group_by === GroupBy.STATUS) && config.to_group) { + const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group); + if (!canContinue) { + return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + completed_deps: canContinue + }); } - }); - // Send success response - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { - success: true, - task_id: task.id, - from_group, - to_group, - group_by - }); + notifyStatusChange(socket, config); + } + if (config.group_by === GroupBy.PHASE) { + updateUnmappedStatus(config); + } + + await db.query(q, [JSON.stringify(config)]); + await emitSortOrderChange(data, socket); + + if (config.group_by === GroupBy.STATUS) { + const userId = getLoggedInUserIdFromSocket(socket); + const isAlreadyAssigned = await TasksControllerV2.checkUserAssignedToTask(data.task.id, userId as string, data.team_id); + + if (!isAlreadyAssigned) { + await assignMemberIfNot(data.task.id, userId as string, data.team_id, _io, socket); + } + } + + if (config.group_by === GroupBy.PHASE) { + void logPhaseChange({ + task_id: data.task.id, + socket, + new_value: data.to_group, + old_value: data.from_group + }); + } + + if (config.group_by === GroupBy.STATUS) { + void logStatusChange({ + task_id: data.task.id, + socket, + new_value: data.to_group, + old_value: data.from_group + }); + } + + if (config.group_by === GroupBy.PRIORITY) { + void logPriorityChange({ + task_id: data.task.id, + socket, + new_value: data.to_group, + old_value: data.from_group + }); + } + + void notifyProjectUpdates(socket, config.task_id); + return; } catch (error) { log_error(error); - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { - error: "Internal server error" - }); } -}; -export default onTaskSortOrderChange; + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), []); +} \ No newline at end of file diff --git a/worklenz-backend/src/socket.io/index.ts b/worklenz-backend/src/socket.io/index.ts index 26ad2bf2..04927214 100644 --- a/worklenz-backend/src/socket.io/index.ts +++ b/worklenz-backend/src/socket.io/index.ts @@ -18,7 +18,7 @@ import { on_task_description_change } from "./commands/on-task-description-chang import { on_get_task_progress } from "./commands/on-get-task-progress"; import { on_task_timer_start } from "./commands/on-task-timer-start"; import { on_task_timer_stop } from "./commands/on-task-timer-stop"; -import on_task_sort_order_change from "./commands/on-task-sort-order-change"; +import { on_task_sort_order_change } from "./commands/on-task-sort-order-change"; import { on_join_project_room as on_join_or_leave_project_room } from "./commands/on-join-or-leave-project-room"; import { on_task_subscriber_change } from "./commands/on-task-subscriber-change"; import { on_project_subscriber_change } from "./commands/on-project-subscriber-change"; diff --git a/worklenz-frontend/src/api/tasks/tasks.api.service.ts b/worklenz-frontend/src/api/tasks/tasks.api.service.ts index 862b0af9..460983d1 100644 --- a/worklenz-frontend/src/api/tasks/tasks.api.service.ts +++ b/worklenz-frontend/src/api/tasks/tasks.api.service.ts @@ -159,4 +159,24 @@ export const tasksApiService = { const response = await apiClient.get(`${rootUrl}/progress-status/${projectId}`); return response.data; }, + + // API method to reorder tasks + reorderTasks: async (params: { taskIds: string[]; newOrder: number[]; projectId: string }): Promise> => { + const response = await apiClient.post(`${rootUrl}/reorder`, { + task_ids: params.taskIds, + new_order: params.newOrder, + project_id: params.projectId, + }); + return response.data; + }, + + // API method to update task group (status, priority, phase) + updateTaskGroup: async (params: { taskId: string; groupType: 'status' | 'priority' | 'phase'; groupValue: string; projectId: string }): Promise> => { + const response = await apiClient.put(`${rootUrl}/${params.taskId}/group`, { + group_type: params.groupType, + group_value: params.groupValue, + project_id: params.projectId, + }); + return response.data; + }, }; diff --git a/worklenz-frontend/src/components/task-management/antd-imports.ts b/worklenz-frontend/src/components/task-management/antd-imports.ts deleted file mode 100644 index 16991148..00000000 --- a/worklenz-frontend/src/components/task-management/antd-imports.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Centralized Ant Design imports for Task Management components - * - * This file provides: - * - Tree-shaking optimization by importing only used components - * - Type safety with proper TypeScript types - * - Performance optimization through selective imports - * - Consistent component versions across task management - * - Easy maintenance and updates - */ - -// Core Components -export { - Button, - Input, - Select, - Typography, - Card, - Spin, - Empty, - Space, - Tooltip, - Badge, - Popconfirm, - message, - Checkbox, - Dropdown, - Menu -} from 'antd/es'; - -// Date & Time Components -export { - DatePicker, - TimePicker -} from 'antd/es'; - -// Form Components (if needed for task management) -export { - Form, - InputNumber -} from 'antd/es'; - -// Layout Components -export { - Row, - Col, - Divider, - Flex -} from 'antd/es'; - -// Icon Components (commonly used in task management) -export { - EditOutlined, - DeleteOutlined, - PlusOutlined, - MoreOutlined, - CheckOutlined, - CloseOutlined, - CalendarOutlined, - ClockCircleOutlined, - UserOutlined, - TeamOutlined, - TagOutlined, - FlagOutlined, - BarsOutlined, - TableOutlined, - AppstoreOutlined, - FilterOutlined, - SortAscendingOutlined, - SortDescendingOutlined, - SearchOutlined, - ReloadOutlined, - SettingOutlined, - EyeOutlined, - EyeInvisibleOutlined, - CopyOutlined, - ExportOutlined, - ImportOutlined, - DownOutlined, - RightOutlined, - LeftOutlined, - UpOutlined, - DragOutlined, - HolderOutlined, - MessageOutlined, - PaperClipOutlined, - GroupOutlined, - InboxOutlined, - TagsOutlined, - UsergroupAddOutlined, - UserAddOutlined, - RetweetOutlined -} from '@ant-design/icons'; - -// TypeScript Types -export type { - ButtonProps, - InputProps, - InputRef, - SelectProps, - TypographyProps, - CardProps, - SpinProps, - EmptyProps, - SpaceProps, - TooltipProps, - BadgeProps, - PopconfirmProps, - CheckboxProps, - CheckboxChangeEvent, - DropdownProps, - MenuProps, - DatePickerProps, - TimePickerProps, - FormProps, - FormInstance, - InputNumberProps, - RowProps, - ColProps, - DividerProps, - FlexProps -} from 'antd/es'; - -// Dayjs (used with DatePicker) -export { default as dayjs } from 'dayjs'; -export type { Dayjs } from 'dayjs'; - -// Re-export commonly used Ant Design utilities -export { - ConfigProvider, - theme -} from 'antd'; - -// Custom hooks for task management (if any Ant Design specific hooks are needed) -export const useAntdBreakpoint = () => { - // You can add custom breakpoint logic here if needed - return { - xs: window.innerWidth < 576, - sm: window.innerWidth >= 576 && window.innerWidth < 768, - md: window.innerWidth >= 768 && window.innerWidth < 992, - lg: window.innerWidth >= 992 && window.innerWidth < 1200, - xl: window.innerWidth >= 1200 && window.innerWidth < 1600, - xxl: window.innerWidth >= 1600, - }; -}; - -// Import message separately to avoid circular dependency -import { message as antdMessage } from 'antd'; - -// Performance optimized message utility -export const taskMessage = { - success: (content: string) => antdMessage.success(content), - error: (content: string) => antdMessage.error(content), - warning: (content: string) => antdMessage.warning(content), - info: (content: string) => antdMessage.info(content), - loading: (content: string) => antdMessage.loading(content), -}; - -// Commonly used Ant Design configurations for task management -export const taskManagementAntdConfig = { - // DatePicker default props for consistency - datePickerDefaults: { - format: 'MMM DD, YYYY', - placeholder: 'Set Date', - suffixIcon: null, - size: 'small' as const, - }, - - // Button default props for task actions - taskButtonDefaults: { - size: 'small' as const, - type: 'text' as const, - }, - - // Input default props for task editing - taskInputDefaults: { - size: 'small' as const, - variant: 'borderless' as const, - }, - - // Select default props for dropdowns - taskSelectDefaults: { - size: 'small' as const, - variant: 'borderless' as const, - showSearch: true, - optionFilterProp: 'label' as const, - }, - - // Tooltip default props - tooltipDefaults: { - placement: 'top' as const, - mouseEnterDelay: 0.5, - mouseLeaveDelay: 0.1, - }, - - // Dropdown default props - dropdownDefaults: { - trigger: ['click'] as const, - placement: 'bottomLeft' as const, - }, -}; - -// Theme tokens specifically for task management -export const taskManagementTheme = { - light: { - colorBgContainer: '#ffffff', - colorBorder: '#e5e7eb', - colorText: '#374151', - colorTextSecondary: '#6b7280', - colorPrimary: '#3b82f6', - colorSuccess: '#10b981', - colorWarning: '#f59e0b', - colorError: '#ef4444', - colorBgHover: '#f9fafb', - colorBgSelected: '#eff6ff', - }, - dark: { - colorBgContainer: '#1f2937', - colorBorder: '#374151', - colorText: '#f9fafb', - colorTextSecondary: '#d1d5db', - colorPrimary: '#60a5fa', - colorSuccess: '#34d399', - colorWarning: '#fbbf24', - colorError: '#f87171', - colorBgHover: '#374151', - colorBgSelected: '#1e40af', - }, -}; - -// Export default configuration object -export default { - config: taskManagementAntdConfig, - theme: taskManagementTheme, - message: taskMessage, - useBreakpoint: useAntdBreakpoint, -}; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css index c604cbcb..5b70bc21 100644 --- a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css +++ b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css @@ -1,149 +1,40 @@ -/* DRAG AND DROP PERFORMANCE OPTIMIZATIONS */ +/* MINIMAL DRAG AND DROP CSS - SHOW ONLY TASK NAME */ -/* Force GPU acceleration for all drag operations */ -[data-dnd-draggable], -[data-dnd-drag-handle], -[data-dnd-overlay] { - transform: translateZ(0); - will-change: transform; - backface-visibility: hidden; - perspective: 1000px; -} - -/* Optimize drag handle for instant response */ +/* Basic drag handle styling */ .drag-handle-optimized { cursor: grab; - user-select: none; - touch-action: none; - -webkit-user-select: none; - -webkit-touch-callout: none; - -webkit-tap-highlight-color: transparent; + opacity: 0.6; + transition: opacity 0.2s ease; +} + +.drag-handle-optimized:hover { + opacity: 1; } .drag-handle-optimized:active { cursor: grabbing; } -/* Disable all transitions during drag for instant response */ -[data-dnd-dragging="true"] *, -[data-dnd-dragging="true"] { - transition: none !important; - animation: none !important; -} - -/* Optimize drag overlay for smooth movement */ +/* Simple drag overlay - just show task name */ [data-dnd-overlay] { + background: white; + border: 1px solid #d9d9d9; + border-radius: 4px; + padding: 8px 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); pointer-events: none; - position: fixed !important; z-index: 9999; - transform: translateZ(0); - will-change: transform; - backface-visibility: hidden; } -/* Reduce layout thrashing during drag */ -.task-row-dragging { - contain: layout style paint; - will-change: transform; - transform: translateZ(0); +/* Dark mode support for drag overlay */ +.dark [data-dnd-overlay], +[data-theme="dark"] [data-dnd-overlay] { + background: #1f1f1f; + border-color: #404040; + color: white; } -/* Optimize virtualized lists during drag */ -.react-window-list { - contain: layout style; - will-change: scroll-position; -} - -.react-window-list-item { - contain: layout style; - will-change: transform; -} - -/* Disable hover effects during drag */ -[data-dnd-dragging="true"] .task-row:hover { - background-color: inherit !important; -} - -/* Optimize cursor changes */ -.task-row { - cursor: default; -} - -.task-row[data-dnd-dragging="true"] { - cursor: grabbing; -} - -/* Performance optimizations for large lists */ -.virtualized-task-container { - contain: layout style paint; - will-change: scroll-position; - transform: translateZ(0); -} - -/* Reduce repaints during scroll */ -.task-groups-container { - contain: layout style; - will-change: scroll-position; -} - -/* Optimize sortable context */ -[data-dnd-sortable-context] { - contain: layout style; -} - -/* Disable animations during drag operations */ -[data-dnd-context] [data-dnd-dragging="true"] * { - transition: none !important; - animation: none !important; -} - -/* Optimize drop indicators */ -.drop-indicator { - contain: layout style; - will-change: opacity; - transition: opacity 0.1s ease; -} - -/* Performance optimizations for touch devices */ -@media (pointer: coarse) { - .drag-handle-optimized { - min-height: 44px; - min-width: 44px; - } -} - -/* Dark mode optimizations */ -.dark [data-dnd-dragging="true"], -[data-theme="dark"] [data-dnd-dragging="true"] { - background-color: rgba(255, 255, 255, 0.05) !important; -} - -/* Reduce memory usage during drag */ -[data-dnd-dragging="true"] img, -[data-dnd-dragging="true"] svg { - contain: layout style paint; -} - -/* Optimize for high DPI displays */ -@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { - [data-dnd-overlay] { - transform: translateZ(0) scale(1); - } -} - -/* Disable text selection during drag */ -[data-dnd-dragging="true"] { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -/* Optimize for reduced motion preferences */ -@media (prefers-reduced-motion: reduce) { - [data-dnd-overlay], - [data-dnd-dragging="true"] { - transition: none !important; - animation: none !important; - } +/* Hide drag handle during drag */ +[data-dnd-dragging="true"] .drag-handle-optimized { + opacity: 0; } \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index c32ca90f..dd777df4 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -14,7 +14,7 @@ import { EyeOutlined, InboxOutlined, CheckOutlined, -} from './antd-imports'; +} from '@/shared/antd-imports'; import { RootState } from '@/app/store'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index a6fce7f9..51f9b7d8 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -9,7 +9,7 @@ import { PlusOutlined, RightOutlined, DownOutlined -} from './antd-imports'; +} from '@/shared/antd-imports'; import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types'; import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; import { RootState } from '@/app/store'; 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 2c759513..668dc961 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -21,6 +21,7 @@ import { reorderTasks, moveTaskToGroup, optimisticTaskMove, + reorderTasksInGroup, setLoading, fetchTasks, fetchTasksV3, @@ -39,6 +40,8 @@ import { } from '@/features/task-management/selection.slice'; import { Task } from '@/types/task-management.types'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; import TaskRow from './task-row'; // import BulkActionBar from './bulk-action-bar'; import OptimizedBulkActionBar from './optimized-bulk-action-bar'; @@ -136,7 +139,6 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const renderCountRef = useRef(0); const [shouldThrottle, setShouldThrottle] = useState(false); - // Refs for performance optimization const dragOverTimeoutRef = useRef(null); const containerRef = useRef(null); @@ -144,6 +146,9 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Enable real-time socket updates for task changes useTaskSocketHandlers(); + // Socket connection for drag and drop + const { socket, connected } = useSocket(); + // Redux selectors using V3 API (pre-processed data, minimal loops) const tasks = useSelector(taskManagementSelectors.selectAll); const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual); @@ -151,7 +156,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const selectedTaskIds = useSelector(selectSelectedTaskIds); const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual); const error = useSelector((state: RootState) => state.taskManagement.error); - + // Bulk action selectors const statusList = useSelector((state: RootState) => state.taskStatusReducer.status); const priorityList = useSelector((state: RootState) => state.priorityReducer.priorities); @@ -234,8 +239,12 @@ const TaskListBoard: React.FC = ({ projectId, className = '' [dispatch] ); + // Add isDragging state + const [isDragging, setIsDragging] = useState(false); + const handleDragStart = useCallback( (event: DragStartEvent) => { + setIsDragging(true); const { active } = event; const taskId = active.id as string; @@ -244,13 +253,12 @@ const TaskListBoard: React.FC = ({ projectId, className = '' let activeGroupId: string | null = null; if (activeTask) { - // Determine group ID based on current grouping - if (currentGrouping === 'status') { - activeGroupId = `status-${activeTask.status}`; - } else if (currentGrouping === 'priority') { - activeGroupId = `priority-${activeTask.priority}`; - } else if (currentGrouping === 'phase') { - activeGroupId = `phase-${activeTask.phase}`; + // Find which group contains this task by looking through all groups + for (const group of taskGroups) { + if (group.taskIds.includes(taskId)) { + activeGroupId = group.id; + break; + } } } @@ -259,7 +267,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' activeGroupId, }); }, - [tasks, currentGrouping] + [tasks, currentGrouping, taskGroups] ); // Throttled drag over handler for smoother performance @@ -270,15 +278,14 @@ const TaskListBoard: React.FC = ({ projectId, className = '' if (!over || !dragState.activeTask) return; const activeTaskId = active.id as string; - const overContainer = over.id as string; + const overId = over.id as string; - // PERFORMANCE OPTIMIZATION: Immediate response for instant UX - // Only update if we're hovering over a different container - const targetTask = tasks.find(t => t.id === overContainer); - let targetGroupId = overContainer; + // Check if we're hovering over a task or a group container + const targetTask = tasks.find(t => t.id === overId); + let targetGroupId = overId; if (targetTask) { - // PERFORMANCE OPTIMIZATION: Use switch instead of multiple if statements + // We're hovering over a task, determine its group switch (currentGrouping) { case 'status': targetGroupId = `status-${targetTask.status}`; @@ -291,29 +298,13 @@ const TaskListBoard: React.FC = ({ projectId, className = '' break; } } - - if (targetGroupId !== dragState.activeGroupId) { - // PERFORMANCE OPTIMIZATION: Use findIndex for better performance - const targetGroupIndex = taskGroups.findIndex(g => g.id === targetGroupId); - if (targetGroupIndex !== -1) { - const targetGroup = taskGroups[targetGroupIndex]; - dispatch( - optimisticTaskMove({ - taskId: activeTaskId, - newGroupId: targetGroupId, - newIndex: targetGroup.taskIds.length, - }) - ); - } - } }, 16), // 60fps throttling for smooth performance - [dragState, tasks, taskGroups, currentGrouping, dispatch] + [dragState, tasks, taskGroups, currentGrouping] ); const handleDragEnd = useCallback( (event: DragEndEvent) => { - const { active, over } = event; - + setIsDragging(false); // Clear any pending drag over timeouts if (dragOverTimeoutRef.current) { clearTimeout(dragOverTimeoutRef.current); @@ -327,36 +318,27 @@ const TaskListBoard: React.FC = ({ projectId, className = '' activeGroupId: null, }); - if (!over || !currentDragState.activeTask || !currentDragState.activeGroupId) { + if (!event.over || !currentDragState.activeTask || !currentDragState.activeGroupId) { return; } + const { active, over } = event; const activeTaskId = active.id as string; - const overContainer = over.id as string; + const overId = over.id as string; - // Parse the group ID to get group type and value - optimized - const parseGroupId = (groupId: string) => { - const [groupType, ...groupValueParts] = groupId.split('-'); - return { - groupType: groupType as 'status' | 'priority' | 'phase', - groupValue: groupValueParts.join('-'), - }; - }; - - // Determine target group - let targetGroupId = overContainer; + // Determine target group and position + let targetGroupId = overId; let targetIndex = -1; // Check if dropping on a task or a group - const targetTask = tasks.find(t => t.id === overContainer); + const targetTask = tasks.find(t => t.id === overId); if (targetTask) { - // Dropping on a task, determine its group - if (currentGrouping === 'status') { - targetGroupId = `status-${targetTask.status}`; - } else if (currentGrouping === 'priority') { - targetGroupId = `priority-${targetTask.priority}`; - } else if (currentGrouping === 'phase') { - targetGroupId = `phase-${targetTask.phase}`; + // Dropping on a task, find which group contains this task + for (const group of taskGroups) { + if (group.taskIds.includes(targetTask.id)) { + targetGroupId = group.id; + break; + } } // Find the index of the target task within its group @@ -364,23 +346,15 @@ const TaskListBoard: React.FC = ({ projectId, className = '' if (targetGroup) { targetIndex = targetGroup.taskIds.indexOf(targetTask.id); } + } else { + // Dropping on a group container, add to the end + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + if (targetGroup) { + targetIndex = targetGroup.taskIds.length; + } } - const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId); - const targetGroupInfo = parseGroupId(targetGroupId); - - // If moving between different groups, update the task's group property - if (currentDragState.activeGroupId !== targetGroupId) { - dispatch( - moveTaskToGroup({ - taskId: activeTaskId, - groupType: targetGroupInfo.groupType, - groupValue: targetGroupInfo.groupValue, - }) - ); - } - - // Handle reordering within the same group or between groups + // Find source and target groups const sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId); const targetGroup = taskGroups.find(g => g.id === targetGroupId); @@ -390,27 +364,41 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Only reorder if actually moving to a different position if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) { - // Calculate new order values - simplified - const allTasksInTargetGroup = targetGroup.taskIds.map( - (id: string) => tasks.find((t: any) => t.id === id)! - ); - const newOrder = allTasksInTargetGroup.map((task, index) => { - if (index < finalTargetIndex) return task.order; - if (index === finalTargetIndex) return currentDragState.activeTask!.order; - return task.order + 1; - }); - - // Dispatch reorder action + // Use the new reorderTasksInGroup action that properly handles group arrays dispatch( - reorderTasks({ - taskIds: [activeTaskId, ...allTasksInTargetGroup.map((t: any) => t.id)], - newOrder: [currentDragState.activeTask!.order, ...newOrder], + reorderTasksInGroup({ + taskId: activeTaskId, + fromGroupId: currentDragState.activeGroupId, + toGroupId: targetGroupId, + fromIndex: sourceIndex, + toIndex: finalTargetIndex, + groupType: targetGroup.groupType, + groupValue: targetGroup.groupValue, }) ); + + // Emit socket event to backend + if (connected && socket && currentDragState.activeTask) { + const currentSession = JSON.parse(localStorage.getItem('session') || '{}'); + + const socketData = { + from_index: sourceIndex, + to_index: finalTargetIndex, + to_last_index: finalTargetIndex >= targetGroup.taskIds.length, + from_group: currentDragState.activeGroupId, + to_group: targetGroupId, + group_by: currentGrouping, + project_id: projectId, + task: currentDragState.activeTask, + team_id: currentSession.team_id, + }; + + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); + } } } }, - [dragState, tasks, taskGroups, currentGrouping, dispatch] + [dragState, tasks, taskGroups, currentGrouping, dispatch, connected, socket, projectId] ); const handleSelectTask = useCallback( @@ -651,17 +639,14 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Additional handlers for new actions const handleBulkDuplicate = useCallback(async () => { // This would need to be implemented in the API service - console.log('Bulk duplicate not yet implemented in API:', selectedTaskIds); }, [selectedTaskIds]); const handleBulkExport = useCallback(async () => { // This would need to be implemented in the API service - console.log('Bulk export not yet implemented in API:', selectedTaskIds); }, [selectedTaskIds]); const handleBulkSetDueDate = useCallback(async (date: string) => { // This would need to be implemented in the API service - console.log('Bulk set due date not yet implemented in API:', date, selectedTaskIds); }, [selectedTaskIds]); // Cleanup effect @@ -689,24 +674,19 @@ const TaskListBoard: React.FC = ({ projectId, className = '' onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd} + autoScroll={false} > - - - - {/* Task Filters */}
- {/* Performance Analysis - Only show in development */} {/* {process.env.NODE_ENV === 'development' && ( )} */} - {/* Fixed Height Task Groups Container - Asana Style */}
-
+
{loading ? (
@@ -775,14 +755,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' {dragOverlayContent} @@ -1277,6 +1250,10 @@ const TaskListBoard: React.FC = ({ projectId, className = '' .react-window-list-item { contain: layout style; } + + .task-groups-scrollable.lock-scroll { + overflow: hidden !important; + } `}
); diff --git a/worklenz-frontend/src/components/task-management/task-row-utils.ts b/worklenz-frontend/src/components/task-management/task-row-utils.ts index f51b1c59..dcfa05db 100644 --- a/worklenz-frontend/src/components/task-management/task-row-utils.ts +++ b/worklenz-frontend/src/components/task-management/task-row-utils.ts @@ -1,5 +1,5 @@ import { Task } from '@/types/task-management.types'; -import { dayjs } from './antd-imports'; +import { dayjs } from '@/shared/antd-imports'; // Performance constants export const PERFORMANCE_CONSTANTS = { diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 635f4675..3d4e8b91 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -15,8 +15,15 @@ import { UserOutlined, type InputRef, Tooltip -} from './antd-imports'; -import { DownOutlined, RightOutlined, ExpandAltOutlined, CheckCircleOutlined, MinusCircleOutlined, EyeOutlined, RetweetOutlined } from '@ant-design/icons'; +} from '@/shared/antd-imports'; +import { + RightOutlined, + ExpandAltOutlined, + CheckCircleOutlined, + MinusCircleOutlined, + EyeOutlined, + RetweetOutlined, +} from '@/shared/antd-imports'; import { useTranslation } from 'react-i18next'; import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; @@ -68,22 +75,25 @@ const STATUS_COLORS = { } as const; // Memoized sub-components for maximum performance -const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => ( -
- -
-)); +const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => { + return ( +
+ +
+ ); +}); const TaskKey = React.memo<{ taskKey: string; isDarkMode: boolean }>(({ taskKey, isDarkMode }) => ( = React.memo(({ return { transform: CSS.Transform.toString(transform), - transition: isDragging ? 'opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1)' : 'none', - opacity: isDragging ? 0.3 : 1, + opacity: isDragging ? 0.5 : 1, zIndex: isDragging ? 1000 : 'auto', - // PERFORMANCE OPTIMIZATION: Force GPU acceleration - willChange: 'transform, opacity', - filter: isDragging ? 'blur(0.5px)' : 'none', }; }, [transform, isDragging]); @@ -1223,58 +1229,27 @@ const TaskRow: React.FC = React.memo(({ // Compute theme class const themeClass = isDarkMode ? 'dark' : ''; - if (isDragging) { - console.log('TaskRow isDragging:', task.id); - } - // DRAG OVERLAY: Render simplified version when dragging if (isDragOverlay) { return (
-
-
- -
- - {task.title} - -
+ {task.title}
); } diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx index 34e3fb75..46a64073 100644 --- a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useCallback, useEffect, useRef } from 'react'; -import { FixedSizeList as List } from 'react-window'; +import { FixedSizeList as List, FixedSizeList } from 'react-window'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSelector, useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; @@ -205,7 +205,7 @@ const VirtualizedTaskList: React.FC = React.memo(({ return group.taskIds .map((taskId: string) => tasksById[taskId]) .filter((task: Task | undefined): task is Task => task !== undefined); - }, [group.taskIds, tasksById]); + }, [group.taskIds, tasksById, group.id]); // Calculate selection state for the group checkbox const selectionState = useMemo(() => { @@ -329,7 +329,7 @@ const VirtualizedTaskList: React.FC = React.memo(({ }, [groupTasks.length]); // Build displayRows array - const displayRows = []; + const displayRows: Array<{ type: 'task'; task: Task } | { type: 'add-subtask'; parentTask: Task }> = []; for (let i = 0; i < groupTasks.length; i++) { const task = groupTasks[i]; displayRows.push({ type: 'task', task }); @@ -340,6 +340,7 @@ const VirtualizedTaskList: React.FC = React.memo(({ const scrollContainerRef = useRef(null); const headerScrollRef = useRef(null); + const listRef = useRef(null); // PERFORMANCE OPTIMIZATION: Throttled scroll handler const handleScroll = useCallback(() => { @@ -551,14 +552,17 @@ const VirtualizedTaskList: React.FC = React.memo(({ contain: 'layout style', // CSS containment for better performance }} > - + {shouldVirtualize ? ( {({ index, style }) => { diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 644b0b46..b73b4ccf 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -208,6 +208,60 @@ export const refreshTaskProgress = createAsyncThunk( } ); +// Async thunk to reorder tasks with API call +export const reorderTasksWithAPI = createAsyncThunk( + 'taskManagement/reorderTasksWithAPI', + async ({ taskIds, newOrder, projectId }: { taskIds: string[]; newOrder: number[]; projectId: string }, { rejectWithValue }) => { + try { + // Make API call to update task order + const response = await tasksApiService.reorderTasks({ + taskIds, + newOrder, + projectId, + }); + + if (response.done) { + return { taskIds, newOrder }; + } else { + return rejectWithValue('Failed to reorder tasks'); + } + } catch (error) { + logger.error('Reorder Tasks API Error:', error); + return rejectWithValue('Failed to reorder tasks'); + } + } +); + +// Async thunk to move task between groups with API call +export const moveTaskToGroupWithAPI = createAsyncThunk( + 'taskManagement/moveTaskToGroupWithAPI', + async ({ taskId, groupType, groupValue, projectId }: { + taskId: string; + groupType: 'status' | 'priority' | 'phase'; + groupValue: string; + projectId: string; + }, { rejectWithValue }) => { + try { + // Make API call to update task group + const response = await tasksApiService.updateTaskGroup({ + taskId, + groupType, + groupValue, + projectId, + }); + + if (response.done) { + return { taskId, groupType, groupValue }; + } else { + return rejectWithValue('Failed to move task'); + } + } catch (error) { + logger.error('Move Task API Error:', error); + return rejectWithValue('Failed to move task'); + } + } +); + const taskManagementSlice = createSlice({ name: 'taskManagement', initialState: tasksAdapter.getInitialState(initialState), @@ -328,15 +382,6 @@ const taskManagementSlice = createSlice({ }>) => { const { taskId, fromGroupId, toGroupId, taskUpdate } = action.payload; - console.log('🔧 moveTaskBetweenGroups action:', { - taskId, - fromGroupId, - toGroupId, - taskUpdate, - hasGroups: !!state.groups, - groupsCount: state.groups?.length || 0 - }); - // Update the task entity with new values tasksAdapter.updateOne(state, { id: taskId, @@ -351,25 +396,15 @@ const taskManagementSlice = createSlice({ // Remove task from old group const fromGroup = state.groups.find(group => group.id === fromGroupId); if (fromGroup) { - const beforeCount = fromGroup.taskIds.length; fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId); - console.log(`🔧 Removed task from ${fromGroup.title}: ${beforeCount} -> ${fromGroup.taskIds.length}`); - } else { - console.warn('🚨 From group not found:', fromGroupId); } // Add task to new group const toGroup = state.groups.find(group => group.id === toGroupId); if (toGroup) { - const beforeCount = toGroup.taskIds.length; // Add to the end of the group (newest last) toGroup.taskIds.push(taskId); - console.log(`🔧 Added task to ${toGroup.title}: ${beforeCount} -> ${toGroup.taskIds.length}`); - } else { - console.warn('🚨 To group not found:', toGroupId); } - } else { - console.warn('🚨 No groups available for task movement'); } }, @@ -397,7 +432,76 @@ const taskManagementSlice = createSlice({ changes.phase = groupValue; } + // Update the task entity tasksAdapter.updateOne(state, { id: taskId, changes }); + + // Update groups if they exist + if (state.groups && state.groups.length > 0) { + // Find the target group + const targetGroup = state.groups.find(group => group.id === newGroupId); + if (targetGroup) { + // Remove task from all groups first + state.groups.forEach(group => { + group.taskIds = group.taskIds.filter(id => id !== taskId); + }); + + // Add task to target group at the specified index + if (newIndex >= targetGroup.taskIds.length) { + targetGroup.taskIds.push(taskId); + } else { + targetGroup.taskIds.splice(newIndex, 0, taskId); + } + } + } + } + }, + + // Proper reorder action that handles both task entities and group arrays + reorderTasksInGroup: (state, action: PayloadAction<{ + taskId: string; + fromGroupId: string; + toGroupId: string; + fromIndex: number; + toIndex: number; + groupType: 'status' | 'priority' | 'phase'; + groupValue: string; + }>) => { + const { taskId, fromGroupId, toGroupId, fromIndex, toIndex, groupType, groupValue } = action.payload; + + // Update the task entity + const changes: Partial = { + order: toIndex, + updatedAt: new Date().toISOString(), + }; + + // Update group-specific field + if (groupType === 'status') { + changes.status = groupValue as Task['status']; + } else if (groupType === 'priority') { + changes.priority = groupValue as Task['priority']; + } else if (groupType === 'phase') { + changes.phase = groupValue; + } + + tasksAdapter.updateOne(state, { id: taskId, changes }); + + // Update groups if they exist + if (state.groups && state.groups.length > 0) { + // Remove task from source group + const fromGroup = state.groups.find(group => group.id === fromGroupId); + if (fromGroup) { + fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId); + } + + // Add task to target group + const toGroup = state.groups.find(group => group.id === toGroupId); + if (toGroup) { + if (toIndex >= toGroup.taskIds.length) { + toGroup.taskIds.push(taskId); + } else { + toGroup.taskIds.splice(toIndex, 0, taskId); + } + } } }, @@ -483,6 +587,7 @@ export const { moveTaskToGroup, moveTaskBetweenGroups, optimisticTaskMove, + reorderTasksInGroup, setLoading, setError, setSelectedPriorities, diff --git a/worklenz-frontend/src/shared/antd-imports.ts b/worklenz-frontend/src/shared/antd-imports.ts index b5cd5e9e..f6181b82 100644 --- a/worklenz-frontend/src/shared/antd-imports.ts +++ b/worklenz-frontend/src/shared/antd-imports.ts @@ -87,7 +87,17 @@ export { TableOutlined, BarChartOutlined, FileOutlined, - MessageOutlined + MessageOutlined, + FlagOutlined, + GroupOutlined, + EyeOutlined, + InboxOutlined, + PaperClipOutlined, + HolderOutlined, + ExpandAltOutlined, + CheckCircleOutlined, + MinusCircleOutlined, + RetweetOutlined, } from '@ant-design/icons'; // Re-export all components with React @@ -196,4 +206,48 @@ export default { config: antdConfig, message: appMessage, notification: appNotification, -}; \ No newline at end of file +}; + +// Commonly used Ant Design configurations for task management +export const taskManagementAntdConfig = { + // DatePicker default props for consistency + datePickerDefaults: { + format: 'MMM DD, YYYY', + placeholder: 'Set Date', + suffixIcon: null, + size: 'small' as const, + }, + + // Button default props for task actions + taskButtonDefaults: { + size: 'small' as const, + type: 'text' as const, + }, + + // Input default props for task editing + taskInputDefaults: { + size: 'small' as const, + variant: 'borderless' as const, + }, + + // Select default props for dropdowns + taskSelectDefaults: { + size: 'small' as const, + variant: 'borderless' as const, + showSearch: true, + optionFilterProp: 'label' as const, + }, + + // Tooltip default props + tooltipDefaults: { + placement: 'top' as const, + mouseEnterDelay: 0.5, + mouseLeaveDelay: 0.1, + }, + + // Dropdown default props + dropdownDefaults: { + trigger: ['click'] as const, + placement: 'bottomLeft' as const, + }, +}; From 365369cc31549b80a63dfa57052b101b4730d7cf Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 2 Jul 2025 15:37:24 +0530 Subject: [PATCH 5/6] feat(i18n): enhance translation loading and preloading mechanism - Introduced a utility function `ensureTranslationsLoaded` to preload essential translation namespaces, improving app initialization. - Updated `App` component to initialize translations alongside CSRF token on startup. - Created custom hooks `useTranslationPreloader`, `useBulkActionTranslations`, and `useTaskManagementTranslations` to manage translation readiness and prevent Suspense issues. - Refactored components to utilize new translation hooks, ensuring translations are ready before rendering. - Enhanced `OptimizedBulkActionBar` and `TaskListBoard` components to improve user experience during language switching. --- worklenz-frontend/src/App.tsx | 21 +++-- .../optimized-bulk-action-bar.tsx | 9 ++- .../task-management/task-list-board.tsx | 15 +++- .../src/hooks/useTranslationPreloader.ts | 77 +++++++++++++++++++ worklenz-frontend/src/i18n.ts | 58 ++++++++++++++ 5 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 worklenz-frontend/src/hooks/useTranslationPreloader.ts diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 1116b739..404cddd1 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -2,6 +2,7 @@ import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react'; import { RouterProvider } from 'react-router-dom'; import i18next from 'i18next'; +import { ensureTranslationsLoaded } from './i18n'; // Components import ThemeWrapper from './features/theme/ThemeWrapper'; @@ -56,15 +57,25 @@ const App: React.FC = memo(() => { handleLanguageChange(language || Language.EN); }, [language, handleLanguageChange]); - // Initialize CSRF token on app startup - memoize to prevent re-initialization + // Initialize CSRF token and translations on app startup useEffect(() => { let isMounted = true; - initializeCsrfToken().catch(error => { - if (isMounted) { - logger.error('Failed to initialize CSRF token:', error); + const initializeApp = async () => { + try { + // Initialize CSRF token + await initializeCsrfToken(); + + // Preload essential translations + await ensureTranslationsLoaded(); + } catch (error) { + if (isMounted) { + logger.error('Failed to initialize app:', error); + } } - }); + }; + + initializeApp(); return () => { isMounted = false; diff --git a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx index ea008987..9ce9f643 100644 --- a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx +++ b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx @@ -21,10 +21,10 @@ import { FlagOutlined, BulbOutlined } from '@ant-design/icons'; -import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { RootState } from '@/app/store'; import { useAppSelector } from '@/hooks/useAppSelector'; +import { useBulkActionTranslations } from '@/hooks/useTranslationPreloader'; const { Text } = Typography; @@ -138,7 +138,7 @@ const OptimizedBulkActionBarContent: React.FC = Rea onBulkExport, onBulkSetDueDate, }) => { - const { t } = useTranslation('tasks/task-table-bulk-actions'); + const { t, ready, isLoading } = useBulkActionTranslations(); const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); // Get data from Redux store @@ -324,6 +324,11 @@ const OptimizedBulkActionBarContent: React.FC = Rea whiteSpace: 'nowrap' as const, }), [isDarkMode]); + // Don't render until translations are ready to prevent Suspense + if (!ready || isLoading) { + return null; + } + if (!totalSelected || Number(totalSelected) < 1) { return null; } 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 668dc961..ed344b4d 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { useTranslation } from 'react-i18next'; +import { useTaskManagementTranslations } from '@/hooks/useTranslationPreloader'; import { DndContext, DragOverlay, @@ -124,7 +124,7 @@ const throttle = void>(func: T, delay: number): T const TaskListBoard: React.FC = ({ projectId, className = '' }) => { const dispatch = useDispatch(); - const { t } = useTranslation('task-management'); + const { t, ready, isLoading } = useTaskManagementTranslations(); const { trackMixpanelEvent } = useMixpanelTracking(); const [dragState, setDragState] = useState({ activeTask: null, @@ -658,6 +658,17 @@ const TaskListBoard: React.FC = ({ projectId, className = '' }; }, []); + // Don't render until translations are ready to prevent Suspense + if (!ready || isLoading) { + return ( + +
+ +
+
+ ); + } + if (error) { return ( diff --git a/worklenz-frontend/src/hooks/useTranslationPreloader.ts b/worklenz-frontend/src/hooks/useTranslationPreloader.ts new file mode 100644 index 00000000..abf20b22 --- /dev/null +++ b/worklenz-frontend/src/hooks/useTranslationPreloader.ts @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ensureTranslationsLoaded } from '@/i18n'; + +interface UseTranslationPreloaderOptions { + namespaces?: string[]; + fallback?: React.ReactNode; +} + +/** + * Hook to ensure translations are loaded before rendering components + * This prevents Suspense issues when components use useTranslation + */ +export const useTranslationPreloader = ( + namespaces: string[] = ['tasks/task-table-bulk-actions', 'task-management'], + options: UseTranslationPreloaderOptions = {} +) => { + const [isLoaded, setIsLoaded] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const { t, ready } = useTranslation(namespaces); + + useEffect(() => { + let isMounted = true; + + const loadTranslations = async () => { + try { + setIsLoading(true); + + // Ensure translations are loaded + await ensureTranslationsLoaded(namespaces); + + // Wait for i18next to be ready + if (!ready) { + // If i18next is not ready, wait a bit and check again + await new Promise(resolve => setTimeout(resolve, 100)); + } + + if (isMounted) { + setIsLoaded(true); + setIsLoading(false); + } + } catch (error) { + if (isMounted) { + setIsLoaded(true); // Still set as loaded to prevent infinite loading + setIsLoading(false); + } + } + }; + + loadTranslations(); + + return () => { + isMounted = false; + }; + }, [namespaces, ready]); + + return { + t, + ready: isLoaded && ready, + isLoading, + isLoaded, + }; +}; + +/** + * Hook specifically for bulk action bar translations + */ +export const useBulkActionTranslations = () => { + return useTranslationPreloader(['tasks/task-table-bulk-actions']); +}; + +/** + * Hook for task management translations + */ +export const useTaskManagementTranslations = () => { + return useTranslationPreloader(['task-management', 'tasks/task-table-bulk-actions']); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/i18n.ts b/worklenz-frontend/src/i18n.ts index f2bc0994..b325bb68 100644 --- a/worklenz-frontend/src/i18n.ts +++ b/worklenz-frontend/src/i18n.ts @@ -1,6 +1,16 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import HttpApi from 'i18next-http-backend'; +import logger from './utils/errorLogger'; + +// Essential namespaces that should be preloaded to prevent Suspense +const ESSENTIAL_NAMESPACES = [ + 'common', + 'tasks/task-table-bulk-actions', + 'task-management', + 'auth/login', + 'settings' +]; i18n .use(HttpApi) @@ -11,9 +21,57 @@ i18n loadPath: '/locales/{{lng}}/{{ns}}.json', }, defaultNS: 'common', + ns: ESSENTIAL_NAMESPACES, interpolation: { escapeValue: false, }, + // Preload essential namespaces + preload: ['en', 'es', 'pt', 'alb', 'de'], + // Load all namespaces on initialization + load: 'languageOnly', + // Cache translations + cache: { + enabled: true, + expirationTime: 24 * 60 * 60 * 1000, // 24 hours + }, }); +// Utility function to ensure translations are loaded +export const ensureTranslationsLoaded = async (namespaces: string[] = ESSENTIAL_NAMESPACES) => { + const currentLang = i18n.language || 'en'; + + try { + // Load all essential namespaces for the current language + await Promise.all( + namespaces.map(ns => + i18n.loadNamespaces(ns).catch(() => { + logger.error(`Failed to load namespace: ${ns}`); + }) + ) + ); + + // Also preload for other languages to prevent delays on language switch + const otherLangs = ['en', 'es', 'pt', 'alb', 'de'].filter(lang => lang !== currentLang); + await Promise.all( + otherLangs.map(lang => + Promise.all( + namespaces.map(ns => + i18n.loadNamespaces(ns).catch(() => { + logger.error(`Failed to load namespace: ${ns}`); + }) + ) + ) + ) + ); + + return true; + } catch (error) { + logger.error('Failed to load translations:', error); + return false; + } +}; + +// Initialize translations on app startup +ensureTranslationsLoaded(); + export default i18n; From a1e8a4c464245e647ae01bc16474f446bc6d5bee Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Wed, 2 Jul 2025 16:01:04 +0530 Subject: [PATCH 6/6] feat(task-management): enhance bulk action bar and localization updates - Added new features to the OptimizedBulkActionBar, including dropdowns for labels and assignees, improving task management capabilities. - Integrated task template creation functionality for owners/admins, allowing users to create templates from selected tasks. - Updated localization files for multiple languages, adding new strings for label searching and template name requirements to enhance user experience. - Refactored LabelsDropdown component to support label filtering and improved UI feedback for label creation. --- .../alb/tasks/task-table-bulk-actions.json | 2 + .../de/tasks/task-table-bulk-actions.json | 2 + .../locales/en/task-template-drawer.json | 1 + .../en/tasks/task-table-bulk-actions.json | 2 + .../es/tasks/task-table-bulk-actions.json | 2 + .../pt/tasks/task-table-bulk-actions.json | 2 + .../optimized-bulk-action-bar.tsx | 291 +++++++++++++++++- .../task-management/task-list-board.tsx | 77 ++++- .../task-templates/task-template-drawer.tsx | 3 +- .../components/LabelsDropdown.tsx | 94 ++++-- 10 files changed, 425 insertions(+), 51 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/alb/tasks/task-table-bulk-actions.json index cb433bf9..45980b24 100644 --- a/worklenz-frontend/public/locales/alb/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/alb/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Krijo Shabllon Detyre", "apply": "Apliko", "createLabel": "+ Krijo Etiketë", + "searchOrCreateLabel": "Kërko ose krijo etiketë...", "hitEnterToCreate": "Shtyp Enter për të krijuar", + "labelExists": "Etiketa ekziston tashmë", "pendingInvitation": "Ftesë në Pritje", "noMatchingLabels": "Asnjë etiketë që përputhet", "noLabels": "Asnjë etiketë" diff --git a/worklenz-frontend/public/locales/de/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/de/tasks/task-table-bulk-actions.json index 297987c5..e8b039f2 100644 --- a/worklenz-frontend/public/locales/de/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/de/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Aufgabenvorlage erstellen", "apply": "Anwenden", "createLabel": "+ Label erstellen", + "searchOrCreateLabel": "Label suchen oder erstellen...", "hitEnterToCreate": "Enter drücken zum Erstellen", + "labelExists": "Label existiert bereits", "pendingInvitation": "Einladung ausstehend", "noMatchingLabels": "Keine passenden Labels", "noLabels": "Keine Labels", diff --git a/worklenz-frontend/public/locales/en/task-template-drawer.json b/worklenz-frontend/public/locales/en/task-template-drawer.json index f2e23bee..9bc59126 100644 --- a/worklenz-frontend/public/locales/en/task-template-drawer.json +++ b/worklenz-frontend/public/locales/en/task-template-drawer.json @@ -4,6 +4,7 @@ "cancelText": "Cancel", "saveText": "Save", "templateNameText": "Template Name", + "templateNameRequired": "Template name is required", "selectedTasks": "Selected Tasks", "removeTask": "Remove", "cancelButton": "Cancel", diff --git a/worklenz-frontend/public/locales/en/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/en/tasks/task-table-bulk-actions.json index 4beab4f6..99eb3178 100644 --- a/worklenz-frontend/public/locales/en/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/en/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Create Task Template", "apply": "Apply", "createLabel": "+ Create Label", + "searchOrCreateLabel": "Search or create label...", "hitEnterToCreate": "Press Enter to create", + "labelExists": "Label already exists", "pendingInvitation": "Pending Invitation", "noMatchingLabels": "No matching labels", "noLabels": "No labels", diff --git a/worklenz-frontend/public/locales/es/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/es/tasks/task-table-bulk-actions.json index 94963c91..5ba35bdf 100644 --- a/worklenz-frontend/public/locales/es/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/es/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Crear plantilla de tarea", "apply": "Aplicar", "createLabel": "+ Crear etiqueta", + "searchOrCreateLabel": "Buscar o crear etiqueta...", "hitEnterToCreate": "Presione Enter para crear", + "labelExists": "La etiqueta ya existe", "pendingInvitation": "Invitación Pendiente", "noMatchingLabels": "No hay etiquetas coincidentes", "noLabels": "Sin etiquetas", diff --git a/worklenz-frontend/public/locales/pt/tasks/task-table-bulk-actions.json b/worklenz-frontend/public/locales/pt/tasks/task-table-bulk-actions.json index 6581803a..8d03c678 100644 --- a/worklenz-frontend/public/locales/pt/tasks/task-table-bulk-actions.json +++ b/worklenz-frontend/public/locales/pt/tasks/task-table-bulk-actions.json @@ -17,7 +17,9 @@ "createTaskTemplate": "Criar Modelo de Tarefa", "apply": "Aplicar", "createLabel": "+ Criar etiqueta", + "searchOrCreateLabel": "Pesquisar ou criar etiqueta...", "hitEnterToCreate": "Pressione Enter para criar", + "labelExists": "A etiqueta já existe", "pendingInvitation": "Convite Pendente", "noMatchingLabels": "Nenhuma etiqueta correspondente", "noLabels": "Sem etiquetas", diff --git a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx index 9ce9f643..50422656 100644 --- a/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx +++ b/worklenz-frontend/src/components/task-management/optimized-bulk-action-bar.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useState, useEffect } from 'react'; +import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { Button, @@ -19,12 +19,23 @@ import { TagsOutlined, UsergroupAddOutlined, FlagOutlined, - BulbOutlined + BulbOutlined, + MoreOutlined } from '@ant-design/icons'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { RootState } from '@/app/store'; import { useAppSelector } from '@/hooks/useAppSelector'; +import { selectTasks } from '@/features/projects/bulkActions/bulkActionSlice'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { useBulkActionTranslations } from '@/hooks/useTranslationPreloader'; +import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown'; +import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown'; +import { ITaskLabel } from '@/types/tasks/taskLabel.types'; +import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types'; +import { InputRef } from 'antd/es/input'; +import { CheckboxChangeEvent } from 'antd/es/checkbox'; +import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; +import { useAuthService } from '@/hooks/useAuth'; const { Text } = Typography; @@ -139,12 +150,16 @@ const OptimizedBulkActionBarContent: React.FC = Rea onBulkSetDueDate, }) => { const { t, ready, isLoading } = useBulkActionTranslations(); + const dispatch = useDispatch(); const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); // Get data from Redux store const statusList = useAppSelector(state => state.taskStatusReducer.status); const priorityList = useAppSelector(state => state.priorityReducer.priorities); const phaseList = useAppSelector(state => state.phaseReducer.phaseList); + const labelsList = useAppSelector(state => state.taskLabelsReducer.labels); + const members = useAppSelector(state => state.teamMembersReducer.teamMembers); + const tasks = useAppSelector(state => state.taskManagement.entities); // Performance state management const [isVisible, setIsVisible] = useState(false); @@ -162,6 +177,20 @@ const OptimizedBulkActionBarContent: React.FC = Rea dueDate: false, }); + // Labels dropdown state + const [selectedLabels, setSelectedLabels] = useState([]); + const [createLabelText, setCreateLabelText] = useState(''); + const labelsInputRef = useRef(null); + + // Assignees dropdown state + const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false); + + // Task template state + const [showDrawer, setShowDrawer] = useState(false); + + // Auth service for permissions + const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + // Smooth entrance animation useEffect(() => { if (totalSelected > 0) { @@ -200,6 +229,8 @@ const OptimizedBulkActionBarContent: React.FC = Rea })), [phaseList] ); + + // Menu click handlers const handleStatusMenuClick = useCallback((e: any) => { onBulkStatusChange?.(e.key); @@ -213,6 +244,126 @@ const OptimizedBulkActionBarContent: React.FC = Rea onBulkPhaseChange?.(e.key); }, [onBulkPhaseChange]); + const handleLabelsMenuClick = useCallback((e: any) => { + onBulkAddLabels?.([e.key]); + }, [onBulkAddLabels]); + + // Labels dropdown handlers + const handleLabelChange = useCallback((e: CheckboxChangeEvent, label: ITaskLabel) => { + if (e.target.checked) { + setSelectedLabels(prev => [...prev, label]); + } else { + setSelectedLabels(prev => prev.filter(l => l.id !== label.id)); + } + }, []); + + const handleApplyLabels = useCallback(async () => { + if (!projectId) return; + try { + updateLoadingState('labels', true); + const body = { + tasks: selectedTaskIds, + labels: selectedLabels, + text: selectedLabels.length > 0 ? null : createLabelText.trim() !== '' ? createLabelText.trim() : null, + }; + await onBulkAddLabels?.(selectedLabels.map(l => l.id).filter((id): id is string => id !== undefined)); + setCreateLabelText(''); + setSelectedLabels([]); + } catch (error) { + // Error handling is done in the parent component + } finally { + updateLoadingState('labels', false); + } + }, [selectedLabels, createLabelText, selectedTaskIds, projectId, onBulkAddLabels, updateLoadingState]); + + // Assignees dropdown handlers + const handleChangeAssignees = useCallback(async (selectedAssignees: ITeamMemberViewModel[]) => { + if (!projectId) return; + try { + updateLoadingState('assignMembers', true); + await onBulkAssignMembers?.(selectedAssignees.map(m => m.id).filter((id): id is string => id !== undefined)); + } catch (error) { + // Error handling is done in the parent component + } finally { + updateLoadingState('assignMembers', false); + } + }, [projectId, onBulkAssignMembers, updateLoadingState]); + + const onAssigneeDropdownOpenChange = useCallback((open: boolean) => { + setAssigneeDropdownOpen(open); + }, []); + + // Get selected task objects for template creation + const selectedTaskObjects = useMemo(() => { + return Object.values(tasks).filter((task: any) => selectedTaskIds.includes(task.id)); + }, [tasks, selectedTaskIds]); + + // Update Redux state when opening template drawer + const handleOpenTemplateDrawer = useCallback(() => { + // Convert Task objects to IProjectTask format for template creation + const projectTasks: IProjectTask[] = selectedTaskObjects.map((task: any) => ({ + id: task.id, + name: task.title, // Always use title as the name + task_key: task.task_key, + status: task.status, + status_id: task.status, + priority: task.priority, + phase_id: task.phase, + phase_name: task.phase, + description: task.description, + start_date: task.startDate, + end_date: task.dueDate, + 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: string) => ({ + id: assigneeId, + name: '', + email: '', + avatar_url: '', + team_member_id: assigneeId, + project_member_id: assigneeId, + })) || [], + labels: task.labels || [], + manual_progress: false, + created_at: task.createdAt, + updated_at: task.updatedAt, + sort_order: task.order, + })); + + // Update the bulkActionReducer with selected tasks + dispatch(selectTasks(projectTasks)); + setShowDrawer(true); + }, [selectedTaskObjects, dispatch]); + + // Labels dropdown content + const labelsDropdownContent = useMemo(() => ( + } + onLabelChange={handleLabelChange} + onCreateLabelTextChange={setCreateLabelText} + onApply={handleApplyLabels} + t={t} + loading={loadingStates.labels} + /> + ), [labelsList, isDarkMode, createLabelText, selectedLabels, handleLabelChange, handleApplyLabels, t, loadingStates.labels]); + + // Assignees dropdown content + const assigneesDropdownContent = useMemo(() => ( + setAssigneeDropdownOpen(false)} + t={t} + /> + ), [members?.data, isDarkMode, handleChangeAssignees, t]); + // Memoized handlers with loading states const handleStatusChange = useCallback(async () => { updateLoadingState('status', true); @@ -466,13 +617,41 @@ const OptimizedBulkActionBarContent: React.FC = Rea {/* Change Labels */} - } - tooltip={t('ADD_LABELS')} - onClick={() => onBulkAddLabels?.([])} - loading={loadingStates.labels} - isDarkMode={isDarkMode} - /> + + labelsDropdownContent} + trigger={['click']} + placement="top" + arrow + onOpenChange={(open) => { + if (!open) { + setSelectedLabels([]); + setCreateLabelText(''); + } + }} + > +
); }); 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 ed344b4d..74c082bc 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -38,6 +38,11 @@ import { toggleTaskSelection, clearSelection, } from '@/features/task-management/selection.slice'; +import { + selectTaskIds, + selectTasks, + deselectAll as deselectAllBulk, +} from '@/features/projects/bulkActions/bulkActionSlice'; import { Task } from '@/types/task-management.types'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useSocket } from '@/socket/socketContext'; @@ -49,7 +54,6 @@ import OptimizedBulkActionBar from './optimized-bulk-action-bar'; import VirtualizedTaskList from './virtualized-task-list'; import { AppDispatch } from '@/app/store'; import { shallowEqual } from 'react-redux'; -import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { @@ -73,6 +77,7 @@ 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'; @@ -153,7 +158,9 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const tasks = useSelector(taskManagementSelectors.selectAll); const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual); const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual); - const selectedTaskIds = useSelector(selectSelectedTaskIds); + // Use bulk action slice for selected tasks instead of selection slice + const selectedTaskIds = useSelector((state: RootState) => state.bulkActionReducer.selectedTaskIdsList); + const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks); const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual); const error = useSelector((state: RootState) => state.taskManagement.error); @@ -403,9 +410,53 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const handleSelectTask = useCallback( (taskId: string, selected: boolean) => { - dispatch(toggleTaskSelection(taskId)); + if (selected) { + // Add task to bulk selection + const task = tasks.find(t => t.id === taskId); + if (task) { + // Convert Task to IProjectTask format for bulk actions + const projectTask: IProjectTask = { + id: task.id, + name: task.title, // Always use title as the name + task_key: task.task_key, + status: task.status, + status_id: task.status, + priority: task.priority, + phase_id: task.phase, + phase_name: task.phase, + description: task.description, + start_date: task.startDate, + end_date: task.dueDate, + 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 => ({ + id: assigneeId, + name: '', + email: '', + avatar_url: '', + team_member_id: assigneeId, + project_member_id: assigneeId, + })), + labels: task.labels, + manual_progress: false, // Default value for Task type + created_at: task.createdAt, + updated_at: task.updatedAt, + sort_order: task.order, + }; + dispatch(selectTasks([...selectedTasks, projectTask])); + dispatch(selectTaskIds([...selectedTaskIds, taskId])); + } + } else { + // Remove task from bulk selection + const updatedTasks = selectedTasks.filter(t => t.id !== taskId); + const updatedTaskIds = selectedTaskIds.filter(id => id !== taskId); + dispatch(selectTasks(updatedTasks)); + dispatch(selectTaskIds(updatedTaskIds)); + } }, - [dispatch] + [dispatch, selectedTasks, selectedTaskIds, tasks] ); const handleToggleSubtasks = useCallback((taskId: string) => { @@ -430,7 +481,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar const handleClearSelection = useCallback(() => { - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); }, [dispatch]); @@ -468,7 +519,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const res = await taskListBulkActionsApiService.changeStatus(body, projectId); if (res.done) { trackMixpanelEvent(evt_project_task_list_bulk_change_status); - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); dispatch(fetchTasksV3(projectId)); } @@ -490,7 +541,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const res = await taskListBulkActionsApiService.changePriority(body, projectId); if (res.done) { trackMixpanelEvent(evt_project_task_list_bulk_change_priority); - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); dispatch(fetchTasksV3(projectId)); } @@ -512,7 +563,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const res = await taskListBulkActionsApiService.changePhase(body, projectId); if (res.done) { trackMixpanelEvent(evt_project_task_list_bulk_change_phase); - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); dispatch(fetchTasksV3(projectId)); } @@ -531,7 +582,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const res = await taskListBulkActionsApiService.assignToMe(body); if (res.done) { trackMixpanelEvent(evt_project_task_list_bulk_assign_me); - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); dispatch(fetchTasksV3(projectId)); } @@ -563,7 +614,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const res = await taskListBulkActionsApiService.assignTasks(body); if (res.done) { trackMixpanelEvent(evt_project_task_list_bulk_assign_members); - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); dispatch(fetchTasksV3(projectId)); } @@ -588,7 +639,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const res = await taskListBulkActionsApiService.assignLabels(body, projectId); if (res.done) { trackMixpanelEvent(evt_project_task_list_bulk_update_labels); - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); dispatch(fetchTasksV3(projectId)); dispatch(fetchLabels()); @@ -608,7 +659,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const res = await taskListBulkActionsApiService.archiveTasks(body, archived); if (res.done) { trackMixpanelEvent(evt_project_task_list_bulk_archive); - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); dispatch(fetchTasksV3(projectId)); } @@ -627,7 +678,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const res = await taskListBulkActionsApiService.deleteTasks(body, projectId); if (res.done) { trackMixpanelEvent(evt_project_task_list_bulk_delete); - dispatch(deselectAll()); + dispatch(deselectAllBulk()); dispatch(clearSelection()); dispatch(fetchTasksV3(projectId)); } diff --git a/worklenz-frontend/src/components/task-templates/task-template-drawer.tsx b/worklenz-frontend/src/components/task-templates/task-template-drawer.tsx index eab34400..6d295865 100644 --- a/worklenz-frontend/src/components/task-templates/task-template-drawer.tsx +++ b/worklenz-frontend/src/components/task-templates/task-template-drawer.tsx @@ -59,6 +59,7 @@ const TaskTemplateDrawer = ({ fetchTemplateData(); return; } + // Tasks should already have the name property set correctly setTemplateData({ tasks: selectedTasks }); }; @@ -126,7 +127,7 @@ const TaskTemplateDrawer = ({ open={showDrawer} onClose={onCloseDrawer} afterOpenChange={afterOpenChange} - destroyOnClose={true} + destroyOnHidden={true} footer={
diff --git a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown.tsx b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown.tsx index 8c44ffd6..ceb7e6ff 100644 --- a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown.tsx +++ b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown.tsx @@ -35,6 +35,23 @@ const LabelsDropdown = ({ } }, []); + // Filter labels based on search input + const filteredLabels = useMemo(() => { + if (!createLabelText.trim()) { + return labelsList; + } + return labelsList.filter(label => + label.name?.toLowerCase().includes(createLabelText.toLowerCase()) + ); + }, [labelsList, createLabelText]); + + // Check if the search text matches any existing label exactly + const exactMatch = useMemo(() => { + return labelsList.some(label => + label.name?.toLowerCase() === createLabelText.toLowerCase() + ); + }, [labelsList, createLabelText]); + const isOnApply = () => { if (!createLabelText.trim() && selectedLabels.length === 0) return; onApply(); @@ -42,18 +59,17 @@ const LabelsDropdown = ({ return ( - {/* Always show the list, filtered by input */} - {!createLabelText && ( - 10 ? '200px' : 'auto', // Set max height if more than 10 labels - maxWidth: 250, + {/* Show filtered labels list */} + 10 ? '200px' : 'auto', + maxWidth: 250, }} > - {labelsList.length > 0 && ( - labelsList.map(label => ( + {filteredLabels.length > 0 ? ( + filteredLabels.map(label => ( )) - )} - - )} + ) : createLabelText.trim() ? ( + + + {t('noMatchingLabels')} + + + ) : ( + + + {t('noLabels')} + + + )} + onCreateLabelTextChange(e.currentTarget.value)} - placeholder={t('createLabel')} + placeholder={t('searchOrCreateLabel')} onPressEnter={() => { isOnApply(); }} /> {createLabelText && ( - {t('hitEnterToCreate')} + {exactMatch + ? t('labelExists') + : t('hitEnterToCreate') + } )} - {!createLabelText && ( - - )} +