Merge pull request #213 from Worklenz/fix/task-list-realtime-update

chore(dependencies): update package-lock.json and package.json for de…
This commit is contained in:
Chamika J
2025-07-01 15:22:59 +05:30
committed by GitHub
6 changed files with 2623 additions and 2234 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -86,7 +86,6 @@
"passport-local": "^1.0.0",
"path": "^0.12.7",
"pg": "^8.14.1",
"pg-native": "^3.3.0",
"pug": "^3.0.2",
"redis": "^4.6.7",
"sanitize-html": "^2.11.0",

View File

@@ -8,28 +8,70 @@ import {
Tooltip,
Space,
Badge,
Divider
} from 'antd';
Divider,
type InputRef,
message
} from '@/shared/antd-imports';
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
import {
DeleteOutlined,
CloseOutlined,
MoreOutlined,
RetweetOutlined,
UserAddOutlined,
InboxOutlined,
TagsOutlined,
SyncOutlined,
UserOutlined,
BellOutlined,
TagOutlined,
UsergroupAddOutlined,
CheckOutlined,
EditOutlined,
CopyOutlined,
ExportOutlined,
FileOutlined,
ImportOutlined,
CalendarOutlined,
FlagOutlined,
BulbOutlined
} from '@ant-design/icons';
BarChartOutlined,
SettingOutlined
} from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { RootState } from '@/app/store';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { clearSelection } from '@/features/task-management/selection.slice';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import {
evt_project_task_list_bulk_archive,
evt_project_task_list_bulk_assign_me,
evt_project_task_list_bulk_assign_members,
evt_project_task_list_bulk_change_phase,
evt_project_task_list_bulk_change_priority,
evt_project_task_list_bulk_change_status,
evt_project_task_list_bulk_delete,
evt_project_task_list_bulk_update_labels,
} from '@/shared/worklenz-analytics-events';
import {
IBulkTasksLabelsRequest,
IBulkTasksPhaseChangeRequest,
IBulkTasksPriorityChangeRequest,
IBulkTasksStatusChangeRequest,
} from '@/types/tasks/bulk-action-bar.types';
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
import { ITaskAssignee } from '@/types/tasks/task.types';
import { createPortal as createReactPortal } from 'react-dom';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown';
import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown';
import { sortTeamMembers } from '@/utils/sort-team-members';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { useAuthService } from '@/hooks/useAuth';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
const { Text } = Typography;
@@ -38,17 +80,6 @@ interface OptimizedBulkActionBarProps {
totalSelected: number;
projectId: string;
onClearSelection?: () => void;
onBulkStatusChange?: (statusId: string) => void;
onBulkPriorityChange?: (priorityId: string) => void;
onBulkPhaseChange?: (phaseId: string) => void;
onBulkAssignToMe?: () => void;
onBulkAssignMembers?: (memberIds: string[]) => void;
onBulkAddLabels?: (labelIds: string[]) => void;
onBulkArchive?: () => void;
onBulkDelete?: () => void;
onBulkDuplicate?: () => void;
onBulkExport?: () => void;
onBulkSetDueDate?: (date: string) => void;
}
// Performance-optimized memoized action button component
@@ -62,51 +93,44 @@ const ActionButton = React.memo<{
isDarkMode: boolean;
badge?: number;
}>(({ icon, tooltip, onClick, loading = false, danger = false, disabled = false, isDarkMode, badge }) => {
const buttonStyle = useMemo(() => ({
background: 'transparent',
color: isDarkMode ? '#e5e7eb' : '#374151',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px',
height: '32px',
width: '32px',
fontSize: '14px',
borderRadius: '6px',
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
...(danger && {
color: '#ef4444',
}),
}), [isDarkMode, danger, disabled]);
const buttonClasses = useMemo(() => {
const baseClasses = [
'flex items-center justify-center',
'h-8 w-8 p-1.5',
'text-sm rounded-md',
'transition-all duration-150 ease-out',
'border-none bg-transparent',
'hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2',
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
];
const hoverStyle = useMemo(() => ({
backgroundColor: isDarkMode
? (danger ? 'rgba(239, 68, 68, 0.1)' : 'rgba(255, 255, 255, 0.1)')
: (danger ? 'rgba(239, 68, 68, 0.1)' : 'rgba(0, 0, 0, 0.05)'),
transform: 'scale(1.05)',
}), [isDarkMode, danger]);
if (danger) {
baseClasses.push(
isDarkMode
? 'text-red-400 hover:bg-red-400/10 focus:ring-red-400/20'
: 'text-red-500 hover:bg-red-500/10 focus:ring-red-500/20'
);
} else {
baseClasses.push(
isDarkMode
? 'text-gray-300 hover:bg-white/10 focus:ring-gray-400/20'
: 'text-gray-600 hover:bg-black/5 focus:ring-gray-400/20'
);
}
const [isHovered, setIsHovered] = useState(false);
const combinedStyle = useMemo(() => ({
...buttonStyle,
...(isHovered && !disabled ? hoverStyle : {}),
}), [buttonStyle, hoverStyle, isHovered, disabled]);
return baseClasses.join(' ');
}, [isDarkMode, danger, disabled]);
const ButtonComponent = (
<Button
icon={icon}
style={combinedStyle}
className={buttonClasses}
size="small"
loading={loading}
disabled={disabled}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
type="text"
style={{ background: 'transparent', border: 'none' }}
/>
);
@@ -131,41 +155,44 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
totalSelected,
projectId,
onClearSelection,
onBulkStatusChange,
onBulkPriorityChange,
onBulkPhaseChange,
onBulkAssignToMe,
onBulkAssignMembers,
onBulkAddLabels,
onBulkArchive,
onBulkDelete,
onBulkDuplicate,
onBulkExport,
onBulkSetDueDate,
}) => {
const { t } = useTranslation('task-management');
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
const dispatch = useAppDispatch();
const { t } = useTranslation('tasks/task-table-bulk-actions');
const { trackMixpanelEvent } = useMixpanelTracking();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
// Performance state management
const [isVisible, setIsVisible] = useState(false);
const [loadingStates, setLoadingStates] = useState({
status: false,
priority: false,
phase: false,
assignToMe: false,
assignMembers: false,
labels: false,
archive: false,
delete: false,
duplicate: false,
export: false,
dueDate: false,
const [loading, setLoading] = useState(false);
const [updatingLabels, setUpdatingLabels] = useState(false);
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
const [updatingAssignees, setUpdatingAssignees] = useState(false);
const [updatingArchive, setUpdatingArchive] = useState(false);
const [updatingDelete, setUpdatingDelete] = useState(false);
// Selectors
const statusList = useAppSelector(state => state.taskStatusReducer.status);
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
const labelsList = useAppSelector(state => state.taskLabelsReducer.labels);
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
const archived = useAppSelector(state => state.taskReducer.archived);
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Local state for dropdowns
const labelsInputRef = useRef<InputRef>(null);
const [createLabelText, setCreateLabelText] = useState<string>('');
const [teamMembersSorted, setTeamMembersSorted] = useState<ITeamMembersViewModel>({
data: [],
total: 0,
});
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
const [showDrawer, setShowDrawer] = useState(false);
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
// Smooth entrance animation
useEffect(() => {
if (totalSelected > 0) {
// Micro-delay for smoother animation
const timer = setTimeout(() => setIsVisible(true), 50);
return () => clearTimeout(timer);
} else {
@@ -173,83 +200,312 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
}
}, [totalSelected]);
// Optimized loading state updater
const updateLoadingState = useCallback((action: keyof typeof loadingStates, loading: boolean) => {
setLoadingStates(prev => ({ ...prev, [action]: loading }));
}, []);
// Memoized handlers with loading states
const handleStatusChange = useCallback(async () => {
updateLoadingState('status', true);
try {
await onBulkStatusChange?.('new-status-id');
} finally {
updateLoadingState('status', false);
// Update team members when dropdown opens
useEffect(() => {
if (members?.data && assigneeDropdownOpen) {
let sortedMembers = sortTeamMembers(members.data);
setTeamMembersSorted({ data: sortedMembers, total: members.total });
}
}, [onBulkStatusChange, updateLoadingState]);
}, [assigneeDropdownOpen, members?.data]);
const handlePriorityChange = useCallback(async () => {
updateLoadingState('priority', true);
// Handlers
const handleChangeStatus = useCallback(async (status: ITaskStatus) => {
if (!status.id || !projectId) return;
try {
await onBulkPriorityChange?.('new-priority-id');
} finally {
updateLoadingState('priority', false);
}
}, [onBulkPriorityChange, updateLoadingState]);
setLoading(true);
const handlePhaseChange = useCallback(async () => {
updateLoadingState('phase', true);
try {
await onBulkPhaseChange?.('new-phase-id');
const body: IBulkTasksStatusChangeRequest = {
tasks: selectedTaskIds,
status_id: status.id,
};
// Check task dependencies first
for (const taskId of selectedTaskIds) {
const canContinue = await checkTaskDependencyStatus(taskId, status.id);
if (!canContinue) {
if (selectedTaskIds.length > 1) {
alertService.warning(
'Incomplete Dependencies!',
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
);
} else {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
}
return;
}
}
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
dispatch(clearSelection());
dispatch(fetchTasksV3(projectId));
onClearSelection?.();
message.success(`Status updated for ${selectedTaskIds.length} task${selectedTaskIds.length > 1 ? 's' : ''}`);
}
} catch (error) {
logger.error('Error changing status:', error);
} finally {
updateLoadingState('phase', false);
setLoading(false);
}
}, [onBulkPhaseChange, updateLoadingState]);
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch, onClearSelection]);
const handleChangePriority = useCallback(async (priority: ITaskPriority) => {
if (!priority.id || !projectId) return;
try {
setLoading(true);
const body: IBulkTasksPriorityChangeRequest = {
tasks: selectedTaskIds,
priority_id: priority.id,
};
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
dispatch(clearSelection());
dispatch(fetchTasksV3(projectId));
onClearSelection?.();
message.success(`Priority updated for ${selectedTaskIds.length} task${selectedTaskIds.length > 1 ? 's' : ''}`);
}
} catch (error) {
logger.error('Error changing priority:', error);
} finally {
setLoading(false);
}
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch, onClearSelection]);
const handleChangePhase = useCallback(async (phase: ITaskPhase) => {
if (!phase.id || !projectId) return;
try {
setLoading(true);
const body: IBulkTasksPhaseChangeRequest = {
tasks: selectedTaskIds,
phase_id: phase.id,
};
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
dispatch(clearSelection());
dispatch(fetchTasksV3(projectId));
onClearSelection?.();
message.success(`Phase updated for ${selectedTaskIds.length} task${selectedTaskIds.length > 1 ? 's' : ''}`);
}
} catch (error) {
logger.error('Error changing phase:', error);
} finally {
setLoading(false);
}
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch, onClearSelection]);
const handleAssignToMe = useCallback(async () => {
updateLoadingState('assignToMe', true);
if (!projectId) return;
try {
await onBulkAssignToMe?.();
setUpdatingAssignToMe(true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
dispatch(clearSelection());
dispatch(fetchTasksV3(projectId));
onClearSelection?.();
message.success(`Assigned ${selectedTaskIds.length} task${selectedTaskIds.length > 1 ? 's' : ''} to you`);
}
} catch (error) {
logger.error('Error assigning to me:', error);
} finally {
updateLoadingState('assignToMe', false);
setUpdatingAssignToMe(false);
}
}, [onBulkAssignToMe, updateLoadingState]);
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch, onClearSelection]);
const handleChangeAssignees = useCallback(async (selectedAssignees: ITeamMemberViewModel[]) => {
if (!projectId) return;
try {
setUpdatingAssignees(true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
members: selectedAssignees.map(member => ({
id: member.id || '',
name: member.name || '',
email: member.email || '',
avatar_url: member.avatar_url || '',
team_member_id: member.id || '',
project_member_id: member.id || '',
})) as ITaskAssignee[],
};
const res = await taskListBulkActionsApiService.assignTasks(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
dispatch(clearSelection());
dispatch(fetchTasksV3(projectId));
onClearSelection?.();
message.success(`Assigned ${selectedTaskIds.length} task${selectedTaskIds.length > 1 ? 's' : ''} to ${selectedAssignees.length} member${selectedAssignees.length > 1 ? 's' : ''}`);
}
} catch (error) {
logger.error('Error assigning tasks:', error);
} finally {
setUpdatingAssignees(false);
}
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch, onClearSelection]);
const handleArchive = useCallback(async () => {
updateLoadingState('archive', true);
if (!projectId) return;
try {
await onBulkArchive?.();
setUpdatingArchive(true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_archive);
dispatch(clearSelection());
dispatch(fetchTasksV3(projectId));
onClearSelection?.();
message.success(`${archived ? 'Unarchived' : 'Archived'} ${selectedTaskIds.length} task${selectedTaskIds.length > 1 ? 's' : ''}`);
}
} catch (error) {
logger.error('Error archiving tasks:', error);
} finally {
updateLoadingState('archive', false);
setUpdatingArchive(false);
}
}, [onBulkArchive, updateLoadingState]);
}, [selectedTaskIds, projectId, archived, trackMixpanelEvent, dispatch, onClearSelection]);
const handleDelete = useCallback(async () => {
updateLoadingState('delete', true);
if (!projectId) return;
try {
await onBulkDelete?.();
setUpdatingDelete(true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_delete);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
onClearSelection?.();
message.success(`Deleted ${selectedTaskIds.length} task${selectedTaskIds.length > 1 ? 's' : ''}`);
}
} catch (error) {
logger.error('Error deleting tasks:', error);
} finally {
updateLoadingState('delete', false);
setUpdatingDelete(false);
}
}, [onBulkDelete, updateLoadingState]);
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch, onClearSelection]);
const handleDuplicate = useCallback(async () => {
updateLoadingState('duplicate', true);
try {
await onBulkDuplicate?.();
} finally {
updateLoadingState('duplicate', false);
const handleLabelChange = useCallback((e: CheckboxChangeEvent, label: ITaskLabel) => {
if (e.target.checked) {
setSelectedLabels(prev => [...prev, label]);
} else {
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
}
}, [onBulkDuplicate, updateLoadingState]);
}, []);
const handleExport = useCallback(async () => {
updateLoadingState('export', true);
const applyLabels = useCallback(async () => {
if (!projectId) return;
try {
await onBulkExport?.();
setUpdatingLabels(true);
const body: IBulkTasksLabelsRequest = {
tasks: selectedTaskIds,
labels: selectedLabels,
text:
selectedLabels.length > 0
? null
: createLabelText.trim() !== ''
? createLabelText.trim()
: null,
};
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
dispatch(fetchLabels());
setCreateLabelText('');
setSelectedLabels([]);
onClearSelection?.();
const labelCount = selectedLabels.length > 0 ? selectedLabels.length : 1;
const action = selectedLabels.length > 0 ? 'Labels updated' : 'Label created';
message.success(`${action} for ${selectedTaskIds.length} task${selectedTaskIds.length > 1 ? 's' : ''}`);
}
} catch (error) {
logger.error('Error updating labels:', error);
} finally {
updateLoadingState('export', false);
setUpdatingLabels(false);
}
}, [onBulkExport, updateLoadingState]);
}, [selectedTaskIds, selectedLabels, createLabelText, projectId, trackMixpanelEvent, dispatch, onClearSelection]);
// Menu Generators
const getChangeOptionsMenu = useMemo(() => [
{
key: '1',
label: t('status'),
children: statusList.map(status => ({
key: status.id,
onClick: () => handleChangeStatus(status),
label: <Badge color={status.color_code} text={status.name} />,
})),
},
{
key: '2',
label: t('priority'),
children: priorityList.map(priority => ({
key: priority.id,
onClick: () => handleChangePriority(priority),
label: <Badge color={priority.color_code} text={priority.name} />,
})),
},
{
key: '3',
label: t('phase'),
children: phaseList.map(phase => ({
key: phase.id,
onClick: () => handleChangePhase(phase),
label: <Badge color={phase.color_code} text={phase.name} />,
})),
},
], [statusList, priorityList, phaseList, handleChangeStatus, handleChangePriority, handleChangePhase, t]);
const getAssigneesMenu = useCallback(() => {
return (
<AssigneesDropdown
members={teamMembersSorted?.data || []}
themeMode={isDarkMode ? 'dark' : 'light'}
onApply={handleChangeAssignees}
onClose={() => setAssigneeDropdownOpen(false)}
t={t}
/>
);
}, [teamMembersSorted?.data, isDarkMode, handleChangeAssignees, t]);
const labelsDropdownContent = useMemo(() => (
<LabelsDropdown
labelsList={labelsList}
themeMode={isDarkMode ? 'dark' : 'light'}
createLabelText={createLabelText}
selectedLabels={selectedLabels}
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
onLabelChange={handleLabelChange}
onCreateLabelTextChange={value => setCreateLabelText(value)}
onApply={applyLabels}
t={t}
loading={updatingLabels}
/>
), [labelsList, isDarkMode, createLabelText, selectedLabels, handleLabelChange, applyLabels, t, updatingLabels]);
const onAssigneeDropdownOpenChange = useCallback((open: boolean) => {
if (!open) {
setAssigneeDropdownOpen(false);
} else {
setAssigneeDropdownOpen(true);
}
}, []);
// Memoized styles for better performance
const containerStyle = useMemo((): React.CSSProperties => ({
@@ -281,59 +537,12 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
: '1px solid rgba(229, 231, 235, 0.8)',
}), [isDarkMode, isVisible]);
const textStyle = useMemo(() => ({
color: isDarkMode ? '#f3f4f6' : '#374151',
fontSize: '14px',
fontWeight: 500,
marginRight: '12px',
whiteSpace: 'nowrap' as const,
}), [isDarkMode]);
// Quick actions dropdown menu
const quickActionsMenu = useMemo(() => ({
items: [
{
key: 'change-status',
label: 'Change Status',
icon: <RetweetOutlined />,
onClick: handleStatusChange,
},
{
key: 'change-priority',
label: 'Change Priority',
icon: <FlagOutlined />,
onClick: handlePriorityChange,
},
{
key: 'change-phase',
label: 'Change Phase',
icon: <RetweetOutlined />,
onClick: handlePhaseChange,
},
{
key: 'set-due-date',
label: 'Set Due Date',
icon: <CalendarOutlined />,
onClick: () => onBulkSetDueDate?.(new Date().toISOString()),
},
{
type: 'divider' as const,
key: 'divider-1',
},
{
key: 'duplicate',
label: 'Duplicate Tasks',
icon: <CopyOutlined />,
onClick: handleDuplicate,
},
{
key: 'export',
label: 'Export Tasks',
icon: <ExportOutlined />,
onClick: handleExport,
},
],
}), [handleStatusChange, handlePriorityChange, handlePhaseChange, handleDuplicate, handleExport, onBulkSetDueDate]);
const getLabel = useMemo(() => {
const word = totalSelected < 2 ? t('taskSelected') : t('tasksSelected');
return `${totalSelected} ${word}`;
}, [totalSelected, t]);
// Don't render if no tasks selected
if (totalSelected === 0) {
@@ -341,139 +550,193 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
}
return (
<div style={containerStyle}>
{/* Selection Count */}
<Text style={textStyle}>
<Badge
count={totalSelected}
style={{
backgroundColor: isDarkMode ? '#3b82f6' : '#2563eb',
color: 'white',
fontSize: '11px',
height: '18px',
lineHeight: '18px',
minWidth: '18px',
marginRight: '6px'
}}
/>
{totalSelected} {totalSelected === 1 ? 'task' : 'tasks'} selected
</Text>
<>
<div
className={`
fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50
flex items-center gap-1 px-5 py-3
min-w-fit max-w-[90vw]
rounded-2xl backdrop-blur-xl
transition-all duration-300 ease-out
${isVisible ? 'translate-y-0 opacity-100 visible' : 'translate-y-5 opacity-0 invisible'}
${isDarkMode
? 'bg-gray-800/95 border border-gray-600/30 shadow-2xl shadow-black/40'
: 'bg-white/95 border border-gray-200/80 shadow-xl shadow-black/10'
}
`}
>
{/* Selection Count */}
<div className={`
flex items-center gap-2 mr-3
${isDarkMode ? 'text-gray-100' : 'text-gray-700'}
`}>
<Badge
count={totalSelected}
className={`
${isDarkMode ? 'bg-blue-500' : 'bg-blue-600'}
text-white text-xs font-medium
h-4.5 min-w-[18px] leading-[18px]
`}
/>
<span className="text-sm font-medium whitespace-nowrap">
{getLabel}
</span>
</div>
<Divider
type="vertical"
style={{
height: '20px',
margin: '0 8px',
borderColor: isDarkMode ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)'
}}
/>
<div className={`
w-px h-5 mx-2
${isDarkMode ? 'bg-gray-600/50' : 'bg-gray-300/80'}
`} />
{/* Actions in same order as original component */}
<Space size={2}>
{/* Change Status/Priority/Phase */}
<Tooltip title="Change Status/Priority/Phase" placement="top">
<Dropdown
menu={quickActionsMenu}
trigger={['click']}
placement="top"
arrow
>
<Button
icon={<RetweetOutlined />}
style={{
background: 'transparent',
color: isDarkMode ? '#e5e7eb' : '#374151',
border: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px',
height: '32px',
width: '32px',
fontSize: '14px',
borderRadius: '6px',
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
{/* Actions */}
<div className="flex items-center gap-0.5">
{/* Change Status/Priority/Phase */}
<Tooltip title={t('changeStatus')} placement="top">
<Dropdown
menu={{ items: getChangeOptionsMenu }}
trigger={['click']}
placement="bottom"
arrow
>
<ActionButton
icon={<SyncOutlined />}
tooltip={t('changeStatus')}
loading={loading}
isDarkMode={isDarkMode}
/>
</Dropdown>
</Tooltip>
{/* Change Labels */}
<Tooltip title={t('changeLabel')} placement="top">
<Dropdown
dropdownRender={() => labelsDropdownContent}
placement="top"
arrow
trigger={['click']}
onOpenChange={value => {
if (!value) {
setSelectedLabels([]);
}
}}
size="small"
type="text"
loading={loadingStates.status || loadingStates.priority || loadingStates.phase}
/>
</Dropdown>
</Tooltip>
>
<ActionButton
icon={<TagOutlined />}
tooltip={t('changeLabel')}
loading={updatingLabels}
isDarkMode={isDarkMode}
/>
</Dropdown>
</Tooltip>
{/* Change Labels */}
<ActionButton
icon={<TagsOutlined />}
tooltip="Add Labels"
onClick={() => onBulkAddLabels?.([])}
loading={loadingStates.labels}
isDarkMode={isDarkMode}
/>
{/* Assign to Me */}
<ActionButton
icon={<UserAddOutlined />}
tooltip="Assign to Me"
onClick={handleAssignToMe}
loading={loadingStates.assignToMe}
isDarkMode={isDarkMode}
/>
{/* Change Assignees */}
<ActionButton
icon={<UsergroupAddOutlined />}
tooltip="Assign Members"
onClick={() => onBulkAssignMembers?.([])}
loading={loadingStates.assignMembers}
isDarkMode={isDarkMode}
/>
{/* Archive */}
<ActionButton
icon={<InboxOutlined />}
tooltip="Archive"
onClick={handleArchive}
loading={loadingStates.archive}
isDarkMode={isDarkMode}
/>
{/* Delete */}
<Popconfirm
title={`Delete ${totalSelected} ${totalSelected === 1 ? 'task' : 'tasks'}?`}
description="This action cannot be undone."
onConfirm={handleDelete}
okText="Delete"
cancelText="Cancel"
okType="danger"
placement="top"
>
{/* Assign to Me */}
<ActionButton
icon={<DeleteOutlined />}
tooltip="Delete"
loading={loadingStates.delete}
danger
icon={<UserOutlined />}
tooltip={t('assignToMe')}
onClick={handleAssignToMe}
loading={updatingAssignToMe}
isDarkMode={isDarkMode}
/>
</Popconfirm>
<Divider
type="vertical"
style={{
height: '20px',
margin: '0 4px',
borderColor: isDarkMode ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)'
}}
/>
{/* Change Assignees */}
<Tooltip title={t('changeAssignees')} placement="top">
<Dropdown
dropdownRender={getAssigneesMenu}
open={assigneeDropdownOpen}
onOpenChange={onAssigneeDropdownOpenChange}
placement="top"
arrow
trigger={['click']}
>
<ActionButton
icon={<UsergroupAddOutlined />}
tooltip={t('changeAssignees')}
loading={updatingAssignees}
isDarkMode={isDarkMode}
/>
</Dropdown>
</Tooltip>
{/* Clear Selection */}
<ActionButton
icon={<CloseOutlined />}
tooltip="Clear Selection"
onClick={onClearSelection}
isDarkMode={isDarkMode}
/>
</Space>
</div>
{/* Archive */}
<ActionButton
icon={<BellOutlined />}
tooltip={archived ? t('unarchive') : t('archive')}
onClick={handleArchive}
loading={updatingArchive}
isDarkMode={isDarkMode}
/>
{/* Delete */}
<Popconfirm
title={`${t('delete')} ${totalSelected} ${totalSelected === 1 ? t('task') : t('tasks')}?`}
description={t('deleteConfirmation')}
onConfirm={handleDelete}
okText={t('delete')}
cancelText={t('cancel')}
okType="danger"
placement="top"
>
<ActionButton
icon={<DeleteOutlined />}
tooltip={t('delete')}
loading={updatingDelete}
danger
isDarkMode={isDarkMode}
/>
</Popconfirm>
{isOwnerOrAdmin && (
<Tooltip title={t('moreOptions')} placement="top">
<Dropdown
trigger={['click']}
menu={{
items: [
{
key: '1',
label: t('createTaskTemplate'),
onClick: () => setShowDrawer(true),
},
],
}}
>
<ActionButton
icon={<MoreOutlined />}
tooltip={t('moreOptions')}
isDarkMode={isDarkMode}
/>
</Dropdown>
</Tooltip>
)}
<div className={`
w-px h-5 mx-1
${isDarkMode ? 'bg-gray-600/50' : 'bg-gray-300/80'}
`} />
{/* Clear Selection */}
<ActionButton
icon={<CloseOutlined />}
tooltip={t('deselectAll')}
onClick={onClearSelection}
isDarkMode={isDarkMode}
/>
</div>
</div>
{/* Portals for modals */}
{createReactPortal(
<TaskTemplateDrawer
showDrawer={showDrawer}
selectedTemplateId={null}
onClose={() => {
setShowDrawer(false);
dispatch(deselectAll());
}}
/>,
document.body,
'create-task-template'
)}
</>
);
});

