feat(task-list): implement drag-and-drop functionality for task reordering

- Integrated drag-and-drop capabilities in the task list using `@dnd-kit` for improved user experience.
- Created a `DraggableRow` component to handle individual task dragging and dropping.
- Updated task list rendering to support dynamic reordering and socket integration for backend persistence.
- Enhanced task selection and hover effects for better visual feedback during drag operations.
- Refactored task list components to streamline rendering and improve performance.
This commit is contained in:
chamikaJ
2025-06-09 09:58:28 +05:30
parent de28f87c62
commit 520888988e
6 changed files with 406 additions and 170 deletions

View File

@@ -97,30 +97,28 @@ const InfoTabFooter = () => {
// mentions options
const mentionsOptions =
members?.map(member => ({
value: member.id,
value: member.name,
label: member.name,
key: member.id,
})) ?? [];
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
console.log('member', member);
if (!member?.value || !member?.label) return;
// Find the member ID from the members list using the name
const selectedMember = members.find(m => m.name === member.value);
if (!selectedMember) return;
// Add to selected members if not already present
setSelectedMembers(prev =>
prev.some(mention => mention.team_member_id === member.value)
prev.some(mention => mention.team_member_id === selectedMember.id)
? prev
: [...prev, { team_member_id: member.value, name: member.label }]
: [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }]
);
setCommentValue(prev => {
const parts = prev.split('@');
const lastPart = parts[parts.length - 1];
const mentionText = member.label;
// Keep only the part before the @ and add the new mention
return prev.slice(0, prev.length - lastPart.length) + mentionText;
});
}, []);
}, [members]);
const handleCommentChange = useCallback((value: string) => {
// Only update the value without trying to replace mentions
setCommentValue(value);
setCharacterLength(value.trim().length);
}, []);
@@ -275,6 +273,12 @@ const InfoTabFooter = () => {
maxLength={5000}
onClick={() => setIsCommentBoxExpand(true)}
onChange={e => setCharacterLength(e.length)}
prefix="@"
filterOption={(input, option) => {
if (!input) return true;
const optionLabel = (option as any)?.label || '';
return optionLabel.toLowerCase().includes(input.toLowerCase());
}}
style={{
minHeight: 60,
resize: 'none',
@@ -371,7 +375,11 @@ const InfoTabFooter = () => {
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
onChange={handleCommentChange}
prefix="@"
split=""
filterOption={(input, option) => {
if (!input) return true;
const optionLabel = (option as any)?.label || '';
return optionLabel.toLowerCase().includes(input.toLowerCase());
}}
style={{
minHeight: 100,
maxHeight: 200,

View File

@@ -57,20 +57,31 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
},
];
const calculateEndDate = (dueDate: string): Date | undefined => {
const calculateEndDate = (dueDate: string): string | undefined => {
const today = new Date();
let targetDate: Date;
switch (dueDate) {
case 'Today':
return today;
targetDate = new Date(today);
break;
case 'Tomorrow':
return new Date(today.setDate(today.getDate() + 1));
targetDate = new Date(today);
targetDate.setDate(today.getDate() + 1);
break;
case 'Next Week':
return new Date(today.setDate(today.getDate() + 7));
targetDate = new Date(today);
targetDate.setDate(today.getDate() + 7);
break;
case 'Next Month':
return new Date(today.setMonth(today.getMonth() + 1));
targetDate = new Date(today);
targetDate.setMonth(today.getMonth() + 1);
break;
default:
return undefined;
}
return targetDate.toISOString().split('T')[0]; // Returns YYYY-MM-DD format
};
const projectOptions = [
@@ -82,12 +93,16 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
];
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
const newTask: IHomeTaskCreateRequest = {
const endDate = calendarView
? homeTasksConfig.selected_date?.format('YYYY-MM-DD')
: calculateEndDate(values.dueDate);
const newTask = {
name: values.name,
project_id: values.project,
reporter_id: currentSession?.id,
team_id: currentSession?.team_id,
end_date: (calendarView ? homeTasksConfig.selected_date?.format('YYYY-MM-DD') : calculateEndDate(values.dueDate)),
end_date: endDate || new Date().toISOString().split('T')[0], // Fallback to today if undefined
};
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask));

View File

@@ -1,12 +1,110 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
import { useVirtualizer } from '@tanstack/react-virtual';
import { HolderOutlined } from '@ant-design/icons';
import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
KeyboardSensor,
TouchSensor,
UniqueIdentifier,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { SocketEvents } from '@/shared/socket-events';
import { reorderTasks } from '@/features/tasks/tasks.slice';
import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
// Draggable Row Component
interface DraggableRowProps {
task: IProjectTask;
visibleColumns: Array<{ key: string; width: number }>;
renderCell: (columnKey: string | number, task: IProjectTask, isSubtask?: boolean) => React.ReactNode;
hoverRow: string | null;
onRowHover: (taskId: string | null) => void;
isSubtask?: boolean;
}
const DraggableRow = ({
task,
visibleColumns,
renderCell,
hoverRow,
onRowHover,
isSubtask = false
}: DraggableRowProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: task.id as UniqueIdentifier,
data: {
type: 'task',
task,
},
disabled: isSubtask, // Disable drag for subtasks
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
};
return (
<div
ref={setNodeRef}
style={style}
className="flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
onMouseEnter={() => onRowHover(task.id)}
onMouseLeave={() => onRowHover(null)}
>
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
{!isSubtask && (
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
<HolderOutlined />
</div>
)}
</div>
{visibleColumns.map(column => (
<div
key={column.key}
className={`flex items-center px-3 border-r ${
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
}`}
style={{ width: column.width }}
>
{renderCell(column.key, task, isSubtask)}
</div>
))}
</div>
);
};
const TaskListTable = ({
taskListGroup,
tableId,
visibleColumns,
onTaskSelect,
onTaskExpand,
@@ -18,11 +116,38 @@ const TaskListTable = ({
onTaskExpand?: (taskId: string) => void;
}) => {
const [hoverRow, setHoverRow] = useState<string | null>(null);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const tableRef = useRef<HTMLDivElement | null>(null);
const parentRef = useRef<HTMLDivElement | null>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
// Memoize all tasks including subtasks for virtualization
// 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,
},
})
);
// Memoize all tasks including subtasks
const flattenedTasks = useMemo(() => {
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
acc.push(task);
@@ -33,13 +158,10 @@ const TaskListTable = ({
}, []);
}, [taskListGroup.tasks]);
// Virtual row renderer
const rowVirtualizer = useVirtualizer({
count: flattenedTasks.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 42, // row height
overscan: 5,
});
// Get only main tasks for sortable context (exclude subtasks)
const mainTasks = useMemo(() => {
return taskListGroup.tasks.filter(task => !task.isSubtask);
}, [taskListGroup.tasks]);
// Memoize cell render functions
const renderCell = useCallback(
@@ -54,7 +176,7 @@ const TaskListTable = ({
);
},
task: () => (
<Flex align="center" className="pl-2">
<Flex align="center" className={isSubtask ? "pl-6" : "pl-2"}>
{task.name}
</Flex>
),
@@ -66,6 +188,77 @@ const TaskListTable = ({
[]
);
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id);
document.body.style.cursor = 'grabbing';
}, []);
// Handle drag end with socket integration
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
document.body.style.cursor = '';
if (!over || active.id === over.id) {
return;
}
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
const overIndex = mainTasks.findIndex(task => task.id === over.id);
if (activeIndex !== -1 && overIndex !== -1) {
const activeTask = mainTasks[activeIndex];
const overTask = mainTasks[overIndex];
// Create updated task arrays
const updatedTasks = [...mainTasks];
updatedTasks.splice(activeIndex, 1);
updatedTasks.splice(overIndex, 0, activeTask);
// Dispatch Redux action for optimistic update
dispatch(reorderTasks({
activeGroupId: tableId,
overGroupId: tableId,
fromIndex: activeIndex,
toIndex: overIndex,
task: activeTask,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
}));
// Emit socket event for backend persistence
if (socket && projectId && currentSession?.team_id) {
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: activeTask.sort_order,
to_index: toPos,
to_last_index: overIndex === mainTasks.length - 1,
from_group: tableId,
to_group: tableId,
group_by: groupBy,
task: activeTask,
team_id: currentSession.team_id,
});
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
}
}
}, [
mainTasks,
tableId,
dispatch,
socket,
projectId,
currentSession?.team_id,
groupBy,
trackMixpanelEvent
]);
// Memoize header rendering
const TableHeader = useMemo(
() => (
@@ -94,48 +287,55 @@ const TaskListTable = ({
target.classList.toggle('show-shadow', hasHorizontalShadow);
}, []);
return (
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
{TableHeader}
// Find active task for drag overlay
const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null;
<div
ref={tableRef}
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
{TableHeader}
<SortableContext items={mainTasks.map(task => task.id)} strategy={verticalListSortingStrategy}>
<div ref={tableRef} style={{ width: '100%' }}>
{flattenedTasks.map((task, index) => (
<DraggableRow
key={task.id}
task={task}
visibleColumns={visibleColumns}
renderCell={renderCell}
hoverRow={hoverRow}
onRowHover={setHoverRow}
isSubtask={task.isSubtask}
/>
))}
</div>
</SortableContext>
</div>
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const task = flattenedTasks[virtualRow.index];
return (
<div
key={task.id}
className="absolute top-0 left-0 flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
style={{
height: 42,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
{/* <Checkbox checked={task.selected} /> */}
</div>
{visibleColumns.map(column => (
<div
key={column.key}
className={`flex items-center px-3 border-r ${
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
}`}
style={{ width: column.width }}
>
{renderCell(column.key, task, task.is_sub_task)}
</div>
))}
</div>
);
})}
</div>
</div>
{activeTask && (
<div className="bg-white dark:bg-gray-800 shadow-lg rounded border">
<DraggableRow
task={activeTask}
visibleColumns={visibleColumns}
renderCell={renderCell}
hoverRow={null}
onRowHover={() => {}}
isSubtask={activeTask.isSubtask}
/>
</div>
)}
</DragOverlay>
</DndContext>
);
};

