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:
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
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 headerTextColor = getContrastColor(headerBackgroundColor);
|
||||
|
||||
// Make the group header droppable
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: group.id,
|
||||
data: {
|
||||
type: 'group',
|
||||
group,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<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={{
|
||||
backgroundColor: headerBackgroundColor,
|
||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
||||
color: headerTextColor,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { GroupedVirtuoso } from 'react-virtuoso';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
useSensors,
|
||||
KeyboardSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
@@ -27,6 +29,8 @@ import {
|
||||
selectSelectedPriorities,
|
||||
selectSearch,
|
||||
fetchTasksV3,
|
||||
reorderTasksInGroup,
|
||||
moveTaskBetweenGroups,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
selectCurrentGrouping,
|
||||
@@ -57,7 +61,7 @@ import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||
// Base column configuration
|
||||
const BASE_COLUMNS = [
|
||||
{ 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: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
|
||||
{ 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
|
||||
const visibleColumns = useMemo(() => {
|
||||
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;
|
||||
// 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);
|
||||
return field?.visible ?? false;
|
||||
});
|
||||
@@ -176,6 +180,42 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
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 { active, over } = event;
|
||||
setActiveId(null);
|
||||
@@ -184,47 +224,115 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
// Find the active task
|
||||
const activeTask = allTasks.find(task => task.id === active.id);
|
||||
const activeTask = allTasks.find(task => task.id === activeId);
|
||||
if (!activeTask) {
|
||||
console.error('Active task not found:', active.id);
|
||||
console.error('Active task not found:', activeId);
|
||||
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
|
||||
// Find the groups
|
||||
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');
|
||||
if (!activeGroup) {
|
||||
console.error('Could not find active group for task:', activeId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate new positions
|
||||
const activeIndex = allTasks.findIndex(task => task.id === active.id);
|
||||
const overIndex = allTasks.findIndex(task => task.id === over.id);
|
||||
// Check if we're dropping on a task or a group
|
||||
const overTask = allTasks.find(task => task.id === overId);
|
||||
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:', {
|
||||
activeId: active.id,
|
||||
overId: over.id,
|
||||
activeIndex,
|
||||
overIndex,
|
||||
activeId,
|
||||
overId,
|
||||
activeTask: activeTask.name || activeTask.title,
|
||||
activeGroup: activeGroup.id,
|
||||
overGroup: overGroup.id,
|
||||
targetGroup: targetGroup.id,
|
||||
activeIndex,
|
||||
insertIndex,
|
||||
isCrossGroup,
|
||||
});
|
||||
|
||||
// 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
|
||||
if (isCrossGroup) {
|
||||
// Moving task between groups
|
||||
console.log('Moving task between groups:', {
|
||||
task: activeTask.name || activeTask.title,
|
||||
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]);
|
||||
|
||||
@@ -233,7 +341,14 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
let currentTaskIndex = 0;
|
||||
return groups.map(group => {
|
||||
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 => ({
|
||||
...task,
|
||||
originalIndex: allTasks.indexOf(task),
|
||||
@@ -258,20 +373,55 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
return virtuosoGroups.flatMap(group => group.tasks);
|
||||
}, [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
|
||||
const renderGroup = useCallback((groupIndex: number) => {
|
||||
const group = virtuosoGroups[groupIndex];
|
||||
const isGroupEmpty = group.count === 0;
|
||||
|
||||
return (
|
||||
<TaskGroupHeader
|
||||
group={{
|
||||
id: group.id,
|
||||
name: group.title,
|
||||
count: group.count,
|
||||
color: group.color,
|
||||
}}
|
||||
isCollapsed={collapsedGroups.has(group.id)}
|
||||
onToggle={() => handleGroupCollapse(group.id)}
|
||||
/>
|
||||
<div>
|
||||
<TaskGroupHeader
|
||||
group={{
|
||||
id: group.id,
|
||||
name: group.title,
|
||||
count: group.count,
|
||||
color: group.color,
|
||||
}}
|
||||
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]);
|
||||
|
||||
@@ -289,19 +439,12 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
// Log data for debugging
|
||||
console.log('Rendering with:', {
|
||||
groups,
|
||||
allTasks,
|
||||
virtuosoGroups,
|
||||
virtuosoGroupCounts,
|
||||
virtuosoItems,
|
||||
});
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<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 */}
|
||||
<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' ? (
|
||||
<HolderOutlined className="text-gray-400" />
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{columnHeaders}
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SortableContext
|
||||
items={virtuosoItems.map(task => task.id)}
|
||||
items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<GroupedVirtuoso
|
||||
@@ -372,13 +488,23 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
</div>
|
||||
|
||||
{/* Drag Overlay */}
|
||||
<DragOverlay>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{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 className="bg-white dark:bg-gray-800 shadow-xl rounded-md border-2 border-blue-400 opacity-95">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<HolderOutlined className="text-blue-500" />
|
||||
<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>
|
||||
) : null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
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 { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
import Avatar from '@/components/Avatar';
|
||||
@@ -11,6 +11,8 @@ 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';
|
||||
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: Task;
|
||||
@@ -30,16 +32,20 @@ const getTaskDisplayName = (task: Task): string => {
|
||||
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
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
data: {
|
||||
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),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
}), [transform, transition, isDragging]);
|
||||
|
||||
// Convert Task to IProjectTask format for AssigneeSelector compatibility
|
||||
const convertTaskToProjectTask = (task: Task) => {
|
||||
// Get dark mode from Redux state
|
||||
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 {
|
||||
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
|
||||
visibleLabels,
|
||||
remainingCount: remainingCount > 0 ? remainingCount : null,
|
||||
};
|
||||
};
|
||||
}, [task.labels]);
|
||||
|
||||
const renderColumn = (columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
const baseStyle = {
|
||||
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,
|
||||
// }
|
||||
// : {}),
|
||||
};
|
||||
const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
const baseStyle = { width };
|
||||
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return (
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing flex items-center justify-center"
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing flex items-center justify-center"
|
||||
style={baseStyle}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
@@ -114,7 +165,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
return (
|
||||
<div className="flex items-center" style={baseStyle}>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{getTaskDisplayName(task)}
|
||||
{taskDisplayName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -124,10 +175,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
<div style={baseStyle}>
|
||||
<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)',
|
||||
color: task.statusColor || 'rgb(31, 41, 55)',
|
||||
}}
|
||||
style={statusStyle}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
@@ -137,20 +185,16 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
case 'assignees':
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={baseStyle}>
|
||||
{/* Show existing assignee avatars */}
|
||||
{
|
||||
<AvatarGroup
|
||||
members={task.assignee_names || []}
|
||||
maxCount={3}
|
||||
isDarkMode={document.documentElement.classList.contains('dark')}
|
||||
size={24}
|
||||
/>
|
||||
}
|
||||
{/* Add AssigneeSelector for adding/managing assignees */}
|
||||
<AvatarGroup
|
||||
members={task.assignee_names || []}
|
||||
maxCount={3}
|
||||
isDarkMode={isDarkMode}
|
||||
size={24}
|
||||
/>
|
||||
<AssigneeSelector
|
||||
task={convertTaskToProjectTask(task)}
|
||||
task={convertedTask}
|
||||
groupId={null}
|
||||
isDarkMode={document.documentElement.classList.contains('dark')}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -160,12 +204,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
<div style={baseStyle}>
|
||||
<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)',
|
||||
color: task.priorityColor || 'rgb(31, 41, 55)',
|
||||
}}
|
||||
style={priorityStyle}
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
@@ -175,9 +214,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
case 'dueDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.dueDate && (
|
||||
{formattedDueDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.dueDate), 'MMM d')}
|
||||
{formattedDueDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -186,21 +225,33 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
case 'progress':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{task.progress !== undefined &&
|
||||
task.progress >= 0 &&
|
||||
(task.progress === 100 ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<CheckCircleOutlined
|
||||
className="text-green-500"
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
color: '#52c41a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<TaskProgress
|
||||
progress={task.progress}
|
||||
numberOfSubTasks={task.sub_tasks?.length || 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'labels':
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={baseStyle}>
|
||||
{task.labels?.slice(0, 2).map((label, index) => (
|
||||
{labelsDisplay?.visibleLabels.map((label, index) => (
|
||||
<span
|
||||
key={index}
|
||||
key={`${label.id}-${index}`}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: label.color ? `${label.color}20` : 'rgb(229, 231, 235)',
|
||||
@@ -210,9 +261,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(task.labels?.length || 0) > 2 && (
|
||||
{labelsDisplay?.remainingCount && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
+{task.labels!.length - 2}
|
||||
+{labelsDisplay.remainingCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -256,9 +307,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
case 'startDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.startDate && (
|
||||
{formattedStartDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.startDate), 'MMM d')}
|
||||
{formattedStartDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -267,9 +318,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
case 'completedDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.completedAt && (
|
||||
{formattedCompletedDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.completedAt), 'MMM d')}
|
||||
{formattedCompletedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -278,9 +329,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
case 'createdDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.created_at && (
|
||||
{formattedCreatedDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.created_at), 'MMM d')}
|
||||
{formattedCreatedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -289,9 +340,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
case 'lastUpdated':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.updatedAt && (
|
||||
{formattedUpdatedDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.updatedAt), 'MMM d')}
|
||||
{formattedUpdatedDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -309,10 +360,33 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
default:
|
||||
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 (
|
||||
<div
|
||||
<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 ${
|
||||
@@ -324,6 +398,8 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
TaskRow.displayName = 'TaskRow';
|
||||
|
||||
export default TaskRow;
|
||||
|
||||
@@ -223,6 +223,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
// Log raw response for debugging
|
||||
console.log('Raw API response:', response.body);
|
||||
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
|
||||
const tasks = response.body.allTasks.map((task: any) => {
|
||||
@@ -232,6 +233,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
task_key: task.task_key || task.key || '',
|
||||
title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
|
||||
description: task.description || '',
|
||||
status: task.status || 'todo',
|
||||
@@ -327,7 +329,7 @@ export const fetchSubTasks = createAsyncThunk(
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
archived: false,
|
||||
group: currentGrouping,
|
||||
group: currentGrouping || '',
|
||||
field: '',
|
||||
order: '',
|
||||
search: '',
|
||||
@@ -644,7 +646,7 @@ const taskManagementSlice = createSlice({
|
||||
} else {
|
||||
// Set empty state but don't show error
|
||||
state.ids = [];
|
||||
state.entities = {};
|
||||
state.entities = {} as Record<string, Task>;
|
||||
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.';
|
||||
// Clear task data on error to prevent stale state
|
||||
state.ids = [];
|
||||
state.entities = {};
|
||||
state.entities = {} as Record<string, Task>;
|
||||
state.groups = [];
|
||||
})
|
||||
.addCase(fetchSubTasks.pending, (state, action) => {
|
||||
|
||||
Reference in New Issue
Block a user