Files
worklenz/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx
shancds 0a92d38ccf refactor(task-sort-order): optimize access check and clean up code
- Improved the access check logic by incorporating team member validation in the SQL query, enhancing security and accuracy.
- Removed unnecessary whitespace for cleaner code formatting.
- Updated socket event emission for consistency in response structure.
2025-07-01 16:57:07 +05:30

493 lines
16 KiB
TypeScript

import React, { useEffect, useState, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Card, Spin, Empty } from 'antd';
import {
DndContext,
DragOverlay,
DragStartEvent,
DragEndEvent,
DragOverEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
UniqueIdentifier,
getFirstCollision,
pointerWithin,
rectIntersection,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable';
import { RootState } from '@/app/store';
import {
fetchEnhancedKanbanGroups,
reorderEnhancedKanbanTasks,
reorderEnhancedKanbanGroups,
setDragState,
reorderTasks,
reorderGroups,
fetchEnhancedKanbanTaskAssignees,
fetchEnhancedKanbanLabels,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import EnhancedKanbanGroup from './EnhancedKanbanGroup';
import './EnhancedKanbanBoard.css';
import { useSocket } from '@/socket/socketContext';
import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
import alertService from '@/services/alerts/alertService';
import { IGroupBy } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import EnhancedKanbanCreateSection from './EnhancedKanbanCreateSection';
import ImprovedTaskFilters from '../task-management/improved-task-filters';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useAuthService } from '@/hooks/useAuth';
// Import the TaskListFilters component
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
interface EnhancedKanbanBoardProps {
projectId: string;
className?: string;
}
const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, className = '' }) => {
const dispatch = useDispatch();
const {
taskGroups,
loadingGroups,
error,
dragState,
performanceMetrics
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
const { socket } = useSocket();
const authService = useAuthService();
const teamId = authService.getCurrentSession()?.team_id;
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
const project = useAppSelector((state: RootState) => state.projectReducer.project);
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Load filter data
useFilterDataLoader();
// Set up socket event handlers for real-time updates
useTaskSocketHandlers();
// Local state for drag overlay
const [activeTask, setActiveTask] = useState<any>(null);
const [activeGroup, setActiveGroup] = useState<any>(null);
const [overId, setOverId] = useState<UniqueIdentifier | null>(null);
// Sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor)
);
useEffect(() => {
if (projectId) {
dispatch(fetchEnhancedKanbanGroups(projectId) as any);
// Load filter data for enhanced kanban
dispatch(fetchEnhancedKanbanTaskAssignees(projectId) as any);
dispatch(fetchEnhancedKanbanLabels(projectId) as any);
}
if (!statusCategories.length) {
dispatch(fetchStatusesCategories() as any);
}
}, [dispatch, projectId]);
// Get all task IDs for sortable context
const allTaskIds = useMemo(() =>
taskGroups.flatMap(group => group.tasks.map(task => task.id!)),
[taskGroups]
);
const allGroupIds = useMemo(() =>
taskGroups.map(group => group.id),
[taskGroups]
);
// Enhanced collision detection
const collisionDetectionStrategy = (args: any) => {
// First, let's see if we're colliding with any droppable areas
const pointerIntersections = pointerWithin(args);
const intersections = pointerIntersections.length > 0
? pointerIntersections
: rectIntersection(args);
let overId = getFirstCollision(intersections, 'id');
if (overId) {
// Check if we're over a task or a group
const overGroup = taskGroups.find(g => g.id === overId);
if (overGroup) {
// We're over a group, check if there are tasks in it
if (overGroup.tasks.length > 0) {
// Find the closest task within this group
const taskIntersections = pointerWithin({
...args,
droppableContainers: args.droppableContainers.filter(
(container: any) => container.data.current?.type === 'task'
),
});
if (taskIntersections.length > 0) {
overId = taskIntersections[0].id;
}
}
}
}
return overId ? [{ id: overId }] : [];
};
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const activeId = active.id as string;
const activeData = active.data.current;
// Check if dragging a group or a task
if (activeData?.type === 'group') {
// Dragging a group
const foundGroup = taskGroups.find(g => g.id === activeId);
setActiveGroup(foundGroup);
setActiveTask(null);
dispatch(setDragState({
activeTaskId: null,
activeGroupId: activeId,
isDragging: true,
}));
} else {
// Dragging a task
let foundTask = null;
let foundGroup = null;
for (const group of taskGroups) {
const task = group.tasks.find(t => t.id === activeId);
if (task) {
foundTask = task;
foundGroup = group;
break;
}
}
setActiveTask(foundTask);
setActiveGroup(null);
dispatch(setDragState({
activeTaskId: activeId,
activeGroupId: foundGroup?.id || null,
isDragging: true,
}));
}
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) {
setOverId(null);
dispatch(setDragState({ overId: null }));
return;
}
const activeId = active.id as string;
const overId = over.id as string;
setOverId(overId);
// Update over ID in Redux
dispatch(setDragState({ overId }));
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
const activeData = active.data.current;
// Reset local state
setActiveTask(null);
setActiveGroup(null);
setOverId(null);
// Reset Redux drag state
dispatch(setDragState({
activeTaskId: null,
activeGroupId: null,
overId: null,
isDragging: false,
}));
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// Handle group (column) reordering
if (activeData?.type === 'group') {
// Don't allow reordering if groupBy is phases
if (groupBy === IGroupBy.PHASE) {
return;
}
const fromIndex = taskGroups.findIndex(g => g.id === activeId);
const toIndex = taskGroups.findIndex(g => g.id === overId);
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
// Create new array with reordered groups
const reorderedGroups = [...taskGroups];
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
reorderedGroups.splice(toIndex, 0, movedGroup);
// Synchronous UI update for immediate feedback
dispatch(reorderGroups({ fromIndex, toIndex, reorderedGroups }));
dispatch(reorderEnhancedKanbanGroups({ fromIndex, toIndex, reorderedGroups }) as any);
// Prepare column order for API
const columnOrder = reorderedGroups.map(group => group.id);
// Call API to update status order
try {
const requestBody: ITaskStatusCreateRequest = {
status_order: columnOrder
};
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIndex, toIndex: fromIndex, reorderedGroups: revertedGroups }));
alertService.error('Failed to update column order', 'Please try again');
}
} catch (error) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIndex, toIndex: fromIndex, reorderedGroups: revertedGroups }));
alertService.error('Failed to update column order', 'Please try again');
logger.error('Failed to update column order', error);
}
}
return;
}
// Handle task reordering (within or between groups)
let sourceGroup = null;
let targetGroup = null;
let sourceIndex = -1;
let targetIndex = -1;
// Find source group and index
for (const group of taskGroups) {
const taskIndex = group.tasks.findIndex(t => t.id === activeId);
if (taskIndex !== -1) {
sourceGroup = group;
sourceIndex = taskIndex;
break;
}
}
// Find target group and index
for (const group of taskGroups) {
const taskIndex = group.tasks.findIndex(t => t.id === overId);
if (taskIndex !== -1) {
targetGroup = group;
targetIndex = taskIndex;
break;
}
}
// If dropping on a group (not a task)
if (!targetGroup) {
targetGroup = taskGroups.find(g => g.id === overId);
if (targetGroup) {
targetIndex = targetGroup.tasks.length; // Add to end of group
}
}
if (!sourceGroup || !targetGroup || sourceIndex === -1) return;
// Don't do anything if dropping in the same position
if (sourceGroup.id === targetGroup.id && sourceIndex === targetIndex) return;
// Create updated task arrays
const updatedSourceTasks = [...sourceGroup.tasks];
const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1);
let updatedTargetTasks: any[];
if (sourceGroup.id === targetGroup.id) {
// Moving within the same group
updatedTargetTasks = updatedSourceTasks;
updatedTargetTasks.splice(targetIndex, 0, movedTask);
} else {
// Moving between different groups
updatedTargetTasks = [...targetGroup.tasks];
updatedTargetTasks.splice(targetIndex, 0, movedTask);
}
// Synchronous UI update
dispatch(reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}));
dispatch(reorderEnhancedKanbanTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}) as any);
// --- Socket emit for task sort order ---
if (socket && projectId && movedTask) {
// Find sort_order for from and to
const fromSortOrder = movedTask.sort_order;
let toSortOrder = -1;
let toLastIndex = false;
if (targetIndex === targetGroup.tasks.length) {
// Dropping at the end
toSortOrder = -1;
toLastIndex = true;
} else if (targetGroup.tasks[targetIndex]) {
toSortOrder = typeof targetGroup.tasks[targetIndex].sort_order === 'number' ? targetGroup.tasks[targetIndex].sort_order! : -1;
toLastIndex = false;
} else if (targetGroup.tasks.length > 0) {
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder! : -1;
toLastIndex = false;
}
const body = {
project_id: projectId,
from_index: fromSortOrder,
to_index: toSortOrder,
to_last_index: toLastIndex,
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: groupBy || 'status',
task: movedTask,
team_id: teamId || project?.team_id || '',
};
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
}
};
if (error) {
return (
<Card className={className}>
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
);
}
return (
<>
{/* Task Filters */}
<div className="mb-4">
<React.Suspense fallback={<div>Loading filters...</div>}>
<ImprovedTaskFilters position="board" />
</React.Suspense>
</div>
<div className={`enhanced-kanban-board ${className}`}>
{/* Performance Monitor - only show for large datasets */}
{/* {performanceMetrics.totalTasks > 100 && <PerformanceMonitor />} */}
{loadingGroups ? (
<Card>
<div className="flex justify-center items-center py-8">
<Spin size="large" />
</div>
</Card>
) : taskGroups.length === 0 ? (
<Card>
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext items={allGroupIds} strategy={horizontalListSortingStrategy}>
<div className="kanban-groups-container">
{taskGroups.map(group => (
<EnhancedKanbanGroup
key={group.id}
group={group}
activeTaskId={dragState.activeTaskId}
overId={overId as string | null}
/>
))}
<EnhancedKanbanCreateSection />
</div>
</SortableContext>
<DragOverlay>
{activeTask && (
<div
style={{
background: themeMode === 'dark' ? '#23272f' : '#fff',
borderRadius: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
padding: '12px 20px',
minWidth: 180,
maxWidth: 340,
opacity: 0.95,
fontWeight: 600,
fontSize: 16,
color: themeMode === 'dark' ? '#fff' : '#23272f',
display: 'flex',
alignItems: 'center',
}}
>
{activeTask.name}
</div>
)}
{activeGroup && (
<div
style={{
background: themeMode === 'dark' ? '#23272f' : '#fff',
borderRadius: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
padding: '16px 24px',
minWidth: 220,
maxWidth: 320,
display: 'flex',
alignItems: 'center',
gap: 12,
opacity: 0.95,
}}
>
<h3 style={{ margin: 0, fontWeight: 600, fontSize: 18 }}>{activeGroup.name}</h3>
<span style={{ fontSize: 15, color: '#888' }}>({activeGroup.tasks.length})</span>
</div>
)}
</DragOverlay>
</DndContext>
)}
</div>
</>
);
};
export default EnhancedKanbanBoard;