diff --git a/worklenz-frontend/src/components/task-management/BulkActionBar.tsx b/worklenz-frontend/src/components/task-management/BulkActionBar.tsx deleted file mode 100644 index 5f4cba8f..00000000 --- a/worklenz-frontend/src/components/task-management/BulkActionBar.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React from 'react'; -import { Card, Button, Space, Typography, Dropdown, Menu, Popconfirm, message } from 'antd'; -import { - DeleteOutlined, - EditOutlined, - TagOutlined, - UserOutlined, - CheckOutlined, - CloseOutlined, - MoreOutlined, -} from '@ant-design/icons'; -import { useDispatch, useSelector } from 'react-redux'; -import { IGroupBy, bulkUpdateTasks, bulkDeleteTasks } from '@/features/tasks/tasks.slice'; -import { AppDispatch, RootState } from '@/app/store'; - -const { Text } = Typography; - -interface BulkActionBarProps { - selectedTaskIds: string[]; - totalSelected: number; - currentGrouping: IGroupBy; - projectId: string; - onClearSelection?: () => void; -} - -const BulkActionBar: React.FC = ({ - selectedTaskIds, - totalSelected, - currentGrouping, - projectId, - onClearSelection, -}) => { - const dispatch = useDispatch(); - const { statuses, priorities } = useSelector((state: RootState) => state.taskReducer); - - const handleBulkStatusChange = (statusId: string) => { - // dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { status: statusId } })); - message.success(`Updated ${totalSelected} tasks`); - onClearSelection?.(); - }; - - const handleBulkPriorityChange = (priority: string) => { - // dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { priority } })); - message.success(`Updated ${totalSelected} tasks`); - onClearSelection?.(); - }; - - const handleBulkDelete = () => { - // dispatch(bulkDeleteTasks(selectedTaskIds)); - message.success(`Deleted ${totalSelected} tasks`); - onClearSelection?.(); - }; - - const statusMenu = ( - handleBulkStatusChange(key)} - items={statuses.map(status => ({ - key: status.id!, - label: ( -
-
- {status.name} -
- ), - }))} - /> - ); - - const priorityMenu = ( - handleBulkPriorityChange(key)} - items={[ - { key: 'critical', label: 'Critical', icon:
}, - { key: 'high', label: 'High', icon:
}, - { key: 'medium', label: 'Medium', icon:
}, - { key: 'low', label: 'Low', icon:
}, - ]} - /> - ); - - const moreActionsMenu = ( - , - }, - { - key: 'labels', - label: 'Add labels', - icon: , - }, - { - key: 'archive', - label: 'Archive tasks', - icon: , - }, - ]} - /> - ); - - return ( - -
-
- - {totalSelected} task{totalSelected > 1 ? 's' : ''} selected - -
- - - {/* Status Change */} - {currentGrouping !== 'status' && ( - - - - )} - - {/* Priority Change */} - {currentGrouping !== 'priority' && ( - - - - )} - - {/* More Actions */} - - - - - {/* Delete */} - 1 ? 's' : ''}?`} - description="This action cannot be undone." - onConfirm={handleBulkDelete} - okText="Delete" - cancelText="Cancel" - okType="danger" - > - - - - {/* Clear Selection */} - - -
-
- ); -}; - -export default BulkActionBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx b/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx new file mode 100644 index 00000000..1148e623 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/bulk-action-bar.tsx @@ -0,0 +1,590 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Button, Typography, Dropdown, Menu, Popconfirm, message, Tooltip, Badge, CheckboxChangeEvent, InputRef } from 'antd'; +import { + DeleteOutlined, + EditOutlined, + TagOutlined, + UserOutlined, + CheckOutlined, + CloseOutlined, + MoreOutlined, + RetweetOutlined, + UserAddOutlined, + InboxOutlined, + TagsOutlined, + UsergroupAddOutlined, +} from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { IGroupBy, fetchTaskGroups } from '@/features/tasks/tasks.slice'; +import { AppDispatch, RootState } from '@/app/store'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +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 } 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; + +interface BulkActionBarProps { + selectedTaskIds: string[]; + totalSelected: number; + currentGrouping: IGroupBy; + projectId: string; + onClearSelection?: () => void; +} + +const BulkActionBarContent: React.FC = ({ + selectedTaskIds, + totalSelected, + currentGrouping, + projectId, + onClearSelection, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('tasks/task-table-bulk-actions'); + const { trackMixpanelEvent } = useMixpanelTracking(); + + // Add permission hooks + const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + + // loading state + 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 { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer); + 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 themeMode = useAppSelector(state => state.themeReducer.mode); + + const labelsInputRef = useRef(null); + const [createLabelText, setCreateLabelText] = useState(''); + const [teamMembersSorted, setTeamMembersSorted] = useState({ + data: [], + total: 0, + }); + const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false); + const [showDrawer, setShowDrawer] = useState(false); + const [selectedLabels, setSelectedLabels] = useState([]); + + // Handlers + const handleChangeStatus = async (status: ITaskStatus) => { + if (!status.id || !projectId) return; + try { + setLoading(true); + + const body: IBulkTasksStatusChangeRequest = { + tasks: selectedTaskIds, + status_id: status.id, + }; + const res = await taskListBulkActionsApiService.changeStatus(body, projectId); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_bulk_change_status); + dispatch(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + for (const it of selectedTaskIds) { + const canContinue = await checkTaskDependencyStatus(it, 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; + } + } + } catch (error) { + logger.error('Error changing status:', error); + } finally { + setLoading(false); + } + }; + + const handleChangePriority = 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(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error changing priority:', error); + } finally { + setLoading(false); + } + }; + + const handleChangePhase = 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(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error changing phase:', error); + } finally { + setLoading(false); + } + }; + + const handleAssignToMe = async () => { + if (!projectId) return; + try { + 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(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error assigning to me:', error); + } finally { + setUpdatingAssignToMe(false); + } + }; + + const handleArchive = async () => { + if (!projectId) return; + try { + 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(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error archiving tasks:', error); + } finally { + setUpdatingArchive(false); + } + }; + + const handleChangeAssignees = 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 || member.email || 'Unknown', // Fix: Ensure name is always a string + 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(deselectAll()); + dispatch(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error assigning tasks:', error); + } finally { + setUpdatingAssignees(false); + } + }; + + const handleDelete = async () => { + if (!projectId) return; + try { + 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(fetchTaskGroups(projectId)); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error deleting tasks:', error); + } finally { + setUpdatingDelete(false); + } + }; + + // Menu Generators + const getChangeOptionsMenu = () => [ + { + key: '1', + label: t('status'), + children: statusList.map(status => ({ + key: status.id, + onClick: () => handleChangeStatus(status), + label: , + })), + }, + { + key: '2', + label: t('priority'), + children: priorityList.map(priority => ({ + key: priority.id, + onClick: () => handleChangePriority(priority), + label: , + })), + }, + { + key: '3', + label: t('phase'), + children: phaseList.map(phase => ({ + key: phase.id, + onClick: () => handleChangePhase(phase), + label: , + })), + }, + ]; + + useEffect(() => { + if (members?.data && assigneeDropdownOpen) { + let sortedMembers = sortTeamMembers(members.data); + setTeamMembersSorted({ data: sortedMembers, total: members.total }); + } + }, [assigneeDropdownOpen, members?.data]); + + const getAssigneesMenu = () => { + return ( + setAssigneeDropdownOpen(false)} + t={t} + /> + ); + }; + + const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => { + if (e.target.checked) { + setSelectedLabels(prev => [...prev, label]); + } else { + setSelectedLabels(prev => prev.filter(l => l.id !== label.id)); + } + }; + + const applyLabels = async () => { + if (!projectId) return; + try { + 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(fetchTaskGroups(projectId)); + dispatch(fetchLabels()); // Fallback: refetch all labels + setCreateLabelText(''); + setSelectedLabels([]); + onClearSelection?.(); + } + } catch (error) { + logger.error('Error updating labels:', error); + } finally { + setUpdatingLabels(false); + } + }; + + const labelsDropdownContent = ( + } + onLabelChange={handleLabelChange} + onCreateLabelTextChange={value => setCreateLabelText(value)} + onApply={applyLabels} + t={t} + loading={updatingLabels} + /> + ); + + const onAssigneeDropdownOpenChange = (open: boolean) => { + setAssigneeDropdownOpen(open); + }; + + const buttonStyle = { + background: 'transparent', + color: '#fff', + border: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '4px 8px', + height: '32px', + fontSize: '16px', + }; + + return ( +
+ + {totalSelected} task{totalSelected > 1 ? 's' : ''} selected + + + {/* Status/Priority/Phase Change */} + + +
+ ); +}; + +const BulkActionBar: React.FC = (props) => { + // Render the bulk action bar through a portal to avoid suspense issues + return createPortal( + , + document.body, + 'bulk-action-bar' + ); +}; + +export default BulkActionBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/GroupingSelector.tsx b/worklenz-frontend/src/components/task-management/grouping-selector.tsx similarity index 100% rename from worklenz-frontend/src/components/task-management/GroupingSelector.tsx rename to worklenz-frontend/src/components/task-management/grouping-selector.tsx diff --git a/worklenz-frontend/src/components/task-management/TaskGroup.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx similarity index 97% rename from worklenz-frontend/src/components/task-management/TaskGroup.tsx rename to worklenz-frontend/src/components/task-management/task-group.tsx index 9a3fbb1d..dff7eb74 100644 --- a/worklenz-frontend/src/components/task-management/TaskGroup.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -8,7 +8,7 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; import { RootState } from '@/app/store'; -import TaskRow from './TaskRow'; +import TaskRow from './task-row'; import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; const { Text } = Typography; @@ -126,7 +126,10 @@ const TaskGroup: React.FC = ({ {/* Column Headers */} {!isCollapsed && totalTasks > 0 && ( -
+
= ({ {/* Tasks List */} {!isCollapsed && ( -
+
{group.tasks.length === 0 ? (
@@ -262,7 +268,7 @@ const TaskGroup: React.FC = ({ display: inline-flex; align-items: center; padding: 8px 12px; - border-radius: 6px; + border-radius: 6px 6px 0 0; background-color: #f0f0f0; color: white; font-weight: 500; diff --git a/worklenz-frontend/src/components/task-management/TaskListBoard.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx similarity index 98% rename from worklenz-frontend/src/components/task-management/TaskListBoard.tsx rename to worklenz-frontend/src/components/task-management/task-list-board.tsx index 96d92085..60367f0b 100644 --- a/worklenz-frontend/src/components/task-management/TaskListBoard.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -24,9 +24,9 @@ import { reorderTasks, } from '@/features/tasks/tasks.slice'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import TaskGroup from './TaskGroup'; -import TaskRow from './TaskRow'; -import BulkActionBar from './BulkActionBar'; +import TaskGroup from './task-group'; +import TaskRow from './task-row'; +import BulkActionBar from './bulk-action-bar'; import { AppDispatch } from '@/app/store'; // Import the TaskListFilters component @@ -242,6 +242,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' totalSelected={selectedTaskIds.length} currentGrouping={groupBy} projectId={projectId} + onClearSelection={() => setSelectedTaskIds([])} /> )} diff --git a/worklenz-frontend/src/components/task-management/TaskRow.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx similarity index 100% rename from worklenz-frontend/src/components/task-management/TaskRow.tsx rename to worklenz-frontend/src/components/task-management/task-row.tsx diff --git a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx index 5ac8b726..c8043046 100644 --- a/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx +++ b/worklenz-frontend/src/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar.tsx @@ -247,7 +247,7 @@ const TaskListBulkActionsBar = () => { project_id: projectId, members: selectedAssignees.map(member => ({ id: member.id, - name: member.name, + name: member.name || '', email: member.email, avatar_url: member.avatar_url, })) as ITaskAssignee[], @@ -437,7 +437,6 @@ const TaskListBulkActionsBar = () => { placement="top" arrow trigger={['click']} - destroyOnHidden onOpenChange={value => { if (!value) { setSelectedLabels([]); diff --git a/worklenz-frontend/src/pages/TaskManagementDemo.tsx b/worklenz-frontend/src/pages/TaskManagementDemo.tsx index 8204f525..836b4a3f 100644 --- a/worklenz-frontend/src/pages/TaskManagementDemo.tsx +++ b/worklenz-frontend/src/pages/TaskManagementDemo.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import { Layout, Typography, Card, Space, Alert } from 'antd'; import { useDispatch } from 'react-redux'; -import TaskListBoard from '@/components/task-management/TaskListBoard'; +import TaskListBoard from '@/components/task-management/task-list-board'; import { AppDispatch } from '@/app/store'; const { Header, Content } = Layout; diff --git a/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx b/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx index c6829889..4fbfec50 100644 --- a/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; -import TaskListBoard from '@/components/task-management/TaskListBoard'; +import TaskListBoard from '@/components/task-management/task-list-board'; const ProjectViewEnhancedTasks: React.FC = () => { const { project } = useAppSelector(state => state.projectReducer);