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

- Integrated droppable areas in TaskGroupHeader for better task organization.
- Implemented drag-over visual feedback to indicate valid drop zones.
- Enhanced TaskListV2 to handle cross-group task movement and reordering.
- Optimized TaskRow for better rendering performance and added memoization for task details.
- Improved Redux state management for task reordering and group handling.
This commit is contained in:
chamikaJ
2025-07-03 18:02:00 +05:30
parent edf051adc7
commit 6b7f412341
4 changed files with 399 additions and 182 deletions

View File

@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useDroppable } from '@dnd-kit/core';
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { getContrastColor } from '@/utils/colorUtils'; import { getContrastColor } from '@/utils/colorUtils';
@@ -17,11 +18,23 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
const headerTextColor = getContrastColor(headerBackgroundColor); const headerTextColor = getContrastColor(headerBackgroundColor);
// Make the group header droppable
const { isOver, setNodeRef } = useDroppable({
id: group.id,
data: {
type: 'group',
group,
},
});
return ( return (
<div <div
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" ref={setNodeRef}
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 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
style={{ style={{
backgroundColor: headerBackgroundColor, backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
color: headerTextColor, color: headerTextColor,
position: 'sticky', position: 'sticky',
top: 0, top: 0,

View File

@@ -3,6 +3,7 @@ import { GroupedVirtuoso } from 'react-virtuoso';
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
DragOverEvent,
DragOverlay, DragOverlay,
DragStartEvent, DragStartEvent,
PointerSensor, PointerSensor,
@@ -10,6 +11,7 @@ import {
useSensors, useSensors,
KeyboardSensor, KeyboardSensor,
TouchSensor, TouchSensor,
closestCenter,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { import {
SortableContext, SortableContext,
@@ -27,6 +29,8 @@ import {
selectSelectedPriorities, selectSelectedPriorities,
selectSearch, selectSearch,
fetchTasksV3, fetchTasksV3,
reorderTasksInGroup,
moveTaskBetweenGroups,
} from '@/features/task-management/task-management.slice'; } from '@/features/task-management/task-management.slice';
import { import {
selectCurrentGrouping, selectCurrentGrouping,
@@ -57,7 +61,7 @@ import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
// Base column configuration // Base column configuration
const BASE_COLUMNS = [ const BASE_COLUMNS = [
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' }, { id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
{ id: 'taskKey', label: 'Key', width: '100px', isSticky: true, key: COLUMN_KEYS.KEY }, { id: 'taskKey', label: 'Key', width: '100px', key: COLUMN_KEYS.KEY },
{ id: 'title', label: 'Title', width: '300px', isSticky: true, key: COLUMN_KEYS.NAME }, { id: 'title', label: 'Title', width: '300px', isSticky: true, key: COLUMN_KEYS.NAME },
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS }, { id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
{ id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, { id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
@@ -133,9 +137,9 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
// Filter visible columns based on fields // Filter visible columns based on fields
const visibleColumns = useMemo(() => { const visibleColumns = useMemo(() => {
return BASE_COLUMNS.filter(column => { return BASE_COLUMNS.filter(column => {
// Always show drag handle, task key, and title // Always show drag handle and title (sticky columns)
if (column.isSticky) return true; if (column.isSticky) return true;
// Check if field is visible // Check if field is visible for all other columns (including task key)
const field = fields.find(f => f.key === column.key); const field = fields.find(f => f.key === column.key);
return field?.visible ?? false; return field?.visible ?? false;
}); });
@@ -176,6 +180,42 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
setActiveId(event.active.id as string); setActiveId(event.active.id as string);
}, []); }, []);
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) return;
// Check if we're dragging over a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
let targetGroup = overGroup;
if (overTask) {
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
}
if (!activeGroup || !targetGroup) return;
// If dragging to a different group, we need to handle cross-group movement
if (activeGroup.id !== targetGroup.id) {
console.log('Cross-group drag detected:', {
activeTask: activeTask.id,
fromGroup: activeGroup.id,
toGroup: targetGroup.id,
});
}
}, [allTasks, groups]);
const handleDragEnd = useCallback((event: DragEndEvent) => { const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
setActiveId(null); setActiveId(null);
@@ -184,47 +224,115 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
return; return;
} }
const activeId = active.id;
const overId = over.id;
// Find the active task // Find the active task
const activeTask = allTasks.find(task => task.id === active.id); const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) { if (!activeTask) {
console.error('Active task not found:', active.id); console.error('Active task not found:', activeId);
return; return;
} }
// Find which group the task is being moved to // Find the groups
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 activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
const overGroup = groups.find(group => group.taskIds.includes(overTask.id)); if (!activeGroup) {
console.error('Could not find active group for task:', activeId);
if (!activeGroup || !overGroup) {
console.error('Could not find groups for tasks');
return; return;
} }
// Calculate new positions // Check if we're dropping on a task or a group
const activeIndex = allTasks.findIndex(task => task.id === active.id); const overTask = allTasks.find(task => task.id === overId);
const overIndex = allTasks.findIndex(task => task.id === over.id); const overGroup = groups.find(group => group.id === overId);
let targetGroup = overGroup;
let insertIndex = 0;
if (overTask) {
// Dropping on a task
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (targetGroup) {
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
}
} else if (overGroup) {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
}
if (!targetGroup) {
console.error('Could not find target group');
return;
}
const isCrossGroup = activeGroup.id !== targetGroup.id;
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
console.log('Drag operation:', { console.log('Drag operation:', {
activeId: active.id, activeId,
overId: over.id, overId,
activeIndex, activeTask: activeTask.name || activeTask.title,
overIndex,
activeGroup: activeGroup.id, activeGroup: activeGroup.id,
overGroup: overGroup.id, targetGroup: targetGroup.id,
activeIndex,
insertIndex,
isCrossGroup,
}); });
// TODO: Implement the actual reordering logic if (isCrossGroup) {
// This would typically involve: // Moving task between groups
// 1. Updating the task order in Redux console.log('Moving task between groups:', {
// 2. Sending the update to the backend task: activeTask.name || activeTask.title,
// 3. Optimistic UI updates from: activeGroup.title,
to: targetGroup.title,
newPosition: insertIndex,
});
// Move task to the target group
dispatch(moveTaskBetweenGroups({
taskId: activeId as string,
sourceGroupId: activeGroup.id,
targetGroupId: targetGroup.id,
}));
// If we need to insert at a specific position (not at the end)
if (insertIndex < targetGroup.taskIds.length) {
const newTaskIds = [...targetGroup.taskIds];
// Remove the task if it was already added at the end
const taskIndex = newTaskIds.indexOf(activeId as string);
if (taskIndex > -1) {
newTaskIds.splice(taskIndex, 1);
}
// Insert at the correct position
newTaskIds.splice(insertIndex, 0, activeId as string);
dispatch(reorderTasksInGroup({
taskIds: newTaskIds,
groupId: targetGroup.id,
}));
}
} else {
// Reordering within the same group
console.log('Reordering task within same group:', {
task: activeTask.name || activeTask.title,
group: activeGroup.title,
from: activeIndex,
to: insertIndex,
});
if (activeIndex !== insertIndex) {
const newTaskIds = [...activeGroup.taskIds];
// Remove task from old position
newTaskIds.splice(activeIndex, 1);
// Insert at new position
newTaskIds.splice(insertIndex, 0, activeId as string);
dispatch(reorderTasksInGroup({
taskIds: newTaskIds,
groupId: activeGroup.id,
}));
}
}
}, [allTasks, groups]); }, [allTasks, groups]);
@@ -233,7 +341,14 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
let currentTaskIndex = 0; let currentTaskIndex = 0;
return groups.map(group => { return groups.map(group => {
const isCurrentGroupCollapsed = collapsedGroups.has(group.id); const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
const visibleTasksInGroup = isCurrentGroupCollapsed ? [] : allTasks.filter(task => group.taskIds.includes(task.id));
// Order tasks according to group.taskIds array to maintain proper order
const visibleTasksInGroup = isCurrentGroupCollapsed
? []
: group.taskIds
.map(taskId => allTasks.find(task => task.id === taskId))
.filter((task): task is Task => task !== undefined); // Type guard to filter out undefined tasks
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({ const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
...task, ...task,
originalIndex: allTasks.indexOf(task), originalIndex: allTasks.indexOf(task),
@@ -258,20 +373,55 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
return virtuosoGroups.flatMap(group => group.tasks); return virtuosoGroups.flatMap(group => group.tasks);
}, [virtuosoGroups]); }, [virtuosoGroups]);
// Memoize column headers to prevent unnecessary re-renders
const columnHeaders = useMemo(() => (
<div className="flex items-center min-w-max px-4 py-2">
{visibleColumns.map((column) => {
const columnStyle: ColumnStyle = {
width: column.width,
};
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>
), [visibleColumns]);
// Render functions // Render functions
const renderGroup = useCallback((groupIndex: number) => { const renderGroup = useCallback((groupIndex: number) => {
const group = virtuosoGroups[groupIndex]; const group = virtuosoGroups[groupIndex];
const isGroupEmpty = group.count === 0;
return ( return (
<TaskGroupHeader <div>
group={{ <TaskGroupHeader
id: group.id, group={{
name: group.title, id: group.id,
count: group.count, name: group.title,
color: group.color, count: group.count,
}} color: group.color,
isCollapsed={collapsedGroups.has(group.id)} }}
onToggle={() => handleGroupCollapse(group.id)} isCollapsed={collapsedGroups.has(group.id)}
/> onToggle={() => handleGroupCollapse(group.id)}
/>
{/* Empty group drop zone */}
{isGroupEmpty && !collapsedGroups.has(group.id) && (
<div className="px-4 py-8 text-center text-gray-400 dark:text-gray-500 border-2 border-dashed border-transparent hover:border-blue-300 transition-colors">
<div className="text-sm">Drop tasks here</div>
</div>
)}
</div>
); );
}, [virtuosoGroups, collapsedGroups, handleGroupCollapse]); }, [virtuosoGroups, collapsedGroups, handleGroupCollapse]);
@@ -289,19 +439,12 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
if (loading) return <div>Loading...</div>; if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>; if (error) return <div>Error: {error}</div>;
// Log data for debugging
console.log('Rendering with:', {
groups,
allTasks,
virtuosoGroups,
virtuosoGroupCounts,
virtuosoItems,
});
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="flex flex-col h-screen bg-white dark:bg-gray-900"> <div className="flex flex-col h-screen bg-white dark:bg-gray-900">
@@ -313,40 +456,13 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
{/* Column Headers */} {/* Column Headers */}
<div className="overflow-x-auto"> <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-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"> {columnHeaders}
{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' ? (
<HolderOutlined className="text-gray-400" />
) : (
column.label
)}
</div>
);
})}
</div>
</div> </div>
{/* Task List */} {/* Task List */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<SortableContext <SortableContext
items={virtuosoItems.map(task => task.id)} items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<GroupedVirtuoso <GroupedVirtuoso
@@ -372,13 +488,23 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
</div> </div>
{/* Drag Overlay */} {/* Drag Overlay */}
<DragOverlay> <DragOverlay dropAnimation={null}>
{activeId ? ( {activeId ? (
<div className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-300 opacity-90"> <div className="bg-white dark:bg-gray-800 shadow-xl rounded-md border-2 border-blue-400 opacity-95">
<div className="px-4 py-2"> <div className="px-4 py-3">
<span className="text-sm font-medium"> <div className="flex items-center gap-3">
{allTasks.find(task => task.id === activeId)?.name || 'Task'} <HolderOutlined className="text-blue-500" />
</span> <div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{allTasks.find(task => task.id === activeId)?.name ||
allTasks.find(task => task.id === activeId)?.title ||
'Task'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{allTasks.find(task => task.id === activeId)?.task_key}
</div>
</div>
</div>
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React, { memo, useMemo, useCallback } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { HolderOutlined } from '@ant-design/icons'; import { CheckCircleOutlined, HolderOutlined } from '@ant-design/icons';
import { Task } from '@/types/task-management.types'; import { Task } from '@/types/task-management.types';
import { InlineMember } from '@/types/teamMembers/inlineMember.types'; import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import Avatar from '@/components/Avatar'; import Avatar from '@/components/Avatar';
@@ -11,6 +11,8 @@ import { Bars3Icon } from '@heroicons/react/24/outline';
import { ClockIcon } from '@heroicons/react/24/outline'; import { ClockIcon } from '@heroicons/react/24/outline';
import AvatarGroup from '../AvatarGroup'; import AvatarGroup from '../AvatarGroup';
import { DEFAULT_TASK_NAME } from '@/shared/constants'; import { DEFAULT_TASK_NAME } from '@/shared/constants';
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
import { useAppSelector } from '@/hooks/useAppSelector';
interface TaskRowProps { interface TaskRowProps {
task: Task; task: Task;
@@ -30,16 +32,20 @@ const getTaskDisplayName = (task: Task): string => {
return DEFAULT_TASK_NAME; return DEFAULT_TASK_NAME;
}; };
const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => { // Memoized date formatter to avoid repeated date parsing
const formatDate = (dateString: string): string => {
try {
return format(new Date(dateString), 'MMM d');
} catch {
return '';
}
};
// Memoized date formatter to avoid repeated date parsing
const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
// Drag and drop functionality // Drag and drop functionality
const { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: task.id, id: task.id,
data: { data: {
type: 'task', type: 'task',
@@ -47,52 +53,97 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
}, },
}); });
const style = { // Memoize style object to prevent unnecessary re-renders
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
}; }), [transform, transition, isDragging]);
// Convert Task to IProjectTask format for AssigneeSelector compatibility // Get dark mode from Redux state
const convertTaskToProjectTask = (task: Task) => { const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDarkMode = themeMode === 'dark';
// Memoize task display name
const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]);
// Memoize converted task for AssigneeSelector to prevent recreation
const convertedTask = useMemo(() => ({
id: task.id,
name: taskDisplayName,
task_key: task.task_key || taskDisplayName,
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,
status_id: undefined,
project_id: undefined,
manual_progress: undefined,
}), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
// Memoize formatted dates
const formattedDueDate = useMemo(() =>
task.dueDate ? formatDate(task.dueDate) : null,
[task.dueDate]
);
const formattedStartDate = useMemo(() =>
task.startDate ? formatDate(task.startDate) : null,
[task.startDate]
);
const formattedCompletedDate = useMemo(() =>
task.completedAt ? formatDate(task.completedAt) : null,
[task.completedAt]
);
const formattedCreatedDate = useMemo(() =>
task.created_at ? formatDate(task.created_at) : null,
[task.created_at]
);
const formattedUpdatedDate = useMemo(() =>
task.updatedAt ? formatDate(task.updatedAt) : null,
[task.updatedAt]
);
// Memoize status style
const statusStyle = useMemo(() => ({
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
color: task.statusColor || 'rgb(31, 41, 55)',
}), [task.statusColor]);
// Memoize priority style
const priorityStyle = useMemo(() => ({
backgroundColor: task.priorityColor ? `${task.priorityColor}20` : 'rgb(229, 231, 235)',
color: task.priorityColor || 'rgb(31, 41, 55)',
}), [task.priorityColor]);
// Memoize labels display
const labelsDisplay = useMemo(() => {
if (!task.labels || task.labels.length === 0) return null;
const visibleLabels = task.labels.slice(0, 2);
const remainingCount = task.labels.length - 2;
return { return {
id: task.id, visibleLabels,
name: getTaskDisplayName(task), remainingCount: remainingCount > 0 ? remainingCount : null,
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
}; };
}; }, [task.labels]);
const renderColumn = (columnId: string, width: string, isSticky?: boolean, index?: number) => { const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
const baseStyle = { const baseStyle = { width };
width,
// 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) { switch (columnId) {
case 'dragHandle': case 'dragHandle':
return ( return (
<div <div
className="cursor-grab active:cursor-grabbing flex items-center justify-center" className="cursor-grab active:cursor-grabbing flex items-center justify-center"
style={baseStyle} style={baseStyle}
{...attributes} {...attributes}
{...listeners} {...listeners}
@@ -114,7 +165,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
return ( 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"> <span className="text-sm text-gray-700 dark:text-gray-300 truncate">
{getTaskDisplayName(task)} {taskDisplayName}
</span> </span>
</div> </div>
); );
@@ -124,10 +175,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
<div style={baseStyle}> <div style={baseStyle}>
<span <span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{ style={statusStyle}
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
color: task.statusColor || 'rgb(31, 41, 55)',
}}
> >
{task.status} {task.status}
</span> </span>
@@ -137,20 +185,16 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'assignees': case 'assignees':
return ( return (
<div className="flex items-center gap-1" style={baseStyle}> <div className="flex items-center gap-1" style={baseStyle}>
{/* Show existing assignee avatars */} <AvatarGroup
{ members={task.assignee_names || []}
<AvatarGroup maxCount={3}
members={task.assignee_names || []} isDarkMode={isDarkMode}
maxCount={3} size={24}
isDarkMode={document.documentElement.classList.contains('dark')} />
size={24}
/>
}
{/* Add AssigneeSelector for adding/managing assignees */}
<AssigneeSelector <AssigneeSelector
task={convertTaskToProjectTask(task)} task={convertedTask}
groupId={null} groupId={null}
isDarkMode={document.documentElement.classList.contains('dark')} isDarkMode={isDarkMode}
/> />
</div> </div>
); );
@@ -160,12 +204,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
<div style={baseStyle}> <div style={baseStyle}>
<span <span
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{ style={priorityStyle}
backgroundColor: task.priorityColor
? `${task.priorityColor}20`
: 'rgb(229, 231, 235)',
color: task.priorityColor || 'rgb(31, 41, 55)',
}}
> >
{task.priority} {task.priority}
</span> </span>
@@ -175,9 +214,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'dueDate': case 'dueDate':
return ( return (
<div style={baseStyle}> <div style={baseStyle}>
{task.dueDate && ( {formattedDueDate && (
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">
{format(new Date(task.dueDate), 'MMM d')} {formattedDueDate}
</span> </span>
)} )}
</div> </div>
@@ -186,21 +225,33 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'progress': case 'progress':
return ( return (
<div style={baseStyle}> <div style={baseStyle}>
<div className="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700"> {task.progress !== undefined &&
<div task.progress >= 0 &&
className="bg-blue-600 h-2 rounded-full" (task.progress === 100 ? (
style={{ width: `${task.progress}%` }} <div className="flex items-center justify-center">
/> <CheckCircleOutlined
</div> className="text-green-500"
style={{
fontSize: '20px',
color: '#52c41a',
}}
/>
</div>
) : (
<TaskProgress
progress={task.progress}
numberOfSubTasks={task.sub_tasks?.length || 0}
/>
))}
</div> </div>
); );
case 'labels': case 'labels':
return ( return (
<div className="flex items-center gap-1" style={baseStyle}> <div className="flex items-center gap-1" style={baseStyle}>
{task.labels?.slice(0, 2).map((label, index) => ( {labelsDisplay?.visibleLabels.map((label, index) => (
<span <span
key={index} key={`${label.id}-${index}`}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium" className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{ style={{
backgroundColor: label.color ? `${label.color}20` : 'rgb(229, 231, 235)', backgroundColor: label.color ? `${label.color}20` : 'rgb(229, 231, 235)',
@@ -210,9 +261,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
{label.name} {label.name}
</span> </span>
))} ))}
{(task.labels?.length || 0) > 2 && ( {labelsDisplay?.remainingCount && (
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
+{task.labels!.length - 2} +{labelsDisplay.remainingCount}
</span> </span>
)} )}
</div> </div>
@@ -256,9 +307,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'startDate': case 'startDate':
return ( return (
<div style={baseStyle}> <div style={baseStyle}>
{task.startDate && ( {formattedStartDate && (
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">
{format(new Date(task.startDate), 'MMM d')} {formattedStartDate}
</span> </span>
)} )}
</div> </div>
@@ -267,9 +318,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'completedDate': case 'completedDate':
return ( return (
<div style={baseStyle}> <div style={baseStyle}>
{task.completedAt && ( {formattedCompletedDate && (
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">
{format(new Date(task.completedAt), 'MMM d')} {formattedCompletedDate}
</span> </span>
)} )}
</div> </div>
@@ -278,9 +329,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'createdDate': case 'createdDate':
return ( return (
<div style={baseStyle}> <div style={baseStyle}>
{task.created_at && ( {formattedCreatedDate && (
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">
{format(new Date(task.created_at), 'MMM d')} {formattedCreatedDate}
</span> </span>
)} )}
</div> </div>
@@ -289,9 +340,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
case 'lastUpdated': case 'lastUpdated':
return ( return (
<div style={baseStyle}> <div style={baseStyle}>
{task.updatedAt && ( {formattedUpdatedDate && (
<span className="text-sm text-gray-500 dark:text-gray-400"> <span className="text-sm text-gray-500 dark:text-gray-400">
{format(new Date(task.updatedAt), 'MMM d')} {formattedUpdatedDate}
</span> </span>
)} )}
</div> </div>
@@ -309,10 +360,33 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
default: default:
return null; return null;
} }
}; }, [
attributes,
listeners,
task.task_key,
task.status,
task.priority,
task.phase,
task.reporter,
task.assignee_names,
task.timeTracking,
task.progress,
task.sub_tasks,
taskDisplayName,
statusStyle,
priorityStyle,
formattedDueDate,
formattedStartDate,
formattedCompletedDate,
formattedCreatedDate,
formattedUpdatedDate,
labelsDisplay,
isDarkMode,
convertedTask,
]);
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 ${ className={`flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 ${
@@ -324,6 +398,8 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
)} )}
</div> </div>
); );
}; });
TaskRow.displayName = 'TaskRow';
export default TaskRow; export default TaskRow;

View File

@@ -223,6 +223,7 @@ export const fetchTasksV3 = createAsyncThunk(
// Log raw response for debugging // Log raw response for debugging
console.log('Raw API response:', response.body); console.log('Raw API response:', response.body);
console.log('Sample task from backend:', response.body.allTasks?.[0]); console.log('Sample task from backend:', response.body.allTasks?.[0]);
console.log('Task key from backend:', response.body.allTasks?.[0]?.task_key);
// Ensure tasks are properly normalized // Ensure tasks are properly normalized
const tasks = response.body.allTasks.map((task: any) => { const tasks = response.body.allTasks.map((task: any) => {
@@ -232,6 +233,7 @@ export const fetchTasksV3 = createAsyncThunk(
return { return {
id: task.id, id: task.id,
task_key: task.task_key || task.key || '',
title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME, title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
description: task.description || '', description: task.description || '',
status: task.status || 'todo', status: task.status || 'todo',
@@ -327,7 +329,7 @@ export const fetchSubTasks = createAsyncThunk(
const config: ITaskListConfigV2 = { const config: ITaskListConfigV2 = {
id: projectId, id: projectId,
archived: false, archived: false,
group: currentGrouping, group: currentGrouping || '',
field: '', field: '',
order: '', order: '',
search: '', search: '',
@@ -644,7 +646,7 @@ const taskManagementSlice = createSlice({
} else { } else {
// Set empty state but don't show error // Set empty state but don't show error
state.ids = []; state.ids = [];
state.entities = {}; state.entities = {} as Record<string, Task>;
state.groups = []; state.groups = [];
} }
}) })
@@ -654,7 +656,7 @@ const taskManagementSlice = createSlice({
state.error = action.error.message || action.payload || 'An error occurred while fetching tasks. Please try again.'; state.error = action.error.message || action.payload || 'An error occurred while fetching tasks. Please try again.';
// Clear task data on error to prevent stale state // Clear task data on error to prevent stale state
state.ids = []; state.ids = [];
state.entities = {}; state.entities = {} as Record<string, Task>;
state.groups = []; state.groups = [];
}) })
.addCase(fetchSubTasks.pending, (state, action) => { .addCase(fetchSubTasks.pending, (state, action) => {