feat(task-management): enhance task list with drag-and-drop functionality and improved styling

- Integrated drag-and-drop capabilities using @dnd-kit for task reordering within the TaskListV2 component.
- Updated TaskGroupHeader to dynamically adjust colors based on group properties, improving visual clarity.
- Refactored TaskRow to support drag-and-drop interactions and optimized rendering of task details.
- Enhanced Redux state management for collapsed groups, transitioning from Set to array for better compatibility.
- Improved task display logic to handle optional fields and ensure consistent rendering across components.
This commit is contained in:
chamikaJ
2025-07-03 17:34:31 +05:30
parent d15c00c29b
commit edf051adc7
7 changed files with 437 additions and 187 deletions

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { getContrastColor } from '@/utils/colorUtils';
interface TaskGroupHeaderProps {
group: {
@@ -13,40 +14,50 @@ interface TaskGroupHeaderProps {
}
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
const headerTextColor = getContrastColor(headerBackgroundColor);
return (
<div
className="flex items-center px-4 py-2 bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700"
className="flex items-center px-4 py-2 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700"
style={{
backgroundColor: headerBackgroundColor,
color: headerTextColor,
position: 'sticky',
top: 0,
zIndex: 20 // Higher than sticky columns (zIndex: 1) and column headers (zIndex: 2)
}}
onClick={onToggle}
>
{/* Chevron button */}
<button
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
className="p-1 rounded-md hover:bg-opacity-20 transition-colors"
style={{ backgroundColor: headerBackgroundColor, color: headerTextColor, borderColor: headerTextColor, border: '1px solid' }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
{isCollapsed ? (
<ChevronRightIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
<ChevronRightIcon className="h-4 w-4" style={{ color: headerTextColor }} />
) : (
<ChevronDownIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
)}
</button>
{/* Group indicator and name */}
<div className="ml-2 flex items-center gap-3 flex-1">
{/* Color indicator */}
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: group.color || '#94A3B8' }}
/>
{/* Color indicator (removed as full header is colored) */}
{/* Group name and count */}
<div className="flex items-center justify-between flex-1">
<span className="text-sm font-medium text-gray-900 dark:text-white">
<span className="text-sm font-medium">
{group.name}
</span>
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full">
<span
className="text-xs font-medium px-2 py-0.5 rounded-full"
style={{ backgroundColor: getContrastColor(headerTextColor) === '#000000' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.2)', color: headerTextColor }}
>
{group.count}
</span>
</div>

View File

@@ -1,5 +1,21 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { GroupedVirtuoso } from 'react-virtuoso';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
KeyboardSensor,
TouchSensor,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
@@ -15,7 +31,6 @@ import {
import {
selectCurrentGrouping,
selectCollapsedGroups,
selectIsGroupCollapsed,
toggleGroupCollapsed,
} from '@/features/task-management/grouping.slice';
import {
@@ -36,6 +51,7 @@ import { TaskListField } from '@/types/task-list-field.types';
import { useParams } from 'react-router-dom';
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
import { Bars3Icon } from '@heroicons/react/24/outline';
import { HolderOutlined } from '@ant-design/icons';
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
// Base column configuration
@@ -75,10 +91,33 @@ interface TaskListV2Props {
const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
const dispatch = useAppDispatch();
const { projectId: urlProjectId } = useParams();
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
// Drag and drop state
const [activeId, setActiveId] = useState<string | null>(null);
// Configure sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
// Using Redux state for collapsedGroups instead of local state
const collapsedGroups = useAppSelector(selectCollapsedGroups);
// Selectors
const tasks = useAppSelector(selectAllTasksArray);
const allTasks = useAppSelector(selectAllTasksArray); // Renamed to allTasks for clarity
const groups = useAppSelector(selectGroups);
const grouping = useAppSelector(selectGrouping);
const loading = useAppSelector(selectLoading);
@@ -114,7 +153,7 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
if (event.ctrlKey || event.metaKey) {
dispatch(toggleTaskSelection(taskId));
} else if (event.shiftKey && lastSelectedTaskId) {
const taskIds = tasks.map(t => t.id);
const taskIds = allTasks.map(t => t.id); // Use allTasks here
const startIdx = taskIds.indexOf(lastSelectedTaskId);
const endIdx = taskIds.indexOf(taskId);
const rangeIds = taskIds.slice(
@@ -126,58 +165,126 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
dispatch(clearSelection());
dispatch(selectTask(taskId));
}
}, [dispatch, lastSelectedTaskId, tasks]);
}, [dispatch, lastSelectedTaskId, allTasks]);
const handleGroupCollapse = useCallback((groupId: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
}, [dispatch]);
// Drag and drop handlers
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
// Memoized values
const groupCounts = useMemo(() => {
return groups.map(group => {
const visibleTasks = tasks.filter(task => group.taskIds.includes(task.id));
return visibleTasks.length;
});
}, [groups, tasks]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
const visibleGroups = useMemo(() => {
return groups.filter(group => !collapsedGroups.has(group.id));
}, [groups, collapsedGroups]);
if (!over || active.id === over.id) {
return;
}
// Find the active task
const activeTask = allTasks.find(task => task.id === active.id);
if (!activeTask) {
console.error('Active task not found:', active.id);
return;
}
// Find which group the task is being moved to
const overTask = allTasks.find(task => task.id === over.id);
if (!overTask) {
console.error('Over task not found:', over.id);
return;
}
// Find the groups for both tasks
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (!activeGroup || !overGroup) {
console.error('Could not find groups for tasks');
return;
}
// Calculate new positions
const activeIndex = allTasks.findIndex(task => task.id === active.id);
const overIndex = allTasks.findIndex(task => task.id === over.id);
console.log('Drag operation:', {
activeId: active.id,
overId: over.id,
activeIndex,
overIndex,
activeGroup: activeGroup.id,
overGroup: overGroup.id,
});
// TODO: Implement the actual reordering logic
// This would typically involve:
// 1. Updating the task order in Redux
// 2. Sending the update to the backend
// 3. Optimistic UI updates
}, [allTasks, groups]);
// Memoized values for GroupedVirtuoso
const virtuosoGroups = useMemo(() => {
let currentTaskIndex = 0;
return groups.map(group => {
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
const visibleTasksInGroup = isCurrentGroupCollapsed ? [] : allTasks.filter(task => group.taskIds.includes(task.id));
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
...task,
originalIndex: allTasks.indexOf(task),
}));
const groupData = {
...group,
tasks: tasksForVirtuoso,
startIndex: currentTaskIndex,
count: tasksForVirtuoso.length,
};
currentTaskIndex += tasksForVirtuoso.length;
return groupData;
});
}, [groups, allTasks, collapsedGroups]);
const virtuosoGroupCounts = useMemo(() => {
return virtuosoGroups.map(group => group.count);
}, [virtuosoGroups]);
const virtuosoItems = useMemo(() => {
return virtuosoGroups.flatMap(group => group.tasks);
}, [virtuosoGroups]);
// Render functions
const renderGroup = useCallback((groupIndex: number) => {
const group = groups[groupIndex];
const group = virtuosoGroups[groupIndex];
return (
<TaskGroupHeader
group={{
id: group.id,
name: group.title,
count: groupCounts[groupIndex],
count: group.count,
color: group.color,
}}
isCollapsed={collapsedGroups.has(group.id)}
onToggle={() => handleGroupCollapse(group.id)}
/>
);
}, [groups, groupCounts, collapsedGroups, handleGroupCollapse]);
}, [virtuosoGroups, collapsedGroups, handleGroupCollapse]);
const renderTask = useCallback((taskIndex: number) => {
const task = tasks[taskIndex];
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
if (!task) return null; // Should not happen if logic is correct
return (
<TaskRow
task={task}
visibleColumns={visibleColumns}
/>
);
}, [tasks, visibleColumns]);
}, [virtuosoItems, visibleColumns]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
@@ -185,79 +292,99 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
// Log data for debugging
console.log('Rendering with:', {
groups,
tasks,
groupCounts
allTasks,
virtuosoGroups,
virtuosoGroupCounts,
virtuosoItems,
});
return (
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
{/* Task Filters */}
<div className="flex-none px-4 py-3">
<ImprovedTaskFilters position="list" />
</div>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
{/* Task Filters */}
<div className="flex-none px-4 py-3">
<ImprovedTaskFilters position="list" />
</div>
{/* Column Headers */}
<div className="overflow-x-auto">
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="flex items-center min-w-max px-4 py-2">
{visibleColumns.map((column, index) => {
const columnStyle: ColumnStyle = {
width: column.width,
...(column.isSticky ? {
position: 'sticky',
left: index === 0 ? 0 : index === 1 ? 32 : 132,
backgroundColor: 'inherit',
zIndex: 2,
} : {}),
};
{/* Column Headers */}
<div className="overflow-x-auto">
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="flex items-center min-w-max px-4 py-2">
{visibleColumns.map((column, index) => {
const columnStyle: ColumnStyle = {
width: column.width,
// Removed sticky functionality to prevent overlap with group headers
// ...(column.isSticky ? {
// position: 'sticky',
// left: index === 0 ? 0 : index === 1 ? 32 : 132,
// backgroundColor: 'inherit',
// zIndex: 2,
// } : {}),
};
return (
<div
key={column.id}
className="text-xs font-medium text-gray-500 dark:text-gray-400"
style={columnStyle}
>
{column.id === 'dragHandle' ? (
<Bars3Icon className="w-4 h-4 text-gray-400" />
) : (
column.label
)}
</div>
);
})}
return (
<div
key={column.id}
className="text-xs font-medium text-gray-500 dark:text-gray-400"
style={columnStyle}
>
{column.id === 'dragHandle' ? (
<HolderOutlined className="text-gray-400" />
) : (
column.label
)}
</div>
);
})}
</div>
</div>
{/* Task List */}
<div className="flex-1 overflow-hidden">
<SortableContext
items={virtuosoItems.map(task => task.id)}
strategy={verticalListSortingStrategy}
>
<GroupedVirtuoso
style={{ height: 'calc(100vh - 200px)' }}
groupCounts={virtuosoGroupCounts}
groupContent={renderGroup}
itemContent={renderTask}
components={{
// Removed custom Group component as TaskGroupHeader now handles stickiness
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
<div
ref={ref}
style={style || {}}
className="virtuoso-list-container" // Add a class for potential debugging/styling
>
{children}
</div>
)),
}}
/>
</SortableContext>
</div>
</div>
{/* Task List */}
<div className="flex-1 overflow-hidden">
<GroupedVirtuoso
style={{ height: 'calc(100vh - 200px)' }}
groupCounts={groupCounts}
groupContent={renderGroup}
itemContent={renderTask}
components={{
Group: ({ children, ...props }) => (
<div
{...props}
className="sticky top-0 z-10 bg-white dark:bg-gray-800"
>
{children}
</div>
),
List: React.forwardRef(({ style, children }, ref) => (
<div
ref={ref as any}
style={style}
className="divide-y divide-gray-200 dark:divide-gray-700"
>
{children}
</div>
)),
}}
/>
</div>
{/* Drag Overlay */}
<DragOverlay>
{activeId ? (
<div className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-300 opacity-90">
<div className="px-4 py-2">
<span className="text-sm font-medium">
{allTasks.find(task => task.id === activeId)?.name || 'Task'}
</span>
</div>
</div>
) : null}
</DragOverlay>
</div>
</div>
</DndContext>
);
};