View File

@@ -35,7 +35,6 @@ import {
import {
selectSelectedTaskIds,
toggleTaskSelection,
clearSelection,
} from '@/features/task-management/selection.slice';
import { Task } from '@/types/task-management.types';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
@@ -45,34 +44,7 @@ import OptimizedBulkActionBar from './optimized-bulk-action-bar';
import VirtualizedTaskList from './virtualized-task-list';
import { AppDispatch } from '@/app/store';
import { shallowEqual } from 'react-redux';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
evt_project_task_list_bulk_archive,
evt_project_task_list_bulk_assign_me,
evt_project_task_list_bulk_assign_members,
evt_project_task_list_bulk_change_phase,
evt_project_task_list_bulk_change_priority,
evt_project_task_list_bulk_change_status,
evt_project_task_list_bulk_delete,
evt_project_task_list_bulk_update_labels,
} from '@/shared/worklenz-analytics-events';
import {
IBulkTasksLabelsRequest,
IBulkTasksPhaseChangeRequest,
IBulkTasksPriorityChangeRequest,
IBulkTasksStatusChangeRequest,
} from '@/types/tasks/bulk-action-bar.types';
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { clearSelection } from '@/features/task-management/selection.slice';
import { performanceMonitor } from '@/utils/performance-monitor';
import debugPerformance from '@/utils/debug-performance';
@@ -121,7 +93,6 @@ const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
const dispatch = useDispatch<AppDispatch>();
const { t } = useTranslation('task-management');
const { trackMixpanelEvent } = useMixpanelTracking();
const [dragState, setDragState] = useState<DragState>({
activeTask: null,
activeGroupId: null,
@@ -151,13 +122,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
const error = useSelector((state: RootState) => state.taskManagement.error);
// Bulk action selectors
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
const priorityList = useSelector((state: RootState) => state.priorityReducer.priorities);
const phaseList = useSelector((state: RootState) => state.phaseReducer.phaseList);
const labelsList = useSelector((state: RootState) => state.taskLabelsReducer.labels);
const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers);
const archived = useSelector((state: RootState) => state.taskReducer.archived);
// Get theme from Redux store
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
@@ -440,221 +405,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
);
}, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]);
// Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar
// Bulk action handler
const handleClearSelection = useCallback(() => {
dispatch(deselectAll());
dispatch(clearSelection());
}, [dispatch]);
const handleBulkStatusChange = useCallback(async (statusId: string) => {
if (!statusId || !projectId) return;
try {
// Find the status object
const status = statusList.find(s => s.id === statusId);
if (!status || !status.id) return;
const body: IBulkTasksStatusChangeRequest = {
tasks: selectedTaskIds,
status_id: status.id,
};
// Check task dependencies first
for (const taskId of selectedTaskIds) {
const canContinue = await checkTaskDependencyStatus(taskId, status.id);
if (!canContinue) {
if (selectedTaskIds.length > 1) {
alertService.warning(
'Incomplete Dependencies!',
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
);
} else {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
}
return;
}
}
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error changing status:', error);
}
}, [selectedTaskIds, statusList, projectId, trackMixpanelEvent, dispatch]);
const handleBulkPriorityChange = useCallback(async (priorityId: string) => {
if (!priorityId || !projectId) return;
try {
const priority = priorityList.find(p => p.id === priorityId);
if (!priority || !priority.id) return;
const body: IBulkTasksPriorityChangeRequest = {
tasks: selectedTaskIds,
priority_id: priority.id,
};
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error changing priority:', error);
}
}, [selectedTaskIds, priorityList, projectId, trackMixpanelEvent, dispatch]);
const handleBulkPhaseChange = useCallback(async (phaseId: string) => {
if (!phaseId || !projectId) return;
try {
const phase = phaseList.find(p => p.id === phaseId);
if (!phase || !phase.id) return;
const body: IBulkTasksPhaseChangeRequest = {
tasks: selectedTaskIds,
phase_id: phase.id,
};
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error changing phase:', error);
}
}, [selectedTaskIds, phaseList, projectId, trackMixpanelEvent, dispatch]);
const handleBulkAssignToMe = useCallback(async () => {
if (!projectId) return;
try {
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error assigning to me:', error);
}
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch]);
const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => {
if (!projectId || !members?.data) return;
try {
// Convert memberIds to member objects with proper type checking
const selectedMembers = members.data.filter(member =>
member.id && memberIds.includes(member.id)
);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
members: selectedMembers.map(member => ({
id: member.id!,
name: member.name || '',
email: member.email || '',
avatar_url: member.avatar_url || '',
team_member_id: member.id!,
project_member_id: member.id!,
})),
};
const res = await taskListBulkActionsApiService.assignTasks(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error assigning tasks:', error);
}
}, [selectedTaskIds, projectId, members, trackMixpanelEvent, dispatch]);
const handleBulkAddLabels = useCallback(async (labelIds: string[]) => {
if (!projectId) return;
try {
// Convert labelIds to label objects with proper type checking
const selectedLabels = labelsList.filter(label =>
label.id && labelIds.includes(label.id)
);
const body: IBulkTasksLabelsRequest = {
tasks: selectedTaskIds,
labels: selectedLabels,
text: null,
};
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
dispatch(fetchLabels());
}
} catch (error) {
logger.error('Error updating labels:', error);
}
}, [selectedTaskIds, projectId, labelsList, trackMixpanelEvent, dispatch]);
const handleBulkArchive = useCallback(async () => {
if (!projectId) return;
try {
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_archive);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error archiving tasks:', error);
}
}, [selectedTaskIds, projectId, archived, trackMixpanelEvent, dispatch]);
const handleBulkDelete = useCallback(async () => {
if (!projectId) return;
try {
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_delete);
dispatch(deselectAll());
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error deleting tasks:', error);
}
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch]);
// Additional handlers for new actions
const handleBulkDuplicate = useCallback(async () => {
// This would need to be implemented in the API service
console.log('Bulk duplicate not yet implemented in API:', selectedTaskIds);
}, [selectedTaskIds]);
const handleBulkExport = useCallback(async () => {
// This would need to be implemented in the API service
console.log('Bulk export not yet implemented in API:', selectedTaskIds);
}, [selectedTaskIds]);
const handleBulkSetDueDate = useCallback(async (date: string) => {
// This would need to be implemented in the API service
console.log('Bulk set due date not yet implemented in API:', date, selectedTaskIds);
}, [selectedTaskIds]);
// Cleanup effect
useEffect(() => {
return () => {
@@ -785,17 +540,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
totalSelected={selectedTaskIds.length}
projectId={projectId}
onClearSelection={handleClearSelection}
onBulkStatusChange={handleBulkStatusChange}
onBulkPriorityChange={handleBulkPriorityChange}
onBulkPhaseChange={handleBulkPhaseChange}
onBulkAssignToMe={handleBulkAssignToMe}
onBulkAssignMembers={handleBulkAssignMembers}
onBulkAddLabels={handleBulkAddLabels}
onBulkArchive={handleBulkArchive}
onBulkDelete={handleBulkDelete}
onBulkDuplicate={handleBulkDuplicate}
onBulkExport={handleBulkExport}
onBulkSetDueDate={handleBulkSetDueDate}
/>
<style>{`

View File

@@ -1,10 +1,12 @@
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';
import { useSelector, useDispatch } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { Empty } from 'antd';
import { Empty, Button } from 'antd';
import { RightOutlined, DownOutlined } from '@ant-design/icons';
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
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';
@@ -35,6 +37,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
width,
tasksById
}) => {
const dispatch = useDispatch();
const { t } = useTranslation('task-management');
// Get theme from Redux store
@@ -43,6 +46,10 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
// Get field visibility from taskListFields slice
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
// Get group collapse state from Redux
const groupStates = useSelector((state: RootState) => state.grouping.groupStates);
const isCollapsed = groupStates[group.id]?.collapsed || false;
// PERFORMANCE OPTIMIZATION: Improved virtualization for better user experience
const VIRTUALIZATION_THRESHOLD = 25; // Increased threshold - virtualize when there are more tasks
const TASK_ROW_HEIGHT = 40;
@@ -52,7 +59,12 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
// PERFORMANCE OPTIMIZATION: Batch rendering to prevent long tasks
const RENDER_BATCH_SIZE = 5; // Render max 5 tasks per frame
const FRAME_BUDGET_MS = 8; // Leave 8ms per frame for other operations
const FRAME_BUDGET_MS = 8;
// Handle collapse/expand toggle
const handleToggleCollapse = useCallback(() => {
dispatch(toggleGroupCollapsed(group.id));
}, [dispatch, group.id]);
// PERFORMANCE OPTIMIZATION: Add early return for empty groups
if (!group || !group.taskIds || group.taskIds.length === 0) {
@@ -84,6 +96,22 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
// No margin - header should overlap the sticky border
}}
>
<Button
type="text"
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
onClick={handleToggleCollapse}
className="task-group-collapse-button"
style={{
color: 'white',
border: 'none',
background: 'transparent',
padding: '4px',
marginRight: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
<span className="task-group-header-text">
{group?.title || 'Empty Group'} (0)
</span>
@@ -92,7 +120,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
</div>
{/* Column Headers */}
<div className="task-group-column-headers" style={{
<div style={{
marginLeft: '4px', // Account for sticky border
height: COLUMN_HEADER_HEIGHT,
background: 'var(--task-bg-secondary, #f5f5f5)',
@@ -141,35 +169,22 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
);
}
// PERFORMANCE OPTIMIZATION: Get tasks for this group using direct lookup (no mapping/filtering)
// Get tasks for this group using memoization for performance
const groupTasks = useMemo(() => {
// PERFORMANCE OPTIMIZATION: Use for loop instead of map for better performance
const tasks: Task[] = [];
for (let i = 0; i < group.taskIds.length; i++) {
const task = tasksById[group.taskIds[i]];
if (task) {
tasks.push(task);
}
}
return tasks;
return group.taskIds
.map((taskId: string) => tasksById[taskId])
.filter((task: Task | undefined): task is Task => task !== undefined);
}, [group.taskIds, tasksById]);
// PERFORMANCE OPTIMIZATION: Only calculate selection state when needed
// Calculate selection state for the group checkbox
const selectionState = useMemo(() => {
if (groupTasks.length === 0) {
return { isAllSelected: false, isIndeterminate: false };
}
// PERFORMANCE OPTIMIZATION: Use for loop instead of filter for better performance
let selectedCount = 0;
for (let i = 0; i < groupTasks.length; i++) {
if (selectedTaskIds.includes(groupTasks[i].id)) {
selectedCount++;
}
}
const isAllSelected = selectedCount === groupTasks.length;
const isIndeterminate = selectedCount > 0 && selectedCount < groupTasks.length;
const selectedTasksInGroup = groupTasks.filter((task: Task) => selectedTaskIds.includes(task.id));
const isAllSelected = selectedTasksInGroup.length === groupTasks.length;
const isIndeterminate = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < groupTasks.length;
return { isAllSelected, isIndeterminate };
}, [groupTasks, selectedTaskIds]);
@@ -339,6 +354,69 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
};
}, [handleScroll]);
// If group is collapsed, show only header
if (isCollapsed) {
return (
<div className="virtualized-task-list collapsed" style={{ height: HEADER_HEIGHT, position: 'relative' }}>
{/* Sticky Group Color Border */}
<div
className="sticky-group-border"
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '4px',
backgroundColor: group.color || '#f0f0f0',
zIndex: 15,
pointerEvents: 'none',
}}
/>
{/* Group Header */}
<div className="task-group-header" style={{ height: HEADER_HEIGHT }}>
<div className="task-group-header-row">
<div
className="task-group-header-content"
style={{
backgroundColor: group.color || '#f0f0f0',
// No margin - header should overlap the sticky border
}}
>
<Button
type="text"
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
onClick={handleToggleCollapse}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleToggleCollapse();
}
}}
className="task-group-collapse-button"
aria-label={isCollapsed ? 'Expand group' : 'Collapse group'}
title={isCollapsed ? 'Click to expand group' : 'Click to collapse group'}
style={{
color: 'white',
border: 'none',
background: 'transparent',
padding: '4px',
marginRight: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
<span className="task-group-header-text">
{group.title} ({groupTasks.length})
</span>
</div>
</div>
</div>
</div>
);
}
return (
<div className="virtualized-task-list" style={{ height: groupHeight, position: 'relative' }}>
{/* Sticky Group Color Border */}
@@ -366,6 +444,30 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
// No margin - header should overlap the sticky border
}}
>
<Button
type="text"
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
onClick={handleToggleCollapse}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleToggleCollapse();
}
}}
className="task-group-collapse-button"
aria-label={isCollapsed ? 'Expand group' : 'Collapse group'}
title={isCollapsed ? 'Click to expand group' : 'Click to collapse group'}
style={{
color: 'white',
border: 'none',
background: 'transparent',
padding: '4px',
marginRight: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
<span className="task-group-header-text">
{group.title} ({groupTasks.length})
</span>
@@ -586,6 +688,24 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
font-weight: 600 !important;
margin: 0 !important;
}
/* Collapse button styles */
.task-group-collapse-button {
transition: all 0.2s ease;
border-radius: 4px;
min-width: 24px;
height: 24px;
}
.task-group-collapse-button:hover {
background-color: rgba(255, 255, 255, 0.15) !important;
transform: scale(1.05);
}
.task-group-collapse-button:active {
transform: scale(0.95);
}
.task-group-collapse-button .anticon {
font-size: 12px;
transition: transform 0.2s ease;
}
/* Column headers styles */
.task-table-header-cell {
background: var(--task-bg-secondary, #f5f5f5);

View File

@@ -2,20 +2,6 @@ import React from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListBoard from '@/components/task-management/task-list-board';
/**
* Enhanced Tasks View - Optimized for Performance
*
* PERFORMANCE IMPROVEMENTS:
* - Task loading is now ~5x faster (200-500ms vs 2-5s previously)
* - Progress calculations are skipped by default to improve initial load
* - Real-time updates still work via socket connections
* - Performance monitoring available in development mode
*
* If you're experiencing slow loading:
* 1. Check the browser console for performance metrics
* 2. Performance alerts will show automatically if loading > 2 seconds
* 3. Contact support if issues persist
*/
const ProjectViewEnhancedTasks: React.FC = () => {
const { project } = useAppSelector(state => state.projectReducer);