feat(enhanced-kanban): implement group reordering and improve drag-and-drop functionality

- Added support for reordering kanban groups via drag-and-drop, enhancing user experience.
- Updated EnhancedKanbanBoard and EnhancedKanbanGroup components to handle group dragging and state management.
- Introduced visual feedback for dragging groups and tasks, improving usability.
- Refined CSS styles for better layout and responsiveness during drag operations.
This commit is contained in:
shancds
2025-06-23 11:37:40 +05:30
parent 67c26a973e
commit b3d39b65b0
5 changed files with 284 additions and 52 deletions

View File

@@ -26,6 +26,7 @@ import { RootState } from '@/app/store';
import {
fetchEnhancedKanbanGroups,
reorderEnhancedKanbanTasks,
reorderEnhancedKanbanGroups,
setDragState
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import EnhancedKanbanGroup from './EnhancedKanbanGroup';
@@ -119,29 +120,43 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const activeId = active.id as string;
const activeData = active.data.current;
// Find the active task and group
let foundTask = null;
let foundGroup = null;
// 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);
for (const group of taskGroups) {
const task = group.tasks.find(t => t.id === activeId);
if (task) {
foundTask = task;
foundGroup = group;
break;
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,
}));
}
setActiveTask(foundTask);
setActiveGroup(foundGroup);
// Update Redux drag state
dispatch(setDragState({
activeTaskId: activeId,
activeGroupId: foundGroup?.id || null,
isDragging: true,
}));
};
const handleDragOver = (event: DragOverEvent) => {
@@ -164,6 +179,7 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
const activeData = active.data.current;
// Reset local state
setActiveTask(null);
@@ -183,7 +199,28 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
const activeId = active.id as string;
const overId = over.id as string;
// Find source and target groups
// Handle group reordering
if (activeData?.type === 'group') {
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);
// Dispatch group reorder action
dispatch(reorderEnhancedKanbanGroups({
fromIndex,
toIndex,
reorderedGroups,
}) as any);
}
return;
}
// Handle task reordering (existing logic)
let sourceGroup = null;
let targetGroup = null;
let sourceIndex = -1;
@@ -309,6 +346,14 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
isDragOverlay={true}
/>
)}
{activeGroup && (
<div className="group-drag-overlay">
<div className="group-header-content">
<h3>{activeGroup.name}</h3>
<span className="task-count">({activeGroup.tasks.length})</span>
</div>
</div>
)}
</DragOverlay>
</DndContext>
)}

View File

