diff --git a/worklenz-backend/src/controllers/tasks-controller-base.ts b/worklenz-backend/src/controllers/tasks-controller-base.ts index d2524bad..58558c1e 100644 --- a/worklenz-backend/src/controllers/tasks-controller-base.ts +++ b/worklenz-backend/src/controllers/tasks-controller-base.ts @@ -50,11 +50,16 @@ export default class TasksControllerBase extends WorklenzControllerBase { task.progress = parseInt(task.progress_value); task.complete_ratio = parseInt(task.progress_value); } - // For tasks with no subtasks and no manual progress, calculate based on time + // For tasks with no subtasks and no manual progress else { - task.progress = task.total_minutes_spent && task.total_minutes - ? ~~(task.total_minutes_spent / task.total_minutes * 100) - : 0; + // Only calculate progress based on time if time-based progress is enabled for the project + if (task.project_use_time_progress && task.total_minutes_spent && task.total_minutes) { + // Cap the progress at 100% to prevent showing more than 100% progress + task.progress = Math.min(~~(task.total_minutes_spent / task.total_minutes * 100), 100); + } else { + // Default to 0% progress when time-based calculation is not enabled + task.progress = 0; + } // Set complete_ratio to match progress task.complete_ratio = task.progress; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 6e01c686..10c556d3 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -610,6 +610,21 @@ export default class TasksControllerV2 extends TasksControllerBase { return this.createTagList(result.rows); } + public static async getProjectSubscribers(projectId: string) { + const q = ` + SELECT u.name, u.avatar_url, ps.user_id, ps.team_member_id, ps.project_id + FROM project_subscribers ps + LEFT JOIN users u ON ps.user_id = u.id + WHERE ps.project_id = $1; + `; + const result = await db.query(q, [projectId]); + + for (const member of result.rows) + member.color_code = getColor(member.name); + + return this.createTagList(result.rows); + } + public static async checkUserAssignedToTask(taskId: string, userId: string, teamId: string) { const q = ` SELECT EXISTS( diff --git a/worklenz-backend/src/socket.io/commands/on-project-subscriber-change.ts b/worklenz-backend/src/socket.io/commands/on-project-subscriber-change.ts index 6057e88f..bbe90425 100644 --- a/worklenz-backend/src/socket.io/commands/on-project-subscriber-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-project-subscriber-change.ts @@ -19,7 +19,8 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket, const isSubscribe = data.mode == 0; const q = isSubscribe ? `INSERT INTO project_subscribers (user_id, project_id, team_member_id) - VALUES ($1, $2, $3);` + VALUES ($1, $2, $3) + ON CONFLICT (user_id, project_id, team_member_id) DO NOTHING;` : `DELETE FROM project_subscribers WHERE user_id = $1 @@ -27,7 +28,7 @@ export async function on_project_subscriber_change(_io: Server, socket: Socket, AND team_member_id = $3;`; await db.query(q, [data.user_id, data.project_id, data.team_member_id]); - const subscribers = await TasksControllerV2.getTaskSubscribers(data.project_id); + const subscribers = await TasksControllerV2.getProjectSubscribers(data.project_id); socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), subscribers); return; 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/hooks/useTaskDragAndDrop.ts b/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts deleted file mode 100644 index cfadee8a..00000000 --- a/worklenz-frontend/src/hooks/useTaskDragAndDrop.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { useMemo, useCallback } from 'react'; -import { - DndContext, - DragEndEvent, - DragOverEvent, - DragStartEvent, - PointerSensor, - useSensor, - useSensors, - KeyboardSensor, - TouchSensor, -} from '@dnd-kit/core'; -import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; -import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { useAppSelector } from '@/hooks/useAppSelector'; -import { updateTaskStatus } from '@/features/tasks/tasks.slice'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; - -export const useTaskDragAndDrop = () => { - const dispatch = useAppDispatch(); - - // Memoize the selector to prevent unnecessary rerenders - const taskGroups = useAppSelector(state => state.taskReducer.taskGroups); - const groupBy = useAppSelector(state => state.taskReducer.groupBy); - - // Memoize sensors configuration for better performance - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - useSensor(TouchSensor, { - activationConstraint: { - delay: 250, - tolerance: 5, - }, - }) - ); - - const handleDragStart = useCallback((event: DragStartEvent) => { - // Add visual feedback for drag start - const { active } = event; - if (active) { - document.body.style.cursor = 'grabbing'; - } - }, []); - - const handleDragOver = useCallback((event: DragOverEvent) => { - // Handle drag over logic if needed - // This can be used for visual feedback during drag - }, []); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - // Reset cursor - document.body.style.cursor = ''; - - const { active, over } = event; - - if (!active || !over || !taskGroups) { - return; - } - - try { - const activeId = active.id as string; - const overId = over.id as string; - - // Find the task being dragged - let draggedTask: IProjectTask | null = null; - let sourceGroupId: string | null = null; - - for (const group of taskGroups) { - const task = group.tasks?.find((t: IProjectTask) => t.id === activeId); - if (task) { - draggedTask = task; - sourceGroupId = group.id; - break; - } - } - - if (!draggedTask || !sourceGroupId) { - console.warn('Could not find dragged task'); - return; - } - - // Determine target group - let targetGroupId: string | null = null; - - // Check if dropped on a group container - const targetGroup = taskGroups.find((group: ITaskListGroup) => group.id === overId); - if (targetGroup) { - targetGroupId = targetGroup.id; - } else { - // Check if dropped on another task - for (const group of taskGroups) { - const targetTask = group.tasks?.find((t: IProjectTask) => t.id === overId); - if (targetTask) { - targetGroupId = group.id; - break; - } - } - } - - if (!targetGroupId || targetGroupId === sourceGroupId) { - return; // No change needed - } - - // Update task status based on group change - const targetGroupData = taskGroups.find((group: ITaskListGroup) => group.id === targetGroupId); - if (targetGroupData && groupBy === 'status') { - const updatePayload: any = { - task_id: draggedTask.id, - status_id: targetGroupData.id, - }; - - if (draggedTask.parent_task_id) { - updatePayload.parent_task = draggedTask.parent_task_id; - } - - dispatch(updateTaskStatus(updatePayload)); - } - } catch (error) { - console.error('Error handling drag end:', error); - } - }, - [taskGroups, groupBy, dispatch] - ); - - // Memoize the drag and drop configuration - const dragAndDropConfig = useMemo( - () => ({ - sensors, - onDragStart: handleDragStart, - onDragOver: handleDragOver, - onDragEnd: handleDragEnd, - }), - [sensors, handleDragStart, handleDragOver, handleDragEnd] - ); - - return dragAndDropConfig; -}; \ No newline at end of file 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) => ( + + ))} ); diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx index 5b5d32ff..b2a17504 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view-header.tsx @@ -67,6 +67,7 @@ const ProjectViewHeader = () => { const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer); const [creatingTask, setCreatingTask] = useState(false); + const [subscriptionLoading, setSubscriptionLoading] = useState(false); const handleRefresh = () => { if (!projectId) return; @@ -98,17 +99,51 @@ const ProjectViewHeader = () => { }; const handleSubscribe = () => { - if (selectedProject?.id) { + if (!selectedProject?.id || !socket || subscriptionLoading) return; + + try { + setSubscriptionLoading(true); const newSubscriptionState = !selectedProject.subscribed; - dispatch(setProject({ ...selectedProject, subscribed: newSubscriptionState })); - - socket?.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), { + // Emit socket event first, then update state based on response + socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), { project_id: selectedProject.id, user_id: currentSession?.id, team_member_id: currentSession?.team_member_id, - mode: newSubscriptionState ? 1 : 0, + mode: newSubscriptionState ? 0 : 1, // Fixed: 0 for subscribe, 1 for unsubscribe }); + + // Listen for the response to confirm the operation + socket.once(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), (response) => { + try { + // Update the project state with the confirmed subscription status + dispatch(setProject({ + ...selectedProject, + subscribed: newSubscriptionState + })); + } catch (error) { + logger.error('Error handling project subscription response:', error); + // Revert optimistic update on error + dispatch(setProject({ + ...selectedProject, + subscribed: selectedProject.subscribed + })); + } finally { + setSubscriptionLoading(false); + } + }); + + // Add timeout in case socket response never comes + setTimeout(() => { + if (subscriptionLoading) { + setSubscriptionLoading(false); + logger.error('Project subscription timeout - no response from server'); + } + }, 5000); + + } catch (error) { + logger.error('Error updating project subscription:', error); + setSubscriptionLoading(false); } }; @@ -239,6 +274,7 @@ const ProjectViewHeader = () => {