diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx
index 2e79c42c..fbd3ba43 100644
--- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx
+++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/info-tab-footer.tsx
@@ -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,
diff --git a/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx b/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx
index 2c80df82..878f7c90 100644
--- a/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx
+++ b/worklenz-frontend/src/pages/home/task-list/add-task-inline-form.tsx
@@ -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));
diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/table-v2.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/table-v2.tsx
index a613b25e..9d6b836c 100644
--- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/table-v2.tsx
+++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/table-v2.tsx
@@ -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 (
+
onRowHover(task.id)}
+ onMouseLeave={() => onRowHover(null)}
+ >
+
+ {!isSubtask && (
+
+
+
+ )}
+
+ {visibleColumns.map(column => (
+
+ {renderCell(column.key, task, isSubtask)}
+
+ ))}
+
+ );
+};
const TaskListTable = ({
taskListGroup,
+ tableId,
visibleColumns,
onTaskSelect,
onTaskExpand,
@@ -18,11 +116,38 @@ const TaskListTable = ({
onTaskExpand?: (taskId: string) => void;
}) => {
const [hoverRow, setHoverRow] = useState(null);
+ const [activeId, setActiveId] = useState(null);
const tableRef = useRef(null);
const parentRef = useRef(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: () => (
-
+
{task.name}
),
@@ -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 (
-
- {TableHeader}
+ // Find active task for drag overlay
+ const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null;
-
+
+ {TableHeader}
+
+
task.id)} strategy={verticalListSortingStrategy}>
+
+ {flattenedTasks.map((task, index) => (
+
+ ))}
+
+
+
+
+
- {rowVirtualizer.getVirtualItems().map(virtualRow => {
- const task = flattenedTasks[virtualRow.index];
- return (
-
-
- {/* */}
-
- {visibleColumns.map(column => (
-
- {renderCell(column.key, task, task.is_sub_task)}
-
- ))}
-
- );
- })}
-
-
+ {activeTask && (
+
+ {}}
+ isSubtask={activeTask.isSubtask}
+ />
+
+ )}
+
+
);
};
diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx
index e2ca07f2..da30ca2f 100644
--- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx
+++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-custom.tsx
@@ -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 = ({ 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 = ({ tasks, color, groupId,
flex: 1,
minHeight: 0,
overflowX: 'auto',
+ overflowY: 'auto',
maxHeight: '100%',
}}
>
@@ -161,80 +148,75 @@ const TaskListCustom: React.FC = ({ tasks, color, groupId,
))}
- {paddingTop > 0 &&
}
- {virtualRows.map(virtualRow => {
- const row = rows[virtualRow.index];
- return (
-
-
- {row.getVisibleCells().map((cell, index) => (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- ))}
-
- {expandedRows[row.id] &&
- row.original.sub_tasks?.map(subTask => (
-
- {columns.map((col, index) => (
-
- {flexRender(col.cell, {
- getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
- row: { original: subTask } as Row,
- column: col as Column,
- table,
- })}
-
- ))}
-
- ))}
-
- );
- })}
- {paddingBottom > 0 &&
}
+ {rows.map(row => (
+
+
+ {row.getVisibleCells().map((cell, index) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ {expandedRows[row.id] &&
+ row.original.sub_tasks?.map(subTask => (
+
+ {columns.map((col, index) => (
+
+ {flexRender(col.cell, {
+ getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
+ row: { original: subTask } as Row,
+ column: col as Column,
+ table,
+ })}
+
+ ))}
+
+ ))}
+
+ ))}
diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx
index f3149767..26f01be7 100644
--- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx
+++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list-table-wrapper/task-list-table-wrapper.tsx
@@ -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 (
),
},
diff --git a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx
index 727a510b..a5ad9f85 100644
--- a/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx
+++ b/worklenz-frontend/src/pages/projects/project-view-1/task-list/task-list.tsx
@@ -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 = () => {
- {/* {taskGroups.map((group: ITaskListGroup) => (
-
- ))} */}
+ {taskGroups.map((group: ITaskListGroup) => (
+
+ ))}
);