@@ -1,12 +1,15 @@
.enhanced-kanban-group {
min-width: 280px;
max-width: 320px;
width: 300px;
min-width: 300px;
max-width: 300px;
background: var(--ant-color-bg-elevated);
border-radius: 8px;
padding: 12px;
border: 1px solid var(--ant-color-border);
box-shadow: 0 1px 2px var(--ant-color-shadow);
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.enhanced-kanban-group.drag-over {
@@ -15,6 +18,16 @@
box-shadow: 0 0 0 2px var(--ant-color-primary-border);
}
.enhanced-kanban-group.group-dragging {
opacity: 0.5;
z-index: 1000;
box-shadow: 0 8px 24px var(--ant-color-shadow);
}
.enhanced-kanban-group.group-dragging .enhanced-kanban-group-tasks {
background: var(--ant-color-bg-elevated);
}
.enhanced-kanban-group-header {
display: flex;
justify-content: space-between;
@@ -22,21 +35,44 @@
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--ant-color-border);
cursor: grab;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
transition: all 0.2s ease;
border-radius: 6px;
padding: 8px 12px;
margin: -8px -8px 4px -8px;
}
.enhanced-kanban-group-header:active {
cursor: grabbing;
}
.enhanced-kanban-group-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--ant-color-text);
color: inherit;
text-shadow: 0 1px 2px var(--ant-color-shadow);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
display: inline-block;
vertical-align: middle;
}
.task-count {
background: var(--ant-color-fill-secondary);
background: var(--ant-color-bg-container);
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: var(--ant-color-text-secondary);
color: var(--ant-color-text);
font-weight: 500;
border: 1px solid var(--ant-color-border);
opacity: 0.8;
}
.virtualization-indicator {
@@ -62,7 +98,9 @@
min-height: 200px;
max-height: 600px;
transition: all 0.2s ease;
overflow: hidden;
overflow-y: auto;
overflow-x: hidden;
background: transparent;
}
/* Performance optimizations for large lists */
@@ -127,6 +165,42 @@
color: var(--ant-color-primary);
}
/* Group drag overlay */
.group-drag-overlay {
background: var(--ant-color-bg-elevated);
border: 1px solid var(--ant-color-border);
border-radius: 8px;
padding: 12px;
box-shadow: 0 8px 24px var(--ant-color-shadow);
min-width: 280px;
max-width: 320px;
opacity: 0.9;
pointer-events: none;
z-index: 1000;
}
.group-drag-overlay .group-header-content {
display: flex;
align-items: center;
gap: 8px;
}
.group-drag-overlay h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--ant-color-text);
}
.group-drag-overlay .task-count {
background: var(--ant-color-bg-container);
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: var(--ant-color-text);
border: 1px solid var(--ant-color-border);
}
/* Responsive design for different screen sizes */
@media (max-width: 768px) {
.enhanced-kanban-group {
@@ -148,4 +222,23 @@
.enhanced-kanban-group-tasks {
max-height: 300px;
}
}
.enhanced-kanban-task-card {
width: 100%;
box-sizing: border-box;
}
.task-title {
font-weight: 500;
color: var(--ant-color-text);
margin-bottom: 4px;
line-height: 1.4;
word-break: break-word;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
display: inline-block;
vertical-align: middle;
}

View File

@@ -1,9 +1,11 @@
import React, { useMemo, useRef, useEffect, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import EnhancedKanbanTaskCard from './EnhancedKanbanTaskCard';
import VirtualizedTaskList from './VirtualizedTaskList';
import { useAppSelector } from '@/hooks/useAppSelector';
import './EnhancedKanbanGroup.css';
interface EnhancedKanbanGroupProps {
@@ -20,7 +22,25 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
activeTaskId,
overId
}) => {
const { setNodeRef, isOver } = useDroppable({
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: group.id,
data: {
type: 'group',
group,
},
});
// Add sortable functionality for group header
const {
attributes,
listeners,
setNodeRef: setSortableRef,
transform,
transition,
isDragging: isGroupDragging,
} = useSortable({
id: group.id,
data: {
type: 'group',
@@ -67,13 +87,41 @@ const EnhancedKanbanGroup: React.FC<EnhancedKanbanGroupProps> = React.memo(({
// Performance optimization: Only render drop indicators when needed
const shouldShowDropIndicators = isDraggingOver && !shouldVirtualize;
// Combine refs for the main container
const setRefs = (el: HTMLElement | null) => {
setDroppableRef(el);
setSortableRef(el);
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isGroupDragging ? 0.5 : 1,
};
// Get the appropriate background color based on theme
const headerBackgroundColor = useMemo(() => {
if (themeMode === 'dark') {
return group.color_code_dark || group.color_code || '#1e1e1e';
}
return group.color_code || '#f5f5f5';
}, [themeMode, group.color_code, group.color_code_dark]);
return (
<div
ref={setNodeRef}
className={`enhanced-kanban-group ${isDraggingOver ? 'drag-over' : ''}`}
ref={setRefs}
style={style}
className={`enhanced-kanban-group ${isDraggingOver ? 'drag-over' : ''} ${isGroupDragging ? 'group-dragging' : ''}`}
>
<div className="enhanced-kanban-group-header">
<h3>{group.name}</h3>
<div
className="enhanced-kanban-group-header"
style={{
backgroundColor: headerBackgroundColor,
}}
{...attributes}
{...listeners}
>
<h3 title={group.name}>{group.name}</h3>
<span className="task-count">({group.tasks.length})</span>
{shouldVirtualize && (
<span className="virtualization-indicator" title="Virtualized for performance">

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import './EnhancedKanbanTaskCard.css';
interface EnhancedKanbanTaskCardProps {
@@ -11,12 +12,14 @@ interface EnhancedKanbanTaskCardProps {
isDropTarget?: boolean;
}
const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo(({
task,
const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo(({
task,
isActive = false,
isDragOverlay = false,
isDropTarget = false
}) => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
const {
attributes,
listeners,
@@ -37,6 +40,7 @@ const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
};
return (
@@ -48,7 +52,7 @@ const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo
{...listeners}
>
<div className="task-content">
<div className="task-title">{task.name}</div>
<div className="task-title" title={task.name}>{task.name}</div>
{/* {task.task_key && <div className="task-key">{task.task_key}</div>} */}
{task.assignees && task.assignees.length > 0 && (
<div className="task-assignees">

View File

@@ -49,12 +49,12 @@ interface EnhancedKanbanState {
groupBy: IGroupBy;
isSubtasksInclude: boolean;
fields: ITaskListSortableColumn[];
// Task data
taskGroups: ITaskListGroup[];
loadingGroups: boolean;
error: string | null;
// Filters
taskAssignees: ITaskListMemberFilter[];
loadingAssignees: boolean;
@@ -63,12 +63,12 @@ interface EnhancedKanbanState {
loadingLabels: boolean;
priorities: string[];
members: string[];
// Performance optimizations
virtualizedRendering: boolean;
taskCache: Record<string, IProjectTask>;
groupCache: Record<string, ITaskListGroup>;
// Performance monitoring
performanceMetrics: {
totalTasks: number;
@@ -78,7 +78,7 @@ interface EnhancedKanbanState {
lastUpdateTime: number;
virtualizationEnabled: boolean;
};
// Drag and drop state
dragState: {
activeTaskId: string | null;
@@ -86,7 +86,7 @@ interface EnhancedKanbanState {
overId: string | null;
isDragging: boolean;
};
// UI state
selectedTaskIds: string[];
expandedSubtasks: Record<string, boolean>;
@@ -137,7 +137,7 @@ const calculatePerformanceMetrics = (taskGroups: ITaskListGroup[]) => {
const groupSizes = taskGroups.map(group => group.tasks.length);
const largestGroupSize = Math.max(...groupSizes, 0);
const averageGroupSize = groupSizes.length > 0 ? totalTasks / groupSizes.length : 0;
return {
totalTasks,
largestGroupSize,
@@ -234,6 +234,35 @@ export const reorderEnhancedKanbanTasks = createAsyncThunk(
}
);
// Group reordering
export const reorderEnhancedKanbanGroups = createAsyncThunk(
'enhancedKanban/reorderGroups',
async (
{
fromIndex,
toIndex,
reorderedGroups,
}: {
fromIndex: number;
toIndex: number;
reorderedGroups: ITaskListGroup[];
},
{ rejectWithValue }
) => {
try {
// Optimistic update - return immediately for UI responsiveness
return {
fromIndex,
toIndex,
reorderedGroups,
};
} catch (error) {
logger.error('Reorder Enhanced Kanban Groups', error);
return rejectWithValue('Failed to reorder groups');
}
}
);
const enhancedKanbanSlice = createSlice({
name: 'enhancedKanbanReducer',
initialState,
@@ -327,7 +356,7 @@ const enhancedKanbanSlice = createSlice({
// Status updates
updateTaskStatus: (state, action: PayloadAction<ITaskListStatusChangeResponse>) => {
const { id: task_id, status_id } = action.payload;
// Update in all groups
state.taskGroups.forEach(group => {
group.tasks.forEach(task => {
@@ -342,7 +371,7 @@ const enhancedKanbanSlice = createSlice({
updateTaskPriority: (state, action: PayloadAction<ITaskListPriorityChangeResponse>) => {
const { id: task_id, priority_id } = action.payload;
// Update in all groups
state.taskGroups.forEach(group => {
group.tasks.forEach(task => {
@@ -358,12 +387,12 @@ const enhancedKanbanSlice = createSlice({
// Task deletion
deleteTask: (state, action: PayloadAction<string>) => {
const taskId = action.payload;
// Remove from all groups
state.taskGroups.forEach(group => {
group.tasks = group.tasks.filter(task => task.id !== taskId);
});
// Remove from caches
delete state.taskCache[taskId];
state.selectedTaskIds = state.selectedTaskIds.filter(id => id !== taskId);
@@ -383,10 +412,10 @@ const enhancedKanbanSlice = createSlice({
.addCase(fetchEnhancedKanbanGroups.fulfilled, (state, action) => {
state.loadingGroups = false;
state.taskGroups = action.payload;
// Update performance metrics
state.performanceMetrics = calculatePerformanceMetrics(action.payload);
// Update caches
action.payload.forEach(group => {
state.groupCache[group.id] = group;
@@ -394,7 +423,7 @@ const enhancedKanbanSlice = createSlice({
state.taskCache[task.id!] = task;
});
});
// Initialize column order if not set
if (state.columnOrder.length === 0) {
state.columnOrder = action.payload.map(group => group.id);
@@ -406,20 +435,33 @@ const enhancedKanbanSlice = createSlice({
})
.addCase(reorderEnhancedKanbanTasks.fulfilled, (state, action) => {
const { activeGroupId, overGroupId, updatedSourceTasks, updatedTargetTasks } = action.payload;
// Update groups
const sourceGroupIndex = state.taskGroups.findIndex(group => group.id === activeGroupId);
const targetGroupIndex = state.taskGroups.findIndex(group => group.id === overGroupId);
if (sourceGroupIndex !== -1) {
state.taskGroups[sourceGroupIndex].tasks = updatedSourceTasks;
state.groupCache[activeGroupId] = state.taskGroups[sourceGroupIndex];
}
if (targetGroupIndex !== -1 && activeGroupId !== overGroupId) {
state.taskGroups[targetGroupIndex].tasks = updatedTargetTasks;
state.groupCache[overGroupId] = state.taskGroups[targetGroupIndex];
}
})
.addCase(reorderEnhancedKanbanGroups.fulfilled, (state, action) => {
const { fromIndex, toIndex, reorderedGroups } = action.payload;
// Update groups
state.taskGroups = reorderedGroups;
state.groupCache = reorderedGroups.reduce((cache, group) => {
cache[group.id] = group;
return cache;
}, {} as Record<string, ITaskListGroup>);
// Update column order
state.columnOrder = reorderedGroups.map(group => group.id);
});
},
});