Merge pull request #218 from Worklenz/fix/bulk-action-bar

feat(task-management): enhance bulk action bar with new features and …
This commit is contained in:
Chamika J
2025-07-02 09:08:36 +05:30
committed by GitHub
7 changed files with 727 additions and 592 deletions

View File

@@ -20,5 +20,20 @@
"hitEnterToCreate": "Enter drücken zum Erstellen",
"pendingInvitation": "Einladung ausstehend",
"noMatchingLabels": "Keine passenden Labels",
"noLabels": "Keine Labels"
"noLabels": "Keine Labels",
"CHANGE_STATUS": "Status ändern",
"CHANGE_PRIORITY": "Priorität ändern",
"CHANGE_PHASE": "Phase ändern",
"ADD_LABELS": "Labels hinzufügen",
"ASSIGN_TO_ME": "Mir zuweisen",
"ASSIGN_MEMBERS": "Mitglieder zuweisen",
"ARCHIVE": "Archivieren",
"DELETE": "Löschen",
"CANCEL": "Abbrechen",
"CLEAR_SELECTION": "Auswahl löschen",
"TASKS_SELECTED": "{{count}} Aufgabe ausgewählt",
"TASKS_SELECTED_plural": "{{count}} Aufgaben ausgewählt",
"DELETE_TASKS_CONFIRM": "{{count}} Aufgabe löschen?",
"DELETE_TASKS_CONFIRM_plural": "{{count}} Aufgaben löschen?",
"DELETE_TASKS_WARNING": "Diese Aktion kann nicht rückgängig gemacht werden."
}

View File

@@ -20,5 +20,20 @@
"hitEnterToCreate": "Press Enter to create",
"pendingInvitation": "Pending Invitation",
"noMatchingLabels": "No matching labels",
"noLabels": "No labels"
"noLabels": "No labels",
"CHANGE_STATUS": "Change Status",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_PHASE": "Change Phase",
"ADD_LABELS": "Add Labels",
"ASSIGN_TO_ME": "Assign to Me",
"ASSIGN_MEMBERS": "Assign Members",
"ARCHIVE": "Archive",
"DELETE": "Delete",
"CANCEL": "Cancel",
"CLEAR_SELECTION": "Clear Selection",
"TASKS_SELECTED": "{{count}} task selected",
"TASKS_SELECTED_plural": "{{count}} tasks selected",
"DELETE_TASKS_CONFIRM": "Delete {{count}} task?",
"DELETE_TASKS_CONFIRM_plural": "Delete {{count}} tasks?",
"DELETE_TASKS_WARNING": "This action cannot be undone."
}

View File

@@ -20,5 +20,20 @@
"hitEnterToCreate": "Presione Enter para crear",
"pendingInvitation": "Invitación Pendiente",
"noMatchingLabels": "No hay etiquetas coincidentes",
"noLabels": "Sin etiquetas"
"noLabels": "Sin etiquetas",
"CHANGE_STATUS": "Cambiar Estado",
"CHANGE_PRIORITY": "Cambiar Prioridad",
"CHANGE_PHASE": "Cambiar Fase",
"ADD_LABELS": "Agregar Etiquetas",
"ASSIGN_TO_ME": "Asignar a Mí",
"ASSIGN_MEMBERS": "Asignar Miembros",
"ARCHIVE": "Archivar",
"DELETE": "Eliminar",
"CANCEL": "Cancelar",
"CLEAR_SELECTION": "Limpiar Selección",
"TASKS_SELECTED": "{{count}} tarea seleccionada",
"TASKS_SELECTED_plural": "{{count}} tareas seleccionadas",
"DELETE_TASKS_CONFIRM": "¿Eliminar {{count}} tarea?",
"DELETE_TASKS_CONFIRM_plural": "¿Eliminar {{count}} tareas?",
"DELETE_TASKS_WARNING": "Esta acción no se puede deshacer."
}

View File

@@ -20,5 +20,20 @@
"hitEnterToCreate": "Pressione Enter para criar",
"pendingInvitation": "Convite Pendente",
"noMatchingLabels": "Nenhuma etiqueta correspondente",
"noLabels": "Sem etiquetas"
"noLabels": "Sem etiquetas",
"CHANGE_STATUS": "Alterar Status",
"CHANGE_PRIORITY": "Alterar Prioridade",
"CHANGE_PHASE": "Alterar Fase",
"ADD_LABELS": "Adicionar Etiquetas",
"ASSIGN_TO_ME": "Atribuir a Mim",
"ASSIGN_MEMBERS": "Atribuir Membros",
"ARCHIVE": "Arquivar",
"DELETE": "Deletar",
"CANCEL": "Cancelar",
"CLEAR_SELECTION": "Limpar Seleção",
"TASKS_SELECTED": "{{count}} tarefa selecionada",
"TASKS_SELECTED_plural": "{{count}} tarefas selecionadas",
"DELETE_TASKS_CONFIRM": "Deletar {{count}} tarefa?",
"DELETE_TASKS_CONFIRM_plural": "Deletar {{count}} tarefas?",
"DELETE_TASKS_WARNING": "Esta ação não pode ser desfeita."
}

View File

@@ -1,7 +1,7 @@
import React, { memo } from 'react';
import { Spin } from 'antd';
import { Skeleton } from 'antd';
// Lightweight loading component - removed heavy theme calculations
// Lightweight loading component with skeleton animation
export const SuspenseFallback = memo(() => {
return (
<div
@@ -16,9 +16,16 @@ export const SuspenseFallback = memo(() => {
justifyContent: 'center',
background: 'transparent',
zIndex: 9999,
padding: '20px',
}}
>
<Spin size="large" />
<div style={{ width: '100%', maxWidth: '400px' }}>
<Skeleton
active
paragraph={{ rows: 3, width: ['100%', '80%', '60%'] }}
title={{ width: '70%' }}
/>
</div>
</div>
);
});
@@ -35,7 +42,13 @@ export const InlineSuspenseFallback = memo(() => {
minHeight: '200px',
}}
>
<Spin size="large" />
<div style={{ width: '100%', maxWidth: '300px' }}>
<Skeleton
active
paragraph={{ rows: 2, width: ['100%', '70%'] }}
title={{ width: '60%' }}
/>
</div>
</div>
);
});

View File

@@ -35,16 +35,45 @@ import {
import {
selectSelectedTaskIds,
toggleTaskSelection,
clearSelection,
} from '@/features/task-management/selection.slice';
import { Task } from '@/types/task-management.types';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import TaskRow from './task-row';
// import BulkActionBar from './bulk-action-bar';
import OptimizedBulkActionBar from './optimized-bulk-action-bar';
// import OptimizedBulkActionBar from './optimized-bulk-action-bar';
import VirtualizedTaskList from './virtualized-task-list';
import { AppDispatch } from '@/app/store';
import { shallowEqual } from 'react-redux';
import { clearSelection } from '@/features/task-management/selection.slice';
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 { performanceMonitor } from '@/utils/performance-monitor';
import debugPerformance from '@/utils/debug-performance';
@@ -93,6 +122,7 @@ 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,
@@ -122,7 +152,13 @@ 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');
@@ -405,11 +441,230 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
);
}, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]);
// Bulk action handler
// Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar
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(clearSelection());
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(clearSelection());
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(clearSelection());
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(clearSelection());
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(clearSelection());
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(clearSelection());
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(clearSelection());
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(clearSelection());
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 () => {
@@ -540,6 +795,17 @@ 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>{`
@@ -1018,4 +1284,4 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
);
};
export default TaskListBoard;
export default TaskListBoard;