View File

@@ -10,7 +10,6 @@ import {
Row,
Column,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import React from 'react';
@@ -78,19 +77,6 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 50,
overscan: 20,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
const columnToggleItems = columns.map(column => ({
key: column.id as string,
label: (
@@ -125,6 +111,7 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
flex: 1,
minHeight: 0,
overflowX: 'auto',
overflowY: 'auto',
maxHeight: '100%',
}}
>
@@ -161,80 +148,75 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
))}
</div>
<div className="table-body">
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />}
{virtualRows.map(virtualRow => {
const row = rows[virtualRow.index];
return (
<React.Fragment key={row.id}>
<div
className="table-row"
style={{
'&:hover div': {
background: `${token.colorFillAlter} !important`,
},
}}
>
{row.getVisibleCells().map((cell, index) => (
<div
key={cell.id}
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
style={{
width: cell.column.getSize(),
position: index < 2 ? 'sticky' : 'relative',
left: 'auto',
background: token.colorBgContainer,
color: token.colorText,
height: '42px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
borderRight: `1px solid ${token.colorBorderSecondary}`,
padding: '8px 0px 8px 8px',
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
{expandedRows[row.id] &&
row.original.sub_tasks?.map(subTask => (
<div
key={subTask.task_key}
className="table-row"
style={{
'&:hover div': {
background: `${token.colorFillAlter} !important`,
},
}}
>
{columns.map((col, index) => (
<div
key={`${subTask.task_key}-${col.id}`}
style={{
width: col.getSize(),
position: index < 2 ? 'sticky' : 'relative',
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
background: token.colorBgContainer,
color: token.colorText,
height: '42px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
borderRight: `1px solid ${token.colorBorderSecondary}`,
paddingLeft: index === 3 ? '32px' : '8px',
paddingRight: '8px',
}}
>
{flexRender(col.cell, {
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
row: { original: subTask } as Row<IProjectTask>,
column: col as Column<IProjectTask>,
table,
})}
</div>
))}
</div>
))}
</React.Fragment>
);
})}
{paddingBottom > 0 && <div style={{ height: `${paddingBottom}px` }} />}
{rows.map(row => (
<React.Fragment key={row.id}>
<div
className="table-row"
style={{
'&:hover div': {
background: `${token.colorFillAlter} !important`,
},
}}
>
{row.getVisibleCells().map((cell, index) => (
<div
key={cell.id}
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
style={{
width: cell.column.getSize(),
position: index < 2 ? 'sticky' : 'relative',
left: 'auto',
background: token.colorBgContainer,
color: token.colorText,
height: '42px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
borderRight: `1px solid ${token.colorBorderSecondary}`,
padding: '8px 0px 8px 8px',
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
{expandedRows[row.id] &&
row.original.sub_tasks?.map(subTask => (
<div
key={subTask.task_key}
className="table-row"
style={{
'&:hover div': {
background: `${token.colorFillAlter} !important`,
},
}}
>
{columns.map((col, index) => (
<div
key={`${subTask.task_key}-${col.id}`}
style={{
width: col.getSize(),
position: index < 2 ? 'sticky' : 'relative',
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
background: token.colorBgContainer,
color: token.colorText,
height: '42px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
borderRight: `1px solid ${token.colorBorderSecondary}`,
paddingLeft: index === 3 ? '32px' : '8px',
paddingRight: '8px',
}}
>
{flexRender(col.cell, {
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
row: { original: subTask } as Row<IProjectTask>,
column: col as Column<IProjectTask>,
table,
})}
</div>
))}
</div>
))}
</React.Fragment>
))}
</div>
</div>
</div>

View File

@@ -4,12 +4,12 @@ import { TaskType } from '@/types/task.types';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import './task-list-table-wrapper.css';
import TaskListTable from '../task-list-table-old/task-list-table-old';
import TaskListTable from '../table-v2';
import { MenuProps } from 'antd/lib';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import TaskListCustom from '../task-list-custom';
import { columnList as defaultColumnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
type TaskListTableWrapperProps = {
taskList: ITaskListGroup;
@@ -37,6 +37,22 @@ const TaskListTableWrapper = ({
// localization
const { t } = useTranslation('task-list-table');
// Get column visibility from Redux
const columnVisibilityList = useAppSelector(
state => state.projectViewTaskListColumnsReducer.columnList
);
// Filter visible columns and format them for table-v2
const visibleColumns = defaultColumnList
.filter(column => {
const visibilityConfig = columnVisibilityList.find(col => col.key === column.key);
return visibilityConfig?.isVisible ?? false;
})
.map(column => ({
key: column.key,
width: column.width,
}));
// function to handle toggle expand
const handlToggleExpand = () => {
setIsExpanded(!isExpanded);
@@ -98,6 +114,14 @@ const TaskListTableWrapper = ({
},
];
const handleTaskSelect = (taskId: string) => {
console.log('Task selected:', taskId);
};
const handleTaskExpand = (taskId: string) => {
console.log('Task expanded:', taskId);
};
return (
<ConfigProvider
wave={{ disabled: true }}
@@ -172,11 +196,13 @@ const TaskListTableWrapper = ({
key: groupId || '1',
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
children: (
<TaskListCustom
<TaskListTable
key={groupId}
groupId={groupId}
tasks={taskList.tasks}
color={color || ''}
taskListGroup={taskList}
tableId={groupId || ''}
visibleColumns={visibleColumns}
onTaskSelect={handleTaskSelect}
onTaskExpand={handleTaskExpand}
/>
),
},

View File

@@ -6,9 +6,7 @@ import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types'
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchTaskGroups } from '@/features/tasks/taskSlice';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
import StatusGroupTables from '../taskList/statusTables/StatusGroupTables';
import TaskListTableWrapper from './task-list-table-wrapper/task-list-table-wrapper';
const TaskList = () => {
const dispatch = useAppDispatch();
@@ -31,6 +29,7 @@ const TaskList = () => {
const onTaskExpand = (taskId: string) => {
console.log('taskId:', taskId);
};
useEffect(() => {
if (projectId) {
const config: ITaskListConfigV2 = {
@@ -54,9 +53,15 @@ const TaskList = () => {
<Flex vertical gap={16}>
<TaskListFilters position="list" />
<Skeleton active loading={loadingGroups}>
{/* {taskGroups.map((group: ITaskListGroup) => (
))} */}
{taskGroups.map((group: ITaskListGroup) => (
<TaskListTableWrapper
key={group.id}
taskList={group}
groupId={group.id}
name={group.name}
color={group.color_code}
/>
))}
</Skeleton>
</Flex>
);