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: - -
- ) - }), 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: - -
- ) - }), 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: - -
- ) - }), 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: - -
- ) - }), 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 {