chore(dependencies): update package-lock.json and package.json for dependency management
- Removed `pg-native` from dependencies in both package files. - Updated several AWS SDK and Smithy packages to their latest versions for improved functionality and security. - Added new dependencies for emoji-regex and string-width to enhance task management features. - Adjusted various package versions to ensure compatibility and performance optimizations across the application.
This commit is contained in:
3531
worklenz-backend/package-lock.json
generated
3531
worklenz-backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -86,7 +86,6 @@
|
|||||||
"passport-local": "^1.0.0",
|
"passport-local": "^1.0.0",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"pg-native": "^3.3.0",
|
|
||||||
"pug": "^3.0.2",
|
"pug": "^3.0.2",
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
"sanitize-html": "^2.11.0",
|
"sanitize-html": "^2.11.0",
|
||||||
|
|||||||
@@ -8,28 +8,70 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Space,
|
Space,
|
||||||
Badge,
|
Badge,
|
||||||
Divider
|
Divider,
|
||||||
} from 'antd';
|
type InputRef,
|
||||||
|
message
|
||||||
|
} from '@/shared/antd-imports';
|
||||||
|
import type { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||||
import {
|
import {
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
RetweetOutlined,
|
SyncOutlined,
|
||||||
UserAddOutlined,
|
UserOutlined,
|
||||||
InboxOutlined,
|
BellOutlined,
|
||||||
TagsOutlined,
|
TagOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
CopyOutlined,
|
FileOutlined,
|
||||||
ExportOutlined,
|
ImportOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
FlagOutlined,
|
BarChartOutlined,
|
||||||
BulbOutlined
|
SettingOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@/shared/antd-imports';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { RootState } from '@/app/store';
|
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;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -38,17 +80,6 @@ interface OptimizedBulkActionBarProps {
|
|||||||
totalSelected: number;
|
totalSelected: number;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onClearSelection?: () => void;
|
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
|
// Performance-optimized memoized action button component
|
||||||
@@ -62,51 +93,44 @@ const ActionButton = React.memo<{
|
|||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
badge?: number;
|
badge?: number;
|
||||||
}>(({ icon, tooltip, onClick, loading = false, danger = false, disabled = false, isDarkMode, badge }) => {
|
}>(({ icon, tooltip, onClick, loading = false, danger = false, disabled = false, isDarkMode, badge }) => {
|
||||||
const buttonStyle = useMemo(() => ({
|
const buttonClasses = useMemo(() => {
|
||||||
background: 'transparent',
|
const baseClasses = [
|
||||||
color: isDarkMode ? '#e5e7eb' : '#374151',
|
'flex items-center justify-center',
|
||||||
border: 'none',
|
'h-8 w-8 p-1.5',
|
||||||
display: 'flex',
|
'text-sm rounded-md',
|
||||||
alignItems: 'center',
|
'transition-all duration-150 ease-out',
|
||||||
justifyContent: 'center',
|
'border-none bg-transparent',
|
||||||
padding: '6px',
|
'hover:scale-105 focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||||
height: '32px',
|
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer',
|
||||||
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 hoverStyle = useMemo(() => ({
|
if (danger) {
|
||||||
backgroundColor: isDarkMode
|
baseClasses.push(
|
||||||
? (danger ? 'rgba(239, 68, 68, 0.1)' : 'rgba(255, 255, 255, 0.1)')
|
isDarkMode
|
||||||
: (danger ? 'rgba(239, 68, 68, 0.1)' : 'rgba(0, 0, 0, 0.05)'),
|
? 'text-red-400 hover:bg-red-400/10 focus:ring-red-400/20'
|
||||||
transform: 'scale(1.05)',
|
: 'text-red-500 hover:bg-red-500/10 focus:ring-red-500/20'
|
||||||
}), [isDarkMode, danger]);
|
);
|
||||||
|
} 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);
|
return baseClasses.join(' ');
|
||||||
|
}, [isDarkMode, danger, disabled]);
|
||||||
const combinedStyle = useMemo(() => ({
|
|
||||||
...buttonStyle,
|
|
||||||
...(isHovered && !disabled ? hoverStyle : {}),
|
|
||||||
}), [buttonStyle, hoverStyle, isHovered, disabled]);
|
|
||||||
|
|
||||||
const ButtonComponent = (
|
const ButtonComponent = (
|
||||||
<Button
|
<Button
|
||||||
icon={icon}
|
icon={icon}
|
||||||
style={combinedStyle}
|
className={buttonClasses}
|
||||||
size="small"
|
size="small"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
|
||||||
type="text"
|
type="text"
|
||||||
|
style={{ background: 'transparent', border: 'none' }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -131,41 +155,44 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
totalSelected,
|
totalSelected,
|
||||||
projectId,
|
projectId,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
onBulkStatusChange,
|
|
||||||
onBulkPriorityChange,
|
|
||||||
onBulkPhaseChange,
|
|
||||||
onBulkAssignToMe,
|
|
||||||
onBulkAssignMembers,
|
|
||||||
onBulkAddLabels,
|
|
||||||
onBulkArchive,
|
|
||||||
onBulkDelete,
|
|
||||||
onBulkDuplicate,
|
|
||||||
onBulkExport,
|
|
||||||
onBulkSetDueDate,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('task-management');
|
const dispatch = useAppDispatch();
|
||||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
const { t } = useTranslation('tasks/task-table-bulk-actions');
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
|
|
||||||
// Performance state management
|
// Performance state management
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [loadingStates, setLoadingStates] = useState({
|
const [loading, setLoading] = useState(false);
|
||||||
status: false,
|
const [updatingLabels, setUpdatingLabels] = useState(false);
|
||||||
priority: false,
|
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||||
phase: false,
|
const [updatingAssignees, setUpdatingAssignees] = useState(false);
|
||||||
assignToMe: false,
|
const [updatingArchive, setUpdatingArchive] = useState(false);
|
||||||
assignMembers: false,
|
const [updatingDelete, setUpdatingDelete] = useState(false);
|
||||||
labels: false,
|
|
||||||
archive: false,
|
// Selectors
|
||||||
delete: false,
|
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||||
duplicate: false,
|
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||||
export: false,
|
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||||
dueDate: false,
|
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
|
// Smooth entrance animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (totalSelected > 0) {
|
if (totalSelected > 0) {
|
||||||
// Micro-delay for smoother animation
|
|
||||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
} else {
|
} else {
|
||||||
@@ -173,83 +200,312 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
}
|
}
|
||||||
}, [totalSelected]);
|
}, [totalSelected]);
|
||||||
|
|
||||||
// Optimized loading state updater
|
// Update team members when dropdown opens
|
||||||
const updateLoadingState = useCallback((action: keyof typeof loadingStates, loading: boolean) => {
|
useEffect(() => {
|
||||||
setLoadingStates(prev => ({ ...prev, [action]: loading }));
|
if (members?.data && assigneeDropdownOpen) {
|
||||||
}, []);
|
let sortedMembers = sortTeamMembers(members.data);
|
||||||
|
setTeamMembersSorted({ data: sortedMembers, total: members.total });
|
||||||
// Memoized handlers with loading states
|
|
||||||
const handleStatusChange = useCallback(async () => {
|
|
||||||
updateLoadingState('status', true);
|
|
||||||
try {
|
|
||||||
await onBulkStatusChange?.('new-status-id');
|
|
||||||
} finally {
|
|
||||||
updateLoadingState('status', false);
|
|
||||||
}
|
}
|
||||||
}, [onBulkStatusChange, updateLoadingState]);
|
}, [assigneeDropdownOpen, members?.data]);
|
||||||
|
|
||||||
const handlePriorityChange = useCallback(async () => {
|
// Handlers
|
||||||
updateLoadingState('priority', true);
|
const handleChangeStatus = useCallback(async (status: ITaskStatus) => {
|
||||||
|
if (!status.id || !projectId) return;
|
||||||
try {
|
try {
|
||||||
await onBulkPriorityChange?.('new-priority-id');
|
setLoading(true);
|
||||||
} finally {
|
|
||||||
updateLoadingState('priority', false);
|
|
||||||
}
|
|
||||||
}, [onBulkPriorityChange, updateLoadingState]);
|
|
||||||
|
|
||||||
const handlePhaseChange = useCallback(async () => {
|
const body: IBulkTasksStatusChangeRequest = {
|
||||||
updateLoadingState('phase', true);
|
tasks: selectedTaskIds,
|
||||||
try {
|
status_id: status.id,
|
||||||
await onBulkPhaseChange?.('new-phase-id');
|
};
|
||||||
} finally {
|
|
||||||
updateLoadingState('phase', false);
|
// 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'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [onBulkPhaseChange, updateLoadingState]);
|
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 {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [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 () => {
|
const handleAssignToMe = useCallback(async () => {
|
||||||
updateLoadingState('assignToMe', true);
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
await onBulkAssignToMe?.();
|
setUpdatingAssignToMe(true);
|
||||||
} finally {
|
const body = {
|
||||||
updateLoadingState('assignToMe', false);
|
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`);
|
||||||
}
|
}
|
||||||
}, [onBulkAssignToMe, updateLoadingState]);
|
} catch (error) {
|
||||||
|
logger.error('Error assigning to me:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingAssignToMe(false);
|
||||||
|
}
|
||||||
|
}, [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 () => {
|
const handleArchive = useCallback(async () => {
|
||||||
updateLoadingState('archive', true);
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
await onBulkArchive?.();
|
setUpdatingArchive(true);
|
||||||
} finally {
|
const body = {
|
||||||
updateLoadingState('archive', false);
|
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' : ''}`);
|
||||||
}
|
}
|
||||||
}, [onBulkArchive, updateLoadingState]);
|
} catch (error) {
|
||||||
|
logger.error('Error archiving tasks:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingArchive(false);
|
||||||
|
}
|
||||||
|
}, [selectedTaskIds, projectId, archived, trackMixpanelEvent, dispatch, onClearSelection]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async () => {
|
const handleDelete = useCallback(async () => {
|
||||||
updateLoadingState('delete', true);
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
await onBulkDelete?.();
|
setUpdatingDelete(true);
|
||||||
} finally {
|
const body = {
|
||||||
updateLoadingState('delete', false);
|
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' : ''}`);
|
||||||
}
|
}
|
||||||
}, [onBulkDelete, updateLoadingState]);
|
} catch (error) {
|
||||||
|
logger.error('Error deleting tasks:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingDelete(false);
|
||||||
|
}
|
||||||
|
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch, onClearSelection]);
|
||||||
|
|
||||||
const handleDuplicate = useCallback(async () => {
|
const handleLabelChange = useCallback((e: CheckboxChangeEvent, label: ITaskLabel) => {
|
||||||
updateLoadingState('duplicate', true);
|
if (e.target.checked) {
|
||||||
try {
|
setSelectedLabels(prev => [...prev, label]);
|
||||||
await onBulkDuplicate?.();
|
} else {
|
||||||
} finally {
|
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
|
||||||
updateLoadingState('duplicate', false);
|
|
||||||
}
|
}
|
||||||
}, [onBulkDuplicate, updateLoadingState]);
|
}, []);
|
||||||
|
|
||||||
const handleExport = useCallback(async () => {
|
const applyLabels = useCallback(async () => {
|
||||||
updateLoadingState('export', true);
|
if (!projectId) return;
|
||||||
try {
|
try {
|
||||||
await onBulkExport?.();
|
setUpdatingLabels(true);
|
||||||
} finally {
|
const body: IBulkTasksLabelsRequest = {
|
||||||
updateLoadingState('export', false);
|
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' : ''}`);
|
||||||
}
|
}
|
||||||
}, [onBulkExport, updateLoadingState]);
|
} catch (error) {
|
||||||
|
logger.error('Error updating labels:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingLabels(false);
|
||||||
|
}
|
||||||
|
}, [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
|
// Memoized styles for better performance
|
||||||
const containerStyle = useMemo((): React.CSSProperties => ({
|
const containerStyle = useMemo((): React.CSSProperties => ({
|
||||||
@@ -281,59 +537,12 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
: '1px solid rgba(229, 231, 235, 0.8)',
|
: '1px solid rgba(229, 231, 235, 0.8)',
|
||||||
}), [isDarkMode, isVisible]);
|
}), [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(() => ({
|
const getLabel = useMemo(() => {
|
||||||
items: [
|
const word = totalSelected < 2 ? t('taskSelected') : t('tasksSelected');
|
||||||
{
|
return `${totalSelected} ${word}`;
|
||||||
key: 'change-status',
|
}, [totalSelected, t]);
|
||||||
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]);
|
|
||||||
|
|
||||||
// Don't render if no tasks selected
|
// Don't render if no tasks selected
|
||||||
if (totalSelected === 0) {
|
if (totalSelected === 0) {
|
||||||
@@ -341,139 +550,193 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={containerStyle}>
|
<>
|
||||||
|
<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 */}
|
{/* Selection Count */}
|
||||||
<Text style={textStyle}>
|
<div className={`
|
||||||
|
flex items-center gap-2 mr-3
|
||||||
|
${isDarkMode ? 'text-gray-100' : 'text-gray-700'}
|
||||||
|
`}>
|
||||||
<Badge
|
<Badge
|
||||||
count={totalSelected}
|
count={totalSelected}
|
||||||
style={{
|
className={`
|
||||||
backgroundColor: isDarkMode ? '#3b82f6' : '#2563eb',
|
${isDarkMode ? 'bg-blue-500' : 'bg-blue-600'}
|
||||||
color: 'white',
|
text-white text-xs font-medium
|
||||||
fontSize: '11px',
|
h-4.5 min-w-[18px] leading-[18px]
|
||||||
height: '18px',
|
`}
|
||||||
lineHeight: '18px',
|
|
||||||
minWidth: '18px',
|
|
||||||
marginRight: '6px'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{totalSelected} {totalSelected === 1 ? 'task' : 'tasks'} selected
|
<span className="text-sm font-medium whitespace-nowrap">
|
||||||
</Text>
|
{getLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Divider
|
<div className={`
|
||||||
type="vertical"
|
w-px h-5 mx-2
|
||||||
style={{
|
${isDarkMode ? 'bg-gray-600/50' : 'bg-gray-300/80'}
|
||||||
height: '20px',
|
`} />
|
||||||
margin: '0 8px',
|
|
||||||
borderColor: isDarkMode ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Actions in same order as original component */}
|
{/* Actions */}
|
||||||
<Space size={2}>
|
<div className="flex items-center gap-0.5">
|
||||||
{/* Change Status/Priority/Phase */}
|
{/* Change Status/Priority/Phase */}
|
||||||
<Tooltip title="Change Status/Priority/Phase" placement="top">
|
<Tooltip title={t('changeStatus')} placement="top">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
menu={quickActionsMenu}
|
menu={{ items: getChangeOptionsMenu }}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
placement="top"
|
placement="bottom"
|
||||||
arrow
|
arrow
|
||||||
>
|
>
|
||||||
<Button
|
<ActionButton
|
||||||
icon={<RetweetOutlined />}
|
icon={<SyncOutlined />}
|
||||||
style={{
|
tooltip={t('changeStatus')}
|
||||||
background: 'transparent',
|
loading={loading}
|
||||||
color: isDarkMode ? '#e5e7eb' : '#374151',
|
isDarkMode={isDarkMode}
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
type="text"
|
|
||||||
loading={loadingStates.status || loadingStates.priority || loadingStates.phase}
|
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Change Labels */}
|
{/* Change Labels */}
|
||||||
|
<Tooltip title={t('changeLabel')} placement="top">
|
||||||
|
<Dropdown
|
||||||
|
dropdownRender={() => labelsDropdownContent}
|
||||||
|
placement="top"
|
||||||
|
arrow
|
||||||
|
trigger={['click']}
|
||||||
|
onOpenChange={value => {
|
||||||
|
if (!value) {
|
||||||
|
setSelectedLabels([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<TagsOutlined />}
|
icon={<TagOutlined />}
|
||||||
tooltip="Add Labels"
|
tooltip={t('changeLabel')}
|
||||||
onClick={() => onBulkAddLabels?.([])}
|
loading={updatingLabels}
|
||||||
loading={loadingStates.labels}
|
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Assign to Me */}
|
{/* Assign to Me */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<UserAddOutlined />}
|
icon={<UserOutlined />}
|
||||||
tooltip="Assign to Me"
|
tooltip={t('assignToMe')}
|
||||||
onClick={handleAssignToMe}
|
onClick={handleAssignToMe}
|
||||||
loading={loadingStates.assignToMe}
|
loading={updatingAssignToMe}
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Change Assignees */}
|
{/* Change Assignees */}
|
||||||
|
<Tooltip title={t('changeAssignees')} placement="top">
|
||||||
|
<Dropdown
|
||||||
|
dropdownRender={getAssigneesMenu}
|
||||||
|
open={assigneeDropdownOpen}
|
||||||
|
onOpenChange={onAssigneeDropdownOpenChange}
|
||||||
|
placement="top"
|
||||||
|
arrow
|
||||||
|
trigger={['click']}
|
||||||
|
>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<UsergroupAddOutlined />}
|
icon={<UsergroupAddOutlined />}
|
||||||
tooltip="Assign Members"
|
tooltip={t('changeAssignees')}
|
||||||
onClick={() => onBulkAssignMembers?.([])}
|
loading={updatingAssignees}
|
||||||
loading={loadingStates.assignMembers}
|
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<InboxOutlined />}
|
icon={<BellOutlined />}
|
||||||
tooltip="Archive"
|
tooltip={archived ? t('unarchive') : t('archive')}
|
||||||
onClick={handleArchive}
|
onClick={handleArchive}
|
||||||
loading={loadingStates.archive}
|
loading={updatingArchive}
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete */}
|
{/* Delete */}
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title={`Delete ${totalSelected} ${totalSelected === 1 ? 'task' : 'tasks'}?`}
|
title={`${t('delete')} ${totalSelected} ${totalSelected === 1 ? t('task') : t('tasks')}?`}
|
||||||
description="This action cannot be undone."
|
description={t('deleteConfirmation')}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
okText="Delete"
|
okText={t('delete')}
|
||||||
cancelText="Cancel"
|
cancelText={t('cancel')}
|
||||||
okType="danger"
|
okType="danger"
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
tooltip="Delete"
|
tooltip={t('delete')}
|
||||||
loading={loadingStates.delete}
|
loading={updatingDelete}
|
||||||
danger
|
danger
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
|
||||||
<Divider
|
{isOwnerOrAdmin && (
|
||||||
type="vertical"
|
<Tooltip title={t('moreOptions')} placement="top">
|
||||||
style={{
|
<Dropdown
|
||||||
height: '20px',
|
trigger={['click']}
|
||||||
margin: '0 4px',
|
menu={{
|
||||||
borderColor: isDarkMode ? 'rgba(55, 65, 81, 0.5)' : 'rgba(229, 231, 235, 0.8)'
|
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 */}
|
{/* Clear Selection */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={<CloseOutlined />}
|
icon={<CloseOutlined />}
|
||||||
tooltip="Clear Selection"
|
tooltip={t('deselectAll')}
|
||||||
onClick={onClearSelection}
|
onClick={onClearSelection}
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
</Space>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Portals for modals */}
|
||||||
|
{createReactPortal(
|
||||||
|
<TaskTemplateDrawer
|
||||||
|
showDrawer={showDrawer}
|
||||||
|
selectedTemplateId={null}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDrawer(false);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
'create-task-template'
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
selectSelectedTaskIds,
|
selectSelectedTaskIds,
|
||||||
toggleTaskSelection,
|
toggleTaskSelection,
|
||||||
clearSelection,
|
|
||||||
} from '@/features/task-management/selection.slice';
|
} from '@/features/task-management/selection.slice';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
@@ -45,34 +44,7 @@ import OptimizedBulkActionBar from './optimized-bulk-action-bar';
|
|||||||
import VirtualizedTaskList from './virtualized-task-list';
|
import VirtualizedTaskList from './virtualized-task-list';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
import { shallowEqual } from 'react-redux';
|
import { shallowEqual } from 'react-redux';
|
||||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
import { clearSelection } from '@/features/task-management/selection.slice';
|
||||||
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 { performanceMonitor } from '@/utils/performance-monitor';
|
import { performanceMonitor } from '@/utils/performance-monitor';
|
||||||
import debugPerformance from '@/utils/debug-performance';
|
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 TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const { t } = useTranslation('task-management');
|
const { t } = useTranslation('task-management');
|
||||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
|
||||||
const [dragState, setDragState] = useState<DragState>({
|
const [dragState, setDragState] = useState<DragState>({
|
||||||
activeTask: null,
|
activeTask: null,
|
||||||
activeGroupId: null,
|
activeGroupId: null,
|
||||||
@@ -151,13 +122,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
|
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
|
||||||
const error = useSelector((state: RootState) => state.taskManagement.error);
|
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
|
// Get theme from Redux store
|
||||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
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]);
|
}, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]);
|
||||||
|
|
||||||
// Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar
|
// Bulk action handler
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
dispatch(deselectAll());
|
dispatch(clearSelection());
|
||||||
}, [dispatch]);
|
}, [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
|
// Cleanup effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -785,17 +540,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
totalSelected={selectedTaskIds.length}
|
totalSelected={selectedTaskIds.length}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onClearSelection={handleClearSelection}
|
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>{`
|
<style>{`
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
import { FixedSizeList as List } from 'react-window';
|
import { FixedSizeList as List } from 'react-window';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||||
|
import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-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,
|
width,
|
||||||
tasksById
|
tasksById
|
||||||
}) => {
|
}) => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
const { t } = useTranslation('task-management');
|
const { t } = useTranslation('task-management');
|
||||||
|
|
||||||
// Get theme from Redux store
|
// Get theme from Redux store
|
||||||
@@ -43,6 +46,10 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
// Get field visibility from taskListFields slice
|
// Get field visibility from taskListFields slice
|
||||||
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
|
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
|
// PERFORMANCE OPTIMIZATION: Improved virtualization for better user experience
|
||||||
const VIRTUALIZATION_THRESHOLD = 25; // Increased threshold - virtualize when there are more tasks
|
const VIRTUALIZATION_THRESHOLD = 25; // Increased threshold - virtualize when there are more tasks
|
||||||
const TASK_ROW_HEIGHT = 40;
|
const TASK_ROW_HEIGHT = 40;
|
||||||
@@ -52,7 +59,12 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Batch rendering to prevent long tasks
|
// PERFORMANCE OPTIMIZATION: Batch rendering to prevent long tasks
|
||||||
const RENDER_BATCH_SIZE = 5; // Render max 5 tasks per frame
|
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
|
// PERFORMANCE OPTIMIZATION: Add early return for empty groups
|
||||||
if (!group || !group.taskIds || group.taskIds.length === 0) {
|
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
|
// 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">
|
<span className="task-group-header-text">
|
||||||
{group?.title || 'Empty Group'} (0)
|
{group?.title || 'Empty Group'} (0)
|
||||||
</span>
|
</span>
|
||||||
@@ -92,7 +120,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Headers */}
|
{/* Column Headers */}
|
||||||
<div className="task-group-column-headers" style={{
|
<div style={{
|
||||||
marginLeft: '4px', // Account for sticky border
|
marginLeft: '4px', // Account for sticky border
|
||||||
height: COLUMN_HEADER_HEIGHT,
|
height: COLUMN_HEADER_HEIGHT,
|
||||||
background: 'var(--task-bg-secondary, #f5f5f5)',
|
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(() => {
|
const groupTasks = useMemo(() => {
|
||||||
// PERFORMANCE OPTIMIZATION: Use for loop instead of map for better performance
|
return group.taskIds
|
||||||
const tasks: Task[] = [];
|
.map((taskId: string) => tasksById[taskId])
|
||||||
for (let i = 0; i < group.taskIds.length; i++) {
|
.filter((task: Task | undefined): task is Task => task !== undefined);
|
||||||
const task = tasksById[group.taskIds[i]];
|
|
||||||
if (task) {
|
|
||||||
tasks.push(task);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tasks;
|
|
||||||
}, [group.taskIds, tasksById]);
|
}, [group.taskIds, tasksById]);
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Only calculate selection state when needed
|
// Calculate selection state for the group checkbox
|
||||||
const selectionState = useMemo(() => {
|
const selectionState = useMemo(() => {
|
||||||
if (groupTasks.length === 0) {
|
if (groupTasks.length === 0) {
|
||||||
return { isAllSelected: false, isIndeterminate: false };
|
return { isAllSelected: false, isIndeterminate: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Use for loop instead of filter for better performance
|
const selectedTasksInGroup = groupTasks.filter((task: Task) => selectedTaskIds.includes(task.id));
|
||||||
let selectedCount = 0;
|
const isAllSelected = selectedTasksInGroup.length === groupTasks.length;
|
||||||
for (let i = 0; i < groupTasks.length; i++) {
|
const isIndeterminate = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < groupTasks.length;
|
||||||
if (selectedTaskIds.includes(groupTasks[i].id)) {
|
|
||||||
selectedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAllSelected = selectedCount === groupTasks.length;
|
|
||||||
const isIndeterminate = selectedCount > 0 && selectedCount < groupTasks.length;
|
|
||||||
|
|
||||||
return { isAllSelected, isIndeterminate };
|
return { isAllSelected, isIndeterminate };
|
||||||
}, [groupTasks, selectedTaskIds]);
|
}, [groupTasks, selectedTaskIds]);
|
||||||
@@ -339,6 +354,69 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
};
|
};
|
||||||
}, [handleScroll]);
|
}, [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 (
|
return (
|
||||||
<div className="virtualized-task-list" style={{ height: groupHeight, position: 'relative' }}>
|
<div className="virtualized-task-list" style={{ height: groupHeight, position: 'relative' }}>
|
||||||
{/* Sticky Group Color Border */}
|
{/* Sticky Group Color Border */}
|
||||||
@@ -366,6 +444,30 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
// No margin - header should overlap the sticky border
|
// 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">
|
<span className="task-group-header-text">
|
||||||
{group.title} ({groupTasks.length})
|
{group.title} ({groupTasks.length})
|
||||||
</span>
|
</span>
|
||||||
@@ -586,6 +688,24 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
margin: 0 !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 */
|
/* Column headers styles */
|
||||||
.task-table-header-cell {
|
.task-table-header-cell {
|
||||||
background: var(--task-bg-secondary, #f5f5f5);
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
|
|||||||
@@ -2,20 +2,6 @@ import React from 'react';
|
|||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import TaskListBoard from '@/components/task-management/task-list-board';
|
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 ProjectViewEnhancedTasks: React.FC = () => {
|
||||||
const { project } = useAppSelector(state => state.projectReducer);
|
const { project } = useAppSelector(state => state.projectReducer);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user