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:
3529
worklenz-backend/package-lock.json
generated
3529
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",
|
||||
"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",
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>{`
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user