View File

@@ -1,9 +1,16 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { HolderOutlined } from '@ant-design/icons';
import { Task } from '@/types/task-management.types';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import Avatar from '@/components/Avatar';
import AssigneeSelector from '@/components/AssigneeSelector';
import { format } from 'date-fns';
import { Bars3Icon } from '@heroicons/react/24/outline';
import { ClockIcon } from '@heroicons/react/24/outline';
import AvatarGroup from '../AvatarGroup';
import { DEFAULT_TASK_NAME } from '@/shared/constants';
interface TaskRowProps {
task: Task;
@@ -14,49 +21,100 @@ interface TaskRowProps {
}>;
}
// Utility function to get task display name with fallbacks
const getTaskDisplayName = (task: Task): string => {
// Check each field and only use if it has actual content after trimming
if (task.title && task.title.trim()) return task.title.trim();
if (task.name && task.name.trim()) return task.name.trim();
if (task.task_key && task.task_key.trim()) return task.task_key.trim();
return DEFAULT_TASK_NAME;
};
const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
// Drag and drop functionality
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: task.id,
data: {
type: 'task',
task,
},
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
// Convert Task to IProjectTask format for AssigneeSelector compatibility
const convertTaskToProjectTask = (task: Task) => {
return {
id: task.id,
name: getTaskDisplayName(task),
task_key: task.task_key || getTaskDisplayName(task),
assignees:
task.assignee_names?.map((assignee: InlineMember, index: number) => ({
team_member_id: assignee.team_member_id || `assignee-${index}`,
id: assignee.team_member_id || `assignee-${index}`,
project_member_id: assignee.team_member_id || `assignee-${index}`,
name: assignee.name || '',
})) || [],
parent_task_id: task.parent_task_id,
// Add other required fields with defaults
status_id: undefined,
project_id: undefined,
manual_progress: undefined, // Required field
};
};
const renderColumn = (columnId: string, width: string, isSticky?: boolean, index?: number) => {
const baseStyle = {
width,
...(isSticky ? {
position: 'sticky' as const,
left: index === 0 ? 0 : index === 1 ? 32 : 132,
backgroundColor: 'inherit',
zIndex: 1,
} : {}),
// Removed sticky functionality to prevent overlap with group headers
// ...(isSticky
// ? {
// position: 'sticky' as const,
// left: index === 0 ? 0 : index === 1 ? 32 : 132,
// backgroundColor: 'inherit',
// zIndex: 1,
// }
// : {}),
};
switch (columnId) {
case 'dragHandle':
return (
<div
className="cursor-move flex items-center justify-center"
className="cursor-grab active:cursor-grabbing flex items-center justify-center"
style={baseStyle}
{...attributes}
{...listeners}
>
<Bars3Icon className="w-4 h-4 text-gray-400" />
<HolderOutlined className="text-gray-400 hover:text-gray-600" />
</div>
);
case 'taskKey':
return (
<div
className="flex items-center"
style={baseStyle}
>
<div className="flex items-center" style={baseStyle}>
<span className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
{task.task_key}
{task.task_key || 'N/A'}
</span>
</div>
);
case 'title':
return (
<div
className="flex items-center"
style={baseStyle}
>
<div className="flex items-center" style={baseStyle}>
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
{task.title || task.name}
{getTaskDisplayName(task)}
</span>
</div>
);
@@ -64,7 +122,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'status':
return (
<div style={baseStyle}>
<span
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
@@ -79,29 +137,33 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'assignees':
return (
<div className="flex items-center gap-1" style={baseStyle}>
{task.assignee_names?.slice(0, 3).map((assignee, index) => (
<Avatar
key={index}
name={assignee.name || ''}
size="small"
className="ring-2 ring-white dark:ring-gray-900"
{/* Show existing assignee avatars */}
{
<AvatarGroup
members={task.assignee_names || []}
maxCount={3}
isDarkMode={document.documentElement.classList.contains('dark')}
size={24}
/>
))}
{(task.assignee_names?.length || 0) > 3 && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
+{task.assignee_names!.length - 3}
</span>
)}
}
{/* Add AssigneeSelector for adding/managing assignees */}
<AssigneeSelector
task={convertTaskToProjectTask(task)}
groupId={null}
isDarkMode={document.documentElement.classList.contains('dark')}
/>
</div>
);
case 'priority':
return (
<div style={baseStyle}>
<span
<span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: task.priorityColor ? `${task.priorityColor}20` : 'rgb(229, 231, 235)',
backgroundColor: task.priorityColor
? `${task.priorityColor}20`
: 'rgb(229, 231, 235)',
color: task.priorityColor || 'rgb(31, 41, 55)',
}}
>
@@ -183,7 +245,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'estimation':
return (
<div style={baseStyle}>
{task.timeTracking.estimated && (
{task.timeTracking?.estimated && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{task.timeTracking.estimated}h
</span>
@@ -216,9 +278,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'createdDate':
return (
<div style={baseStyle}>
{task.createdAt && (
{task.created_at && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{format(new Date(task.createdAt), 'MMM d')}
{format(new Date(task.created_at), 'MMM d')}
</span>
)}
</div>
@@ -239,9 +301,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
return (
<div style={baseStyle}>
{task.reporter && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{task.reporter}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{task.reporter}</span>
)}
</div>
);
@@ -252,10 +312,18 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
};
return (
<div className="flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800">
{visibleColumns.map((column, index) => renderColumn(column.id, column.width, column.isSticky, index))}
<div
ref={setNodeRef}
style={style}
className={`flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 ${
isDragging ? 'shadow-lg border border-blue-300' : ''
}`}
>
{visibleColumns.map((column, index) =>
renderColumn(column.id, column.width, column.isSticky, index)
)}
</div>
);
};
export default TaskRow;
export default TaskRow;