From 05729285afe81ef8d28f29f21f963db92b85c403 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Sun, 22 Jun 2025 14:16:39 +0530 Subject: [PATCH 1/5] feat(components): introduce new UI components and enhance Vite configuration - Added AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, and Tooltip components for improved UI functionality. - Updated Vite configuration to change the development server port to 5173 and removed unnecessary interop settings for module compatibility. - Enhanced task management components to utilize new task structure and improve performance. --- .../src/components/AssigneeSelector.tsx | 220 +++++ worklenz-frontend/src/components/Avatar.tsx | 89 ++ .../src/components/AvatarGroup.tsx | 111 +++ worklenz-frontend/src/components/Button.tsx | 64 ++ worklenz-frontend/src/components/Checkbox.tsx | 42 + .../src/components/CustomColordLabel.tsx | 30 + .../src/components/CustomNumberLabel.tsx | 30 + .../src/components/LabelsSelector.tsx | 279 +++++++ worklenz-frontend/src/components/Progress.tsx | 84 ++ worklenz-frontend/src/components/Tag.tsx | 54 ++ worklenz-frontend/src/components/Tooltip.tsx | 35 + worklenz-frontend/src/components/index.ts | 12 + .../components/task-management/task-group.tsx | 115 ++- .../task-management/task-list-board.tsx | 194 +++-- .../components/task-management/task-row.tsx | 780 ++++++------------ .../task-management/task-management.slice.ts | 106 ++- .../src/types/task-management.types.ts | 5 +- worklenz-frontend/vite.config.ts | 5 +- 18 files changed, 1566 insertions(+), 689 deletions(-) create mode 100644 worklenz-frontend/src/components/AssigneeSelector.tsx create mode 100644 worklenz-frontend/src/components/Avatar.tsx create mode 100644 worklenz-frontend/src/components/AvatarGroup.tsx create mode 100644 worklenz-frontend/src/components/Button.tsx create mode 100644 worklenz-frontend/src/components/Checkbox.tsx create mode 100644 worklenz-frontend/src/components/CustomColordLabel.tsx create mode 100644 worklenz-frontend/src/components/CustomNumberLabel.tsx create mode 100644 worklenz-frontend/src/components/LabelsSelector.tsx create mode 100644 worklenz-frontend/src/components/Progress.tsx create mode 100644 worklenz-frontend/src/components/Tag.tsx create mode 100644 worklenz-frontend/src/components/Tooltip.tsx create mode 100644 worklenz-frontend/src/components/index.ts diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx new file mode 100644 index 00000000..d4936f40 --- /dev/null +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -0,0 +1,220 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { PlusOutlined, 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'; + +interface AssigneeSelectorProps { + task: IProjectTask; + groupId?: string | null; + isDarkMode?: boolean; +} + +const AssigneeSelector: React.FC = ({ + task, + groupId = null, + isDarkMode = false +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [teamMembers, setTeamMembers] = useState({ data: [], total: 0 }); + const dropdownRef = useRef(null); + const searchInputRef = useRef(null); + + const { projectId } = useSelector((state: RootState) => state.projectReducer); + const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers); + const currentSession = useAuthService().getCurrentSession(); + const { socket } = useSocket(); + + const filteredMembers = useMemo(() => { + return teamMembers?.data?.filter(member => + member.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [teamMembers, searchQuery]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleDropdownToggle = () => { + if (!isOpen) { + // Prepare team members data when opening + 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); + } + setIsOpen(!isOpen); + }; + + const handleMemberToggle = (memberId: string, checked: boolean) => { + if (!memberId || !projectId || !task?.id || !currentSession?.id) return; + + 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, + }; + + socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); + }; + + const checkMemberSelected = (memberId: string) => { + if (!memberId) return false; + const assignees = task?.assignees?.map(assignee => assignee.team_member_id); + return assignees?.includes(memberId) || false; + }; + + return ( +
+ + + {isOpen && ( +
+ {/* 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} + isDarkMode={isDarkMode} + /> + + + +
+
+ {member.name} +
+
+ {member.email} + {member.pending_invitation && ( + (Pending) + )} +
+
+
+ )) + ) : ( +
+
No members found
+
+ )} +
+ + {/* Footer */} +
+ +
+
+ )} +
+ ); +}; + +export default AssigneeSelector; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Avatar.tsx b/worklenz-frontend/src/components/Avatar.tsx new file mode 100644 index 00000000..413a4e3d --- /dev/null +++ b/worklenz-frontend/src/components/Avatar.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +interface AvatarProps { + name?: string; + size?: number | 'small' | 'default' | 'large'; + isDarkMode?: boolean; + className?: string; + src?: string; + backgroundColor?: string; + onClick?: (e: React.MouseEvent) => void; + style?: React.CSSProperties; +} + +const Avatar: React.FC = ({ + name = '', + size = 'default', + isDarkMode = false, + className = '', + src, + backgroundColor, + onClick, + style = {} +}) => { + // Handle both numeric and string sizes + const getSize = () => { + if (typeof size === 'number') { + return { width: size, height: size, fontSize: `${size * 0.4}px` }; + } + + const sizeMap = { + small: { width: 24, height: 24, fontSize: '10px' }, + default: { width: 32, height: 32, fontSize: '14px' }, + large: { width: 48, height: 48, fontSize: '18px' } + }; + + return sizeMap[size]; + }; + + const sizeStyle = getSize(); + + const lightColors = [ + '#f56565', '#4299e1', '#48bb78', '#ed8936', '#9f7aea', + '#ed64a6', '#667eea', '#38b2ac', '#f6ad55', '#4fd1c7' + ]; + + const darkColors = [ + '#e53e3e', '#3182ce', '#38a169', '#dd6b20', '#805ad5', + '#d53f8c', '#5a67d8', '#319795', '#d69e2e', '#319795' + ]; + + const colors = isDarkMode ? darkColors : lightColors; + const colorIndex = name.charCodeAt(0) % colors.length; + const defaultBgColor = backgroundColor || colors[colorIndex]; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e); + }; + + const avatarStyle = { + ...sizeStyle, + backgroundColor: defaultBgColor, + ...style + }; + + if (src) { + return ( + {name} + ); + } + + return ( +
+ {name.charAt(0)?.toUpperCase() || '?'} +
+ ); +}; + +export default Avatar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/AvatarGroup.tsx b/worklenz-frontend/src/components/AvatarGroup.tsx new file mode 100644 index 00000000..a0eaf410 --- /dev/null +++ b/worklenz-frontend/src/components/AvatarGroup.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useMemo } from 'react'; +import { Avatar, Tooltip } from './index'; + +interface Member { + id?: string; + team_member_id?: string; + name?: string; + names?: string[]; + avatar_url?: string; + color_code?: string; + end?: boolean; +} + +interface AvatarGroupProps { + members: Member[]; + maxCount?: number; + size?: number | 'small' | 'default' | 'large'; + isDarkMode?: boolean; + className?: string; + onClick?: (e: React.MouseEvent) => void; +} + +const AvatarGroup: React.FC = ({ + members, + maxCount, + size = 28, + isDarkMode = false, + className = '', + onClick +}) => { + const stopPropagation = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e); + }, [onClick]); + + const renderAvatar = useCallback((member: Member, index: number) => { + const memberName = member.end && member.names ? member.names.join(', ') : member.name || ''; + const displayName = member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase(); + + return ( + + + + ); + }, [stopPropagation, size, isDarkMode]); + + const visibleMembers = useMemo(() => { + return maxCount ? members.slice(0, maxCount) : members; + }, [members, maxCount]); + + const remainingCount = useMemo(() => { + return maxCount ? Math.max(0, members.length - maxCount) : 0; + }, [members.length, maxCount]); + + const avatarElements = useMemo(() => { + return visibleMembers.map((member, index) => renderAvatar(member, index)); + }, [visibleMembers, renderAvatar]); + + const getSizeStyle = () => { + if (typeof size === 'number') { + return { width: size, height: size, fontSize: `${size * 0.4}px` }; + } + + const sizeMap = { + small: { width: 24, height: 24, fontSize: '10px' }, + default: { width: 32, height: 32, fontSize: '14px' }, + large: { width: 48, height: 48, fontSize: '18px' } + }; + + return sizeMap[size]; + }; + + return ( +
+ {avatarElements} + {remainingCount > 0 && ( + +
+ +{remainingCount} +
+
+ )} +
+ ); +}; + +export default AvatarGroup; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Button.tsx b/worklenz-frontend/src/components/Button.tsx new file mode 100644 index 00000000..e6d28be2 --- /dev/null +++ b/worklenz-frontend/src/components/Button.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +interface ButtonProps { + children?: React.ReactNode; + onClick?: () => void; + variant?: 'text' | 'default' | 'primary' | 'danger'; + size?: 'small' | 'default' | 'large'; + className?: string; + icon?: React.ReactNode; + isDarkMode?: boolean; + disabled?: boolean; + type?: 'button' | 'submit' | 'reset'; +} + +const Button: React.FC> = ({ + children, + onClick, + variant = 'default', + size = 'default', + className = '', + icon, + isDarkMode = false, + disabled = false, + type = 'button', + ...props +}) => { + const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-1 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`; + + const variantClasses = { + text: isDarkMode + ? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50' + : 'text-gray-600 hover:text-gray-800 hover:bg-gray-100', + default: isDarkMode + ? 'bg-gray-800 border border-gray-600 text-gray-200 hover:bg-gray-700' + : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50', + primary: isDarkMode + ? 'bg-blue-600 text-white hover:bg-blue-700' + : 'bg-blue-500 text-white hover:bg-blue-600', + danger: isDarkMode + ? 'bg-red-600 text-white hover:bg-red-700' + : 'bg-red-500 text-white hover:bg-red-600' + }; + + const sizeClasses = { + small: 'px-2 py-1 text-xs rounded', + default: 'px-3 py-2 text-sm rounded-md', + large: 'px-4 py-3 text-base rounded-lg' + }; + + return ( + + ); +}; + +export default Button; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Checkbox.tsx b/worklenz-frontend/src/components/Checkbox.tsx new file mode 100644 index 00000000..6141331a --- /dev/null +++ b/worklenz-frontend/src/components/Checkbox.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +interface CheckboxProps { + checked: boolean; + onChange: (checked: boolean) => void; + isDarkMode?: boolean; + className?: string; + disabled?: boolean; +} + +const Checkbox: React.FC = ({ + checked, + onChange, + isDarkMode = false, + className = '', + disabled = false +}) => { + return ( + + ); +}; + +export default Checkbox; \ No newline at end of file diff --git a/worklenz-frontend/src/components/CustomColordLabel.tsx b/worklenz-frontend/src/components/CustomColordLabel.tsx new file mode 100644 index 00000000..a76359f0 --- /dev/null +++ b/worklenz-frontend/src/components/CustomColordLabel.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Tooltip } from 'antd'; +import { Label } from '@/types/task-management.types'; + +interface CustomColordLabelProps { + label: Label; + isDarkMode?: boolean; +} + +const CustomColordLabel: React.FC = ({ + label, + isDarkMode = false +}) => { + const truncatedName = label.name && label.name.length > 10 + ? `${label.name.substring(0, 10)}...` + : label.name; + + return ( + + + {truncatedName} + + + ); +}; + +export default CustomColordLabel; \ No newline at end of file diff --git a/worklenz-frontend/src/components/CustomNumberLabel.tsx b/worklenz-frontend/src/components/CustomNumberLabel.tsx new file mode 100644 index 00000000..02701724 --- /dev/null +++ b/worklenz-frontend/src/components/CustomNumberLabel.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Tooltip } from 'antd'; + +interface CustomNumberLabelProps { + labelList: string[]; + namesString: string; + isDarkMode?: boolean; +} + +const CustomNumberLabel: React.FC = ({ + labelList, + namesString, + isDarkMode = false +}) => { + return ( + + + {namesString} + + + ); +}; + +export default CustomNumberLabel; \ No newline at end of file diff --git a/worklenz-frontend/src/components/LabelsSelector.tsx b/worklenz-frontend/src/components/LabelsSelector.tsx new file mode 100644 index 00000000..2e987948 --- /dev/null +++ b/worklenz-frontend/src/components/LabelsSelector.tsx @@ -0,0 +1,279 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useSelector } from 'react-redux'; +import { PlusOutlined, TagOutlined } from '@ant-design/icons'; +import { RootState } from '@/app/store'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskLabel } from '@/types/tasks/taskLabel.types'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useAuthService } from '@/hooks/useAuth'; +import { Button, Checkbox, Tag } from '@/components'; + +interface LabelsSelectorProps { + task: IProjectTask; + isDarkMode?: boolean; +} + +const LabelsSelector: React.FC = ({ + task, + isDarkMode = false +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + const searchInputRef = useRef(null); + + const { labels } = useSelector((state: RootState) => state.taskLabelsReducer); + const currentSession = useAuthService().getCurrentSession(); + const { socket } = useSocket(); + + const filteredLabels = useMemo(() => { + return (labels as ITaskLabel[])?.filter(label => + label.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ) || []; + }, [labels, searchQuery]); + + // Update dropdown position + const updateDropdownPosition = useCallback(() => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 2, + left: rect.left + window.scrollX, + }); + } + }, []); + + // Close dropdown when clicking outside and handle scroll + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && !buttonRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + const handleScroll = () => { + if (isOpen) { + updateDropdownPosition(); + } + }; + + const handleResize = () => { + if (isOpen) { + updateDropdownPosition(); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleResize); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleResize); + }; + } else { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen, updateDropdownPosition]); + + const handleDropdownToggle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Labels dropdown toggle clicked, current state:', isOpen); + + if (!isOpen) { + updateDropdownPosition(); + setIsOpen(true); + // Focus search input after opening + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + } else { + setIsOpen(false); + } + }; + + + + const handleLabelToggle = (label: ITaskLabel) => { + const labelData = { + task_id: task.id, + label_id: label.id, + parent_task: task.parent_task_id, + team_id: currentSession?.team_id, + }; + + socket?.emit(SocketEvents.TASK_LABELS_CHANGE.toString(), JSON.stringify(labelData)); + }; + + const handleCreateLabel = () => { + if (!searchQuery.trim()) return; + + const labelData = { + task_id: task.id, + label: searchQuery.trim(), + parent_task: task.parent_task_id, + team_id: currentSession?.team_id, + }; + + socket?.emit(SocketEvents.CREATE_LABEL.toString(), JSON.stringify(labelData)); + setSearchQuery(''); + }; + + const checkLabelSelected = (labelId: string) => { + return task?.all_labels?.some(existingLabel => existingLabel.id === labelId) || false; + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const existingLabel = filteredLabels.find( + label => label.name?.toLowerCase() === searchQuery.toLowerCase() + ); + + if (!existingLabel && e.key === 'Enter') { + handleCreateLabel(); + } + }; + + return ( + <> + + + {isOpen && createPortal( +
+ {/* Header */} +
+ setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search labels..." + 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 + `} + /> +
+ + {/* Labels List */} +
+ {filteredLabels && filteredLabels.length > 0 ? ( + filteredLabels.map((label) => ( +
handleLabelToggle(label)} + > + handleLabelToggle(label)} + isDarkMode={isDarkMode} + /> + +
+ +
+
+ {label.name} +
+
+
+ )) + ) : ( +
+
No labels found
+ {searchQuery.trim() && ( + + )} +
+ )} +
+ + {/* Footer */} +
+ +
+
, + document.body + )} + + ); +}; + +export default LabelsSelector; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Progress.tsx b/worklenz-frontend/src/components/Progress.tsx new file mode 100644 index 00000000..be89433a --- /dev/null +++ b/worklenz-frontend/src/components/Progress.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +interface ProgressProps { + percent: number; + type?: 'line' | 'circle'; + size?: number; + strokeColor?: string; + strokeWidth?: number; + showInfo?: boolean; + isDarkMode?: boolean; + className?: string; +} + +const Progress: React.FC = ({ + percent, + type = 'line', + size = 24, + strokeColor = '#1890ff', + strokeWidth = 2, + showInfo = true, + isDarkMode = false, + className = '' +}) => { + // Ensure percent is between 0 and 100 + const normalizedPercent = Math.min(Math.max(percent, 0), 100); + + if (type === 'circle') { + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const strokeDasharray = circumference; + const strokeDashoffset = circumference - (normalizedPercent / 100) * circumference; + + return ( +
+ + + + + {showInfo && ( + + {normalizedPercent}% + + )} +
+ ); + } + + return ( +
+
+ {showInfo && ( +
+ {normalizedPercent}% +
+ )} +
+ ); +}; + +export default Progress; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Tag.tsx b/worklenz-frontend/src/components/Tag.tsx new file mode 100644 index 00000000..5cdad835 --- /dev/null +++ b/worklenz-frontend/src/components/Tag.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +interface TagProps { + children: React.ReactNode; + color?: string; + backgroundColor?: string; + className?: string; + size?: 'small' | 'default'; + variant?: 'default' | 'outlined'; + isDarkMode?: boolean; +} + +const Tag: React.FC = ({ + children, + color = 'white', + backgroundColor = '#1890ff', + className = '', + size = 'default', + variant = 'default', + isDarkMode = false +}) => { + const sizeClasses = { + small: 'px-1 py-0.5 text-xs', + default: 'px-2 py-1 text-xs' + }; + + const baseClasses = `inline-flex items-center font-medium rounded ${sizeClasses[size]}`; + + if (variant === 'outlined') { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}; + +export default Tag; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Tooltip.tsx b/worklenz-frontend/src/components/Tooltip.tsx new file mode 100644 index 00000000..e61ea1ce --- /dev/null +++ b/worklenz-frontend/src/components/Tooltip.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +interface TooltipProps { + title: string | React.ReactNode; + children: React.ReactNode; + isDarkMode?: boolean; + placement?: 'top' | 'bottom' | 'left' | 'right'; + className?: string; +} + +const Tooltip: React.FC = ({ + title, + children, + isDarkMode = false, + placement = 'top', + className = '' +}) => { + const placementClasses = { + top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2', + bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2', + left: 'right-full top-1/2 transform -translate-y-1/2 mr-2', + right: 'left-full top-1/2 transform -translate-y-1/2 ml-2' + }; + + return ( +
+ {children} +
+ {title} +
+
+ ); +}; + +export default Tooltip; \ No newline at end of file diff --git a/worklenz-frontend/src/components/index.ts b/worklenz-frontend/src/components/index.ts new file mode 100644 index 00000000..dcec2980 --- /dev/null +++ b/worklenz-frontend/src/components/index.ts @@ -0,0 +1,12 @@ +// Reusable UI Components +export { default as AssigneeSelector } from './AssigneeSelector'; +export { default as Avatar } from './Avatar'; +export { default as AvatarGroup } from './AvatarGroup'; +export { default as Button } from './Button'; +export { default as Checkbox } from './Checkbox'; +export { default as CustomColordLabel } from './CustomColordLabel'; +export { default as CustomNumberLabel } from './CustomNumberLabel'; +export { default as LabelsSelector } from './LabelsSelector'; +export { default as Progress } from './Progress'; +export { default as Tag } from './Tag'; +export { default as Tooltip } from './Tooltip'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index dff7eb74..d534beb5 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -1,12 +1,11 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSelector } from 'react-redux'; import { Button, Typography } from 'antd'; import { PlusOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; +import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types'; +import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; import { RootState } from '@/app/store'; import TaskRow from './task-row'; import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; @@ -14,9 +13,9 @@ import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-tabl const { Text } = Typography; interface TaskGroupProps { - group: ITaskListGroup; + group: TaskGroupType; projectId: string; - currentGrouping: IGroupBy; + currentGrouping: 'status' | 'priority' | 'phase'; selectedTaskIds: string[]; onAddTask?: (groupId: string) => void; onToggleCollapse?: (groupId: string) => void; @@ -34,7 +33,7 @@ const TaskGroup: React.FC = ({ onSelectTask, onToggleSubtasks, }) => { - const [isCollapsed, setIsCollapsed] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false); const { setNodeRef, isOver } = useDroppable({ id: group.id, @@ -44,41 +43,37 @@ const TaskGroup: React.FC = ({ }, }); - // Get column visibility from Redux store - const columns = useSelector((state: RootState) => state.taskReducer.columns); - - // Helper function to check if a column is visible - const isColumnVisible = (columnKey: string) => { - const column = columns.find(col => col.key === columnKey); - return column ? column.pinned : true; // Default to visible if column not found - }; - - // Get task IDs for sortable context - const taskIds = group.tasks.map(task => task.id!); + // Get all tasks from the store + const allTasks = useSelector(taskManagementSelectors.selectAll); + + // Get tasks for this group using memoization for performance + const groupTasks = useMemo(() => { + return group.taskIds + .map(taskId => allTasks.find(task => task.id === taskId)) + .filter((task): task is Task => task !== undefined); + }, [group.taskIds, allTasks]); // Calculate group statistics - const completedTasks = group.tasks.filter( - task => task.status_category?.is_done || task.complete_ratio === 100 - ).length; - const totalTasks = group.tasks.length; + const completedTasks = useMemo(() => { + return groupTasks.filter(task => task.progress === 100).length; + }, [groupTasks]); + + const totalTasks = groupTasks.length; const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; // Get group color based on grouping type const getGroupColor = () => { - if (group.color_code) return group.color_code; + if (group.color) return group.color; // Fallback colors based on group value switch (currentGrouping) { case 'status': - return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a'; + return group.groupValue === 'todo' ? '#faad14' : + group.groupValue === 'doing' ? '#1890ff' : '#52c41a'; case 'priority': - return group.id === 'critical' - ? '#ff4d4f' - : group.id === 'high' - ? '#fa8c16' - : group.id === 'medium' - ? '#faad14' - : '#52c41a'; + return group.groupValue === 'critical' ? '#ff4d4f' : + group.groupValue === 'high' ? '#fa8c16' : + group.groupValue === 'medium' ? '#faad14' : '#52c41a'; case 'phase': return '#722ed1'; default: @@ -118,7 +113,7 @@ const TaskGroup: React.FC = ({ className="task-group-header-button" /> - {group.name} ({totalTasks}) + {group.title} ({totalTasks})
@@ -148,36 +143,24 @@ const TaskGroup: React.FC = ({
- {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( -
- Progress -
- )} - {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( -
- Members -
- )} - {isColumnVisible(COLUMN_KEYS.LABELS) && ( -
- Labels -
- )} - {isColumnVisible(COLUMN_KEYS.STATUS) && ( -
- Status -
- )} - {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( -
- Priority -
- )} - {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( -
- Time Tracking -
- )} +
+ Progress +
+
+ Members +
+
+ Labels +
+
+ Status +
+
+ Priority +
+
+ Time Tracking +
@@ -189,7 +172,7 @@ const TaskGroup: React.FC = ({ className="task-group-body" style={{ borderLeft: `4px solid ${getGroupColor()}` }} > - {group.tasks.length === 0 ? ( + {groupTasks.length === 0 ? (
@@ -209,16 +192,16 @@ const TaskGroup: React.FC = ({
) : ( - +
- {group.tasks.map((task, index) => ( + {groupTasks.map((task, index) => ( = ({ projectId, className = '' activeGroupId: null, }); - // Redux selectors - const { - taskGroups, - loadingGroups, - error, - groupBy, - search, - archived, - } = useSelector((state: RootState) => state.taskReducer); + // Enable real-time socket updates for task changes + useTaskSocketHandlers(); - // Selection state - const [selectedTaskIds, setSelectedTaskIds] = useState([]); + // Redux selectors using new task management slices + const tasks = useSelector(taskManagementSelectors.selectAll); + const taskGroups = useSelector(selectTaskGroups); + const currentGrouping = useSelector(selectCurrentGrouping); + const selectedTaskIds = useSelector(selectSelectedTaskIds); + const loading = useSelector((state: RootState) => state.taskManagement.loading); + const error = useSelector((state: RootState) => state.taskManagement.error); // Drag and Drop sensors const sensors = useSensors( @@ -77,24 +87,25 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Fetch task groups when component mounts or dependencies change useEffect(() => { if (projectId) { - dispatch(fetchTaskGroups(projectId)); + // Fetch real tasks from API + dispatch(fetchTasks(projectId)); } - }, [dispatch, projectId, groupBy, search, archived]); + }, [dispatch, projectId, currentGrouping]); // Memoized calculations const allTaskIds = useMemo(() => { - return taskGroups.flatMap(group => group.tasks.map(task => task.id!)); - }, [taskGroups]); + return tasks.map(task => task.id); + }, [tasks]); const totalTasksCount = useMemo(() => { - return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0); - }, [taskGroups]); + return tasks.length; + }, [tasks]); const hasSelection = selectedTaskIds.length > 0; // Handlers - const handleGroupingChange = (newGroupBy: IGroupBy) => { - dispatch(setGroup(newGroupBy)); + const handleGroupingChange = (newGroupBy: typeof currentGrouping) => { + dispatch(setCurrentGrouping(newGroupBy)); }; const handleDragStart = (event: DragStartEvent) => { @@ -102,15 +113,17 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const taskId = active.id as string; // Find the task and its group - let activeTask: IProjectTask | null = null; + const activeTask = tasks.find(t => t.id === taskId) || null; let activeGroupId: string | null = null; - for (const group of taskGroups) { - const task = group.tasks.find(t => t.id === taskId); - if (task) { - activeTask = task; - activeGroupId = group.id; - break; + 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}`; } } @@ -139,71 +152,76 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const activeTaskId = active.id as string; const overContainer = over.id as string; - // Determine if dropping on a group or task - const overGroup = taskGroups.find(g => g.id === overContainer); + // Parse the group ID to get group type and value + 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; let targetIndex = -1; - if (!overGroup) { - // Dropping on a task, find which group it belongs to - for (const group of taskGroups) { - const taskIndex = group.tasks.findIndex(t => t.id === overContainer); - if (taskIndex !== -1) { - targetGroupId = group.id; - targetIndex = taskIndex; - break; - } + // Check if dropping on a task or a group + const targetTask = tasks.find(t => t.id === overContainer); + 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}`; + } + + // Find the index of the target task within its group + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + if (targetGroup) { + targetIndex = targetGroup.taskIds.indexOf(targetTask.id); } } + const sourceGroupInfo = parseGroupId(dragState.activeGroupId); + const targetGroupInfo = parseGroupId(targetGroupId); + + // If moving between different groups, update the task's group property + if (dragState.activeGroupId !== targetGroupId) { + dispatch(moveTaskToGroup({ + taskId: activeTaskId, + groupType: targetGroupInfo.groupType, + groupValue: targetGroupInfo.groupValue + })); + } + + // Handle reordering within the same group or between groups const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId); const targetGroup = taskGroups.find(g => g.id === targetGroupId); - if (!sourceGroup || !targetGroup) return; + if (sourceGroup && targetGroup) { + const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId); + const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex; - const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); - if (sourceIndex === -1) return; + // Calculate new order values + const allTasksInTargetGroup = targetGroup.taskIds.map(id => tasks.find(t => t.id === id)!); + const newOrder = allTasksInTargetGroup.map((task, index) => { + if (index < finalTargetIndex) return task.order; + if (index === finalTargetIndex) return dragState.activeTask!.order; + return task.order + 1; + }); - // Calculate new positions - const finalTargetIndex = targetIndex === -1 ? targetGroup.tasks.length : targetIndex; - - // Create updated task arrays - const updatedSourceTasks = [...sourceGroup.tasks]; - const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1); - - let updatedTargetTasks: IProjectTask[]; - if (sourceGroup.id === targetGroup.id) { - // Moving within the same group - updatedTargetTasks = updatedSourceTasks; - updatedTargetTasks.splice(finalTargetIndex, 0, movedTask); - } else { - // Moving between different groups - updatedTargetTasks = [...targetGroup.tasks]; - updatedTargetTasks.splice(finalTargetIndex, 0, movedTask); + // Dispatch reorder action + dispatch(reorderTasks({ + taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)], + newOrder: [dragState.activeTask!.order, ...newOrder] + })); } - - // Dispatch the reorder action - dispatch(reorderTasks({ - activeGroupId: sourceGroup.id, - overGroupId: targetGroup.id, - fromIndex: sourceIndex, - toIndex: finalTargetIndex, - task: movedTask, - updatedSourceTasks, - updatedTargetTasks, - })); }; - - const handleSelectTask = (taskId: string, selected: boolean) => { - setSelectedTaskIds(prev => { - if (selected) { - return [...prev, taskId]; - } else { - return prev.filter(id => id !== taskId); - } - }); + dispatch(toggleTaskSelection(taskId)); }; const handleToggleSubtasks = (taskId: string) => { @@ -240,15 +258,15 @@ const TaskListBoard: React.FC = ({ projectId, className = '' setSelectedTaskIds([])} + onClearSelection={() => dispatch(clearSelection())} /> )} {/* Task Groups Container */}
- {loadingGroups ? ( + {loading ? (
@@ -275,7 +293,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' key={group.id} group={group} projectId={projectId} - currentGrouping={groupBy} + currentGrouping={currentGrouping} selectedTaskIds={selectedTaskIds} onSelectTask={handleSelectTask} onToggleSubtasks={handleToggleSubtasks} @@ -289,7 +307,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' task={dragState.activeTask} projectId={projectId} groupId={dragState.activeGroupId!} - currentGrouping={groupBy} + currentGrouping={currentGrouping} isSelected={false} isDragOverlay /> diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index fbf392f8..f02d3120 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -1,26 +1,22 @@ -import React from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useSelector } from 'react-redux'; -import { Checkbox, Avatar, Tag, Progress, Typography, Space, Button, Tooltip } from 'antd'; import { HolderOutlined, - EyeOutlined, MessageOutlined, PaperClipOutlined, ClockCircleOutlined, } from '@ant-design/icons'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; +import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; - -const { Text } = Typography; +import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components'; interface TaskRowProps { - task: IProjectTask; + task: Task; projectId: string; groupId: string; - currentGrouping: IGroupBy; + currentGrouping: 'status' | 'priority' | 'phase'; isSelected: boolean; isDragOverlay?: boolean; index?: number; @@ -28,7 +24,7 @@ interface TaskRowProps { onToggleSubtasks?: (taskId: string) => void; } -const TaskRow: React.FC = ({ +const TaskRow: React.FC = React.memo(({ task, projectId, groupId, @@ -47,7 +43,7 @@ const TaskRow: React.FC = ({ transition, isDragging, } = useSortable({ - id: task.id!, + id: task.id, data: { type: 'task', taskId: task.id, @@ -56,33 +52,32 @@ const TaskRow: React.FC = ({ disabled: isDragOverlay, }); - // Get column visibility from Redux store - const columns = useSelector((state: RootState) => state.taskReducer.columns); + // Get theme from Redux store + const themeMode = useSelector((state: RootState) => state.themeReducer?.mode || 'light'); - // Helper function to check if a column is visible - const isColumnVisible = (columnKey: string) => { - const column = columns.find(col => col.key === columnKey); - return column ? column.pinned : true; // Default to visible if column not found - }; + // Memoize derived values for performance + const isDarkMode = useMemo(() => themeMode === 'dark', [themeMode]); - const style = { + // Memoize style calculations + const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, - }; + }), [transform, transition, isDragging]); - const handleSelectChange = (checked: boolean) => { - onSelect?.(task.id!, checked); - }; + // Memoize event handlers to prevent unnecessary re-renders + const handleSelectChange = useCallback((checked: boolean) => { + onSelect?.(task.id, checked); + }, [onSelect, task.id]); - const handleToggleSubtasks = () => { - onToggleSubtasks?.(task.id!); - }; + const handleToggleSubtasks = useCallback(() => { + onToggleSubtasks?.(task.id); + }, [onToggleSubtasks, task.id]); - // Format due date - const formatDueDate = (dateString?: string) => { - if (!dateString) return null; - const date = new Date(dateString); + // Format due date - memoized for performance + const dueDate = useMemo(() => { + if (!task.dueDate) return null; + const date = new Date(task.dueDate); const now = new Date(); const diffTime = date.getTime() - now.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); @@ -96,557 +91,286 @@ const TaskRow: React.FC = ({ } else { return { text: `Due ${date.toLocaleDateString()}`, color: 'default' }; } + }, [task.dueDate]); + + // Memoize assignees for AvatarGroup to prevent unnecessary re-renders + const avatarGroupMembers = useMemo(() => { + return task.assignees?.map(assigneeId => ({ + id: assigneeId, + team_member_id: assigneeId, + name: assigneeId // TODO: Map to actual user names + })) || []; + }, [task.assignees]); + + // Memoize class names for better performance + const containerClassName = useMemo(() => ` + border-b transition-all duration-300 + ${isDarkMode + ? `border-gray-700 bg-gray-900 hover:bg-gray-800 ${isSelected ? 'bg-blue-900/20' : ''}` + : `border-gray-200 bg-white hover:bg-gray-50 ${isSelected ? 'bg-blue-50' : ''}` + } + ${isSelected ? 'border-l-4 border-l-blue-500' : ''} + ${isDragOverlay + ? `rounded shadow-lg ${isDarkMode ? 'bg-gray-900 border border-gray-600' : 'bg-white border border-gray-300'}` + : '' + } + `, [isDarkMode, isSelected, isDragOverlay]); + + const fixedColumnsClassName = useMemo(() => ` + flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'} + `, [isDarkMode]); + + const taskNameClassName = useMemo(() => ` + text-sm font-medium flex-1 + overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300 + ${isDarkMode ? 'text-gray-100' : 'text-gray-900'} + ${task.progress === 100 + ? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}` + : '' + } + `, [isDarkMode, task.progress]); + + // Get priority color + const getPriorityColor = (priority: string) => { + const colors = { + critical: '#ff4d4f', + high: '#ff7a45', + medium: '#faad14', + low: '#52c41a', + }; + return colors[priority as keyof typeof colors] || '#d9d9d9'; }; - const dueDate = formatDueDate(task.end_date); + // Get status color + const getStatusColor = (status: string) => { + const colors = { + todo: '#f0f0f0', + doing: '#1890ff', + done: '#52c41a', + }; + return colors[status as keyof typeof colors] || '#d9d9d9'; + }; + + // Create adapter for LabelsSelector to work with new Task type + const taskAdapter = useMemo(() => { + // Convert new Task type to IProjectTask for compatibility + return { + id: task.id, + name: task.title, + parent_task_id: null, // TODO: Add parent task support + all_labels: task.labels?.map(label => ({ + id: label.id, + name: label.name, + color_code: label.color + })) || [], + labels: task.labels?.map(label => ({ + id: label.id, + name: label.name, + color_code: label.color + })) || [], + } as any; // Type assertion for compatibility + }, [task.id, task.title, task.labels]); return ( <>
-
+
{/* Fixed Columns */} -
+
{/* Drag Handle */} -
+
{/* Selection Checkbox */} -
+
handleSelectChange(e.target.checked)} + onChange={handleSelectChange} + isDarkMode={isDarkMode} />
{/* Task Key */} -
- {task.project_id && task.task_key && ( - - {task.task_key} - - )} +
+ + {task.task_key} +
{/* Task Name */} -
-
-
- - {task.name} - - {task.sub_tasks_count && task.sub_tasks_count > 0 && ( - - )} +
+
+
+ + {task.title} +
{/* Scrollable Columns */} -
+
{/* Progress */} - {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( -
- {task.complete_ratio !== undefined && task.complete_ratio >= 0 && ( -
- {percent}%} - /> -
- )} -
- )} +
+ {task.progress !== undefined && task.progress >= 0 && ( + + )} +
{/* Members */} - {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( -
- {task.assignees && task.assignees.length > 0 && ( - - {task.assignees.map((assignee) => ( - - - {assignee.name?.charAt(0)?.toUpperCase()} - - - ))} - +
+
+ {avatarGroupMembers.length > 0 && ( + )} +
- )} +
{/* Labels */} - {isColumnVisible(COLUMN_KEYS.LABELS) && ( -
- {task.labels && task.labels.length > 0 && ( -
- {task.labels.slice(0, 3).map((label) => ( - - {label.name} - - ))} - {task.labels.length > 3 && ( - - +{task.labels.length - 3} - - )} -
- )} +
+
+ {task.labels?.map((label, index) => ( + label.end && label.names && label.name ? ( + + ) : ( + + ) + ))} +
- )} +
{/* Status */} - {isColumnVisible(COLUMN_KEYS.STATUS) && ( -
- {task.status_name && ( -
- {task.status_name} -
- )} -
- )} +
+ + {task.status} + +
{/* Priority */} - {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( -
- {task.priority_name && ( -
-
- {task.priority_name} +
+
+
+ + {task.priority} + +
+
+ + {/* Time Tracking */} +
+
+ {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( +
+ + + {typeof task.timeTracking.logged === 'number' + ? `${task.timeTracking.logged}h` + : task.timeTracking.logged + } +
)}
- )} - - {/* Time Tracking */} - {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( -
-
- {task.time_spent_string && ( -
- - {task.time_spent_string} -
- )} - {/* Task Indicators */} -
- {task.comments_count && task.comments_count > 0 && ( -
- - {task.comments_count} -
- )} - {task.attachments_count && task.attachments_count > 0 && ( -
- - {task.attachments_count} -
- )} -
-
-
- )} +
- - {/* Subtasks */} - {task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && ( -
- {task.sub_tasks.map((subtask) => ( - - ))} -
- )} - - ); -}; +}, (prevProps, nextProps) => { + // Custom comparison function for React.memo + // Only re-render if these specific props change + const labelsEqual = prevProps.task.labels.length === nextProps.task.labels.length && + prevProps.task.labels.every((label, index) => + label.id === nextProps.task.labels[index]?.id && + label.name === nextProps.task.labels[index]?.name && + label.color === nextProps.task.labels[index]?.color && + label.end === nextProps.task.labels[index]?.end && + JSON.stringify(label.names) === JSON.stringify(nextProps.task.labels[index]?.names) + ); + + return ( + prevProps.task.id === nextProps.task.id && + prevProps.task.assignees === nextProps.task.assignees && + prevProps.task.title === nextProps.task.title && + prevProps.task.progress === nextProps.task.progress && + prevProps.task.status === nextProps.task.status && + prevProps.task.priority === nextProps.task.priority && + labelsEqual && + prevProps.isSelected === nextProps.isSelected && + prevProps.isDragOverlay === nextProps.isDragOverlay && + prevProps.groupId === nextProps.groupId + ); +}); + +TaskRow.displayName = 'TaskRow'; export default TaskRow; \ No newline at end of file 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 6a3a972d..3c5f6298 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -1,10 +1,11 @@ -import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { Task, TaskManagementState } from '@/types/task-management.types'; import { RootState } from '@/app/store'; +import { tasksApiService, ITaskListConfigV2 } from '@/api/tasks/tasks.api.service'; +import logger from '@/utils/errorLogger'; // Entity adapter for normalized state const tasksAdapter = createEntityAdapter({ - selectId: (task) => task.id, sortComparer: (a, b) => a.order - b.order, }); @@ -15,6 +16,91 @@ const initialState: TaskManagementState = { error: null, }; +// Async thunk to fetch tasks from API +export const fetchTasks = createAsyncThunk( + 'taskManagement/fetchTasks', + async (projectId: string, { rejectWithValue, getState }) => { + try { + const state = getState() as RootState; + const currentGrouping = state.grouping.currentGrouping; + + const config: ITaskListConfigV2 = { + id: projectId, + archived: false, + group: currentGrouping, + field: '', + order: '', + search: '', + statuses: '', + members: '', + projects: '', + isSubtasksInclude: false, + labels: '', + priorities: '', + }; + + const response = await tasksApiService.getTaskList(config); + + // Helper function to safely convert time values + const convertTimeValue = (value: any): number => { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; + } + if (typeof value === 'object' && value !== null) { + // Handle time objects like {hours: 2, minutes: 30} + if ('hours' in value || 'minutes' in value) { + const hours = Number(value.hours || 0); + const minutes = Number(value.minutes || 0); + return hours + (minutes / 60); + } + } + return 0; + }; + + // Transform the API response to our Task type + const tasks: Task[] = response.body.flatMap((group: any) => + group.tasks.map((task: any) => ({ + id: task.id, + task_key: task.task_key || '', + title: task.name || '', + description: task.description || '', + status: task.status_name?.toLowerCase() || 'todo', + priority: task.priority_name?.toLowerCase() || 'medium', + phase: task.phase_name || 'Development', + progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0, + assignees: task.assignees?.map((a: any) => a.team_member_id) || [], + labels: task.labels?.map((l: any) => ({ + id: l.id || l.label_id, + name: l.name, + color: l.color_code || '#1890ff', + end: l.end, + names: l.names + })) || [], + dueDate: task.end_date, + timeTracking: { + estimated: convertTimeValue(task.total_time), + logged: convertTimeValue(task.time_spent), + }, + customFields: {}, + createdAt: task.created_at || new Date().toISOString(), + updatedAt: task.updated_at || new Date().toISOString(), + order: typeof task.sort_order === 'number' ? task.sort_order : 0, + })) + ); + + return tasks; + } catch (error) { + logger.error('Fetch Tasks', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch tasks'); + } + } +); + const taskManagementSlice = createSlice({ name: 'taskManagement', initialState: tasksAdapter.getInitialState(initialState), @@ -99,6 +185,22 @@ const taskManagementSlice = createSlice({ state.loading = false; }, }, + extraReducers: (builder) => { + builder + .addCase(fetchTasks.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchTasks.fulfilled, (state, action) => { + state.loading = false; + state.error = null; + tasksAdapter.setAll(state, action.payload); + }) + .addCase(fetchTasks.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, }); export const { diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index dc048c52..e8204805 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -1,5 +1,6 @@ export interface Task { id: string; + task_key: string; title: string; description?: string; status: 'todo' | 'doing' | 'done'; @@ -7,7 +8,7 @@ export interface Task { phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment' progress: number; // 0-100 assignees: string[]; - labels: string[]; + labels: Label[]; dueDate?: string; timeTracking: { estimated?: number; @@ -56,6 +57,8 @@ export interface Label { id: string; name: string; color: string; + end?: boolean; + names?: string[]; } // Redux State Interfaces diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index 425e3d35..a0e74df1 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -29,7 +29,7 @@ export default defineConfig(({ command, mode }) => { // **Development Server** server: { - port: 3000, + port: 5173, open: true, hmr: { overlay: false, @@ -108,9 +108,6 @@ export default defineConfig(({ command, mode }) => { // **Preserve modules to avoid context issues** preserveEntrySignatures: 'strict', - - // **Ensure proper module interop** - interop: 'auto', }, // **Experimental features for better performance** From 687fff9c744f3809b85b8d3e2bc25f6403a528bc Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Mon, 23 Jun 2025 07:29:50 +0530 Subject: [PATCH 2/5] feat(task-management): optimize task components for performance and usability - Refactored TaskGroup and TaskRow components to improve rendering efficiency by utilizing memoization and callbacks. - Moved color mappings for group statuses and priorities outside of components to prevent unnecessary re-creations. - Enhanced drag-and-drop functionality with optimistic updates and throttling for smoother user experience. - Updated task management slice to support new properties and batch updates for better performance. - Simplified selectors and improved error handling in the task management slice. --- .../components/task-management/task-group.tsx | 92 +++- .../task-management/task-list-board.tsx | 351 ++++++++---- .../components/task-management/task-row.tsx | 498 +++++++++--------- .../task-management/task-management.slice.ts | 50 +- .../src/types/task-management.types.ts | 3 + 5 files changed, 596 insertions(+), 398 deletions(-) diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index d534beb5..9919c313 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSelector } from 'react-redux'; @@ -23,7 +23,24 @@ interface TaskGroupProps { onToggleSubtasks?: (taskId: string) => void; } -const TaskGroup: React.FC = ({ +// Group color mapping - moved outside component for better performance +const GROUP_COLORS = { + status: { + todo: '#faad14', + doing: '#1890ff', + done: '#52c41a', + }, + priority: { + critical: '#ff4d4f', + high: '#fa8c16', + medium: '#faad14', + low: '#52c41a', + }, + phase: '#722ed1', + default: '#d9d9d9', +} as const; + +const TaskGroup: React.FC = React.memo(({ group, projectId, currentGrouping, @@ -53,57 +70,63 @@ const TaskGroup: React.FC = ({ .filter((task): task is Task => task !== undefined); }, [group.taskIds, allTasks]); - // Calculate group statistics - const completedTasks = useMemo(() => { - return groupTasks.filter(task => task.progress === 100).length; + // Calculate group statistics - memoized + const { completedTasks, totalTasks, completionRate } = useMemo(() => { + const completed = groupTasks.filter(task => task.progress === 100).length; + const total = groupTasks.length; + const rate = total > 0 ? Math.round((completed / total) * 100) : 0; + + return { + completedTasks: completed, + totalTasks: total, + completionRate: rate, + }; }, [groupTasks]); - - const totalTasks = groupTasks.length; - const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; - // Get group color based on grouping type - const getGroupColor = () => { + // Get group color based on grouping type - memoized + const groupColor = useMemo(() => { if (group.color) return group.color; // Fallback colors based on group value switch (currentGrouping) { case 'status': - return group.groupValue === 'todo' ? '#faad14' : - group.groupValue === 'doing' ? '#1890ff' : '#52c41a'; + return GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] || GROUP_COLORS.default; case 'priority': - return group.groupValue === 'critical' ? '#ff4d4f' : - group.groupValue === 'high' ? '#fa8c16' : - group.groupValue === 'medium' ? '#faad14' : '#52c41a'; + return GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] || GROUP_COLORS.default; case 'phase': - return '#722ed1'; + return GROUP_COLORS.phase; default: - return '#d9d9d9'; + return GROUP_COLORS.default; } - }; + }, [group.color, group.groupValue, currentGrouping]); - const handleToggleCollapse = () => { + // Memoized event handlers + const handleToggleCollapse = useCallback(() => { setIsCollapsed(!isCollapsed); onToggleCollapse?.(group.id); - }; + }, [isCollapsed, onToggleCollapse, group.id]); - const handleAddTask = () => { + const handleAddTask = useCallback(() => { onAddTask?.(group.id); - }; + }, [onAddTask, group.id]); + + // Memoized style object + const containerStyle = useMemo(() => ({ + backgroundColor: isOver ? '#f0f8ff' : undefined, + }), [isOver]); return (
{/* Group Header Row */}
-
- - {/* Selection Checkbox */} -
- -
- - {/* Task Key */} -
- - {task.task_key} - -
- - {/* Task Name */} -
-
-
- - {task.title} - -
-
-
+
+
+ {/* Fixed Columns */} +
+ {/* Drag Handle */} +
+
- {/* Scrollable Columns */} -
- {/* Progress */} -
- {task.progress !== undefined && task.progress >= 0 && ( - - )} -
+ {/* Selection Checkbox */} +
+ +
- {/* Members */} -
-
- {avatarGroupMembers.length > 0 && ( - - )} - -
-
+ {/* Task Key */} +
+ + {task.task_key} + +
- {/* Labels */} -
-
- {task.labels?.map((label, index) => ( - label.end && label.names && label.name ? ( - - ) : ( - - ) - ))} - -
-
- - {/* Status */} -
- - {task.status} - -
- - {/* Priority */} -
-
-
- - {task.priority} + {/* Task Name */} +
+
+
+ + {task.title}
- - {/* Time Tracking */} -
-
- {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( -
- - - {typeof task.timeTracking.logged === 'number' - ? `${task.timeTracking.logged}h` - : task.timeTracking.logged - } - -
- )} -
-
+
+
+ + {/* Scrollable Columns */} +
+ {/* Progress */} +
+ {task.progress !== undefined && task.progress >= 0 && ( + + )} +
+ + {/* Members */} +
+
+ {avatarGroupMembers.length > 0 && ( + + )} + +
+
+ + {/* Labels */} +
+
+ {task.labels?.map((label, index) => ( + label.end && label.names && label.name ? ( + + ) : ( + + ) + ))} + +
+
+ + {/* Status */} +
+ + {task.status} + +
+ + {/* Priority */} +
+
+
+ + {task.priority} + +
+
+ + {/* Time Tracking */} +
+
+ {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( +
+ + + {typeof task.timeTracking.logged === 'number' + ? `${task.timeTracking.logged}h` + : task.timeTracking.logged + } + +
+ )} +
- +
); }, (prevProps, nextProps) => { - // Custom comparison function for React.memo - // Only re-render if these specific props change - const labelsEqual = prevProps.task.labels.length === nextProps.task.labels.length && - prevProps.task.labels.every((label, index) => - label.id === nextProps.task.labels[index]?.id && - label.name === nextProps.task.labels[index]?.name && - label.color === nextProps.task.labels[index]?.color && - label.end === nextProps.task.labels[index]?.end && - JSON.stringify(label.names) === JSON.stringify(nextProps.task.labels[index]?.names) - ); - + // Simplified comparison for better performance return ( prevProps.task.id === nextProps.task.id && - prevProps.task.assignees === nextProps.task.assignees && prevProps.task.title === nextProps.task.title && prevProps.task.progress === nextProps.task.progress && prevProps.task.status === nextProps.task.status && prevProps.task.priority === nextProps.task.priority && - labelsEqual && + prevProps.task.labels?.length === nextProps.task.labels?.length && + prevProps.task.assignee_names?.length === nextProps.task.assignee_names?.length && prevProps.isSelected === nextProps.isSelected && prevProps.isDragOverlay === nextProps.isDragOverlay && prevProps.groupId === nextProps.groupId 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 3c5f6298..861ff4a1 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -71,6 +71,7 @@ export const fetchTasks = createAsyncThunk( phase: task.phase_name || 'Development', progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0, assignees: task.assignees?.map((a: any) => a.team_member_id) || [], + assignee_names: task.assignee_names || task.names || [], labels: task.labels?.map((l: any) => ({ id: l.id || l.label_id, name: l.name, @@ -147,13 +148,19 @@ const taskManagementSlice = createSlice({ tasksAdapter.removeMany(state, action.payload); }, - // Drag and drop operations + // Optimized drag and drop operations reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => { const { taskIds, newOrder } = action.payload; + + // Batch update for better performance const updates = taskIds.map((id, index) => ({ id, - changes: { order: newOrder[index] }, + changes: { + order: newOrder[index], + updatedAt: new Date().toISOString(), + }, })); + tasksAdapter.updateMany(state, updates); }, @@ -175,6 +182,34 @@ const taskManagementSlice = createSlice({ tasksAdapter.updateOne(state, { id: taskId, changes }); }, + // Optimistic update for drag operations - reduces perceived lag + optimisticTaskMove: (state, action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>) => { + const { taskId, newGroupId, newIndex } = action.payload; + const task = state.entities[taskId]; + + if (task) { + // Parse group ID to determine new values + const [groupType, ...groupValueParts] = newGroupId.split('-'); + const groupValue = groupValueParts.join('-'); + + const changes: Partial = { + order: newIndex, + 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 }); + } + }, + // Loading states setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -198,7 +233,7 @@ const taskManagementSlice = createSlice({ }) .addCase(fetchTasks.rejected, (state, action) => { state.loading = false; - state.error = action.payload as string; + state.error = action.payload as string || 'Failed to fetch tasks'; }); }, }); @@ -212,16 +247,19 @@ export const { bulkDeleteTasks, reorderTasks, moveTaskToGroup, + optimisticTaskMove, setLoading, setError, } = taskManagementSlice.actions; +export default taskManagementSlice.reducer; + // Selectors export const taskManagementSelectors = tasksAdapter.getSelectors( (state) => state.taskManagement ); -// Additional selectors +// Enhanced selectors for better performance export const selectTasksByStatus = (state: RootState, status: string) => taskManagementSelectors.selectAll(state).filter(task => task.status === status); @@ -232,6 +270,4 @@ export const selectTasksByPhase = (state: RootState, phase: string) => taskManagementSelectors.selectAll(state).filter(task => task.phase === phase); export const selectTasksLoading = (state: RootState) => state.taskManagement.loading; -export const selectTasksError = (state: RootState) => state.taskManagement.error; - -export default taskManagementSlice.reducer; \ No newline at end of file +export const selectTasksError = (state: RootState) => state.taskManagement.error; \ No newline at end of file diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index e8204805..f2fd5f66 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -1,3 +1,5 @@ +import { InlineMember } from './teamMembers/inlineMember.types'; + export interface Task { id: string; task_key: string; @@ -8,6 +10,7 @@ export interface Task { phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment' progress: number; // 0-100 assignees: string[]; + assignee_names?: InlineMember[]; labels: Label[]; dueDate?: string; timeTracking: { From 2dd756bbb874ee0f4d4e76ae92a7fe378dfa932a Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 23 Jun 2025 16:34:57 +0530 Subject: [PATCH 3/5] feat(tasks): implement V3 API for task management and enhance UI components - Introduced `getTasksV3` and `refreshTaskProgress` methods in `TasksControllerV2` to optimize task retrieval and progress refreshing. - Updated API routes to include new endpoints for V3 task management. - Enhanced frontend components to utilize the new V3 API, improving performance by reducing frontend processing. - Added `VirtualizedTaskList` and `VirtualizedTaskGroup` components for efficient rendering of task lists. - Updated task management slice to support new V3 data structure and improved state management. - Refactored styles for better dark mode support and overall UI consistency. --- .../src/controllers/tasks-controller-v2.ts | 208 +++++++ .../src/routes/apis/tasks-api-router.ts | 2 + worklenz-frontend/package-lock.json | 11 + worklenz-frontend/package.json | 1 + .../src/api/tasks/tasks.api.service.ts | 27 + .../components/task-management/task-group.tsx | 10 +- .../task-management/task-list-board.tsx | 216 +++++++- .../components/task-management/task-row.tsx | 87 ++- .../virtualized-task-group.tsx | 163 ++++++ .../task-management/virtualized-task-list.tsx | 429 +++++++++++++++ .../task-management/task-management.slice.ts | 125 ++++- worklenz-frontend/src/index.css | 1 + .../taskList/ProjectViewTaskList.tsx | 57 +- .../src/styles/task-management.css | 518 +++++++----------- .../src/types/task-management.types.ts | 2 + 15 files changed, 1473 insertions(+), 384 deletions(-) create mode 100644 worklenz-frontend/src/components/task-management/virtualized-task-group.tsx create mode 100644 worklenz-frontend/src/components/task-management/virtualized-task-list.tsx diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 10c556d3..6c6d5e0e 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -967,4 +967,212 @@ export default class TasksControllerV2 extends TasksControllerBase { log_error(`Error updating task weight: ${error}`); } } + + @HandleExceptions() + public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const isSubTasks = !!req.query.parent_task; + const groupBy = (req.query.group || GroupBy.STATUS) as string; + const archived = req.query.archived === "true"; + + // Skip heavy progress calculation for initial load to improve performance + // Progress values are already calculated and stored in the database + // Only refresh if explicitly requested + if (req.query.refresh_progress === "true" && req.params.id) { + await this.refreshProjectTaskProgressValues(req.params.id); + } + + const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + + const result = await db.query(q, params); + const tasks = [...result.rows]; + + // Get groups metadata dynamically from database + const groups = await this.getGroups(groupBy, req.params.id); + + // Create priority value to name mapping + const priorityMap: Record = { + "0": "low", + "1": "medium", + "2": "high" + }; + + // Create status category mapping based on actual status names from database + const statusCategoryMap: Record = {}; + for (const group of groups) { + if (groupBy === GroupBy.STATUS && group.id) { + // Use the actual status name from database, convert to lowercase for consistency + statusCategoryMap[group.id] = group.name.toLowerCase().replace(/\s+/g, "_"); + } + } + + // Transform tasks with all necessary data preprocessing + const transformedTasks = tasks.map((task, index) => { + // Update task with calculated values (lightweight version) + TasksControllerV2.updateTaskViewModel(task); + task.index = index; + + // Convert time values + const convertTimeValue = (value: any): number => { + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; + } + if (value && typeof value === "object") { + if ("hours" in value || "minutes" in value) { + const hours = Number(value.hours || 0); + const minutes = Number(value.minutes || 0); + return hours + (minutes / 60); + } + } + return 0; + }; + + return { + id: task.id, + task_key: task.task_key || "", + title: task.name || "", + description: task.description || "", + // Use dynamic status mapping from database + status: statusCategoryMap[task.status] || task.status, + // Pre-processed priority using mapping + priority: priorityMap[task.priority_value?.toString()] || "medium", + // Use actual phase name from database + phase: task.phase_name || "Development", + progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0, + assignees: task.assignees?.map((a: any) => a.team_member_id) || [], + assignee_names: task.assignee_names || task.names || [], + labels: task.labels?.map((l: any) => ({ + id: l.id || l.label_id, + name: l.name, + color: l.color_code || "#1890ff", + end: l.end, + names: l.names + })) || [], + dueDate: task.end_date, + timeTracking: { + estimated: convertTimeValue(task.total_time), + logged: convertTimeValue(task.time_spent), + }, + customFields: {}, + createdAt: task.created_at || new Date().toISOString(), + updatedAt: task.updated_at || new Date().toISOString(), + order: typeof task.sort_order === "number" ? task.sort_order : 0, + // Additional metadata for frontend + originalStatusId: task.status, + originalPriorityId: task.priority, + statusColor: task.status_color, + priorityColor: task.priority_color, + }; + }); + + // Create groups based on dynamic data from database + const groupedResponse: Record = {}; + + // Initialize groups from database data + groups.forEach(group => { + const groupKey = groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); + + groupedResponse[groupKey] = { + id: group.id, + title: group.name, + groupType: groupBy, + groupValue: groupKey, + collapsed: false, + tasks: [], + taskIds: [], + color: group.color_code || this.getDefaultGroupColor(groupBy, groupKey), + // Include additional metadata from database + category_id: group.category_id, + start_date: group.start_date, + end_date: group.end_date, + sort_index: (group as any).sort_index, + }; + }); + + // Distribute tasks into groups + transformedTasks.forEach(task => { + let groupKey: string; + if (groupBy === GroupBy.STATUS) { + groupKey = task.status; + } else if (groupBy === GroupBy.PRIORITY) { + groupKey = task.priority; + } else { + groupKey = task.phase.toLowerCase().replace(/\s+/g, "_"); + } + + if (groupedResponse[groupKey]) { + groupedResponse[groupKey].tasks.push(task); + groupedResponse[groupKey].taskIds.push(task.id); + } + }); + + // Sort tasks within each group by order + Object.values(groupedResponse).forEach((group: any) => { + group.tasks.sort((a: any, b: any) => a.order - b.order); + }); + + // Convert to array format expected by frontend, maintaining database order + const responseGroups = groups + .map(group => { + const groupKey = groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); + + return groupedResponse[groupKey]; + }) + .filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true")); + + return res.status(200).send(new ServerResponse(true, { + groups: responseGroups, + allTasks: transformedTasks, + grouping: groupBy, + totalTasks: transformedTasks.length + })); + } + + private static getDefaultGroupColor(groupBy: string, groupValue: string): string { + const colorMaps: Record> = { + [GroupBy.STATUS]: { + todo: "#f0f0f0", + doing: "#1890ff", + done: "#52c41a", + }, + [GroupBy.PRIORITY]: { + critical: "#ff4d4f", + high: "#ff7a45", + medium: "#faad14", + low: "#52c41a", + }, + [GroupBy.PHASE]: { + planning: "#722ed1", + development: "#1890ff", + testing: "#faad14", + deployment: "#52c41a", + }, + }; + + return colorMaps[groupBy]?.[groupValue] || "#d9d9d9"; + } + + @HandleExceptions() + public static async refreshTaskProgress(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + try { + if (req.params.id) { + await this.refreshProjectTaskProgressValues(req.params.id); + return res.status(200).send(new ServerResponse(true, { message: "Task progress refreshed successfully" })); + } + return res.status(400).send(new ServerResponse(false, "Project ID is required")); + } catch (error) { + log_error(`Error refreshing task progress: ${error}`); + return res.status(500).send(new ServerResponse(false, "Failed to refresh task progress")); + } + } } diff --git a/worklenz-backend/src/routes/apis/tasks-api-router.ts b/worklenz-backend/src/routes/apis/tasks-api-router.ts index bb6af547..905728ea 100644 --- a/worklenz-backend/src/routes/apis/tasks-api-router.ts +++ b/worklenz-backend/src/routes/apis/tasks-api-router.ts @@ -42,6 +42,8 @@ tasksApiRouter.get("/list/columns/:id", idParamValidator, safeControllerFunction tasksApiRouter.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn)); tasksApiRouter.get("/list/v2/:id", idParamValidator, safeControllerFunction(getList)); +tasksApiRouter.get("/list/v3/:id", idParamValidator, safeControllerFunction(TasksControllerV2.getTasksV3)); +tasksApiRouter.post("/refresh-progress/:id", idParamValidator, safeControllerFunction(TasksControllerV2.refreshTaskProgress)); tasksApiRouter.get("/assignees/:id", idParamValidator, safeControllerFunction(TasksController.getProjectTaskAssignees)); tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus)); diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 68978bd0..4a7afbfa 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -66,6 +66,7 @@ "@types/node": "^20.8.4", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.2", @@ -2635,6 +2636,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 9eaa43ff..33e5571b 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -70,6 +70,7 @@ "@types/node": "^20.8.4", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.2", diff --git a/worklenz-frontend/src/api/tasks/tasks.api.service.ts b/worklenz-frontend/src/api/tasks/tasks.api.service.ts index cd3d80dd..c8710a36 100644 --- a/worklenz-frontend/src/api/tasks/tasks.api.service.ts +++ b/worklenz-frontend/src/api/tasks/tasks.api.service.ts @@ -30,6 +30,22 @@ export interface ITaskListConfigV2 { isSubtasksInclude: boolean; } +export interface ITaskListV3Response { + groups: Array<{ + id: string; + title: string; + groupType: 'status' | 'priority' | 'phase'; + groupValue: string; + collapsed: boolean; + tasks: any[]; + taskIds: string[]; + color: string; + }>; + allTasks: any[]; + grouping: string; + totalTasks: number; +} + export const tasksApiService = { getTaskList: async (config: ITaskListConfigV2): Promise> => { const q = toQueryString(config); @@ -119,4 +135,15 @@ export const tasksApiService = { const response = await apiClient.get(`${rootUrl}/dependency-status${q}`); return response.data; }, + + getTaskListV3: async (config: ITaskListConfigV2): Promise> => { + const q = toQueryString(config); + const response = await apiClient.get(`${rootUrl}/list/v3/${config.id}${q}`); + return response.data; + }, + + refreshTaskProgress: async (projectId: string): Promise> => { + const response = await apiClient.post(`${rootUrl}/refresh-progress/${projectId}`); + return response.data; + }, }; diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index 9919c313..335ebbef 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -31,7 +31,6 @@ const GROUP_COLORS = { done: '#52c41a', }, priority: { - critical: '#ff4d4f', high: '#fa8c16', medium: '#faad14', low: '#52c41a', @@ -63,6 +62,9 @@ const TaskGroup: React.FC = React.memo(({ // Get all tasks from the store const allTasks = useSelector(taskManagementSelectors.selectAll); + // Get theme from Redux store + const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); + // Get tasks for this group using memoization for performance const groupTasks = useMemo(() => { return group.taskIds @@ -112,8 +114,10 @@ const TaskGroup: React.FC = React.memo(({ // Memoized style object const containerStyle = useMemo(() => ({ - backgroundColor: isOver ? '#f0f8ff' : undefined, - }), [isOver]); + backgroundColor: isOver + ? (isDarkMode ? '#1a2332' : '#f0f8ff') + : undefined, + }), [isOver, isDarkMode]); return (
= ({ projectId, className = '' // Refs for performance optimization const dragOverTimeoutRef = useRef(null); + const containerRef = useRef(null); // Enable real-time socket updates for task changes useTaskSocketHandlers(); - // Redux selectors using new task management slices + // Redux selectors using V3 API (pre-processed data, minimal loops) const tasks = useSelector(taskManagementSelectors.selectAll); - const taskGroups = useSelector(selectTaskGroups); - const currentGrouping = useSelector(selectCurrentGrouping); + const taskGroups = useSelector(selectTaskGroupsV3); // Pre-processed groups from backend + const currentGrouping = useSelector(selectCurrentGroupingV3); // Current grouping from backend const selectedTaskIds = useSelector(selectSelectedTaskIds); const loading = useSelector((state: RootState) => state.taskManagement.loading); const error = useSelector((state: RootState) => state.taskManagement.error); + + // Get theme from Redux store + const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); // Drag and Drop sensors - optimized for better performance const sensors = useSensors( @@ -112,8 +119,8 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Fetch task groups when component mounts or dependencies change useEffect(() => { if (projectId) { - // Fetch real tasks from API - dispatch(fetchTasks(projectId)); + // Fetch real tasks from V3 API (minimal processing needed) + dispatch(fetchTasksV3(projectId)); } }, [dispatch, projectId, currentGrouping]); @@ -123,7 +130,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const hasSelection = selectedTaskIds.length > 0; // Memoized handlers for better performance - const handleGroupingChange = useCallback((newGroupBy: typeof currentGrouping) => { + const handleGroupingChange = useCallback((newGroupBy: 'status' | 'priority' | 'phase') => { dispatch(setCurrentGrouping(newGroupBy)); }, [dispatch]); @@ -308,7 +315,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' task={dragState.activeTask} projectId={projectId} groupId={dragState.activeGroupId} - currentGrouping={currentGrouping} + currentGrouping={(currentGrouping as 'status' | 'priority' | 'phase') || 'status'} isSelected={false} isDragOverlay /> @@ -336,7 +343,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' } return ( -
+
= ({ projectId, className = '' /> )} - {/* Task Groups Container */} + {/* Virtualized Task Groups Container */}
{loading ? ( @@ -382,18 +389,31 @@ const TaskListBoard: React.FC = ({ projectId, className = '' /> ) : ( -
- {taskGroups.map((group) => ( - - ))} +
+ {taskGroups.map((group, index) => { + // Calculate dynamic height for each group + const groupTasks = group.taskIds.length; + const baseHeight = 120; // Header + column headers + add task row + const taskRowsHeight = groupTasks * 40; // 40px per task row + const minGroupHeight = 300; // Minimum height for better visual appearance + const maxGroupHeight = 600; // Increased maximum height per group + const calculatedHeight = baseHeight + taskRowsHeight; + const groupHeight = Math.max(minGroupHeight, Math.min(calculatedHeight, maxGroupHeight)); + + return ( + + ); + })}
)}
@@ -422,13 +442,150 @@ const TaskListBoard: React.FC = ({ projectId, className = '' will-change: scroll-position; } - .task-groups { + .virtualized-task-groups { min-width: fit-content; position: relative; /* GPU acceleration for drag operations */ transform: translateZ(0); } + .virtualized-task-group { + border: 1px solid var(--task-border-primary, #e8e8e8); + border-radius: 8px; + margin-bottom: 16px; + background: var(--task-bg-primary, white); + box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1)); + overflow: hidden; + transition: all 0.3s ease; + position: relative; + } + + .virtualized-task-group:last-child { + margin-bottom: 0; + } + + /* Task group header styles */ + .task-group-header { + background: var(--task-bg-primary, white); + transition: background-color 0.3s ease; + } + + .task-group-header-row { + display: inline-flex; + height: auto; + max-height: none; + overflow: hidden; + } + + .task-group-header-content { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 6px 6px 0 0; + background-color: #f0f0f0; + color: white; + font-weight: 500; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + } + + .task-group-header-text { + color: white !important; + font-size: 13px !important; + font-weight: 600 !important; + margin: 0 !important; + } + + /* Column headers styles */ + .task-group-column-headers { + background: var(--task-bg-secondary, #f5f5f5); + border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9); + transition: background-color 0.3s ease; + } + + .task-group-column-headers-row { + display: flex; + height: 40px; + max-height: 40px; + overflow: visible; + position: relative; + min-width: 1200px; + } + + .task-table-header-cell { + background: var(--task-bg-secondary, #f5f5f5); + font-weight: 600; + color: var(--task-text-secondary, #595959); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9); + height: 32px; + max-height: 32px; + overflow: hidden; + transition: all 0.3s ease; + } + + .column-header-text { + font-size: 11px; + font-weight: 600; + color: var(--task-text-secondary, #595959); + text-transform: uppercase; + letter-spacing: 0.5px; + transition: color 0.3s ease; + } + + /* Add task row styles */ + .task-group-add-task { + background: var(--task-bg-primary, white); + border-top: 1px solid var(--task-border-secondary, #f0f0f0); + transition: all 0.3s ease; + padding: 0 12px; + width: 100%; + min-height: 40px; + display: flex; + align-items: center; + } + + .task-group-add-task:hover { + background: var(--task-hover-bg, #fafafa); + } + + .task-table-fixed-columns { + display: flex; + background: var(--task-bg-secondary, #f5f5f5); + position: sticky; + left: 0; + z-index: 11; + border-right: 2px solid var(--task-border-primary, #e8e8e8); + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + } + + .task-table-scrollable-columns { + display: flex; + flex: 1; + min-width: 0; + } + + .task-table-cell { + display: flex; + align-items: center; + padding: 0 12px; + border-right: 1px solid var(--task-border-secondary, #f0f0f0); + font-size: 12px; + white-space: nowrap; + height: 40px; + max-height: 40px; + min-height: 40px; + overflow: hidden; + color: var(--task-text-primary, #262626); + transition: all 0.3s ease; + } + + .task-table-cell:last-child { + border-right: none; + } + /* Optimized drag overlay styles */ [data-dnd-overlay] { /* GPU acceleration for smooth dragging */ @@ -503,7 +660,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' } /* Performance optimizations */ - .task-group { + .virtualized-task-group { contain: layout style paint; } @@ -515,6 +672,15 @@ const TaskListBoard: React.FC = ({ projectId, className = '' .task-table-cell { contain: layout; } + + /* React Window specific optimizations */ + .react-window-list { + outline: none; + } + + .react-window-list-item { + contain: layout style; + } `}
); diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 4f604d03..d95a281e 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -1,7 +1,9 @@ -import React, { useMemo, useCallback } 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'; +import { Input, Typography } from 'antd'; +import type { InputRef } from 'antd'; import { HolderOutlined, MessageOutlined, @@ -11,6 +13,8 @@ import { import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; interface TaskRowProps { task: Task; @@ -49,6 +53,14 @@ const TaskRow: React.FC = React.memo(({ onSelect, onToggleSubtasks, }) => { + const { socket, connected } = useSocket(); + + // Edit task name state + const [editTaskName, setEditTaskName] = useState(false); + const [taskName, setTaskName] = useState(task.title || ''); + const inputRef = useRef(null); + const wrapperRef = useRef(null); + const { attributes, listeners, @@ -69,6 +81,40 @@ const TaskRow: React.FC = React.memo(({ // Get theme from Redux store const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); + // Click outside detection for edit mode + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + handleTaskNameSave(); + } + }; + + if (editTaskName) { + document.addEventListener('mousedown', handleClickOutside); + inputRef.current?.focus(); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [editTaskName]); + + // Handle task name save + const handleTaskNameSave = useCallback(() => { + const newTaskName = inputRef.current?.input?.value; + if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) { + socket?.emit( + SocketEvents.TASK_NAME_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + name: newTaskName, + parent_task: null, // Assuming top-level tasks for now + }) + ); + } + setEditTaskName(false); + }, [connected, socket, task.id, task.title]); + // Memoize style calculations - simplified const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), @@ -97,12 +143,11 @@ const TaskRow: React.FC = React.memo(({ ? 'border-gray-700 bg-gray-900 hover:bg-gray-800' : 'border-gray-200 bg-white hover:bg-gray-50'; const selectedClasses = isSelected - ? (isDarkMode ? 'bg-blue-900/20 border-l-4 border-l-blue-500' : 'bg-blue-50 border-l-4 border-l-blue-500') + ? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50') : ''; const overlayClasses = isDragOverlay ? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}` : ''; - return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`; }, [isDarkMode, isSelected, isDragOverlay]); @@ -112,8 +157,8 @@ const TaskRow: React.FC = React.memo(({ ); const taskNameClasses = useMemo(() => { - const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300'; - const themeClasses = isDarkMode ? 'text-gray-100' : 'text-gray-900'; + const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300 cursor-pointer'; + const themeClasses = isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600'; const completedClasses = task.progress === 100 ? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}` : ''; @@ -207,12 +252,36 @@ const TaskRow: React.FC = React.memo(({
{/* Task Name */} -
+
- - {task.title} - +
+ {!editTaskName ? ( + setEditTaskName(true)} + className={taskNameClasses} + style={{ cursor: 'pointer' }} + > + {task.title} + + ) : ( + ) => setTaskName(e.target.value)} + onPressEnter={handleTaskNameSave} + className={`${isDarkMode ? 'bg-gray-800 text-gray-100 border-gray-600' : 'bg-white text-gray-900 border-gray-300'}`} + style={{ + width: '100%', + padding: '2px 4px', + fontSize: '14px', + fontWeight: 500, + }} + /> + )} +
diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx new file mode 100644 index 00000000..70ae7c2c --- /dev/null +++ b/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx @@ -0,0 +1,163 @@ +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 +
+
+ Members +
+
+ Labels +
+
+ Status +
+
+ 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 new file mode 100644 index 00000000..5fe42380 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -0,0 +1,429 @@ +import React, { useMemo, useCallback, useEffect } 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'; +import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; + +interface VirtualizedTaskListProps { + 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 VirtualizedTaskList: 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 HEADER_HEIGHT = 40; + const COLUMN_HEADER_HEIGHT = 40; + + // Calculate the actual height needed for the virtualized list + const actualContentHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT); + const listHeight = Math.min(height - 40, actualContentHeight); + + // Calculate item count - only include actual content + const getItemCount = () => { + return groupTasks.length + 2; // +2 for header and column headers only + }; + + // Debug logging + useEffect(() => { + console.log('VirtualizedTaskList:', { + groupId: group.id, + groupTasks: groupTasks.length, + height, + listHeight, + itemCount: getItemCount(), + isVirtualized: groupTasks.length > 10, // Show if virtualization should be active + minHeight: 300, + maxHeight: 600 + }); + }, [group.id, groupTasks.length, height, listHeight]); + + // 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 +
+
+ Members +
+
+ Labels +
+
+ Status +
+
+ Priority +
+
+ Time Tracking +
+
+
+
+
+ ); + } + + // Task rows + const taskIndex = index - 2; + if (taskIndex >= 0 && taskIndex < groupTasks.length) { + const task = groupTasks[taskIndex]; + return ( +
+ +
+ ); + } + + return null; + }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]); + + return ( +
+ + + {Row} + + + + {/* Add Task Row - Always show at the bottom */} +
+ +
+ + +
+ ); +}); + +export default VirtualizedTaskList; \ No newline at end of file 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 861ff4a1..248cd1e7 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -1,7 +1,7 @@ import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { Task, TaskManagementState } from '@/types/task-management.types'; import { RootState } from '@/app/store'; -import { tasksApiService, ITaskListConfigV2 } from '@/api/tasks/tasks.api.service'; +import { tasksApiService, ITaskListConfigV2, ITaskListV3Response } from '@/api/tasks/tasks.api.service'; import logger from '@/utils/errorLogger'; // Entity adapter for normalized state @@ -14,6 +14,8 @@ const initialState: TaskManagementState = { ids: [], loading: false, error: null, + groups: [], + grouping: null, }; // Async thunk to fetch tasks from API @@ -59,6 +61,31 @@ export const fetchTasks = createAsyncThunk( return 0; }; + // Create a mapping from status IDs to group names + const statusIdToNameMap: Record = {}; + const priorityIdToNameMap: Record = {}; + + response.body.forEach((group: any) => { + statusIdToNameMap[group.id] = group.name.toLowerCase(); + }); + + // For priority mapping, we need to get priority names from the tasks themselves + // Since the API doesn't provide priority names in the group structure + response.body.forEach((group: any) => { + group.tasks.forEach((task: any) => { + // Map priority value to name (this is an assumption based on common patterns) + if (task.priority_value !== undefined) { + switch (task.priority_value) { + case 0: priorityIdToNameMap[task.priority] = 'low'; break; + case 1: priorityIdToNameMap[task.priority] = 'medium'; break; + case 2: priorityIdToNameMap[task.priority] = 'high'; break; + case 3: priorityIdToNameMap[task.priority] = 'critical'; break; + default: priorityIdToNameMap[task.priority] = 'medium'; + } + } + }); + }); + // Transform the API response to our Task type const tasks: Task[] = response.body.flatMap((group: any) => group.tasks.map((task: any) => ({ @@ -66,8 +93,8 @@ export const fetchTasks = createAsyncThunk( task_key: task.task_key || '', title: task.name || '', description: task.description || '', - status: task.status_name?.toLowerCase() || 'todo', - priority: task.priority_name?.toLowerCase() || 'medium', + status: statusIdToNameMap[task.status] || 'todo', + priority: priorityIdToNameMap[task.priority] || 'medium', phase: task.phase_name || 'Development', progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0, assignees: task.assignees?.map((a: any) => a.team_member_id) || [], @@ -102,6 +129,65 @@ export const fetchTasks = createAsyncThunk( } ); +// New V3 fetch that minimizes frontend processing +export const fetchTasksV3 = createAsyncThunk( + 'taskManagement/fetchTasksV3', + async (projectId: string, { rejectWithValue, getState }) => { + try { + const state = getState() as RootState; + const currentGrouping = state.grouping.currentGrouping; + + const config: ITaskListConfigV2 = { + id: projectId, + archived: false, + group: currentGrouping, + field: '', + order: '', + search: '', + statuses: '', + members: '', + projects: '', + isSubtasksInclude: false, + labels: '', + priorities: '', + }; + + const response = await tasksApiService.getTaskListV3(config); + + // Minimal processing - tasks are already processed by backend + return { + tasks: response.body.allTasks, + groups: response.body.groups, + grouping: response.body.grouping, + totalTasks: response.body.totalTasks + }; + } catch (error) { + logger.error('Fetch Tasks V3', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch tasks'); + } + } +); + +// Refresh task progress separately to avoid slowing down initial load +export const refreshTaskProgress = createAsyncThunk( + 'taskManagement/refreshTaskProgress', + async (projectId: string, { rejectWithValue }) => { + try { + const response = await tasksApiService.refreshTaskProgress(projectId); + return response.body; + } catch (error) { + logger.error('Refresh Task Progress', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to refresh task progress'); + } + } +); + const taskManagementSlice = createSlice({ name: 'taskManagement', initialState: tasksAdapter.getInitialState(initialState), @@ -234,6 +320,33 @@ const taskManagementSlice = createSlice({ .addCase(fetchTasks.rejected, (state, action) => { state.loading = false; state.error = action.payload as string || 'Failed to fetch tasks'; + }) + .addCase(fetchTasksV3.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchTasksV3.fulfilled, (state, action) => { + state.loading = false; + state.error = null; + // Tasks are already processed by backend, minimal setup needed + tasksAdapter.setAll(state, action.payload.tasks); + state.groups = action.payload.groups; + state.grouping = action.payload.grouping; + }) + .addCase(fetchTasksV3.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string || 'Failed to fetch tasks'; + }) + .addCase(refreshTaskProgress.pending, (state) => { + // Don't set loading to true for refresh to avoid UI blocking + state.error = null; + }) + .addCase(refreshTaskProgress.fulfilled, (state) => { + state.error = null; + // Progress refresh completed successfully + }) + .addCase(refreshTaskProgress.rejected, (state, action) => { + state.error = action.payload as string || 'Failed to refresh task progress'; }); }, }); @@ -270,4 +383,8 @@ export const selectTasksByPhase = (state: RootState, phase: string) => taskManagementSelectors.selectAll(state).filter(task => task.phase === phase); export const selectTasksLoading = (state: RootState) => state.taskManagement.loading; -export const selectTasksError = (state: RootState) => state.taskManagement.error; \ No newline at end of file +export const selectTasksError = (state: RootState) => state.taskManagement.error; + +// V3 API selectors - no processing needed, data is pre-processed by backend +export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups; +export const selectCurrentGroupingV3 = (state: RootState) => state.taskManagement.grouping; \ No newline at end of file diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css index 3c1af53d..d6902d25 100644 --- a/worklenz-frontend/src/index.css +++ b/worklenz-frontend/src/index.css @@ -1,5 +1,6 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); @import url("./styles/customOverrides.css"); +@import url("./styles/task-management.css"); @tailwind base; @tailwind components; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx index a3ec005b..e58d7793 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx @@ -1,54 +1,49 @@ import { useEffect } from 'react'; import { Flex } from 'antd'; -import TaskListFilters from './taskListFilters/TaskListFilters'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; -import { fetchTaskGroups } from '@/features/tasks/tasks.slice'; -import { ITaskListConfigV2 } from '@/types/tasks/taskList.types'; -import TanStackTable from '../task-list/task-list-custom'; -import TaskListCustom from '../task-list/task-list-custom'; -import TaskListTableWrapper from '../task-list/task-list-table-wrapper/task-list-table-wrapper'; +import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +import TaskListBoard from '@/components/task-management/task-list-board'; const ProjectViewTaskList = () => { - // sample data from task reducer const dispatch = useAppDispatch(); - const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer); - const { statusCategories } = useAppSelector(state => state.taskStatusReducer); const projectId = useAppSelector(state => state.projectReducer.projectId); + const { statusCategories } = useAppSelector(state => state.taskStatusReducer); useEffect(() => { if (projectId) { - const config: ITaskListConfigV2 = { - id: projectId, - field: 'id', - order: 'desc', - search: '', - statuses: '', - members: '', - projects: '', - isSubtasksInclude: true, - }; - dispatch(fetchTaskGroups(config)); + // Use the optimized V3 API for faster loading + dispatch(fetchTasksV3(projectId)); } if (!statusCategories.length) { dispatch(fetchStatusesCategories()); } }, [dispatch, projectId]); + // Cleanup effect - reset values when component is destroyed + useEffect(() => { + return () => { + // Clear any selected tasks when component unmounts + dispatch(deselectAll()); + }; + }, [dispatch]); + + if (!projectId) { + return ( + +
No project selected
+
+ ); + } + return ( - - - {taskGroups.map(group => ( - - ))} + ); }; diff --git a/worklenz-frontend/src/styles/task-management.css b/worklenz-frontend/src/styles/task-management.css index e1f797c8..82b18b33 100644 --- a/worklenz-frontend/src/styles/task-management.css +++ b/worklenz-frontend/src/styles/task-management.css @@ -213,389 +213,283 @@ outline-offset: 2px; } -/* Dark mode support */ -[data-theme="dark"] .task-list-board { +/* Dark mode support using class-based selectors */ +.dark .task-list-board { background-color: #141414; color: rgba(255, 255, 255, 0.85); } -@media (prefers-color-scheme: dark) { - .task-list-board { - background-color: #141414; - color: rgba(255, 255, 255, 0.85); - } - - /* Task Groups */ - .task-group { - background-color: #1f1f1f; - border-color: #303030; - } - - .task-group.drag-over { - border-color: #1890ff !important; - box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); - background-color: rgba(24, 144, 255, 0.1); - } - - .task-group .group-header { - background: #262626; - border-bottom-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .task-group .group-header:hover { - background: #2f2f2f; - } - - /* Task Rows */ - .task-row { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - border-color: #303030; - } - - .task-row:hover { - background-color: #262626 !important; - border-left-color: #595959; - } - - .task-row.selected { - background-color: rgba(24, 144, 255, 0.15) !important; - border-left-color: #1890ff; - } - - .task-row .drag-handle { - color: rgba(255, 255, 255, 0.45); - } - - .task-row .drag-handle:hover { - color: rgba(255, 255, 255, 0.85); - } - - /* Progress bars */ - .ant-progress-bg { - background-color: #303030; - } - - /* Text colors */ - .task-row .ant-typography { - color: rgba(255, 255, 255, 0.85); - } - - .task-row .text-gray-500 { - color: rgba(255, 255, 255, 0.45) !important; - } - - .task-row .text-gray-600 { - color: rgba(255, 255, 255, 0.65) !important; - } - - .task-row .text-gray-400 { - color: rgba(255, 255, 255, 0.45) !important; - } - - /* Completed task styling */ - .task-row .line-through { - color: rgba(255, 255, 255, 0.45); - } - - /* Bulk Action Bar */ - .bulk-action-bar { - background: rgba(24, 144, 255, 0.15); - border-color: rgba(24, 144, 255, 0.3); - color: rgba(255, 255, 255, 0.85); - } - - /* Cards and containers */ - .ant-card { - background-color: #1f1f1f; - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-card-head { - background-color: #262626; - border-bottom-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-card-body { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - } - - /* Buttons */ - .ant-btn { - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-btn:hover { - border-color: #595959; - color: rgba(255, 255, 255, 0.85); - } - - .ant-btn-primary { - background-color: #1890ff; - border-color: #1890ff; - } - - .ant-btn-primary:hover { - background-color: #40a9ff; - border-color: #40a9ff; - } - - /* Dropdowns and menus */ - .ant-dropdown-menu { - background-color: #1f1f1f; - border-color: #303030; - } - - .ant-dropdown-menu-item { - color: rgba(255, 255, 255, 0.85); - } - - .ant-dropdown-menu-item:hover { - background-color: #262626; - } - - /* Select components */ - .ant-select-selector { - background-color: #1f1f1f !important; - border-color: #303030 !important; - color: rgba(255, 255, 255, 0.85) !important; - } - - .ant-select-arrow { - color: rgba(255, 255, 255, 0.45); - } - - /* Checkboxes */ - .ant-checkbox-wrapper { - color: rgba(255, 255, 255, 0.85); - } - - .ant-checkbox-inner { - background-color: #1f1f1f; - border-color: #303030; - } - - .ant-checkbox-checked .ant-checkbox-inner { - background-color: #1890ff; - border-color: #1890ff; - } - - /* Tags and labels */ - .ant-tag { - background-color: #262626; - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - /* Avatars */ - .ant-avatar { - background-color: #595959; - color: rgba(255, 255, 255, 0.85); - } - - /* Tooltips */ - .ant-tooltip-inner { - background-color: #262626; - color: rgba(255, 255, 255, 0.85); - } - - .ant-tooltip-arrow-content { - background-color: #262626; - } - - /* Popconfirm */ - .ant-popover-inner { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - } - - .ant-popover-arrow-content { - background-color: #1f1f1f; - } - - /* Subtasks */ - .task-subtasks { - border-left-color: #303030; - } - - .task-subtasks .task-row { - background-color: #141414; - } - - .task-subtasks .task-row:hover { - background-color: #1f1f1f !important; - } - - /* Scrollbars */ - .task-groups-container::-webkit-scrollbar-track { - background: #141414; - } - - .task-groups-container::-webkit-scrollbar-thumb { - background: #595959; - } - - .task-groups-container::-webkit-scrollbar-thumb:hover { - background: #777777; - } - - /* Loading states */ - .ant-spin-dot-item { - background-color: #1890ff; - } - - /* Empty states */ - .ant-empty { - color: rgba(255, 255, 255, 0.45); - } - - .ant-empty-description { - color: rgba(255, 255, 255, 0.45); - } - - /* Focus styles for dark mode */ - .task-row:focus-within { - outline-color: #40a9ff; - } - - .drag-handle:focus { - outline-color: #40a9ff; - } - - /* Border colors */ - .border-gray-100 { - border-color: #303030 !important; - } - - .border-gray-200 { - border-color: #404040 !important; - } - - .border-gray-300 { - border-color: #595959 !important; - } - - /* Background utilities */ - .bg-gray-50 { - background-color: #141414 !important; - } - - .bg-gray-100 { - background-color: #1f1f1f !important; - } - - .bg-white { - background-color: #1f1f1f !important; - } - - /* Due date colors in dark mode */ - .text-red-500 { - color: #ff7875 !important; - } - - .text-orange-500 { - color: #ffa940 !important; - } - - /* Group progress bar in dark mode */ - .task-group .group-header .bg-gray-200 { - background-color: #303030 !important; - } -} - -/* Specific dark mode styles using data-theme attribute */ -[data-theme="dark"] .task-group { +.dark .task-group { background-color: #1f1f1f; border-color: #303030; } -[data-theme="dark"] .task-group.drag-over { +.dark .task-group.drag-over { border-color: #1890ff !important; box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); background-color: rgba(24, 144, 255, 0.1); } -[data-theme="dark"] .task-group .group-header { +.dark .task-group .group-header { background: #262626; border-bottom-color: #303030; color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-group .group-header:hover { +.dark .task-group .group-header:hover { background: #2f2f2f; } -[data-theme="dark"] .task-row { +.dark .task-row { background-color: #1f1f1f; color: rgba(255, 255, 255, 0.85); border-color: #303030; } -[data-theme="dark"] .task-row:hover { +.dark .task-row:hover { background-color: #262626 !important; border-left-color: #595959; } -[data-theme="dark"] .task-row.selected { +.dark .task-row.selected { background-color: rgba(24, 144, 255, 0.15) !important; border-left-color: #1890ff; } -[data-theme="dark"] .task-row .drag-handle { +.dark .task-row .drag-handle { color: rgba(255, 255, 255, 0.45); } -[data-theme="dark"] .task-row .drag-handle:hover { +.dark .task-row .drag-handle:hover { color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .bulk-action-bar { +.dark .bulk-action-bar { background: rgba(24, 144, 255, 0.15); border-color: rgba(24, 144, 255, 0.3); color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-row .ant-typography { +.dark .task-row .ant-typography { color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-row .text-gray-500 { +.dark .task-row .text-gray-500 { color: rgba(255, 255, 255, 0.45) !important; } -[data-theme="dark"] .task-row .text-gray-600 { +.dark .task-row .text-gray-600 { color: rgba(255, 255, 255, 0.65) !important; } -[data-theme="dark"] .task-row .text-gray-400 { +.dark .task-row .text-gray-400 { color: rgba(255, 255, 255, 0.45) !important; } -[data-theme="dark"] .task-row .line-through { +.dark .task-row .line-through { color: rgba(255, 255, 255, 0.45); } -[data-theme="dark"] .task-subtasks { +.dark .ant-card { + background-color: #1f1f1f; + border-color: #303030; +} + +.dark .ant-card-head { + background-color: #262626; + border-bottom-color: #303030; +} + +.dark .ant-card-body { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-btn { + background-color: #262626; + border-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-btn:hover { + background-color: #2f2f2f; + border-color: #505050; +} + +.dark .ant-btn-primary { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark .ant-btn-primary:hover { + background-color: #40a9ff; + border-color: #40a9ff; +} + +.dark .ant-dropdown-menu { + background-color: #1f1f1f; + border-color: #303030; +} + +.dark .ant-dropdown-menu-item { + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-dropdown-menu-item:hover { + background-color: #262626; +} + +.dark .ant-select-selector { + background-color: #262626 !important; + border-color: #404040 !important; + color: rgba(255, 255, 255, 0.85) !important; +} + +.dark .ant-select-arrow { + color: rgba(255, 255, 255, 0.45); +} + +.dark .ant-checkbox-wrapper { + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-checkbox-inner { + background-color: #262626; + border-color: #404040; +} + +.dark .ant-checkbox-checked .ant-checkbox-inner { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark .ant-tag { + background-color: #262626; + border-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-avatar { + background-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-tooltip-inner { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-tooltip-arrow-content { + background-color: #1f1f1f; +} + +.dark .ant-popover-inner { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-popover-arrow-content { + background-color: #1f1f1f; +} + +.dark .task-subtasks { border-left-color: #303030; } -[data-theme="dark"] .task-subtasks .task-row { +.dark .task-subtasks .task-row { background-color: #141414; } -[data-theme="dark"] .task-subtasks .task-row:hover { +.dark .task-subtasks .task-row:hover { + background-color: #1a1a1a; +} + +.dark .task-groups-container::-webkit-scrollbar-track { + background-color: #262626; +} + +.dark .task-groups-container::-webkit-scrollbar-thumb { + background-color: #404040; +} + +.dark .task-groups-container::-webkit-scrollbar-thumb:hover { + background-color: #505050; +} + +.dark .ant-spin-dot-item { + background-color: #1890ff; +} + +.dark .ant-empty { + color: rgba(255, 255, 255, 0.45); +} + +.dark .ant-empty-description { + color: rgba(255, 255, 255, 0.45); +} + +.dark .task-row:focus-within { + outline-color: #1890ff; +} + +.dark .drag-handle:focus { + outline-color: #1890ff; +} + +.dark .border-gray-100 { + border-color: #262626 !important; +} + +.dark .border-gray-200 { + border-color: #303030 !important; +} + +.dark .border-gray-300 { + border-color: #404040 !important; +} + +.dark .bg-gray-50 { + background-color: #141414 !important; +} + +.dark .bg-gray-100 { + background-color: #1a1a1a !important; +} + +.dark .bg-white { background-color: #1f1f1f !important; } -[data-theme="dark"] .text-red-500 { +.dark .text-red-500 { color: #ff7875 !important; } -[data-theme="dark"] .text-orange-500 { +.dark .text-orange-500 { color: #ffa940 !important; +} + +.dark .task-group .group-header .bg-gray-200 { + background-color: #262626 !important; +} + +/* System preference fallback */ +@media (prefers-color-scheme: dark) { + .task-list-board:not(.light) { + color: rgba(255, 255, 255, 0.85); + } + + .task-group:not(.light) { + background-color: #1f1f1f; + } + + .task-row:not(.light) { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); + border-color: #303030; + } + + .task-row:not(.light):hover { + background-color: #262626 !important; + border-left-color: #595959; + } } \ No newline at end of file diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index f2fd5f66..e40279b9 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -70,6 +70,8 @@ export interface TaskManagementState { ids: string[]; loading: boolean; error: string | null; + groups: TaskGroup[]; // Pre-processed groups from V3 API + grouping: string | null; // Current grouping from V3 API } export interface TaskGroupsState { From 95d0985f3d8ed290b374d8b936167326a60903d8 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 23 Jun 2025 16:49:57 +0530 Subject: [PATCH 4/5] feat(task-management): enhance task row and virtualized list components for improved layout and performance - Added support for customizable columns in `TaskRow` component, allowing for fixed and scrollable columns. - Implemented synchronized scrolling between header and body in `VirtualizedTaskList` for better user experience. - Refactored column header rendering to dynamically generate based on column definitions, improving maintainability. - Enhanced styles for task group headers and column headers to ensure consistent appearance and responsiveness. --- .../components/task-management/task-row.tsx | 399 ++++++++++-------- .../task-management/virtualized-task-list.tsx | 315 +++++++------- 2 files changed, 370 insertions(+), 344 deletions(-) diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index d95a281e..c2d5b54e 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -26,6 +26,9 @@ interface TaskRowProps { index?: number; onSelect?: (taskId: string, selected: boolean) => void; onToggleSubtasks?: (taskId: string) => void; + 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 }>; } // Priority and status colors - moved outside component to avoid recreation @@ -52,6 +55,9 @@ const TaskRow: React.FC = React.memo(({ index, onSelect, onToggleSubtasks, + columns, + fixedColumns, + scrollableColumns, }) => { const { socket, connected } = useSocket(); @@ -217,189 +223,222 @@ const TaskRow: React.FC = React.memo(({ >
{/* Fixed Columns */} -
- {/* Drag Handle */} -
-
- - {/* Selection Checkbox */} -
- -
- - {/* Task Key */} -
- - {task.task_key} - -
- - {/* Task Name */} -
-
-
-
- {!editTaskName ? ( - setEditTaskName(true)} - className={taskNameClasses} - style={{ cursor: 'pointer' }} - > - {task.title} - - ) : ( - ) => setTaskName(e.target.value)} - onPressEnter={handleTaskNameSave} - className={`${isDarkMode ? 'bg-gray-800 text-gray-100 border-gray-600' : 'bg-white text-gray-900 border-gray-300'}`} - style={{ - width: '100%', - padding: '2px 4px', - fontSize: '14px', - fontWeight: 500, - }} +
sum + col.width, 0) || 0, + }} + > + {fixedColumns?.map(col => { + switch (col.key) { + case 'drag': + return ( +
+
-
-
-
+
+ ); + case 'select': + return ( +
+ +
+ ); + case 'key': + return ( +
+ + {task.task_key} + +
+ ); + case 'task': + return ( +
+
+
+
+ {!editTaskName ? ( + setEditTaskName(true)} + className={taskNameClasses} + style={{ cursor: 'pointer' }} + > + {task.title} + + ) : ( + ) => setTaskName(e.target.value)} + onPressEnter={handleTaskNameSave} + className={`${isDarkMode ? 'bg-gray-800 text-gray-100 border-gray-600' : 'bg-white text-gray-900 border-gray-300'}`} + style={{ + width: '100%', + padding: '2px 4px', + fontSize: '14px', + fontWeight: 500, + }} + /> + )} +
+
+
+
+ ); + default: + return null; + } + })}
- {/* Scrollable Columns */} -
- {/* Progress */} -
- {task.progress !== undefined && task.progress >= 0 && ( - - )} -
- - {/* Members */} -
-
- {avatarGroupMembers.length > 0 && ( - - )} - -
-
- - {/* Labels */} -
-
- {task.labels?.map((label, index) => ( - label.end && label.names && label.name ? ( - - ) : ( - - ) - ))} - -
-
- - {/* Status */} -
- - {task.status} - -
- - {/* Priority */} -
-
-
- - {task.priority} - -
-
- - {/* Time Tracking */} -
-
- {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( -
- - - {typeof task.timeTracking.logged === 'number' - ? `${task.timeTracking.logged}h` - : task.timeTracking.logged - } - -
- )} -
-
+
sum + col.width, 0) || 0 }}> + {scrollableColumns?.map(col => { + switch (col.key) { + case 'progress': + return ( +
+ {task.progress !== undefined && task.progress >= 0 && ( + + )} +
+ ); + case 'members': + return ( +
+
+ {avatarGroupMembers.length > 0 && ( + + )} + +
+
+ ); + case 'labels': + return ( +
+
+ {task.labels?.map((label, index) => ( + label.end && label.names && label.name ? ( + + ) : ( + + ) + ))} + +
+
+ ); + case 'status': + return ( +
+ + {task.status} + +
+ ); + case 'priority': + return ( +
+
+
+ + {task.priority} + +
+
+ ); + case 'timeTracking': + return ( +
+
+ {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( +
+ + + {typeof task.timeTracking.logged === 'number' + ? `${task.timeTracking.logged}h` + : task.timeTracking.logged + } + +
+ )} +
+
+ ); + default: + return null; + } + })}
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 5fe42380..47579249 100644 --- a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useEffect } from 'react'; +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; import { FixedSizeList as List } from 'react-window'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSelector } from 'react-redux'; @@ -64,120 +64,146 @@ const VirtualizedTaskList: React.FC = React.memo(({ }); }, [group.id, groupTasks.length, height, listHeight]); - // Row renderer for virtualization + const scrollContainerRef = useRef(null); + const headerScrollRef = useRef(null); + + // Synchronize header scroll with body scroll + useEffect(() => { + const handleScroll = () => { + if (headerScrollRef.current && scrollContainerRef.current) { + headerScrollRef.current.scrollLeft = scrollContainerRef.current.scrollLeft; + } + }; + const scrollDiv = scrollContainerRef.current; + if (scrollDiv) { + scrollDiv.addEventListener('scroll', handleScroll); + } + return () => { + if (scrollDiv) { + scrollDiv.removeEventListener('scroll', handleScroll); + } + }; + }, []); + + // Define columns array for alignment + const columns = [ + { key: 'drag', label: '', width: 40, fixed: true }, + { key: 'select', label: '', width: 40, fixed: true }, + { key: 'key', label: 'KEY', width: 80, fixed: true }, + { key: 'task', label: 'TASK', width: 475, fixed: true }, + { key: 'progress', label: 'PROGRESS', width: 90 }, + { key: 'members', label: 'MEMBERS', width: 150 }, + { key: 'labels', label: 'LABELS', width: 200 }, + { key: 'status', label: 'STATUS', width: 100 }, + { key: 'priority', label: 'PRIORITY', width: 100 }, + { key: 'timeTracking', label: 'TIME TRACKING', width: 120 }, + ]; + const fixedColumns = columns.filter(col => col.fixed); + const scrollableColumns = columns.filter(col => !col.fixed); + const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0); + const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0); + const totalTableWidth = fixedWidth + scrollableWidth; + + // Row renderer for virtualization (remove header/column header rows) 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 -
-
- Members -
-
- Labels -
-
- Status -
-
- Priority -
-
- Time Tracking -
-
-
-
-
- ); - } - - // Task rows - const taskIndex = index - 2; - if (taskIndex >= 0 && taskIndex < groupTasks.length) { - const task = groupTasks[taskIndex]; - return ( -
- -
- ); - } - - return null; + const task = groupTasks[index]; + if (!task) return null; + return ( +
+ +
+ ); }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]); return (
- - +
+
+ + {group.title} ({groupTasks.length}) + +
+
+
+ {/* Column Headers (sync scroll) */} +
+
- {Row} - - - +
+ {fixedColumns.map(col => ( +
+ {col.label} +
+ ))} +
+
+ {scrollableColumns.map(col => ( +
+ {col.label} +
+ ))} +
+
+
+ {/* Scrollable List */} +
+ + + {Row} + + +
{/* Add Task Row - Always show at the bottom */}
= React.memo(({ >
-