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:
@@ -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'
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user