From 02d814b935d24be4253e5d194708cdc4abf7ea17 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 14 Jul 2025 12:04:31 +0530 Subject: [PATCH 01/14] refactor(task-list): enhance task row components with depth handling - Added depth and maxDepth props to TaskRow, TaskRowWithSubtasks, and TitleColumn components to manage nested subtasks more effectively. - Updated AddSubtaskRow to support depth for proper indentation and visual hierarchy. - Improved styling for subtasks based on their depth level, ensuring better visual distinction. - Adjusted task management slice to utilize actual subtask counts from the backend for accurate display. --- .../task-drawer-phase-selector.tsx | 14 +-- .../src/components/task-list-v2/TaskRow.tsx | 5 +- .../task-list-v2/TaskRowWithSubtasks.tsx | 97 +++++++++++++------ .../task-list-v2/components/TitleColumn.tsx | 19 ++-- .../task-list-v2/hooks/useTaskRowColumns.tsx | 5 + .../task-management/task-management.slice.ts | 2 +- 6 files changed, 96 insertions(+), 46 deletions(-) diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx index b336f091..e7504761 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-phase-selector/task-drawer-phase-selector.tsx @@ -1,6 +1,5 @@ import { useSocket } from '@/socket/socketContext'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { Select } from 'antd'; import { Form } from 'antd'; @@ -27,12 +26,6 @@ const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps) phase_id: value, parent_task: task.parent_task_id || null, }); - - // socket?.once(SocketEvents.TASK_PHASE_CHANGE.toString(), () => { - // if(list.getCurrentGroup().value === this.list.GROUP_BY_PHASE_VALUE && this.list.isSubtasksIncluded) { - // this.list.emitRefreshSubtasksIncluded(); - // } - // }); }; return ( @@ -41,8 +34,11 @@ const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps) allowClear placeholder="Select Phase" options={phaseMenuItems} - style={{ width: 'fit-content' }} - dropdownStyle={{ width: 'fit-content' }} + styles={{ + root: { + width: 'fit-content', + }, + }} onChange={handlePhaseChange} /> diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index 24571b8b..e1dd657f 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -24,6 +24,7 @@ interface TaskRowProps { isSubtask?: boolean; isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; + depth?: number; } const TaskRow: React.FC = memo(({ @@ -32,7 +33,8 @@ const TaskRow: React.FC = memo(({ visibleColumns, isSubtask = false, isFirstInGroup = false, - updateTaskCustomColumnValue + updateTaskCustomColumnValue, + depth = 0 }) => { // Get task data and selection state from Redux const task = useAppSelector(state => selectTaskById(state, taskId)); @@ -107,6 +109,7 @@ const TaskRow: React.FC = memo(({ handleTaskNameEdit, attributes, listeners, + depth, }); // Memoize style object to prevent unnecessary re-renders diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index 10226d03..9e450c60 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -22,6 +22,8 @@ interface TaskRowWithSubtasksProps { }>; isFirstInGroup?: boolean; updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void; + depth?: number; // Add depth prop to track nesting level + maxDepth?: number; // Add maxDepth prop to limit nesting } interface AddSubtaskRowProps { @@ -32,14 +34,15 @@ interface AddSubtaskRowProps { width: string; isSticky?: boolean; }>; - onSubtaskAdded: () => void; // Simplified - no rowId needed - rowId: string; // Unique identifier for this add subtask row - autoFocus?: boolean; // Whether this row should auto-focus on mount - isActive?: boolean; // Whether this row should show the input/button - onActivate?: () => void; // Simplified - no rowId needed + onSubtaskAdded: () => void; + rowId: string; + autoFocus?: boolean; + isActive?: boolean; + onActivate?: () => void; + depth?: number; // Add depth prop for proper indentation } -const AddSubtaskRow: React.FC = memo(({ +const AddSubtaskRow: React.FC = memo(({ parentTaskId, projectId, visibleColumns, @@ -47,25 +50,20 @@ const AddSubtaskRow: React.FC = memo(({ rowId, autoFocus = false, isActive = true, - onActivate + onActivate, + depth = 0 }) => { - const [isAdding, setIsAdding] = useState(autoFocus); + const { t } = useTranslation('task-list-table'); + const [isAdding, setIsAdding] = useState(false); const [subtaskName, setSubtaskName] = useState(''); const inputRef = useRef(null); - const { socket, connected } = useSocket(); - const { t } = useTranslation('task-list-table'); const dispatch = useAppDispatch(); - - // Get session data for reporter_id and team_id + const { socket, connected } = useSocket(); const currentSession = useAuthService().getCurrentSession(); - // Auto-focus when autoFocus prop is true useEffect(() => { if (autoFocus && inputRef.current) { - setIsAdding(true); - setTimeout(() => { - inputRef.current?.focus(); - }, 100); + inputRef.current.focus(); } }, [autoFocus]); @@ -142,10 +140,14 @@ const AddSubtaskRow: React.FC = memo(({
{/* Match subtask indentation pattern - tighter spacing */} -
+
+ {/* Add additional indentation for deeper levels - 16px per level */} + {Array.from({ length: depth }).map((_, i) => ( +
+ ))}
- {isActive ? ( + {isActive ? ( !isAdding ? (
); }; diff --git a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx index 335ad133..de80fb4f 100644 --- a/worklenz-frontend/src/components/task-drawer/task-drawer.tsx +++ b/worklenz-frontend/src/components/task-drawer/task-drawer.tsx @@ -3,7 +3,7 @@ import Drawer from 'antd/es/drawer'; import { InputRef } from 'antd/es/input'; import { useTranslation } from 'react-i18next'; import { useEffect, useRef, useState } from 'react'; -import { PlusOutlined } from '@ant-design/icons'; +import { PlusOutlined, CloseOutlined, ArrowLeftOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; @@ -13,6 +13,7 @@ import { setTaskFormViewModel, setTaskSubscribers, setTimeLogEditing, + fetchTask, } from '@/features/task-drawer/task-drawer.slice'; import './task-drawer.css'; @@ -33,6 +34,7 @@ const TaskDrawer = () => { const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer); const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer); + const { projectId } = useAppSelector(state => state.projectReducer); const taskNameInputRef = useRef(null); const isClosingManually = useRef(false); @@ -54,6 +56,17 @@ const TaskDrawer = () => { dispatch(setTaskSubscribers([])); }; + const handleBackToParent = () => { + if (taskFormViewModel?.task?.parent_task_id && projectId) { + // Navigate to parent task + dispatch(setSelectedTaskId(taskFormViewModel.task.parent_task_id)); + dispatch(fetchTask({ + taskId: taskFormViewModel.task.parent_task_id, + projectId + })); + } + }; + const handleOnClose = ( e?: React.MouseEvent | React.KeyboardEvent ) => { @@ -68,10 +81,8 @@ const TaskDrawer = () => { if (isClickOutsideDrawer || !taskFormViewModel?.task?.is_sub_task) { resetTaskState(); } else { - dispatch(setSelectedTaskId(null)); - dispatch(setTaskFormViewModel({})); - dispatch(setTaskSubscribers([])); - dispatch(setSelectedTaskId(taskFormViewModel?.task?.parent_task_id || null)); + // For sub-tasks, navigate to parent instead of closing + handleBackToParent(); } // Reset the flag after a short delay setTimeout(() => { @@ -205,6 +216,17 @@ const TaskDrawer = () => { }; }; + // Check if current task is a sub-task + const isSubTask = taskFormViewModel?.task?.is_sub_task || !!taskFormViewModel?.task?.parent_task_id; + + // Custom close icon based on whether it's a sub-task + const getCloseIcon = () => { + if (isSubTask) { + return ; + } + return ; + }; + const drawerProps = { open: showTaskDrawer, onClose: handleOnClose, @@ -215,6 +237,7 @@ const TaskDrawer = () => { footer: renderFooter(), bodyStyle: getBodyStyle(), footerStyle: getFooterStyle(), + closeIcon: getCloseIcon(), }; return ( diff --git a/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css new file mode 100644 index 00000000..58f85ab6 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.css @@ -0,0 +1,88 @@ +.task-hierarchy-breadcrumb { + margin-bottom: 4px; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb { + font-size: 14px; + line-height: 1; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb-link { + color: inherit; +} + +.task-hierarchy-breadcrumb .ant-breadcrumb-separator { + color: #8c8c8c; + margin: 0 4px; +} + +/* Dark mode styles */ +[data-theme='dark'] .task-hierarchy-breadcrumb .ant-breadcrumb-separator { + color: #595959; +} + +/* Back button styles */ +.task-hierarchy-breadcrumb .ant-btn-link { + padding: 0; + height: auto; + font-size: 14px; + line-height: 1.3; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: flex; + align-items: center; +} + +.task-hierarchy-breadcrumb .ant-btn-link .anticon { + margin-right: 0; /* Remove default margin */ +} + +.task-hierarchy-breadcrumb .ant-btn-link:hover { + color: #40a9ff; +} + +[data-theme='dark'] .task-hierarchy-breadcrumb .ant-btn-link:hover { + color: #40a9ff; +} + +/* Current task name styles */ +.task-hierarchy-breadcrumb .current-task-name { + font-size: 14px; + color: #000000d9; + font-weight: 500; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + line-height: 1.3; +} + +[data-theme='dark'] .task-hierarchy-breadcrumb .current-task-name { + color: #ffffffd9; +} + +/* Breadcrumb item container */ +.task-hierarchy-breadcrumb .ant-breadcrumb-item { + max-width: 220px; + overflow: hidden; + display: flex; + align-items: center; +} + +/* Ensure breadcrumb items don't break the layout */ +.task-hierarchy-breadcrumb .ant-breadcrumb ol { + display: flex; + flex-wrap: wrap; + align-items: center; + margin: 0; + padding: 0; +} + +/* Better alignment for breadcrumb items */ +.task-hierarchy-breadcrumb .ant-breadcrumb-item .ant-breadcrumb-link { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx new file mode 100644 index 00000000..792b4347 --- /dev/null +++ b/worklenz-frontend/src/components/task-drawer/task-hierarchy-breadcrumb/task-hierarchy-breadcrumb.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from 'react'; +import { Breadcrumb, Button, Typography, Tooltip } from 'antd'; +import { HomeOutlined } from '@ant-design/icons'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { fetchTask, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice'; +import { tasksApiService } from '@/api/tasks/tasks.api.service'; +import { TFunction } from 'i18next'; +import './task-hierarchy-breadcrumb.css'; + +interface TaskHierarchyBreadcrumbProps { + t: TFunction; + onBackClick?: () => void; +} + +interface TaskHierarchyItem { + id: string; + name: string; + parent_task_id?: string; +} + +// Utility function to truncate text +const truncateText = (text: string, maxLength: number = 25): string => { + if (!text || text.length <= maxLength) return text; + return `${text.substring(0, maxLength)}...`; +}; + +const TaskHierarchyBreadcrumb: React.FC = ({ t, onBackClick }) => { + const dispatch = useAppDispatch(); + const { taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer); + const { projectId } = useAppSelector(state => state.projectReducer); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const [hierarchyPath, setHierarchyPath] = useState([]); + const [loading, setLoading] = useState(false); + + const task = taskFormViewModel?.task; + const isSubTask = task?.is_sub_task || !!task?.parent_task_id; + + // Recursively fetch the complete hierarchy path + const fetchHierarchyPath = async (currentTaskId: string): Promise => { + if (!projectId) return []; + + const path: TaskHierarchyItem[] = []; + let taskId = currentTaskId; + + // Traverse up the hierarchy until we reach the root + while (taskId) { + try { + const response = await tasksApiService.getFormViewModel(taskId, projectId); + if (response.done && response.body.task) { + const taskData = response.body.task; + path.unshift({ + id: taskData.id, + name: taskData.name || '', + parent_task_id: taskData.parent_task_id || undefined + }); + + // Move to parent task + taskId = taskData.parent_task_id || ''; + } else { + break; + } + } catch (error) { + console.error('Error fetching task in hierarchy:', error); + break; + } + } + + return path; + }; + + // Fetch the complete hierarchy when component mounts or task changes + useEffect(() => { + const loadHierarchy = async () => { + if (!isSubTask || !task?.parent_task_id || !projectId) { + setHierarchyPath([]); + return; + } + + setLoading(true); + try { + const path = await fetchHierarchyPath(task.parent_task_id); + setHierarchyPath(path); + } catch (error) { + console.error('Error loading task hierarchy:', error); + setHierarchyPath([]); + } finally { + setLoading(false); + } + }; + + loadHierarchy(); + }, [task?.parent_task_id, projectId, isSubTask]); + + const handleNavigateToTask = (taskId: string) => { + if (projectId) { + if (onBackClick) { + onBackClick(); + } + + // Navigate to the selected task + dispatch(setSelectedTaskId(taskId)); + dispatch(fetchTask({ taskId, projectId })); + } + }; + + if (!isSubTask || hierarchyPath.length === 0) { + return null; + } + + // Create breadcrumb items from the hierarchy path + const breadcrumbItems = [ + // Add all parent tasks in the hierarchy + ...hierarchyPath.map((hierarchyTask, index) => { + const truncatedName = truncateText(hierarchyTask.name, 25); + const shouldShowTooltip = hierarchyTask.name.length > 25; + + return { + title: ( + + + + ), + }; + }), + // Add the current task as the last item (non-clickable) + { + title: (() => { + const currentTaskName = task?.name || t('taskHeader.currentTask', 'Current Task'); + const truncatedCurrentName = truncateText(currentTaskName, 25); + const shouldShowCurrentTooltip = currentTaskName.length > 25; + + return ( + + + {truncatedCurrentName} + + + ); + })(), + }, + ]; + + return ( +
+ {loading ? ( + + {t('taskHeader.loadingHierarchy', 'Loading hierarchy...')} + + ) : ( + + )} +
+ ); +}; + +export default TaskHierarchyBreadcrumb; \ No newline at end of file From 3d67145af7832d8c279d76a642541f04d574a7b6 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 14 Jul 2025 12:37:54 +0530 Subject: [PATCH 03/14] refactor(task-list): adjust subtask indentation for improved visual hierarchy - Reduced spacing for level 1 subtasks and increased spacing for deeper levels in TaskRowWithSubtasks and TitleColumn components. - Enhanced comments to clarify indentation logic for better maintainability. --- .../components/task-list-v2/TaskRowWithSubtasks.tsx | 12 ++++++------ .../task-list-v2/components/TitleColumn.tsx | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx index 9e450c60..f6c35cfc 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRowWithSubtasks.tsx @@ -139,13 +139,13 @@ const AddSubtaskRow: React.FC = memo(({ return (
- {/* Match subtask indentation pattern - tighter spacing */} -
- {/* Add additional indentation for deeper levels - 16px per level */} - {Array.from({ length: depth }).map((_, i) => ( -
- ))} + {/* Match subtask indentation pattern - reduced spacing for level 1 */}
+ {/* Add additional indentation for deeper levels - increased spacing for level 2+ */} + {Array.from({ length: depth }).map((_, i) => ( +
+ ))} +
{isActive ? ( !isAdding ? ( diff --git a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx index 55381c6a..990b4a16 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx @@ -152,12 +152,12 @@ export const TitleColumn: React.FC = memo(({ /* Normal layout when not editing */ <>
- {/* Indentation for subtasks - tighter spacing */} - {isSubtask &&
} + {/* Indentation for subtasks - reduced spacing for level 1 */} + {isSubtask &&
} - {/* Additional indentation for deeper levels - 16px per level */} + {/* Additional indentation for deeper levels - increased spacing for level 2+ */} {Array.from({ length: depth }).map((_, i) => ( -
+
))} {/* Expand/Collapse button - show for any task that can have sub-tasks */} @@ -182,8 +182,8 @@ export const TitleColumn: React.FC = memo(({ )} - {/* Additional indentation for subtasks after the expand button space */} - {isSubtask &&
} + {/* Additional indentation for subtasks after the expand button space - reduced for level 1 */} + {isSubtask &&
}
{/* Task name with dynamic width */} From 407dc416ecf5380581b2e08169b5d644812d6a02 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Tue, 15 Jul 2025 07:44:15 +0530 Subject: [PATCH 04/14] feat(sort-orders): implement separate sort orders for task groupings - Introduced new columns for `status_sort_order`, `priority_sort_order`, `phase_sort_order`, and `member_sort_order` in the tasks table to maintain distinct sort orders for each grouping type. - Updated database functions to handle grouping-specific sort orders and avoid unique constraint violations. - Enhanced backend socket handlers to emit changes based on the selected grouping. - Modified frontend components to support drag-and-drop functionality with the new sort order fields, ensuring task organization is preserved across different views. - Added comprehensive migration scripts and verification steps to ensure smooth deployment and backward compatibility. --- DEPLOYMENT_GUIDE_SORT_ORDERS.md | 140 ++++++++++++++ SEPARATE_SORT_ORDERS_IMPLEMENTATION.md | 162 ++++++++++++++++ test_sort_fix.sql | 41 ++++ test_sort_orders.sql | 30 +++ ...0250715000000-add-grouping-sort-orders.sql | 37 ++++ .../20250715000001-update-sort-functions.sql | 172 +++++++++++++++++ .../20250715000002-fix-sort-constraint.sql | 179 ++++++++++++++++++ worklenz-backend/database/sql/4_functions.sql | 124 +++++++----- .../commands/on-task-sort-order-change.ts | 29 ++- .../task-list-v2/hooks/useDragAndDrop.ts | 95 ++++++++-- .../task-management/task-management.slice.ts | 15 +- .../src/types/task-management.types.ts | 22 +++ 12 files changed, 974 insertions(+), 72 deletions(-) create mode 100644 DEPLOYMENT_GUIDE_SORT_ORDERS.md create mode 100644 SEPARATE_SORT_ORDERS_IMPLEMENTATION.md create mode 100644 test_sort_fix.sql create mode 100644 test_sort_orders.sql create mode 100644 worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql create mode 100644 worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql create mode 100644 worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql diff --git a/DEPLOYMENT_GUIDE_SORT_ORDERS.md b/DEPLOYMENT_GUIDE_SORT_ORDERS.md new file mode 100644 index 00000000..541e1f87 --- /dev/null +++ b/DEPLOYMENT_GUIDE_SORT_ORDERS.md @@ -0,0 +1,140 @@ +# Deployment Guide: Separate Sort Orders Feature + +## Issue Resolution +The unique constraint error `"duplicate key value violates unique constraint tasks_sort_order_unique"` has been fixed by ensuring that: + +1. The new grouping-specific sort columns don't have unique constraints +2. We only update the main `sort_order` column when explicitly needed +3. Database functions properly handle the different sort columns + +## Required Migrations (Run in Order) + +### 1. Schema Changes +```bash +psql -d worklenz -f database/migrations/20250715000000-add-grouping-sort-orders.sql +``` + +### 2. Function Updates +```bash +psql -d worklenz -f database/migrations/20250715000001-update-sort-functions.sql +``` + +### 3. Constraint Fixes +```bash +psql -d worklenz -f database/migrations/20250715000002-fix-sort-constraint.sql +``` + +## Verification Steps + +### 1. Test Database Functions +```bash +psql -d worklenz -f test_sort_fix.sql +``` + +### 2. Verify Schema +```sql +-- Check new columns exist +\d tasks + +-- Verify helper function works +SELECT get_sort_column_name('status'); +``` + +### 3. Test Sort Operations +```sql +-- Test bulk update (replace with real UUIDs) +SELECT update_task_sort_orders_bulk( + '[{"task_id": "real-uuid", "sort_order": 1}]'::json, + 'status' +); +``` + +## Key Changes Made + +### Database Layer +- **New Columns:** Added `status_sort_order`, `priority_sort_order`, `phase_sort_order`, `member_sort_order` +- **No Unique Constraints:** New columns allow duplicate values (by design) +- **Fixed Functions:** Updated to avoid touching `sort_order` column unnecessarily +- **Data Migration:** Existing tasks get their current `sort_order` copied to all new columns + +### Backend Layer +- **Socket Handler:** Updated to use correct sort column based on `group_by` +- **Function Calls:** Pass grouping parameter to database functions +- **Error Handling:** Avoid constraint violations by working with right columns + +### Frontend Layer +- **Type Safety:** Added new sort order fields to Task interface +- **Helper Function:** `getSortOrderField()` for consistent field selection +- **Redux Updates:** Use appropriate sort field in state management +- **Drag & Drop:** Updated to work with grouping-specific sort orders + +## Behavior Changes + +### Before Fix +- All groupings shared same `sort_order` column +- Constraint violations when multiple tasks had same sort value +- Lost organization when switching between grouping views + +### After Fix +- Each grouping type has its own sort order column +- No constraint violations (new columns don't have unique constraints) +- Task organization preserved when switching between views +- Backward compatible with existing data + +## Troubleshooting + +### If Migration Fails +1. **Check Permissions:** Ensure database user has CREATE/ALTER privileges +2. **Backup First:** Always backup before running migrations +3. **Check Dependencies:** Ensure functions `is_null_or_empty` exists + +### If Constraint Errors Persist +1. **Check Which Column:** Error should specify which column is causing the issue +2. **Run Data Fix:** The migration includes a data cleanup step +3. **Verify Functions:** Ensure updated functions are being used + +### Rollback Plan +```sql +-- If needed, rollback to original functions +-- (Save original function definitions first) + +-- Remove new columns (WARNING: This loses data) +ALTER TABLE tasks DROP COLUMN IF EXISTS status_sort_order; +ALTER TABLE tasks DROP COLUMN IF EXISTS priority_sort_order; +ALTER TABLE tasks DROP COLUMN IF EXISTS phase_sort_order; +ALTER TABLE tasks DROP COLUMN IF EXISTS member_sort_order; +``` + +## Performance Impact + +### Positive +- ✅ Better user experience with preserved sort orders +- ✅ More efficient queries (appropriate indexes added) +- ✅ Reduced conflicts during concurrent operations + +### Considerations +- 📊 Minimal storage increase (4 integers per task) +- 📊 Slightly more complex database functions +- 📊 No significant performance impact expected + +## Testing Checklist + +- [ ] Migrations run successfully without errors +- [ ] New columns exist and are populated +- [ ] Helper functions return correct column names +- [ ] Drag and drop works in status view +- [ ] Drag and drop works in priority view +- [ ] Drag and drop works in phase view +- [ ] Drag and drop works in member view +- [ ] Sort orders persist when switching between views +- [ ] No constraint violation errors in logs +- [ ] Existing functionality still works +- [ ] Performance is acceptable + +## Success Metrics + +After deployment, verify: +1. **No Error Logs:** No constraint violation errors in application logs +2. **User Feedback:** Users can organize tasks differently in different views +3. **Data Integrity:** Task sort orders are preserved correctly +4. **Performance:** No significant slowdown in task operations \ No newline at end of file diff --git a/SEPARATE_SORT_ORDERS_IMPLEMENTATION.md b/SEPARATE_SORT_ORDERS_IMPLEMENTATION.md new file mode 100644 index 00000000..8426cb02 --- /dev/null +++ b/SEPARATE_SORT_ORDERS_IMPLEMENTATION.md @@ -0,0 +1,162 @@ +# Separate Sort Orders Implementation + +## Overview +This implementation adds support for maintaining different task sort orders for each grouping type (status, priority, phase, members). This allows users to organize tasks differently when switching between different views while preserving their organization intent. + +## Changes Made + +### 1. Database Schema Changes +**File:** `/database/migrations/20250715000000-add-grouping-sort-orders.sql` + +- Added 4 new columns to the `tasks` table: + - `status_sort_order` - Sort order when grouped by status + - `priority_sort_order` - Sort order when grouped by priority + - `phase_sort_order` - Sort order when grouped by phase + - `member_sort_order` - Sort order when grouped by members/assignees + +- Added constraints and indexes for performance +- Initialized new columns with current `sort_order` values for backward compatibility + +### 2. Database Functions Update +**File:** `/database/migrations/20250715000001-update-sort-functions.sql` + +- **`get_sort_column_name()`** - Helper function to get appropriate column name based on grouping +- **`update_task_sort_orders_bulk()`** - Updated to accept grouping parameter and update correct sort column +- **`handle_task_list_sort_order_change()`** - Updated to use dynamic SQL for different sort columns + +### 3. Backend Socket Handler Updates +**File:** `/src/socket.io/commands/on-task-sort-order-change.ts` + +- Updated `emitSortOrderChange()` to use appropriate sort column based on `group_by` +- Modified bulk update calls to pass `group_by` parameter +- Enhanced query to return both general and current sort orders + +### 4. Frontend Type Definitions +**File:** `/src/types/task-management.types.ts` + +- Added new sort order fields to `Task` interface +- Created `getSortOrderField()` helper function for type-safe field selection + +### 5. Redux State Management +**File:** `/src/features/task-management/task-management.slice.ts` + +- Updated `reorderTasksInGroup` reducer to use appropriate sort field based on grouping +- Integrated `getSortOrderField()` helper for consistent field selection + +### 6. Drag and Drop Implementation +**File:** `/src/components/task-list-v2/hooks/useDragAndDrop.ts` + +- Updated `emitTaskSortChange()` to use grouping-specific sort order fields +- Enhanced sort order calculation to work with different sort columns + +## Usage Examples + +### User Experience +1. **Status View:** User arranges tasks by business priority within each status column +2. **Priority View:** User switches to priority view - tasks maintain their status-specific order within each priority group +3. **Phase View:** User switches to phase view - tasks maintain their own organization within each phase +4. **Back to Status:** Returning to status view shows the original organization + +### API Usage +```javascript +// Socket emission now includes group_by parameter +socket.emit('TASK_SORT_ORDER_CHANGE', { + project_id: 'uuid', + group_by: 'status', // 'status', 'priority', 'phase', 'members' + task_updates: [{ + task_id: 'uuid', + sort_order: 1, + status_id: 'uuid' // if moving between status groups + }] +}); +``` + +### Database Query Examples +```sql +-- Get tasks ordered by status grouping +SELECT * FROM tasks +WHERE project_id = $1 +ORDER BY status_sort_order; + +-- Get tasks ordered by priority grouping +SELECT * FROM tasks +WHERE project_id = $1 +ORDER BY priority_sort_order; +``` + +## Migration Steps + +1. **Run Database Migrations:** + ```bash + # Apply schema changes + psql -d worklenz -f database/migrations/20250715000000-add-grouping-sort-orders.sql + + # Apply function updates + psql -d worklenz -f database/migrations/20250715000001-update-sort-functions.sql + ``` + +2. **Test Migration:** + ```bash + # Verify columns and functions + psql -d worklenz -f test_sort_orders.sql + ``` + +3. **Deploy Frontend Changes:** + - No additional steps needed - changes are backward compatible + - Users will immediately benefit from separate sort orders + +## Backward Compatibility + +- ✅ Existing `sort_order` column remains unchanged +- ✅ New columns initialized with current `sort_order` values +- ✅ Old API calls continue to work (default to status grouping) +- ✅ Frontend gracefully falls back to `order` field if new fields not available + +## Performance Considerations + +- Added indexes on new sort order columns for efficient ordering +- Dynamic SQL in functions is minimal and safe (controlled input) +- Memory footprint increase is minimal (4 integers per task) + +## Testing + +1. **Database Level:** + - Verify migrations run successfully + - Test function calls with different grouping parameters + - Validate indexes are created and used + +2. **API Level:** + - Test socket emissions with different `group_by` values + - Verify correct sort columns are updated + - Test cross-group task moves + +3. **Frontend Level:** + - Test drag and drop in different grouping views + - Verify sort order persistence when switching views + - Test that original behavior is preserved + +## Future Enhancements + +1. **UI Indicators:** Show users which view they're currently organizing +2. **Sort Order Reset:** Allow users to reset sort orders for specific groupings +3. **Export/Import:** Include sort order data in project templates +4. **Analytics:** Track how users organize tasks in different views + +## Troubleshooting + +### Common Issues: +1. **Migration Fails:** Check database permissions and existing data integrity +2. **Sort Orders Not Persisting:** Verify socket handler receives `group_by` parameter +3. **Tasks Not Reordering:** Check frontend Redux state updates and sort field usage + +### Debug Queries: +```sql +-- Check current sort orders for a project +SELECT id, name, status_sort_order, priority_sort_order, phase_sort_order, member_sort_order +FROM tasks +WHERE project_id = 'your-project-id' +ORDER BY status_sort_order; + +-- Verify function calls +SELECT get_sort_column_name('status'); -- Should return 'status_sort_order' +``` \ No newline at end of file diff --git a/test_sort_fix.sql b/test_sort_fix.sql new file mode 100644 index 00000000..ceb0b0a0 --- /dev/null +++ b/test_sort_fix.sql @@ -0,0 +1,41 @@ +-- Test script to verify the sort order constraint fix + +-- Test the helper function +SELECT get_sort_column_name('status'); -- Should return 'status_sort_order' +SELECT get_sort_column_name('priority'); -- Should return 'priority_sort_order' +SELECT get_sort_column_name('phase'); -- Should return 'phase_sort_order' +SELECT get_sort_column_name('members'); -- Should return 'member_sort_order' +SELECT get_sort_column_name('unknown'); -- Should return 'status_sort_order' (default) + +-- Test bulk update function (example - would need real project_id and task_ids) +/* +SELECT update_task_sort_orders_bulk( + '[ + {"task_id": "example-uuid", "sort_order": 1, "status_id": "status-uuid"}, + {"task_id": "example-uuid-2", "sort_order": 2, "status_id": "status-uuid"} + ]'::json, + 'status' +); +*/ + +-- Verify that sort_order constraint still exists and works +SELECT + tc.constraint_name, + tc.table_name, + kcu.column_name +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name +WHERE tc.constraint_name = 'tasks_sort_order_unique'; + +-- Check that new sort order columns don't have unique constraints (which is correct) +SELECT + tc.constraint_name, + tc.table_name, + kcu.column_name +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name +WHERE kcu.table_name = 'tasks' + AND kcu.column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order') + AND tc.constraint_type = 'UNIQUE'; \ No newline at end of file diff --git a/test_sort_orders.sql b/test_sort_orders.sql new file mode 100644 index 00000000..6a45de84 --- /dev/null +++ b/test_sort_orders.sql @@ -0,0 +1,30 @@ +-- Test script to validate the separate sort order implementation + +-- Check if new columns exist +SELECT column_name, data_type, is_nullable, column_default +FROM information_schema.columns +WHERE table_name = 'tasks' + AND column_name IN ('status_sort_order', 'priority_sort_order', 'phase_sort_order', 'member_sort_order') +ORDER BY column_name; + +-- Check if helper function exists +SELECT routine_name, routine_type +FROM information_schema.routines +WHERE routine_name IN ('get_sort_column_name', 'update_task_sort_orders_bulk', 'handle_task_list_sort_order_change'); + +-- Sample test data to verify different sort orders work +-- (This would be run after the migrations) +/* +-- Test: Tasks should have different orders for different groupings +SELECT + id, + name, + sort_order, + status_sort_order, + priority_sort_order, + phase_sort_order, + member_sort_order +FROM tasks +WHERE project_id = '' +ORDER BY status_sort_order; +*/ \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql b/worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql new file mode 100644 index 00000000..7fc4efec --- /dev/null +++ b/worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql @@ -0,0 +1,37 @@ +-- Migration: Add separate sort order columns for different grouping types +-- This allows users to maintain different task orders when switching between grouping views + +-- Add new sort order columns +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS status_sort_order INTEGER DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS priority_sort_order INTEGER DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS phase_sort_order INTEGER DEFAULT 0; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS member_sort_order INTEGER DEFAULT 0; + +-- Initialize new columns with current sort_order values +UPDATE tasks SET + status_sort_order = sort_order, + priority_sort_order = sort_order, + phase_sort_order = sort_order, + member_sort_order = sort_order +WHERE status_sort_order = 0 + OR priority_sort_order = 0 + OR phase_sort_order = 0 + OR member_sort_order = 0; + +-- Add constraints to ensure non-negative values +ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_member_sort_order_check CHECK (member_sort_order >= 0); + +-- Add indexes for performance (since these will be used for ordering) +CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_member_sort_order ON tasks(project_id, member_sort_order); + +-- Update comments for documentation +COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status'; +COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority'; +COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase'; +COMMENT ON COLUMN tasks.member_sort_order IS 'Sort order when grouped by members/assignees'; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql b/worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql new file mode 100644 index 00000000..ada55087 --- /dev/null +++ b/worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql @@ -0,0 +1,172 @@ +-- Migration: Update database functions to handle grouping-specific sort orders + +-- Function to get the appropriate sort column name based on grouping type +CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT + LANGUAGE plpgsql +AS +$$ +BEGIN + CASE _group_by + WHEN 'status' THEN RETURN 'status_sort_order'; + WHEN 'priority' THEN RETURN 'priority_sort_order'; + WHEN 'phase' THEN RETURN 'phase_sort_order'; + WHEN 'members' THEN RETURN 'member_sort_order'; + ELSE RETURN 'sort_order'; -- fallback to general sort_order + END CASE; +END; +$$; + +-- Updated bulk sort order function to handle different sort columns +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; + _sort_column TEXT; + _sql TEXT; +BEGIN + -- Get the appropriate sort column based on grouping + _sort_column := get_sort_column_name(_group_by); + + -- Simple approach: update each task's sort_order from the provided array + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + -- Update the appropriate sort column and other fields using dynamic SQL + -- Only update sort_order if we're using the default sorting + IF _sort_column = 'sort_order' THEN + UPDATE tasks SET + sort_order = _update_record.sort_order, + status_id = COALESCE(_update_record.status_id, status_id), + priority_id = COALESCE(_update_record.priority_id, priority_id) + WHERE id = _update_record.task_id; + ELSE + -- Update only the grouping-specific sort column, not the main sort_order + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' || + 'status_id = COALESCE($2, status_id), ' || + 'priority_id = COALESCE($3, priority_id) ' || + 'WHERE id = $4'; + + EXECUTE _sql USING + _update_record.sort_order, + _update_record.status_id, + _update_record.priority_id, + _update_record.task_id; + END IF; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END; +$$; + +-- Updated main sort order change handler +CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _from_index INT; + _to_index INT; + _task_id UUID; + _project_id UUID; + _from_group UUID; + _to_group UUID; + _group_by TEXT; + _batch_size INT := 100; + _sort_column TEXT; + _sql TEXT; +BEGIN + _project_id = (_body ->> 'project_id')::UUID; + _task_id = (_body ->> 'task_id')::UUID; + _from_index = (_body ->> 'from_index')::INT; + _to_index = (_body ->> 'to_index')::INT; + _from_group = (_body ->> 'from_group')::UUID; + _to_group = (_body ->> 'to_group')::UUID; + _group_by = (_body ->> 'group_by')::TEXT; + + -- Get the appropriate sort column + _sort_column := get_sort_column_name(_group_by); + + -- Handle group changes + IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN + IF (_group_by = 'status') THEN + UPDATE tasks + SET status_id = _to_group + WHERE id = _task_id + AND status_id = _from_group + AND project_id = _project_id; + END IF; + + IF (_group_by = 'priority') THEN + UPDATE tasks + SET priority_id = _to_group + WHERE id = _task_id + AND priority_id = _from_group + AND project_id = _project_id; + END IF; + + IF (_group_by = 'phase') THEN + IF (is_null_or_empty(_to_group) IS FALSE) THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_task_id, _to_group) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group; + ELSE + DELETE FROM task_phase WHERE task_id = _task_id; + END IF; + END IF; + END IF; + + -- Handle sort order changes using dynamic SQL + IF (_from_index <> _to_index) THEN + -- For the main sort_order column, we need to be careful about unique constraints + IF _sort_column = 'sort_order' THEN + -- Use a transaction-safe approach for the main sort_order column + IF (_to_index > _from_index) THEN + -- Moving down: decrease sort_order for items between old and new position + UPDATE tasks SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order <= _to_index; + ELSE + -- Moving up: increase sort_order for items between new and old position + UPDATE tasks SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order >= _to_index + AND sort_order < _from_index; + END IF; + + -- Set the new sort_order for the moved task + UPDATE tasks SET sort_order = _to_index WHERE id = _task_id; + ELSE + -- For grouping-specific columns, use dynamic SQL since there's no unique constraint + IF (_to_index > _from_index) THEN + -- Moving down: decrease sort_order for items between old and new position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1 ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3'; + EXECUTE _sql USING _project_id, _from_index, _to_index; + ELSE + -- Moving up: increase sort_order for items between new and old position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1 ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3'; + EXECUTE _sql USING _project_id, _to_index, _from_index; + END IF; + + -- Set the new sort_order for the moved task + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1 WHERE id = $2'; + EXECUTE _sql USING _to_index, _task_id; + END IF; + END IF; +END; +$$; \ No newline at end of file diff --git a/worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql b/worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql new file mode 100644 index 00000000..8068d9aa --- /dev/null +++ b/worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql @@ -0,0 +1,179 @@ +-- Migration: Fix sort order constraint violations + +-- First, let's ensure all existing tasks have unique sort_order values within each project +-- This is a one-time fix to ensure data consistency + +DO $$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + -- For each project, reassign sort_order values to ensure uniqueness + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + -- Reassign sort_order values sequentially for this project + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY sort_order, created_at + LOOP + UPDATE tasks + SET sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; +END +$$; + +-- Now create a better version of our functions that properly handles the constraints + +-- Updated bulk sort order function that avoids sort_order conflicts +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; + _sort_column TEXT; + _sql TEXT; +BEGIN + -- Get the appropriate sort column based on grouping + _sort_column := get_sort_column_name(_group_by); + + -- Process each update record + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + -- Update the grouping-specific sort column and other fields + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' || + 'status_id = COALESCE($2, status_id), ' || + 'priority_id = COALESCE($3, priority_id), ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE id = $4'; + + EXECUTE _sql USING + _update_record.sort_order, + _update_record.status_id, + _update_record.priority_id, + _update_record.task_id; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END; +$$; + +-- Also update the helper function to be more explicit +CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT + LANGUAGE plpgsql +AS +$$ +BEGIN + CASE _group_by + WHEN 'status' THEN RETURN 'status_sort_order'; + WHEN 'priority' THEN RETURN 'priority_sort_order'; + WHEN 'phase' THEN RETURN 'phase_sort_order'; + WHEN 'members' THEN RETURN 'member_sort_order'; + -- For backward compatibility, still support general sort_order but be explicit + WHEN 'general' THEN RETURN 'sort_order'; + ELSE RETURN 'status_sort_order'; -- Default to status sorting + END CASE; +END; +$$; + +-- Updated main sort order change handler that avoids conflicts +CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _from_index INT; + _to_index INT; + _task_id UUID; + _project_id UUID; + _from_group UUID; + _to_group UUID; + _group_by TEXT; + _sort_column TEXT; + _sql TEXT; +BEGIN + _project_id = (_body ->> 'project_id')::UUID; + _task_id = (_body ->> 'task_id')::UUID; + _from_index = (_body ->> 'from_index')::INT; + _to_index = (_body ->> 'to_index')::INT; + _from_group = (_body ->> 'from_group')::UUID; + _to_group = (_body ->> 'to_group')::UUID; + _group_by = (_body ->> 'group_by')::TEXT; + + -- Get the appropriate sort column + _sort_column := get_sort_column_name(_group_by); + + -- Handle group changes first + IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN + IF (_group_by = 'status') THEN + UPDATE tasks + SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP + WHERE id = _task_id + AND project_id = _project_id; + END IF; + + IF (_group_by = 'priority') THEN + UPDATE tasks + SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP + WHERE id = _task_id + AND project_id = _project_id; + END IF; + + IF (_group_by = 'phase') THEN + IF (is_null_or_empty(_to_group) IS FALSE) THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_task_id, _to_group) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group; + ELSE + DELETE FROM task_phase WHERE task_id = _task_id; + END IF; + END IF; + END IF; + + -- Handle sort order changes for the grouping-specific column only + IF (_from_index <> _to_index) THEN + -- Update the grouping-specific sort order (no unique constraint issues) + IF (_to_index > _from_index) THEN + -- Moving down: decrease sort order for items between old and new position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3'; + EXECUTE _sql USING _project_id, _from_index, _to_index; + ELSE + -- Moving up: increase sort order for items between new and old position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3'; + EXECUTE _sql USING _project_id, _to_index, _from_index; + END IF; + + -- Set the new sort order for the moved task + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2'; + EXECUTE _sql USING _to_index, _task_id; + END IF; +END; +$$; \ No newline at end of file diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 2c57d3c4..81d54e52 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -4313,6 +4313,24 @@ BEGIN END $$; +-- Helper function to get the appropriate sort column name based on grouping type +CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT + LANGUAGE plpgsql +AS +$$ +BEGIN + CASE _group_by + WHEN 'status' THEN RETURN 'status_sort_order'; + WHEN 'priority' THEN RETURN 'priority_sort_order'; + WHEN 'phase' THEN RETURN 'phase_sort_order'; + WHEN 'members' THEN RETURN 'member_sort_order'; + -- For backward compatibility, still support general sort_order but be explicit + WHEN 'general' THEN RETURN 'sort_order'; + ELSE RETURN 'status_sort_order'; -- Default to status sorting + END CASE; +END; +$$; + CREATE OR REPLACE FUNCTION handle_task_list_sort_order_change(_body json) RETURNS void LANGUAGE plpgsql AS @@ -4325,66 +4343,67 @@ DECLARE _from_group UUID; _to_group UUID; _group_by TEXT; - _batch_size INT := 100; -- PERFORMANCE OPTIMIZATION: Batch size for large updates + _sort_column TEXT; + _sql TEXT; BEGIN _project_id = (_body ->> 'project_id')::UUID; _task_id = (_body ->> 'task_id')::UUID; - - _from_index = (_body ->> 'from_index')::INT; -- from sort_order - _to_index = (_body ->> 'to_index')::INT; -- to sort_order - + _from_index = (_body ->> 'from_index')::INT; + _to_index = (_body ->> 'to_index')::INT; _from_group = (_body ->> 'from_group')::UUID; _to_group = (_body ->> 'to_group')::UUID; - _group_by = (_body ->> 'group_by')::TEXT; - - -- PERFORMANCE OPTIMIZATION: Use CTE for better query planning - IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) - THEN - -- PERFORMANCE OPTIMIZATION: Batch update group changes - IF (_group_by = 'status') - THEN + + -- Get the appropriate sort column + _sort_column := get_sort_column_name(_group_by); + + -- Handle group changes first + IF (_from_group <> _to_group OR (_from_group <> _to_group) IS NULL) THEN + IF (_group_by = 'status') THEN UPDATE tasks - SET status_id = _to_group + SET status_id = _to_group, updated_at = CURRENT_TIMESTAMP WHERE id = _task_id - AND status_id = _from_group AND project_id = _project_id; END IF; - - IF (_group_by = 'priority') - THEN + + IF (_group_by = 'priority') THEN UPDATE tasks - SET priority_id = _to_group + SET priority_id = _to_group, updated_at = CURRENT_TIMESTAMP WHERE id = _task_id - AND priority_id = _from_group AND project_id = _project_id; END IF; - - IF (_group_by = 'phase') - THEN - IF (is_null_or_empty(_to_group) IS FALSE) - THEN + + IF (_group_by = 'phase') THEN + IF (is_null_or_empty(_to_group) IS FALSE) THEN INSERT INTO task_phase (task_id, phase_id) VALUES (_task_id, _to_group) ON CONFLICT (task_id) DO UPDATE SET phase_id = _to_group; - END IF; - IF (is_null_or_empty(_to_group) IS TRUE) - THEN - DELETE - FROM task_phase - WHERE task_id = _task_id; + ELSE + DELETE FROM task_phase WHERE task_id = _task_id; END IF; END IF; + END IF; - -- PERFORMANCE OPTIMIZATION: Optimized sort order handling - IF ((_body ->> 'to_last_index')::BOOLEAN IS TRUE AND _from_index < _to_index) - THEN - PERFORM handle_task_list_sort_inside_group_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); + -- Handle sort order changes for the grouping-specific column only + IF (_from_index <> _to_index) THEN + -- Update the grouping-specific sort order (no unique constraint issues) + IF (_to_index > _from_index) THEN + -- Moving down: decrease sort order for items between old and new position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' - 1, ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' > $2 AND ' || _sort_column || ' <= $3'; + EXECUTE _sql USING _project_id, _from_index, _to_index; ELSE - PERFORM handle_task_list_sort_between_groups_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); + -- Moving up: increase sort order for items between new and old position + _sql := 'UPDATE tasks SET ' || _sort_column || ' = ' || _sort_column || ' + 1, ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE project_id = $1 AND ' || _sort_column || ' >= $2 AND ' || _sort_column || ' < $3'; + EXECUTE _sql USING _project_id, _to_index, _from_index; END IF; - ELSE - PERFORM handle_task_list_sort_inside_group_optimized(_from_index, _to_index, _task_id, _project_id, _batch_size); + + -- Set the new sort order for the moved task + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2'; + EXECUTE _sql USING _to_index, _task_id; END IF; END $$; @@ -6521,15 +6540,20 @@ BEGIN END $$; --- Simple function to update task sort orders in bulk -CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void +-- Updated bulk sort order function that avoids sort_order conflicts +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void LANGUAGE plpgsql AS $$ DECLARE _update_record RECORD; + _sort_column TEXT; + _sql TEXT; BEGIN - -- Simple approach: update each task's sort_order from the provided array + -- Get the appropriate sort column based on grouping + _sort_column := get_sort_column_name(_group_by); + + -- Process each update record FOR _update_record IN SELECT (item->>'task_id')::uuid as task_id, @@ -6539,12 +6563,18 @@ BEGIN (item->>'phase_id')::uuid as phase_id FROM json_array_elements(_updates) as item LOOP - UPDATE tasks - SET - sort_order = _update_record.sort_order, - status_id = COALESCE(_update_record.status_id, status_id), - priority_id = COALESCE(_update_record.priority_id, priority_id) - WHERE id = _update_record.task_id; + -- Update the grouping-specific sort column and other fields + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' || + 'status_id = COALESCE($2, status_id), ' || + 'priority_id = COALESCE($3, priority_id), ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE id = $4'; + + EXECUTE _sql USING + _update_record.sort_order, + _update_record.status_id, + _update_record.priority_id, + _update_record.task_id; -- Handle phase updates separately since it's in a different table IF _update_record.phase_id IS NOT NULL THEN diff --git a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts index 11ec09cd..77a8af87 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts @@ -53,11 +53,30 @@ function notifyStatusChange(socket: Socket, config: Config) { } async function emitSortOrderChange(data: ChangeRequest, socket: Socket) { + // Determine which sort column to use based on group_by + let sortColumn = 'sort_order'; + switch (data.group_by) { + case 'status': + sortColumn = 'status_sort_order'; + break; + case 'priority': + sortColumn = 'priority_sort_order'; + break; + case 'phase': + sortColumn = 'phase_sort_order'; + break; + case 'members': + sortColumn = 'member_sort_order'; + break; + default: + sortColumn = 'sort_order'; + } + const q = ` - SELECT id, sort_order, completed_at + SELECT id, sort_order, ${sortColumn} as current_sort_order, completed_at FROM tasks WHERE project_id = $1 - ORDER BY sort_order; + ORDER BY ${sortColumn}; `; const tasks = await db.query(q, [data.project_id]); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), tasks.rows); @@ -84,9 +103,9 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat } } - // Use the simple bulk update function - const q = `SELECT update_task_sort_orders_bulk($1);`; - await db.query(q, [JSON.stringify(data.task_updates)]); + // Use the simple bulk update function with group_by parameter + const q = `SELECT update_task_sort_orders_bulk($1, $2);`; + await db.query(q, [JSON.stringify(data.task_updates), data.group_by || 'status']); await emitSortOrderChange(data, socket); // Handle notifications and logging diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts index 4394bd34..ae4930ec 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -1,13 +1,84 @@ import { useState, useCallback } from 'react'; import { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { reorderTasksInGroup, moveTaskBetweenGroups } from '@/features/task-management/task-management.slice'; -import { Task, TaskGroup } from '@/types/task-management.types'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { reorderTasksInGroup } from '@/features/task-management/task-management.slice'; +import { selectCurrentGrouping } from '@/features/task-management/grouping.slice'; +import { Task, TaskGroup, getSortOrderField } from '@/types/task-management.types'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useParams } from 'react-router-dom'; +import { useAuthService } from '@/hooks/useAuth'; export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const dispatch = useAppDispatch(); + const { socket, connected } = useSocket(); + const { projectId } = useParams(); + const currentGrouping = useAppSelector(selectCurrentGrouping); + const currentSession = useAuthService().getCurrentSession(); const [activeId, setActiveId] = useState(null); + // Helper function to emit socket event for persistence + const emitTaskSortChange = useCallback( + (taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => { + if (!socket || !connected || !projectId) { + console.warn('Socket not connected or missing project ID'); + return; + } + + const task = allTasks.find(t => t.id === taskId); + if (!task) { + console.error('Task not found for socket emission:', taskId); + return; + } + + // Get team_id from current session + const teamId = currentSession?.team_id || ''; + + // Calculate sort orders for socket emission using the appropriate sort field + const sortField = getSortOrderField(currentGrouping); + const fromIndex = (task as any)[sortField] || task.order || 0; + let toIndex = 0; + let toLastIndex = false; + + if (targetGroup.taskIds.length === 0) { + toIndex = 0; + toLastIndex = true; + } else if (insertIndex >= targetGroup.taskIds.length) { + // Dropping at the end + const lastTask = allTasks.find(t => t.id === targetGroup.taskIds[targetGroup.taskIds.length - 1]); + toIndex = ((lastTask as any)?.[sortField] || lastTask?.order || 0) + 1; + toLastIndex = true; + } else { + // Dropping at specific position + const targetTask = allTasks.find(t => t.id === targetGroup.taskIds[insertIndex]); + toIndex = (targetTask as any)?.[sortField] || targetTask?.order || insertIndex; + toLastIndex = false; + } + + const socketData = { + project_id: projectId, + from_index: fromIndex, + to_index: toIndex, + to_last_index: toLastIndex, + from_group: sourceGroup.id, + to_group: targetGroup.id, + group_by: currentGrouping || 'status', + task: { + id: task.id, + project_id: projectId, + status: task.status || '', + priority: task.priority || '', + }, + team_id: teamId, + }; + + console.log('Emitting TASK_SORT_ORDER_CHANGE:', socketData); + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); + }, + [socket, connected, projectId, allTasks, currentGrouping, currentSession] + ); + const handleDragStart = useCallback((event: DragStartEvent) => { setActiveId(event.active.id as string); }, []); @@ -124,16 +195,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { newPosition: insertIndex, }); - // Move task to the target group - dispatch( - moveTaskBetweenGroups({ - taskId: activeId as string, - sourceGroupId: activeGroup.id, - targetGroupId: targetGroup.id, - }) - ); - - // Reorder task within target group at drop position + // reorderTasksInGroup handles both same-group and cross-group moves + // No need for separate moveTaskBetweenGroups call dispatch( reorderTasksInGroup({ sourceTaskId: activeId as string, @@ -142,6 +205,9 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { destinationGroupId: targetGroup.id, }) ); + + // Emit socket event for persistence + emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); } else { // Reordering within the same group console.log('Reordering task within same group:', { @@ -161,10 +227,13 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { destinationGroupId: activeGroup.id, }) ); + + // Emit socket event for persistence + emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); } } }, - [allTasks, groups, dispatch] + [allTasks, groups, dispatch, emitTaskSortChange] ); return { diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index ef2f34f9..8f953d1a 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -7,7 +7,7 @@ import { EntityId, createSelector, } from '@reduxjs/toolkit'; -import { Task, TaskManagementState, TaskGroup, TaskGrouping } from '@/types/task-management.types'; +import { Task, TaskManagementState, TaskGroup, TaskGrouping, getSortOrderField } from '@/types/task-management.types'; import { ITaskListColumn } from '@/types/tasks/taskList.types'; import { RootState } from '@/app/store'; import { @@ -661,11 +661,11 @@ const taskManagementSlice = createSlice({ newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); group.taskIds = newTasks; - // Update order for affected tasks. Assuming simple reordering affects order. - // This might need more sophisticated logic based on how `order` is used. + // Update order for affected tasks using the appropriate sort field + const sortField = getSortOrderField(state.grouping?.id); newTasks.forEach((id, index) => { if (newEntities[id]) { - newEntities[id] = { ...newEntities[id], order: index }; + newEntities[id] = { ...newEntities[id], [sortField]: index }; } }); } @@ -723,12 +723,13 @@ const taskManagementSlice = createSlice({ newEntities[sourceTaskId] = updatedTask; } - // Update order for affected tasks in both groups if necessary + // Update order for affected tasks in both groups using the appropriate sort field + const sortField = getSortOrderField(state.grouping?.id); sourceGroup.taskIds.forEach((id, index) => { - if (newEntities[id]) newEntities[id] = { ...newEntities[id], order: index }; + if (newEntities[id]) newEntities[id] = { ...newEntities[id], [sortField]: index }; }); destinationGroup.taskIds.forEach((id, index) => { - if (newEntities[id]) newEntities[id] = { ...newEntities[id], order: index }; + if (newEntities[id]) newEntities[id] = { ...newEntities[id], [sortField]: index }; }); } } diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 87847125..11f6dd6a 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -41,6 +41,10 @@ export interface Task { has_subscribers?: boolean; schedule_id?: string | null; order?: number; + status_sort_order?: number; // Sort order when grouped by status + priority_sort_order?: number; // Sort order when grouped by priority + phase_sort_order?: number; // Sort order when grouped by phase + member_sort_order?: number; // Sort order when grouped by members reporter?: string; // Reporter field timeTracking?: { // Time tracking information logged?: number; @@ -173,3 +177,21 @@ export interface BulkAction { value?: any; taskIds: string[]; } + +// Helper function to get the appropriate sort order field based on grouping +export function getSortOrderField(grouping: string | undefined): keyof Task { + switch (grouping) { + case 'status': + return 'status_sort_order'; + case 'priority': + return 'priority_sort_order'; + case 'phase': + return 'phase_sort_order'; + case 'members': + return 'member_sort_order'; + case 'general': + return 'order'; // explicit general sorting + default: + return 'status_sort_order'; // Default to status sorting to match backend + } +} From 6d8c475e6769629bd425da8cb1070b1783ec924a Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 13:18:51 +0530 Subject: [PATCH 05/14] refactor(sort-orders): remove outdated deployment and implementation guides - Deleted the `DEPLOYMENT_GUIDE_SORT_ORDERS.md` and `SEPARATE_SORT_ORDERS_IMPLEMENTATION.md` files as they are no longer relevant following the recent updates to the sort orders feature. - Introduced new migration scripts to address duplicate sort orders and ensure data integrity across the updated task sorting system. - Updated database schema to include new sort order columns and constraints for improved performance and organization. - Enhanced backend functions and frontend components to support the new sorting logic and maintain user experience during task organization. --- .claude/settings.local.json | 11 + DEPLOYMENT_GUIDE_SORT_ORDERS.md | 140 -------- SEPARATE_SORT_ORDERS_IMPLEMENTATION.md | 162 ---------- .../migrations/fix_duplicate_sort_orders.sql | 300 ++++++++++++++++++ worklenz-backend/database/sql/1_tables.sql | 60 ++-- worklenz-backend/database/sql/4_functions.sql | 89 +++++- .../src/controllers/tasks-controller-v2.ts | 39 ++- .../commands/on-task-sort-order-change.ts | 21 +- .../public/locales/alb/task-list-table.json | 1 + .../public/locales/de/task-list-table.json | 1 + .../public/locales/en/task-list-table.json | 1 + .../public/locales/es/task-list-table.json | 1 + .../public/locales/pt/task-list-table.json | 1 + .../public/locales/zh/task-list-table.json | 1 + .../src/components/CustomColordLabel.tsx | 28 +- .../src/components/CustomNumberLabel.tsx | 15 +- .../task-list-v2/TaskListV2Table.tsx | 176 +++++++--- .../src/components/task-list-v2/TaskRow.tsx | 2 +- .../components/TaskRowColumns.tsx | 3 +- .../task-list-v2/constants/columns.ts | 6 +- .../task-list-v2/hooks/useDragAndDrop.ts | 102 ++++-- .../task-management/taskListFields.slice.ts | 8 +- 22 files changed, 718 insertions(+), 450 deletions(-) create mode 100644 .claude/settings.local.json delete mode 100644 DEPLOYMENT_GUIDE_SORT_ORDERS.md delete mode 100644 SEPARATE_SORT_ORDERS_IMPLEMENTATION.md create mode 100644 worklenz-backend/database/migrations/fix_duplicate_sort_orders.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..06f61982 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(npm run build:*)", + "Bash(npm run type-check:*)", + "Bash(npm run:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/DEPLOYMENT_GUIDE_SORT_ORDERS.md b/DEPLOYMENT_GUIDE_SORT_ORDERS.md deleted file mode 100644 index 541e1f87..00000000 --- a/DEPLOYMENT_GUIDE_SORT_ORDERS.md +++ /dev/null @@ -1,140 +0,0 @@ -# Deployment Guide: Separate Sort Orders Feature - -## Issue Resolution -The unique constraint error `"duplicate key value violates unique constraint tasks_sort_order_unique"` has been fixed by ensuring that: - -1. The new grouping-specific sort columns don't have unique constraints -2. We only update the main `sort_order` column when explicitly needed -3. Database functions properly handle the different sort columns - -## Required Migrations (Run in Order) - -### 1. Schema Changes -```bash -psql -d worklenz -f database/migrations/20250715000000-add-grouping-sort-orders.sql -``` - -### 2. Function Updates -```bash -psql -d worklenz -f database/migrations/20250715000001-update-sort-functions.sql -``` - -### 3. Constraint Fixes -```bash -psql -d worklenz -f database/migrations/20250715000002-fix-sort-constraint.sql -``` - -## Verification Steps - -### 1. Test Database Functions -```bash -psql -d worklenz -f test_sort_fix.sql -``` - -### 2. Verify Schema -```sql --- Check new columns exist -\d tasks - --- Verify helper function works -SELECT get_sort_column_name('status'); -``` - -### 3. Test Sort Operations -```sql --- Test bulk update (replace with real UUIDs) -SELECT update_task_sort_orders_bulk( - '[{"task_id": "real-uuid", "sort_order": 1}]'::json, - 'status' -); -``` - -## Key Changes Made - -### Database Layer -- **New Columns:** Added `status_sort_order`, `priority_sort_order`, `phase_sort_order`, `member_sort_order` -- **No Unique Constraints:** New columns allow duplicate values (by design) -- **Fixed Functions:** Updated to avoid touching `sort_order` column unnecessarily -- **Data Migration:** Existing tasks get their current `sort_order` copied to all new columns - -### Backend Layer -- **Socket Handler:** Updated to use correct sort column based on `group_by` -- **Function Calls:** Pass grouping parameter to database functions -- **Error Handling:** Avoid constraint violations by working with right columns - -### Frontend Layer -- **Type Safety:** Added new sort order fields to Task interface -- **Helper Function:** `getSortOrderField()` for consistent field selection -- **Redux Updates:** Use appropriate sort field in state management -- **Drag & Drop:** Updated to work with grouping-specific sort orders - -## Behavior Changes - -### Before Fix -- All groupings shared same `sort_order` column -- Constraint violations when multiple tasks had same sort value -- Lost organization when switching between grouping views - -### After Fix -- Each grouping type has its own sort order column -- No constraint violations (new columns don't have unique constraints) -- Task organization preserved when switching between views -- Backward compatible with existing data - -## Troubleshooting - -### If Migration Fails -1. **Check Permissions:** Ensure database user has CREATE/ALTER privileges -2. **Backup First:** Always backup before running migrations -3. **Check Dependencies:** Ensure functions `is_null_or_empty` exists - -### If Constraint Errors Persist -1. **Check Which Column:** Error should specify which column is causing the issue -2. **Run Data Fix:** The migration includes a data cleanup step -3. **Verify Functions:** Ensure updated functions are being used - -### Rollback Plan -```sql --- If needed, rollback to original functions --- (Save original function definitions first) - --- Remove new columns (WARNING: This loses data) -ALTER TABLE tasks DROP COLUMN IF EXISTS status_sort_order; -ALTER TABLE tasks DROP COLUMN IF EXISTS priority_sort_order; -ALTER TABLE tasks DROP COLUMN IF EXISTS phase_sort_order; -ALTER TABLE tasks DROP COLUMN IF EXISTS member_sort_order; -``` - -## Performance Impact - -### Positive -- ✅ Better user experience with preserved sort orders -- ✅ More efficient queries (appropriate indexes added) -- ✅ Reduced conflicts during concurrent operations - -### Considerations -- 📊 Minimal storage increase (4 integers per task) -- 📊 Slightly more complex database functions -- 📊 No significant performance impact expected - -## Testing Checklist - -- [ ] Migrations run successfully without errors -- [ ] New columns exist and are populated -- [ ] Helper functions return correct column names -- [ ] Drag and drop works in status view -- [ ] Drag and drop works in priority view -- [ ] Drag and drop works in phase view -- [ ] Drag and drop works in member view -- [ ] Sort orders persist when switching between views -- [ ] No constraint violation errors in logs -- [ ] Existing functionality still works -- [ ] Performance is acceptable - -## Success Metrics - -After deployment, verify: -1. **No Error Logs:** No constraint violation errors in application logs -2. **User Feedback:** Users can organize tasks differently in different views -3. **Data Integrity:** Task sort orders are preserved correctly -4. **Performance:** No significant slowdown in task operations \ No newline at end of file diff --git a/SEPARATE_SORT_ORDERS_IMPLEMENTATION.md b/SEPARATE_SORT_ORDERS_IMPLEMENTATION.md deleted file mode 100644 index 8426cb02..00000000 --- a/SEPARATE_SORT_ORDERS_IMPLEMENTATION.md +++ /dev/null @@ -1,162 +0,0 @@ -# Separate Sort Orders Implementation - -## Overview -This implementation adds support for maintaining different task sort orders for each grouping type (status, priority, phase, members). This allows users to organize tasks differently when switching between different views while preserving their organization intent. - -## Changes Made - -### 1. Database Schema Changes -**File:** `/database/migrations/20250715000000-add-grouping-sort-orders.sql` - -- Added 4 new columns to the `tasks` table: - - `status_sort_order` - Sort order when grouped by status - - `priority_sort_order` - Sort order when grouped by priority - - `phase_sort_order` - Sort order when grouped by phase - - `member_sort_order` - Sort order when grouped by members/assignees - -- Added constraints and indexes for performance -- Initialized new columns with current `sort_order` values for backward compatibility - -### 2. Database Functions Update -**File:** `/database/migrations/20250715000001-update-sort-functions.sql` - -- **`get_sort_column_name()`** - Helper function to get appropriate column name based on grouping -- **`update_task_sort_orders_bulk()`** - Updated to accept grouping parameter and update correct sort column -- **`handle_task_list_sort_order_change()`** - Updated to use dynamic SQL for different sort columns - -### 3. Backend Socket Handler Updates -**File:** `/src/socket.io/commands/on-task-sort-order-change.ts` - -- Updated `emitSortOrderChange()` to use appropriate sort column based on `group_by` -- Modified bulk update calls to pass `group_by` parameter -- Enhanced query to return both general and current sort orders - -### 4. Frontend Type Definitions -**File:** `/src/types/task-management.types.ts` - -- Added new sort order fields to `Task` interface -- Created `getSortOrderField()` helper function for type-safe field selection - -### 5. Redux State Management -**File:** `/src/features/task-management/task-management.slice.ts` - -- Updated `reorderTasksInGroup` reducer to use appropriate sort field based on grouping -- Integrated `getSortOrderField()` helper for consistent field selection - -### 6. Drag and Drop Implementation -**File:** `/src/components/task-list-v2/hooks/useDragAndDrop.ts` - -- Updated `emitTaskSortChange()` to use grouping-specific sort order fields -- Enhanced sort order calculation to work with different sort columns - -## Usage Examples - -### User Experience -1. **Status View:** User arranges tasks by business priority within each status column -2. **Priority View:** User switches to priority view - tasks maintain their status-specific order within each priority group -3. **Phase View:** User switches to phase view - tasks maintain their own organization within each phase -4. **Back to Status:** Returning to status view shows the original organization - -### API Usage -```javascript -// Socket emission now includes group_by parameter -socket.emit('TASK_SORT_ORDER_CHANGE', { - project_id: 'uuid', - group_by: 'status', // 'status', 'priority', 'phase', 'members' - task_updates: [{ - task_id: 'uuid', - sort_order: 1, - status_id: 'uuid' // if moving between status groups - }] -}); -``` - -### Database Query Examples -```sql --- Get tasks ordered by status grouping -SELECT * FROM tasks -WHERE project_id = $1 -ORDER BY status_sort_order; - --- Get tasks ordered by priority grouping -SELECT * FROM tasks -WHERE project_id = $1 -ORDER BY priority_sort_order; -``` - -## Migration Steps - -1. **Run Database Migrations:** - ```bash - # Apply schema changes - psql -d worklenz -f database/migrations/20250715000000-add-grouping-sort-orders.sql - - # Apply function updates - psql -d worklenz -f database/migrations/20250715000001-update-sort-functions.sql - ``` - -2. **Test Migration:** - ```bash - # Verify columns and functions - psql -d worklenz -f test_sort_orders.sql - ``` - -3. **Deploy Frontend Changes:** - - No additional steps needed - changes are backward compatible - - Users will immediately benefit from separate sort orders - -## Backward Compatibility - -- ✅ Existing `sort_order` column remains unchanged -- ✅ New columns initialized with current `sort_order` values -- ✅ Old API calls continue to work (default to status grouping) -- ✅ Frontend gracefully falls back to `order` field if new fields not available - -## Performance Considerations - -- Added indexes on new sort order columns for efficient ordering -- Dynamic SQL in functions is minimal and safe (controlled input) -- Memory footprint increase is minimal (4 integers per task) - -## Testing - -1. **Database Level:** - - Verify migrations run successfully - - Test function calls with different grouping parameters - - Validate indexes are created and used - -2. **API Level:** - - Test socket emissions with different `group_by` values - - Verify correct sort columns are updated - - Test cross-group task moves - -3. **Frontend Level:** - - Test drag and drop in different grouping views - - Verify sort order persistence when switching views - - Test that original behavior is preserved - -## Future Enhancements - -1. **UI Indicators:** Show users which view they're currently organizing -2. **Sort Order Reset:** Allow users to reset sort orders for specific groupings -3. **Export/Import:** Include sort order data in project templates -4. **Analytics:** Track how users organize tasks in different views - -## Troubleshooting - -### Common Issues: -1. **Migration Fails:** Check database permissions and existing data integrity -2. **Sort Orders Not Persisting:** Verify socket handler receives `group_by` parameter -3. **Tasks Not Reordering:** Check frontend Redux state updates and sort field usage - -### Debug Queries: -```sql --- Check current sort orders for a project -SELECT id, name, status_sort_order, priority_sort_order, phase_sort_order, member_sort_order -FROM tasks -WHERE project_id = 'your-project-id' -ORDER BY status_sort_order; - --- Verify function calls -SELECT get_sort_column_name('status'); -- Should return 'status_sort_order' -``` \ No newline at end of file diff --git a/worklenz-backend/database/migrations/fix_duplicate_sort_orders.sql b/worklenz-backend/database/migrations/fix_duplicate_sort_orders.sql new file mode 100644 index 00000000..689edaf7 --- /dev/null +++ b/worklenz-backend/database/migrations/fix_duplicate_sort_orders.sql @@ -0,0 +1,300 @@ +-- Fix Duplicate Sort Orders Script +-- This script detects and fixes duplicate sort order values that break task ordering + +-- 1. DETECTION QUERIES - Run these first to see the scope of the problem + +-- Check for duplicates in main sort_order column +SELECT + project_id, + sort_order, + COUNT(*) as duplicate_count, + STRING_AGG(id::text, ', ') as task_ids +FROM tasks +WHERE project_id IS NOT NULL +GROUP BY project_id, sort_order +HAVING COUNT(*) > 1 +ORDER BY project_id, sort_order; + +-- Check for duplicates in status_sort_order +SELECT + project_id, + status_sort_order, + COUNT(*) as duplicate_count, + STRING_AGG(id::text, ', ') as task_ids +FROM tasks +WHERE project_id IS NOT NULL +GROUP BY project_id, status_sort_order +HAVING COUNT(*) > 1 +ORDER BY project_id, status_sort_order; + +-- Check for duplicates in priority_sort_order +SELECT + project_id, + priority_sort_order, + COUNT(*) as duplicate_count, + STRING_AGG(id::text, ', ') as task_ids +FROM tasks +WHERE project_id IS NOT NULL +GROUP BY project_id, priority_sort_order +HAVING COUNT(*) > 1 +ORDER BY project_id, priority_sort_order; + +-- Check for duplicates in phase_sort_order +SELECT + project_id, + phase_sort_order, + COUNT(*) as duplicate_count, + STRING_AGG(id::text, ', ') as task_ids +FROM tasks +WHERE project_id IS NOT NULL +GROUP BY project_id, phase_sort_order +HAVING COUNT(*) > 1 +ORDER BY project_id, phase_sort_order; + +-- Note: member_sort_order removed - no longer used + +-- 2. CLEANUP FUNCTIONS + +-- Fix duplicates in main sort_order column +CREATE OR REPLACE FUNCTION fix_sort_order_duplicates() RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + -- For each project, reassign sort_order values to ensure uniqueness + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + -- Reassign sort_order values sequentially for this project + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY sort_order, created_at + LOOP + UPDATE tasks + SET sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Fixed sort_order duplicates for all projects'; +END +$$; + +-- Fix duplicates in status_sort_order column +CREATE OR REPLACE FUNCTION fix_status_sort_order_duplicates() RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY status_sort_order, created_at + LOOP + UPDATE tasks + SET status_sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Fixed status_sort_order duplicates for all projects'; +END +$$; + +-- Fix duplicates in priority_sort_order column +CREATE OR REPLACE FUNCTION fix_priority_sort_order_duplicates() RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY priority_sort_order, created_at + LOOP + UPDATE tasks + SET priority_sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Fixed priority_sort_order duplicates for all projects'; +END +$$; + +-- Fix duplicates in phase_sort_order column +CREATE OR REPLACE FUNCTION fix_phase_sort_order_duplicates() RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _project RECORD; + _task RECORD; + _counter INTEGER; +BEGIN + FOR _project IN + SELECT DISTINCT project_id + FROM tasks + WHERE project_id IS NOT NULL + LOOP + _counter := 0; + + FOR _task IN + SELECT id + FROM tasks + WHERE project_id = _project.project_id + ORDER BY phase_sort_order, created_at + LOOP + UPDATE tasks + SET phase_sort_order = _counter + WHERE id = _task.id; + + _counter := _counter + 1; + END LOOP; + END LOOP; + + RAISE NOTICE 'Fixed phase_sort_order duplicates for all projects'; +END +$$; + +-- Note: fix_member_sort_order_duplicates() removed - no longer needed + +-- Master function to fix all sort order duplicates +CREATE OR REPLACE FUNCTION fix_all_duplicate_sort_orders() RETURNS void + LANGUAGE plpgsql +AS +$$ +BEGIN + RAISE NOTICE 'Starting sort order cleanup for all columns...'; + + PERFORM fix_sort_order_duplicates(); + PERFORM fix_status_sort_order_duplicates(); + PERFORM fix_priority_sort_order_duplicates(); + PERFORM fix_phase_sort_order_duplicates(); + + RAISE NOTICE 'Completed sort order cleanup for all columns'; +END +$$; + +-- 3. VERIFICATION FUNCTION + +-- Verify that duplicates have been fixed +CREATE OR REPLACE FUNCTION verify_sort_order_integrity() RETURNS TABLE( + column_name text, + project_id uuid, + duplicate_count bigint, + status text +) + LANGUAGE plpgsql +AS +$$ +BEGIN + -- Check sort_order duplicates + RETURN QUERY + SELECT + 'sort_order'::text as column_name, + t.project_id, + COUNT(*) as duplicate_count, + CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status + FROM tasks t + WHERE t.project_id IS NOT NULL + GROUP BY t.project_id, t.sort_order + HAVING COUNT(*) > 1; + + -- Check status_sort_order duplicates + RETURN QUERY + SELECT + 'status_sort_order'::text as column_name, + t.project_id, + COUNT(*) as duplicate_count, + CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status + FROM tasks t + WHERE t.project_id IS NOT NULL + GROUP BY t.project_id, t.status_sort_order + HAVING COUNT(*) > 1; + + -- Check priority_sort_order duplicates + RETURN QUERY + SELECT + 'priority_sort_order'::text as column_name, + t.project_id, + COUNT(*) as duplicate_count, + CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status + FROM tasks t + WHERE t.project_id IS NOT NULL + GROUP BY t.project_id, t.priority_sort_order + HAVING COUNT(*) > 1; + + -- Check phase_sort_order duplicates + RETURN QUERY + SELECT + 'phase_sort_order'::text as column_name, + t.project_id, + COUNT(*) as duplicate_count, + CASE WHEN COUNT(*) > 1 THEN 'DUPLICATES FOUND' ELSE 'OK' END as status + FROM tasks t + WHERE t.project_id IS NOT NULL + GROUP BY t.project_id, t.phase_sort_order + HAVING COUNT(*) > 1; + + -- Note: member_sort_order verification removed - column no longer used + +END +$$; + +-- 4. USAGE INSTRUCTIONS + +/* +USAGE: + +1. First, run the detection queries to see which projects have duplicates +2. Then run this to fix all duplicates: + SELECT fix_all_duplicate_sort_orders(); +3. Finally, verify the fix worked: + SELECT * FROM verify_sort_order_integrity(); + +If verification returns no rows, all duplicates have been fixed successfully. + +WARNING: This will reassign sort order values based on current order + creation time. +Make sure to backup your database before running these functions. +*/ \ No newline at end of file diff --git a/worklenz-backend/database/sql/1_tables.sql b/worklenz-backend/database/sql/1_tables.sql index 21f498f1..2ab00077 100644 --- a/worklenz-backend/database/sql/1_tables.sql +++ b/worklenz-backend/database/sql/1_tables.sql @@ -1391,27 +1391,30 @@ ALTER TABLE task_work_log CHECK (time_spent >= (0)::NUMERIC); CREATE TABLE IF NOT EXISTS tasks ( - id UUID DEFAULT uuid_generate_v4() NOT NULL, - name TEXT NOT NULL, - description TEXT, - done BOOLEAN DEFAULT FALSE NOT NULL, - total_minutes NUMERIC DEFAULT 0 NOT NULL, - archived BOOLEAN DEFAULT FALSE NOT NULL, - task_no BIGINT NOT NULL, - start_date TIMESTAMP WITH TIME ZONE, - end_date TIMESTAMP WITH TIME ZONE, - priority_id UUID NOT NULL, - project_id UUID NOT NULL, - reporter_id UUID NOT NULL, - parent_task_id UUID, - status_id UUID NOT NULL, - completed_at TIMESTAMP WITH TIME ZONE, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, - sort_order INTEGER DEFAULT 0 NOT NULL, - roadmap_sort_order INTEGER DEFAULT 0 NOT NULL, - billable BOOLEAN DEFAULT TRUE, - schedule_id UUID + id UUID DEFAULT uuid_generate_v4() NOT NULL, + name TEXT NOT NULL, + description TEXT, + done BOOLEAN DEFAULT FALSE NOT NULL, + total_minutes NUMERIC DEFAULT 0 NOT NULL, + archived BOOLEAN DEFAULT FALSE NOT NULL, + task_no BIGINT NOT NULL, + start_date TIMESTAMP WITH TIME ZONE, + end_date TIMESTAMP WITH TIME ZONE, + priority_id UUID NOT NULL, + project_id UUID NOT NULL, + reporter_id UUID NOT NULL, + parent_task_id UUID, + status_id UUID NOT NULL, + completed_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + sort_order INTEGER DEFAULT 0 NOT NULL, + roadmap_sort_order INTEGER DEFAULT 0 NOT NULL, + status_sort_order INTEGER DEFAULT 0 NOT NULL, + priority_sort_order INTEGER DEFAULT 0 NOT NULL, + phase_sort_order INTEGER DEFAULT 0 NOT NULL, + billable BOOLEAN DEFAULT TRUE, + schedule_id UUID ); ALTER TABLE tasks @@ -1499,6 +1502,21 @@ ALTER TABLE tasks ADD CONSTRAINT tasks_total_minutes_check CHECK ((total_minutes >= (0)::NUMERIC) AND (total_minutes <= (999999)::NUMERIC)); +-- Add constraints for new sort order columns +ALTER TABLE tasks ADD CONSTRAINT tasks_status_sort_order_check CHECK (status_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_priority_sort_order_check CHECK (priority_sort_order >= 0); +ALTER TABLE tasks ADD CONSTRAINT tasks_phase_sort_order_check CHECK (phase_sort_order >= 0); + +-- Add indexes for performance on new sort order columns +CREATE INDEX IF NOT EXISTS idx_tasks_status_sort_order ON tasks(project_id, status_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_priority_sort_order ON tasks(project_id, priority_sort_order); +CREATE INDEX IF NOT EXISTS idx_tasks_phase_sort_order ON tasks(project_id, phase_sort_order); + +-- Add comments for documentation +COMMENT ON COLUMN tasks.status_sort_order IS 'Sort order when grouped by status'; +COMMENT ON COLUMN tasks.priority_sort_order IS 'Sort order when grouped by priority'; +COMMENT ON COLUMN tasks.phase_sort_order IS 'Sort order when grouped by phase'; + CREATE TABLE IF NOT EXISTS tasks_assignees ( task_id UUID NOT NULL, project_member_id UUID NOT NULL, diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 81d54e52..d2c752d2 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -4608,31 +4608,31 @@ BEGIN INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) VALUES (_project_id, 'Progress', 'PROGRESS', 3, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Members', 'ASSIGNEES', 4, TRUE); + VALUES (_project_id, 'Status', 'STATUS', 4, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Labels', 'LABELS', 5, TRUE); + VALUES (_project_id, 'Members', 'ASSIGNEES', 5, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Status', 'STATUS', 6, TRUE); + VALUES (_project_id, 'Labels', 'LABELS', 6, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Priority', 'PRIORITY', 7, TRUE); + VALUES (_project_id, 'Phase', 'PHASE', 7, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 8, TRUE); + VALUES (_project_id, 'Priority', 'PRIORITY', 8, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Estimation', 'ESTIMATION', 9, FALSE); + VALUES (_project_id, 'Time Tracking', 'TIME_TRACKING', 9, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Start Date', 'START_DATE', 10, FALSE); + VALUES (_project_id, 'Estimation', 'ESTIMATION', 10, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Due Date', 'DUE_DATE', 11, TRUE); + VALUES (_project_id, 'Start Date', 'START_DATE', 11, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 12, FALSE); + VALUES (_project_id, 'Due Date', 'DUE_DATE', 12, TRUE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Created Date', 'CREATED_DATE', 13, FALSE); + VALUES (_project_id, 'Completed Date', 'COMPLETED_DATE', 13, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 14, FALSE); + VALUES (_project_id, 'Created Date', 'CREATED_DATE', 14, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Reporter', 'REPORTER', 15, FALSE); + VALUES (_project_id, 'Last Updated', 'LAST_UPDATED', 15, FALSE); INSERT INTO project_task_list_cols (project_id, name, key, index, pinned) - VALUES (_project_id, 'Phase', 'PHASE', 16, FALSE); + VALUES (_project_id, 'Reporter', 'REPORTER', 16, FALSE); END $$; @@ -6585,3 +6585,66 @@ BEGIN END LOOP; END $$; + +-- Function to get the appropriate sort column name based on grouping type +CREATE OR REPLACE FUNCTION get_sort_column_name(_group_by TEXT) RETURNS TEXT + LANGUAGE plpgsql +AS +$$ +BEGIN + CASE _group_by + WHEN 'status' THEN RETURN 'status_sort_order'; + WHEN 'priority' THEN RETURN 'priority_sort_order'; + WHEN 'phase' THEN RETURN 'phase_sort_order'; + -- For backward compatibility, still support general sort_order but be explicit + WHEN 'general' THEN RETURN 'sort_order'; + ELSE RETURN 'status_sort_order'; -- Default to status sorting + END CASE; +END; +$$; + +-- Updated bulk sort order function to handle different sort columns +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json, _group_by text DEFAULT 'status') RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; + _sort_column TEXT; + _sql TEXT; +BEGIN + -- Get the appropriate sort column based on grouping + _sort_column := get_sort_column_name(_group_by); + + -- Process each update record + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + -- Update the grouping-specific sort column and other fields + _sql := 'UPDATE tasks SET ' || _sort_column || ' = $1, ' || + 'status_id = COALESCE($2, status_id), ' || + 'priority_id = COALESCE($3, priority_id), ' || + 'updated_at = CURRENT_TIMESTAMP ' || + 'WHERE id = $4'; + + EXECUTE _sql USING + _update_record.sort_order, + _update_record.status_id, + _update_record.priority_id, + _update_record.task_id; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END; +$$; diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index d941f824..d38a563d 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -109,12 +109,29 @@ export default class TasksControllerV2 extends TasksControllerBase { } private static getQuery(userId: string, options: ParsedQs) { - const searchField = options.search ? ["t.name", "CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no)"] : "sort_order"; + // Determine which sort column to use based on grouping + const groupBy = options.group || 'status'; + let defaultSortColumn = 'sort_order'; + switch (groupBy) { + case 'status': + defaultSortColumn = 'status_sort_order'; + break; + case 'priority': + defaultSortColumn = 'priority_sort_order'; + break; + case 'phase': + defaultSortColumn = 'phase_sort_order'; + break; + default: + defaultSortColumn = 'sort_order'; + } + + const searchField = options.search ? ["t.name", "CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no)"] : defaultSortColumn; const { searchQuery, sortField } = TasksControllerV2.toPaginationOptions(options, searchField); const isSubTasks = !!options.parent_task; - const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || "sort_order"; + const sortFields = sortField.replace(/ascend/g, "ASC").replace(/descend/g, "DESC") || defaultSortColumn; // Filter tasks by statuses const statusesFilter = TasksControllerV2.getFilterByStatusWhereClosure(options.statuses as string); @@ -196,6 +213,9 @@ export default class TasksControllerV2 extends TasksControllerBase { t.archived, t.description, t.sort_order, + t.status_sort_order, + t.priority_sort_order, + t.phase_sort_order, t.progress_value, t.manual_progress, t.weight, @@ -1088,7 +1108,7 @@ export default class TasksControllerV2 extends TasksControllerBase { custom_column_values: task.custom_column_values || {}, // Include custom column values createdAt: task.created_at || new Date().toISOString(), updatedAt: task.updated_at || new Date().toISOString(), - order: typeof task.sort_order === "number" ? task.sort_order : 0, + order: TasksControllerV2.getTaskSortOrder(task, groupBy), // Additional metadata for frontend originalStatusId: task.status, originalPriorityId: task.priority, @@ -1292,6 +1312,19 @@ export default class TasksControllerV2 extends TasksControllerBase { })); } + private static getTaskSortOrder(task: any, groupBy: string): number { + switch (groupBy) { + case GroupBy.STATUS: + return typeof task.status_sort_order === "number" ? task.status_sort_order : 0; + case GroupBy.PRIORITY: + return typeof task.priority_sort_order === "number" ? task.priority_sort_order : 0; + case GroupBy.PHASE: + return typeof task.phase_sort_order === "number" ? task.phase_sort_order : 0; + default: + return typeof task.sort_order === "number" ? task.sort_order : 0; + } + } + private static getDefaultGroupColor(groupBy: string, groupValue: string): string { const colorMaps: Record> = { [GroupBy.STATUS]: { diff --git a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts index 77a8af87..8493df10 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts @@ -54,22 +54,19 @@ function notifyStatusChange(socket: Socket, config: Config) { async function emitSortOrderChange(data: ChangeRequest, socket: Socket) { // Determine which sort column to use based on group_by - let sortColumn = 'sort_order'; + let sortColumn = "sort_order"; switch (data.group_by) { - case 'status': - sortColumn = 'status_sort_order'; + case "status": + sortColumn = "status_sort_order"; break; - case 'priority': - sortColumn = 'priority_sort_order'; + case "priority": + sortColumn = "priority_sort_order"; break; - case 'phase': - sortColumn = 'phase_sort_order'; - break; - case 'members': - sortColumn = 'member_sort_order'; + case "phase": + sortColumn = "phase_sort_order"; break; default: - sortColumn = 'sort_order'; + sortColumn = "sort_order"; } const q = ` @@ -105,7 +102,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat // Use the simple bulk update function with group_by parameter const q = `SELECT update_task_sort_orders_bulk($1, $2);`; - await db.query(q, [JSON.stringify(data.task_updates), data.group_by || 'status']); + await db.query(q, [JSON.stringify(data.task_updates), data.group_by || "status"]); await emitSortOrderChange(data, socket); // Handle notifications and logging diff --git a/worklenz-frontend/public/locales/alb/task-list-table.json b/worklenz-frontend/public/locales/alb/task-list-table.json index 7e3f83dd..c009e734 100644 --- a/worklenz-frontend/public/locales/alb/task-list-table.json +++ b/worklenz-frontend/public/locales/alb/task-list-table.json @@ -39,6 +39,7 @@ "addTaskText": "Shto Detyrë", "addSubTaskText": "+ Shto Nën-Detyrë", "noTasksInGroup": "Nuk ka detyra në këtë grup", + "dropTaskHere": "Lëshoje detyrën këtu", "addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter", "openButton": "Hap", diff --git a/worklenz-frontend/public/locales/de/task-list-table.json b/worklenz-frontend/public/locales/de/task-list-table.json index 9c2ff314..23439a1b 100644 --- a/worklenz-frontend/public/locales/de/task-list-table.json +++ b/worklenz-frontend/public/locales/de/task-list-table.json @@ -40,6 +40,7 @@ "addSubTaskText": "+ Unteraufgabe hinzufügen", "addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken", "noTasksInGroup": "Keine Aufgaben in dieser Gruppe", + "dropTaskHere": "Aufgabe hier ablegen", "openButton": "Öffnen", "okButton": "OK", diff --git a/worklenz-frontend/public/locales/en/task-list-table.json b/worklenz-frontend/public/locales/en/task-list-table.json index 5c03f203..abd97ca5 100644 --- a/worklenz-frontend/public/locales/en/task-list-table.json +++ b/worklenz-frontend/public/locales/en/task-list-table.json @@ -40,6 +40,7 @@ "addSubTaskText": "Add Sub Task", "addTaskInputPlaceholder": "Type your task and hit enter", "noTasksInGroup": "No tasks in this group", + "dropTaskHere": "Drop task here", "openButton": "Open", "okButton": "Ok", diff --git a/worklenz-frontend/public/locales/es/task-list-table.json b/worklenz-frontend/public/locales/es/task-list-table.json index 0648c2ff..779c76ed 100644 --- a/worklenz-frontend/public/locales/es/task-list-table.json +++ b/worklenz-frontend/public/locales/es/task-list-table.json @@ -39,6 +39,7 @@ "addTaskText": "Agregar tarea", "addSubTaskText": "Agregar subtarea", "noTasksInGroup": "No hay tareas en este grupo", + "dropTaskHere": "Soltar tarea aquí", "addTaskInputPlaceholder": "Escribe tu tarea y presiona enter", "openButton": "Abrir", diff --git a/worklenz-frontend/public/locales/pt/task-list-table.json b/worklenz-frontend/public/locales/pt/task-list-table.json index f53d834f..54fd2a33 100644 --- a/worklenz-frontend/public/locales/pt/task-list-table.json +++ b/worklenz-frontend/public/locales/pt/task-list-table.json @@ -39,6 +39,7 @@ "addTaskText": "Adicionar Tarefa", "addSubTaskText": "+ Adicionar Subtarefa", "noTasksInGroup": "Nenhuma tarefa neste grupo", + "dropTaskHere": "Soltar tarefa aqui", "addTaskInputPlaceholder": "Digite sua tarefa e pressione enter", "openButton": "Abrir", diff --git a/worklenz-frontend/public/locales/zh/task-list-table.json b/worklenz-frontend/public/locales/zh/task-list-table.json index f3ec040f..63718830 100644 --- a/worklenz-frontend/public/locales/zh/task-list-table.json +++ b/worklenz-frontend/public/locales/zh/task-list-table.json @@ -37,6 +37,7 @@ "addSubTaskText": "+ 添加子任务", "addTaskInputPlaceholder": "输入任务并按回车键", "noTasksInGroup": "此组中没有任务", + "dropTaskHere": "将任务拖到这里", "openButton": "打开", "okButton": "确定", "noLabelsFound": "未找到标签", diff --git a/worklenz-frontend/src/components/CustomColordLabel.tsx b/worklenz-frontend/src/components/CustomColordLabel.tsx index 068907f0..ebe9087b 100644 --- a/worklenz-frontend/src/components/CustomColordLabel.tsx +++ b/worklenz-frontend/src/components/CustomColordLabel.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Tooltip } from 'antd'; import { Label } from '@/types/task-management.types'; import { ITaskLabel } from '@/types/tasks/taskLabel.types'; +import { ALPHA_CHANNEL } from '@/shared/constants'; interface CustomColordLabelProps { label: Label | ITaskLabel; @@ -14,36 +15,21 @@ const CustomColordLabel = React.forwardRef 10 ? `${label.name.substring(0, 10)}...` : label.name; // Handle different color property names for different types - const backgroundColor = (label as Label).color || (label as ITaskLabel).color_code || '#6b7280'; // Default to gray-500 if no color + const baseColor = (label as Label).color || (label as ITaskLabel).color_code || '#6b7280'; // Default to gray-500 if no color - // Function to determine if we should use white or black text based on background color - const getTextColor = (bgColor: string): string => { - // Remove # if present - const color = bgColor.replace('#', ''); - - // Convert to RGB - const r = parseInt(color.substr(0, 2), 16); - const g = parseInt(color.substr(2, 2), 16); - const b = parseInt(color.substr(4, 2), 16); - - // Calculate luminance - const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; - - // Return white for dark backgrounds, black for light backgrounds - return luminance > 0.5 ? '#000000' : '#ffffff'; - }; - - const textColor = getTextColor(backgroundColor); + // Add alpha channel to the base color + const backgroundColor = baseColor + ALPHA_CHANNEL; + const textColor = baseColor; return ( {truncatedName} diff --git a/worklenz-frontend/src/components/CustomNumberLabel.tsx b/worklenz-frontend/src/components/CustomNumberLabel.tsx index 89c2d740..c603c1d4 100644 --- a/worklenz-frontend/src/components/CustomNumberLabel.tsx +++ b/worklenz-frontend/src/components/CustomNumberLabel.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Tooltip } from 'antd'; -import { NumbersColorMap } from '@/shared/constants'; +import { NumbersColorMap, ALPHA_CHANNEL } from '@/shared/constants'; interface CustomNumberLabelProps { labelList: string[]; @@ -12,17 +12,24 @@ interface CustomNumberLabelProps { const CustomNumberLabel = React.forwardRef( ({ labelList, namesString, isDarkMode = false, color }, ref) => { // Use provided color, or fall back to NumbersColorMap based on first digit - const backgroundColor = color || (() => { + const baseColor = color || (() => { const firstDigit = namesString.match(/\d/)?.[0] || '0'; return NumbersColorMap[firstDigit] || NumbersColorMap['0']; })(); + + // Add alpha channel to the base color + const backgroundColor = baseColor + ALPHA_CHANNEL; return ( {namesString} diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 624ff623..1cc6c680 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -9,6 +9,7 @@ import { KeyboardSensor, TouchSensor, closestCenter, + useDroppable, } from '@dnd-kit/core'; import { SortableContext, @@ -67,6 +68,101 @@ import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomCo import TaskListSkeleton from './components/TaskListSkeleton'; import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer'; +// Empty Group Drop Zone Component +const EmptyGroupDropZone: React.FC<{ + groupId: string; + visibleColumns: any[]; + t: (key: string) => string; +}> = ({ groupId, visibleColumns, t }) => { + const { setNodeRef, isOver, active } = useDroppable({ + id: `empty-group-${groupId}`, + data: { + type: 'group', + groupId: groupId, + isEmpty: true, + }, + }); + + return ( +
+
+ {visibleColumns.map((column, index) => { + const emptyColumnStyle = { + width: column.width, + flexShrink: 0, + }; + return ( +
+ ); + })} +
+
+
+ {isOver && active ? t('dropTaskHere') || 'Drop task here' : t('noTasksInGroup')} +
+
+ {isOver && active && ( +
+ )} +
+ ); +}; + +// Placeholder Drop Indicator Component +const PlaceholderDropIndicator: React.FC<{ + isVisible: boolean; + visibleColumns: any[]; +}> = ({ isVisible, visibleColumns }) => { + if (!isVisible) return null; + + return ( +
+ {visibleColumns.map((column, index) => { + const columnStyle = { + width: column.width, + flexShrink: 0, + }; + return ( +
+ {/* Show "Drop task here" message in the title column */} + {column.id === 'title' && ( +
+ Drop task here +
+ )} + {/* Show subtle placeholder content in other columns */} + {column.id !== 'title' && column.id !== 'dragHandle' && ( +
+ )} +
+ ); + })} +
+ ); +}; + // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useSocket } from '@/socket/socketContext'; @@ -127,7 +223,7 @@ const TaskListV2Section: React.FC = () => { ); // Custom hooks - const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( + const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( allTasks, groups ); @@ -465,31 +561,11 @@ const TaskListV2Section: React.FC = () => { projectId={urlProjectId || ''} /> {isGroupEmpty && !isGroupCollapsed && ( -
-
- {visibleColumns.map((column, index) => { - const emptyColumnStyle = { - width: column.width, - flexShrink: 0, - ...(column.id === 'labels' && column.width === 'auto' - ? { minWidth: '200px', flexGrow: 1 } - : {}), - }; - return ( -
- ); - })} -
-
-
- {t('noTasksInGroup')} -
-
-
+ )}
); @@ -546,12 +622,6 @@ const TaskListV2Section: React.FC = () => { const columnStyle: ColumnStyle = { width: column.width, flexShrink: 0, - ...(column.id === 'labels' && column.width === 'auto' - ? { - minWidth: '200px', - flexGrow: 1, - } - : {}), ...((column as any).minWidth && { minWidth: (column as any).minWidth }), ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), }; @@ -687,8 +757,9 @@ const TaskListV2Section: React.FC = () => { {renderGroup(groupIndex)} {/* Group Tasks */} - {!collapsedGroups.has(group.id) && - group.tasks.map((task, taskIndex) => { + {!collapsedGroups.has(group.id) && ( + group.tasks.length > 0 ? ( + group.tasks.map((task, taskIndex) => { const globalTaskIndex = virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) + taskIndex; @@ -696,12 +767,41 @@ const TaskListV2Section: React.FC = () => { // Check if this is the first actual task in the group (not AddTaskRow) const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); + // Check if we should show drop indicators + const isTaskBeingDraggedOver = overId === task.id; + const isGroupBeingDraggedOver = overId === group.id; + const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver; + return (
+ {/* Placeholder drop indicator before first task in group */} + {isFirstTaskInGroupBeingDraggedOver && ( + + )} + + {/* Placeholder drop indicator between tasks */} + {isTaskBeingDraggedOver && !isFirstTaskInGroup && ( + + )} + {renderTask(globalTaskIndex, isFirstTaskInGroup)} + + {/* Placeholder drop indicator at end of group when dragging over group */} + {isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && ( + + )}
); - })} + }) + ) : ( + // Handle empty groups with placeholder drop indicator + overId === group.id && ( +
+ +
+ ) + ) + )}
))}
@@ -710,12 +810,12 @@ const TaskListV2Section: React.FC = () => {
{/* Drag Overlay */} - + {activeId ? ( -
+
- +
{allTasks.find(task => task.id === activeId)?.name || diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index 24571b8b..a58b2bb7 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -113,7 +113,7 @@ const TaskRow: React.FC = memo(({ const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), transition, - opacity: isDragging ? 0.5 : 1, + opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging }), [transform, transition, isDragging]); return ( diff --git a/worklenz-frontend/src/components/task-list-v2/components/TaskRowColumns.tsx b/worklenz-frontend/src/components/task-list-v2/components/TaskRowColumns.tsx index b22690ca..78548f50 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/TaskRowColumns.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/TaskRowColumns.tsx @@ -252,10 +252,9 @@ interface LabelsColumnProps { } export const LabelsColumn: React.FC = memo(({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => { - const labelsColumn = visibleColumns.find(col => col.id === 'labels'); const labelsStyle = { width, - ...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {}) + flexShrink: 0 }; return ( diff --git a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts index 5f8add14..e7b34373 100644 --- a/worklenz-frontend/src/components/task-list-v2/constants/columns.ts +++ b/worklenz-frontend/src/components/task-list-v2/constants/columns.ts @@ -19,10 +19,10 @@ export const BASE_COLUMNS = [ { id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME }, { id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION }, { id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS }, - { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, - { id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS }, - { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, { id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS }, + { id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES }, + { id: 'labels', label: 'labelsColumn', width: '250px', key: COLUMN_KEYS.LABELS }, + { id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE }, { id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY }, { id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING }, { id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION }, diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts index ae4930ec..b70f4c69 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -17,6 +17,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const currentGrouping = useAppSelector(selectCurrentGrouping); const currentSession = useAuthService().getCurrentSession(); const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); // Helper function to emit socket event for persistence const emitTaskSortChange = useCallback( @@ -35,35 +36,67 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { // Get team_id from current session const teamId = currentSession?.team_id || ''; - // Calculate sort orders for socket emission using the appropriate sort field - const sortField = getSortOrderField(currentGrouping); - const fromIndex = (task as any)[sortField] || task.order || 0; - let toIndex = 0; - let toLastIndex = false; - - if (targetGroup.taskIds.length === 0) { - toIndex = 0; - toLastIndex = true; - } else if (insertIndex >= targetGroup.taskIds.length) { - // Dropping at the end - const lastTask = allTasks.find(t => t.id === targetGroup.taskIds[targetGroup.taskIds.length - 1]); - toIndex = ((lastTask as any)?.[sortField] || lastTask?.order || 0) + 1; - toLastIndex = true; + // Use new bulk update approach - recalculate ALL task orders to prevent duplicates + const taskUpdates = []; + + // Create a copy of all groups and perform the move operation + const updatedGroups = groups.map(group => ({ + ...group, + taskIds: [...group.taskIds] + })); + + // Find the source and target groups in our copy + const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!; + const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!; + + if (sourceGroup.id === targetGroup.id) { + // Same group - reorder within the group + const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); + // Remove task from old position + sourceGroupCopy.taskIds.splice(sourceIndex, 1); + // Insert at new position + sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId); } else { - // Dropping at specific position - const targetTask = allTasks.find(t => t.id === targetGroup.taskIds[insertIndex]); - toIndex = (targetTask as any)?.[sortField] || targetTask?.order || insertIndex; - toLastIndex = false; + // Different groups - move task between groups + // Remove from source group + const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); + sourceGroupCopy.taskIds.splice(sourceIndex, 1); + + // Add to target group + targetGroupCopy.taskIds.splice(insertIndex, 0, taskId); } + + // Now assign sequential sort orders to ALL tasks across ALL groups + let currentSortOrder = 0; + updatedGroups.forEach(group => { + group.taskIds.forEach(id => { + const update: any = { + task_id: id, + sort_order: currentSortOrder + }; + + // Add group-specific fields for the moved task if it changed groups + if (id === taskId && sourceGroup.id !== targetGroup.id) { + if (currentGrouping === 'status') { + update.status_id = targetGroup.id; + } else if (currentGrouping === 'priority') { + update.priority_id = targetGroup.id; + } else if (currentGrouping === 'phase') { + update.phase_id = targetGroup.id; + } + } + + taskUpdates.push(update); + currentSortOrder++; + }); + }); const socketData = { project_id: projectId, - from_index: fromIndex, - to_index: toIndex, - to_last_index: toLastIndex, + group_by: currentGrouping || 'status', + task_updates: taskUpdates, from_group: sourceGroup.id, to_group: targetGroup.id, - group_by: currentGrouping || 'status', task: { id: task.id, project_id: projectId, @@ -76,7 +109,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { console.log('Emitting TASK_SORT_ORDER_CHANGE:', socketData); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); }, - [socket, connected, projectId, allTasks, currentGrouping, currentSession] + [socket, connected, projectId, allTasks, groups, currentGrouping, currentSession] ); const handleDragStart = useCallback((event: DragStartEvent) => { @@ -87,11 +120,17 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { (event: DragOverEvent) => { const { active, over } = event; - if (!over) return; + if (!over) { + setOverId(null); + return; + } const activeId = active.id; const overId = over.id; + // Set the overId for drop indicators + setOverId(overId as string); + // Find the active task and the item being dragged over const activeTask = allTasks.find(task => task.id === activeId); if (!activeTask) return; @@ -126,6 +165,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { (event: DragEndEvent) => { const { active, over } = event; setActiveId(null); + setOverId(null); if (!over || active.id === over.id) { return; @@ -148,11 +188,16 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { return; } - // Check if we're dropping on a task or a group + // Check if we're dropping on a task, group, or empty group const overTask = allTasks.find(task => task.id === overId); const overGroup = groups.find(group => group.id === overId); + + // Check if dropping on empty group drop zone + const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-'); + const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null; + const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null; - let targetGroup = overGroup; + let targetGroup = overGroup || emptyGroup; let insertIndex = 0; if (overTask) { @@ -165,6 +210,10 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { // Dropping on a group (at the end) targetGroup = overGroup; insertIndex = targetGroup.taskIds.length; + } else if (emptyGroup) { + // Dropping on an empty group + targetGroup = emptyGroup; + insertIndex = 0; // First position in empty group } if (!targetGroup) { @@ -238,6 +287,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { return { activeId, + overId, handleDragStart, handleDragOver, handleDragEnd, diff --git a/worklenz-frontend/src/features/task-management/taskListFields.slice.ts b/worklenz-frontend/src/features/task-management/taskListFields.slice.ts index 51a9d474..7ed2cc51 100644 --- a/worklenz-frontend/src/features/task-management/taskListFields.slice.ts +++ b/worklenz-frontend/src/features/task-management/taskListFields.slice.ts @@ -14,10 +14,10 @@ const DEFAULT_FIELDS: TaskListField[] = [ { key: 'KEY', label: 'Key', visible: false, order: 1 }, { key: 'DESCRIPTION', label: 'Description', visible: false, order: 2 }, { key: 'PROGRESS', label: 'Progress', visible: true, order: 3 }, - { key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 4 }, - { key: 'LABELS', label: 'Labels', visible: true, order: 5 }, - { key: 'PHASE', label: 'Phase', visible: true, order: 6 }, - { key: 'STATUS', label: 'Status', visible: true, order: 7 }, + { key: 'STATUS', label: 'Status', visible: true, order: 4 }, + { key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 5 }, + { key: 'LABELS', label: 'Labels', visible: true, order: 6 }, + { key: 'PHASE', label: 'Phase', visible: true, order: 7 }, { key: 'PRIORITY', label: 'Priority', visible: true, order: 8 }, { key: 'TIME_TRACKING', label: 'Time Tracking', visible: true, order: 9 }, { key: 'ESTIMATION', label: 'Estimation', visible: false, order: 10 }, From d970cbb626c4c9bb43554ee0aef323fe5b7f56df Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 13:30:59 +0530 Subject: [PATCH 06/14] feat(task-management): enhance task timer synchronization and color handling - Updated `CustomColordLabel` and `CustomNumberLabel` components to improve color handling by removing the alpha channel logic and implementing a dynamic text color based on background luminance. - Enhanced task management slice to preserve timer state when fetching tasks, ensuring active timers are maintained across updates. - Modified socket handlers to synchronize timer state between task slices, improving consistency in task time tracking. - Refactored `useTaskTimer` hook to streamline local and Redux state synchronization for timer management. --- .../src/components/CustomColordLabel.tsx | 26 ++++++++++++++----- .../src/components/CustomNumberLabel.tsx | 13 +++------- .../task-management/task-management.slice.ts | 22 ++++++++++++++-- .../src/hooks/useTaskSocketHandlers.ts | 11 +++++++- worklenz-frontend/src/hooks/useTaskTimer.ts | 6 ++++- 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/worklenz-frontend/src/components/CustomColordLabel.tsx b/worklenz-frontend/src/components/CustomColordLabel.tsx index ebe9087b..83c25281 100644 --- a/worklenz-frontend/src/components/CustomColordLabel.tsx +++ b/worklenz-frontend/src/components/CustomColordLabel.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { Tooltip } from 'antd'; import { Label } from '@/types/task-management.types'; import { ITaskLabel } from '@/types/tasks/taskLabel.types'; -import { ALPHA_CHANNEL } from '@/shared/constants'; interface CustomColordLabelProps { label: Label | ITaskLabel; @@ -15,11 +14,26 @@ const CustomColordLabel = React.forwardRef 10 ? `${label.name.substring(0, 10)}...` : label.name; // Handle different color property names for different types - const baseColor = (label as Label).color || (label as ITaskLabel).color_code || '#6b7280'; // Default to gray-500 if no color + const backgroundColor = (label as Label).color || (label as ITaskLabel).color_code || '#6b7280'; // Default to gray-500 if no color - // Add alpha channel to the base color - const backgroundColor = baseColor + ALPHA_CHANNEL; - const textColor = baseColor; + // Function to determine if we should use white or black text based on background color + const getTextColor = (bgColor: string): string => { + // Remove # if present + const color = bgColor.replace('#', ''); + + // Convert to RGB + const r = parseInt(color.substr(0, 2), 16); + const g = parseInt(color.substr(2, 2), 16); + const b = parseInt(color.substr(4, 2), 16); + + // Calculate luminance + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + + // Return white for dark backgrounds, black for light backgrounds + return luminance > 0.5 ? '#000000' : '#ffffff'; + }; + + const textColor = getTextColor(backgroundColor); return ( @@ -29,7 +43,7 @@ const CustomColordLabel = React.forwardRef {truncatedName} diff --git a/worklenz-frontend/src/components/CustomNumberLabel.tsx b/worklenz-frontend/src/components/CustomNumberLabel.tsx index c603c1d4..e13289aa 100644 --- a/worklenz-frontend/src/components/CustomNumberLabel.tsx +++ b/worklenz-frontend/src/components/CustomNumberLabel.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Tooltip } from 'antd'; -import { NumbersColorMap, ALPHA_CHANNEL } from '@/shared/constants'; +import { NumbersColorMap } from '@/shared/constants'; interface CustomNumberLabelProps { labelList: string[]; @@ -12,24 +12,17 @@ interface CustomNumberLabelProps { const CustomNumberLabel = React.forwardRef( ({ labelList, namesString, isDarkMode = false, color }, ref) => { // Use provided color, or fall back to NumbersColorMap based on first digit - const baseColor = color || (() => { + const backgroundColor = color || (() => { const firstDigit = namesString.match(/\d/)?.[0] || '0'; return NumbersColorMap[firstDigit] || NumbersColorMap['0']; })(); - - // Add alpha channel to the base color - const backgroundColor = baseColor + ALPHA_CHANNEL; return ( {namesString} diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 8f953d1a..d647a815 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -959,8 +959,26 @@ const taskManagementSlice = createSlice({ .addCase(fetchTasksV3.fulfilled, (state, action) => { state.loading = false; const { allTasks, groups, grouping } = action.payload; - tasksAdapter.setAll(state as EntityState, allTasks || []); // Ensure allTasks is an array - state.ids = (allTasks || []).map(task => task.id); // Also update ids + + // Preserve existing timer state from old tasks before replacing + const oldTasks = state.entities; + const tasksWithTimers = (allTasks || []).map(task => { + const oldTask = oldTasks[task.id]; + if (oldTask?.timeTracking?.activeTimer) { + // Preserve the timer state from the old task + return { + ...task, + timeTracking: { + ...task.timeTracking, + activeTimer: oldTask.timeTracking.activeTimer + } + }; + } + return task; + }); + + tasksAdapter.setAll(state as EntityState, tasksWithTimers); // Ensure allTasks is an array + state.ids = tasksWithTimers.map(task => task.id); // Also update ids state.groups = groups; state.grouping = grouping; }) diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 7cf88cfc..77e18263 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -33,6 +33,7 @@ import { updateTaskDescription, updateSubTasks, updateTaskProgress, + updateTaskTimeTracking, } from '@/features/tasks/tasks.slice'; import { addTask, @@ -936,6 +937,8 @@ export const useTaskSocketHandlers = () => { const { task_id, start_time } = typeof data === 'string' ? JSON.parse(data) : data; if (!task_id) return; + const timerTimestamp = start_time ? (typeof start_time === 'number' ? start_time : parseInt(start_time)) : Date.now(); + // Update the task-management slice to include timer state const currentTask = store.getState().taskManagement.entities[task_id]; if (currentTask) { @@ -943,13 +946,16 @@ export const useTaskSocketHandlers = () => { ...currentTask, timeTracking: { ...currentTask.timeTracking, - activeTimer: start_time ? (typeof start_time === 'number' ? start_time : parseInt(start_time)) : Date.now(), + activeTimer: timerTimestamp, }, updatedAt: new Date().toISOString(), updated_at: new Date().toISOString(), }; dispatch(updateTask(updatedTask)); } + + // Also update the tasks slice activeTimers to keep both slices in sync + dispatch(updateTaskTimeTracking({ taskId: task_id, timeTracking: timerTimestamp })); } catch (error) { logger.error('Error handling timer start event:', error); } @@ -975,6 +981,9 @@ export const useTaskSocketHandlers = () => { }; dispatch(updateTask(updatedTask)); } + + // Also update the tasks slice activeTimers to keep both slices in sync + dispatch(updateTaskTimeTracking({ taskId: task_id, timeTracking: null })); } catch (error) { logger.error('Error handling timer stop event:', error); } diff --git a/worklenz-frontend/src/hooks/useTaskTimer.ts b/worklenz-frontend/src/hooks/useTaskTimer.ts index 0e7f7885..806cde90 100644 --- a/worklenz-frontend/src/hooks/useTaskTimer.ts +++ b/worklenz-frontend/src/hooks/useTaskTimer.ts @@ -50,7 +50,11 @@ export const useTaskTimer = (taskId: string, initialStartTime: number | null) => // Timer management effect useEffect(() => { - if (started && localStarted && reduxStartTime) { + if (started && reduxStartTime) { + // Sync local state with Redux state + if (!localStarted) { + setLocalStarted(true); + } clearTimerInterval(); timerTick(); intervalRef.current = setInterval(timerTick, 1000); From 17371200cad9ab1215f57f375a9fc1fafff0c525 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 14:22:27 +0530 Subject: [PATCH 07/14] feat(drag-and-drop): enhance task grouping updates and socket event handling - Updated the `useDragAndDrop` hook to emit specific grouping field change events (phase, priority, status) when tasks are moved between groups. - Refactored the task management slice to prevent direct updates to task grouping fields during drag-and-drop operations, ensuring these updates are handled via socket events after backend confirmation. - Introduced a new socket handler for task sort order changes to update task properties based on backend responses, improving synchronization between frontend and backend task states. --- .../task-list-v2/hooks/useDragAndDrop.ts | 28 +++++++- .../task-management/task-management.slice.ts | 48 ++----------- .../src/hooks/useTaskSocketHandlers.ts | 67 +++++++++++++++++++ 3 files changed, 101 insertions(+), 42 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts index b70f4c69..5838ba8e 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -37,7 +37,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const teamId = currentSession?.team_id || ''; // Use new bulk update approach - recalculate ALL task orders to prevent duplicates - const taskUpdates = []; + const taskUpdates: any[] = []; // Create a copy of all groups and perform the move operation const updatedGroups = groups.map(group => ({ @@ -108,6 +108,32 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { console.log('Emitting TASK_SORT_ORDER_CHANGE:', socketData); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); + + // Also emit the specific grouping field change event for the moved task + if (sourceGroup.id !== targetGroup.id) { + if (currentGrouping === 'phase') { + // Emit phase change event + socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { + task_id: taskId, + phase_id: targetGroup.id, + parent_task: task.parent_task_id || null, + }); + } else if (currentGrouping === 'priority') { + // Emit priority change event + socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify({ + task_id: taskId, + priority_id: targetGroup.id, + team_id: teamId, + })); + } else if (currentGrouping === 'status') { + // Emit status change event + socket.emit(SocketEvents.TASK_STATUS_CHANGE.toString(), JSON.stringify({ + task_id: taskId, + status_id: targetGroup.id, + team_id: teamId, + })); + } + } }, [socket, connected, projectId, allTasks, groups, currentGrouping, currentSession] ); diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 15020447..f0e9c494 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -660,7 +660,7 @@ const taskManagementSlice = createSlice({ const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1); newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); group.taskIds = newTasks; - + // Update order for affected tasks using the appropriate sort field const sortField = getSortOrderField(state.grouping?.id); newTasks.forEach((id, index) => { @@ -673,11 +673,11 @@ const taskManagementSlice = createSlice({ // Moving between different groups const sourceGroup = state.groups.find(g => g.id === sourceGroupId); const destinationGroup = state.groups.find(g => g.id === destinationGroupId); - + if (sourceGroup && destinationGroup) { // Remove from source group sourceGroup.taskIds = sourceGroup.taskIds.filter(id => id !== sourceTaskId); - + // Add to destination group at the correct position relative to destinationTask const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId); if (destinationIndex !== -1) { @@ -685,44 +685,10 @@ const taskManagementSlice = createSlice({ } else { destinationGroup.taskIds.push(sourceTaskId); // Add to end if destination task not found } - - // Update task's grouping field to reflect new group (e.g., status, priority, phase) - // This assumes the group ID directly corresponds to the task's field value - if (sourceTask) { - let updatedTask = { ...sourceTask }; - switch (state.grouping?.id) { - case IGroupBy.STATUS: - updatedTask.status = destinationGroup.id; - break; - case IGroupBy.PRIORITY: - updatedTask.priority = destinationGroup.id; - break; - case IGroupBy.PHASE: - // Handle unmapped group specially - if (destinationGroup.id === 'Unmapped' || destinationGroup.title === 'Unmapped') { - updatedTask.phase = ''; // Clear phase for unmapped group - } else { - updatedTask.phase = destinationGroup.id; - } - break; - case IGroupBy.MEMBERS: - // If moving to a member group, ensure task is assigned to that member - // This assumes the group ID is the member ID - if (!updatedTask.assignees) { - updatedTask.assignees = []; - } - if (!updatedTask.assignees.includes(destinationGroup.id)) { - updatedTask.assignees.push(destinationGroup.id); - } - // If moving from a member group, and the task is no longer in any member group, - // consider removing the assignment (more complex logic might be needed here) - break; - default: - break; - } - newEntities[sourceTaskId] = updatedTask; - } - + + // Do NOT update the task's grouping field (priority, phase, status) here. + // This will be handled by the socket event handler after backend confirmation. + // Update order for affected tasks in both groups using the appropriate sort field const sortField = getSortOrderField(state.grouping?.id); sourceGroup.taskIds.forEach((id, index) => { diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 77e18263..2cb419a9 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -989,6 +989,71 @@ export const useTaskSocketHandlers = () => { } }, [dispatch]); + // Handler for task sort order change events + const handleTaskSortOrderChange = useCallback((data: any[]) => { + try { + if (!Array.isArray(data) || data.length === 0) return; + + // DEBUG: Log the data received from the backend + console.log('[TASK_SORT_ORDER_CHANGE] Received data:', data); + + // Get canonical lists from Redux + const state = store.getState(); + const priorityList = state.priorityReducer?.priorities || []; + const phaseList = state.phaseReducer?.phaseList || []; + const statusList = state.taskStatusReducer?.status || []; + + // The backend sends an array of tasks with updated sort orders and possibly grouping fields + data.forEach((taskData: any) => { + const currentTask = state.taskManagement.entities[taskData.id]; + if (currentTask) { + let updatedTask: Task = { + ...currentTask, + order: taskData.sort_order || taskData.current_sort_order || currentTask.order, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + // Update grouping fields if present + if (typeof taskData.priority_id !== 'undefined') { + const found = priorityList.find(p => p.id === taskData.priority_id); + if (found) { + updatedTask.priority = found.name; + // updatedTask.priority_id = found.id; // Only if Task type has priority_id + } else { + updatedTask.priority = taskData.priority_id || ''; + // updatedTask.priority_id = taskData.priority_id; + } + } + if (typeof taskData.phase_id !== 'undefined') { + const found = phaseList.find(p => p.id === taskData.phase_id); + if (found) { + updatedTask.phase = found.name; + // updatedTask.phase_id = found.id; // Only if Task type has phase_id + } else { + updatedTask.phase = taskData.phase_id || ''; + // updatedTask.phase_id = taskData.phase_id; + } + } + if (typeof taskData.status_id !== 'undefined') { + const found = statusList.find(s => s.id === taskData.status_id); + if (found) { + updatedTask.status = found.name; + // updatedTask.status_id = found.id; // Only if Task type has status_id + } else { + updatedTask.status = taskData.status_id || ''; + // updatedTask.status_id = taskData.status_id; + } + } + + dispatch(updateTask(updatedTask)); + } + }); + } catch (error) { + logger.error('Error handling task sort order change event:', error); + } + }, [dispatch]); + // Register socket event listeners useEffect(() => { if (!socket) return; @@ -1022,6 +1087,7 @@ export const useTaskSocketHandlers = () => { { event: SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), handler: handleCustomColumnUpdate }, { event: SocketEvents.TASK_TIMER_START.toString(), handler: handleTimerStart }, { event: SocketEvents.TASK_TIMER_STOP.toString(), handler: handleTimerStop }, + { event: SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), handler: handleTaskSortOrderChange }, ]; @@ -1056,6 +1122,7 @@ export const useTaskSocketHandlers = () => { handleCustomColumnUpdate, handleTimerStart, handleTimerStop, + handleTaskSortOrderChange, ]); }; From 55a0028e26ce128d4c579a0231b4cbbf7a754378 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 14:24:24 +0530 Subject: [PATCH 08/14] refactor(drag-and-drop): replace console logging with error logging - Integrated an error logging utility to replace console warnings and errors in the `useDragAndDrop` hook, enhancing error tracking and debugging. - Removed unnecessary console logs related to drag-and-drop operations, streamlining the code and improving performance. --- .../task-list-v2/hooks/useDragAndDrop.ts | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts index 5838ba8e..4a5b8848 100644 --- a/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts +++ b/worklenz-frontend/src/components/task-list-v2/hooks/useDragAndDrop.ts @@ -9,6 +9,7 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { useParams } from 'react-router-dom'; import { useAuthService } from '@/hooks/useAuth'; +import logger from '@/utils/errorLogger'; export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const dispatch = useAppDispatch(); @@ -23,13 +24,13 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { const emitTaskSortChange = useCallback( (taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => { if (!socket || !connected || !projectId) { - console.warn('Socket not connected or missing project ID'); + logger.warning('Socket not connected or missing project ID'); return; } const task = allTasks.find(t => t.id === taskId); if (!task) { - console.error('Task not found for socket emission:', taskId); + logger.error('Task not found for socket emission:', taskId); return; } @@ -106,7 +107,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { team_id: teamId, }; - console.log('Emitting TASK_SORT_ORDER_CHANGE:', socketData); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); // Also emit the specific grouping field change event for the moved task @@ -174,15 +174,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { } 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] ); @@ -203,14 +194,14 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { // Find the active task const activeTask = allTasks.find(task => task.id === activeId); if (!activeTask) { - console.error('Active task not found:', activeId); + logger.error('Active task not found:', activeId); return; } // Find the groups const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); if (!activeGroup) { - console.error('Could not find active group for task:', activeId); + logger.error('Could not find active group for task:', activeId); return; } @@ -243,24 +234,13 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { } if (!targetGroup) { - console.error('Could not find target group'); + logger.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, - overId, - activeTask: activeTask.name || activeTask.title, - activeGroup: activeGroup.id, - targetGroup: targetGroup.id, - activeIndex, - insertIndex, - isCrossGroup, - }); - if (isCrossGroup) { // Moving task between groups console.log('Moving task between groups:', { @@ -284,14 +264,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => { // Emit socket event for persistence emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); } 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) { // Reorder task within same group at drop position dispatch( From 0bb748cf895831ebae07972eb5b9a4df722a41ac Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 15:19:16 +0530 Subject: [PATCH 09/14] feat(database-migrations): implement grouping-specific sort orders and constraints - Added new sort order columns for tasks based on different grouping types (status, priority, phase, member). - Updated database functions to handle these new sort orders, ensuring proper task updates during sorting operations. - Implemented a migration to fix existing sort order constraint violations, ensuring data consistency across projects. - Enhanced functions to avoid sort order conflicts and maintain unique sort orders within each project. --- .../20250715000000-add-grouping-sort-orders.sql | 0 .../{ => release-2.1.2}/20250715000001-update-sort-functions.sql | 0 .../{ => release-2.1.2}/20250715000002-fix-sort-constraint.sql | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename worklenz-backend/database/migrations/{ => release-2.1.2}/20250715000000-add-grouping-sort-orders.sql (100%) rename worklenz-backend/database/migrations/{ => release-2.1.2}/20250715000001-update-sort-functions.sql (100%) rename worklenz-backend/database/migrations/{ => release-2.1.2}/20250715000002-fix-sort-constraint.sql (100%) diff --git a/worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql b/worklenz-backend/database/migrations/release-2.1.2/20250715000000-add-grouping-sort-orders.sql similarity index 100% rename from worklenz-backend/database/migrations/20250715000000-add-grouping-sort-orders.sql rename to worklenz-backend/database/migrations/release-2.1.2/20250715000000-add-grouping-sort-orders.sql diff --git a/worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql b/worklenz-backend/database/migrations/release-2.1.2/20250715000001-update-sort-functions.sql similarity index 100% rename from worklenz-backend/database/migrations/20250715000001-update-sort-functions.sql rename to worklenz-backend/database/migrations/release-2.1.2/20250715000001-update-sort-functions.sql diff --git a/worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql b/worklenz-backend/database/migrations/release-2.1.2/20250715000002-fix-sort-constraint.sql similarity index 100% rename from worklenz-backend/database/migrations/20250715000002-fix-sort-constraint.sql rename to worklenz-backend/database/migrations/release-2.1.2/20250715000002-fix-sort-constraint.sql From 6e911d79fcc775b83f0daf074a328b077f119d01 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 15:34:43 +0530 Subject: [PATCH 10/14] feat(auth): add combined AuthAndSetupGuard for route protection - Introduced AuthAndSetupGuard to enforce both authentication and setup completion for protected routes. - Updated main routes to utilize the new guard, ensuring users are redirected appropriately based on their authentication and setup status. - Adjusted setup route to only require authentication, allowing access without completed setup. - Refactored account setup component to use custom dispatch hook for improved state management and added session refresh after setup completion. --- worklenz-frontend/src/app/routes/index.tsx | 23 +++++++++++++++++-- .../src/pages/account-setup/account-setup.tsx | 20 ++++++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/worklenz-frontend/src/app/routes/index.tsx b/worklenz-frontend/src/app/routes/index.tsx index a7f75760..722fcd48 100644 --- a/worklenz-frontend/src/app/routes/index.tsx +++ b/worklenz-frontend/src/app/routes/index.tsx @@ -90,6 +90,23 @@ export const SetupGuard = memo(({ children }: GuardProps) => { SetupGuard.displayName = 'SetupGuard'; +// Combined guard for routes that require both authentication and setup completion +export const AuthAndSetupGuard = memo(({ children }: GuardProps) => { + const { isAuthenticated, isSetupComplete, location } = useAuthStatus(); + + if (!isAuthenticated) { + return ; + } + + if (!isSetupComplete) { + return ; + } + + return <>{children}; +}); + +AuthAndSetupGuard.displayName = 'AuthAndSetupGuard'; + // Optimized route wrapping function with Suspense boundaries const wrapRoutes = ( routes: RouteObject[], @@ -171,9 +188,11 @@ StaticLicenseExpired.displayName = 'StaticLicenseExpired'; // Create route arrays (moved outside of useMemo to avoid hook violations) const publicRoutes = [...rootRoutes, ...authRoutes, notFoundRoute]; -const protectedMainRoutes = wrapRoutes(mainRoutes, AuthGuard); +// Apply combined guard to main routes that require both auth and setup completion +const protectedMainRoutes = wrapRoutes(mainRoutes, AuthAndSetupGuard); const adminRoutes = wrapRoutes(reportingRoutes, AdminGuard); -const setupRoutes = wrapRoutes([accountSetupRoute], SetupGuard); +// Setup route should be accessible without setup completion, only requires authentication +const setupRoutes = wrapRoutes([accountSetupRoute], AuthGuard); // License expiry check function const withLicenseExpiryCheck = (routes: RouteObject[]): RouteObject[] => { diff --git a/worklenz-frontend/src/pages/account-setup/account-setup.tsx b/worklenz-frontend/src/pages/account-setup/account-setup.tsx index 5c522e96..b9138c80 100644 --- a/worklenz-frontend/src/pages/account-setup/account-setup.tsx +++ b/worklenz-frontend/src/pages/account-setup/account-setup.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Space, Steps, Button, Typography } from 'antd/es'; @@ -26,6 +26,7 @@ import { validateEmail } from '@/utils/validateEmail'; import { sanitizeInput } from '@/utils/sanitizeInput'; import logo from '@/assets/images/worklenz-light-mode.png'; import logoDark from '@/assets/images/worklenz-dark-mode.png'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; import './account-setup.css'; import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types'; @@ -34,7 +35,7 @@ import { profileSettingsApiService } from '@/api/settings/profile/profile-settin const { Title } = Typography; const AccountSetup: React.FC = () => { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const { t } = useTranslation('account-setup'); useDocumentTitle(t('setupYourAccount', 'Account Setup')); const navigate = useNavigate(); @@ -52,8 +53,7 @@ const AccountSetup: React.FC = () => { trackMixpanelEvent(evt_account_setup_visit); const verifyAuthStatus = async () => { try { - const response = (await dispatch(verifyAuthentication()).unwrap()) - .payload as IAuthorizeResponse; + const response = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse; if (response?.authenticated) { setSession(response.user); dispatch(setUser(response.user)); @@ -163,6 +163,18 @@ const AccountSetup: React.FC = () => { const res = await profileSettingsApiService.setupAccount(model); if (res.done && res.body.id) { trackMixpanelEvent(skip ? evt_account_setup_skip_invite : evt_account_setup_complete); + + // Refresh user session to update setup_completed status + try { + const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse; + if (authResponse?.authenticated && authResponse?.user) { + setSession(authResponse.user); + dispatch(setUser(authResponse.user)); + } + } catch (error) { + logger.error('Failed to refresh user session after setup completion', error); + } + navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`); } } catch (error) { From 0434bbb73b36eceebe7d83f6670eaa863bdf82a2 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 15:40:06 +0530 Subject: [PATCH 11/14] refactor(account-setup): update ProjectStep to use custom dispatch and refresh user session - Replaced useDispatch with useAppDispatch for improved state management in ProjectStep component. - Added logic to refresh user session and update user state after template setup completion. - Enhanced error handling for session refresh to ensure robustness in user experience. --- .../components/account-setup/project-step.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/worklenz-frontend/src/components/account-setup/project-step.tsx b/worklenz-frontend/src/components/account-setup/project-step.tsx index ec42b8d0..89e86ea9 100644 --- a/worklenz-frontend/src/components/account-setup/project-step.tsx +++ b/worklenz-frontend/src/components/account-setup/project-step.tsx @@ -1,5 +1,5 @@ import React, { startTransition, useEffect, useRef, useState } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -18,6 +18,11 @@ import { IAccountSetupRequest } from '@/types/project-templates/project-template import { evt_account_setup_template_complete } from '@/shared/worklenz-analytics-events'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { createPortal } from 'react-dom'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { verifyAuthentication } from '@/features/auth/authSlice'; +import { setUser } from '@/features/user/userSlice'; +import { setSession } from '@/utils/session-helper'; +import { IAuthorizeResponse } from '@/types/auth/login.types'; const { Title } = Typography; @@ -29,7 +34,7 @@ interface Props { export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = false }) => { const { t } = useTranslation('account-setup'); - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const navigate = useNavigate(); const { trackMixpanelEvent } = useMixpanelTracking(); @@ -69,6 +74,18 @@ export const ProjectStep: React.FC = ({ onEnter, styles, isDarkMode = fal if (res.done && res.body.id) { toggleTemplateSelector(false); trackMixpanelEvent(evt_account_setup_template_complete); + + // Refresh user session to update setup_completed status + try { + const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse; + if (authResponse?.authenticated && authResponse?.user) { + setSession(authResponse.user); + dispatch(setUser(authResponse.user)); + } + } catch (error) { + logger.error('Failed to refresh user session after template setup completion', error); + } + navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`); } } catch (error) { From cb5610d99bedce3bcf05b3d05f5eb8655f654843 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 15:57:01 +0530 Subject: [PATCH 12/14] feat(timer): add useTimerInitialization hook for managing running timers - Introduced a new custom hook, useTimerInitialization, to fetch and initialize running timers from the backend when the project view loads. - Integrated the hook into the ProjectView component to update Redux state with active timers and their corresponding task details. - Enhanced error handling and logging for timer initialization to improve debugging and user experience. --- .../src/hooks/useTimerInitialization.ts | 79 +++++++++++++++++++ .../projects/projectView/project-view.tsx | 4 + 2 files changed, 83 insertions(+) create mode 100644 worklenz-frontend/src/hooks/useTimerInitialization.ts diff --git a/worklenz-frontend/src/hooks/useTimerInitialization.ts b/worklenz-frontend/src/hooks/useTimerInitialization.ts new file mode 100644 index 00000000..013a56e8 --- /dev/null +++ b/worklenz-frontend/src/hooks/useTimerInitialization.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { updateTaskTimeTracking } from '@/features/tasks/tasks.slice'; +import { updateTask } from '@/features/task-management/task-management.slice'; +import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service'; +import { store } from '@/app/store'; +import { Task } from '@/types/task-management.types'; +import logger from '@/utils/errorLogger'; +import moment from 'moment'; + +export const useTimerInitialization = () => { + const dispatch = useAppDispatch(); + const hasInitialized = useRef(false); + + useEffect(() => { + const initializeTimers = async () => { + // Prevent duplicate initialization + if (hasInitialized.current) { + return; + } + + try { + hasInitialized.current = true; + + // Fetch running timers from backend + const response = await taskTimeLogsApiService.getRunningTimers(); + + if (response && response.done && Array.isArray(response.body)) { + const runningTimers = response.body; + + // Update Redux state for each running timer + runningTimers.forEach(timer => { + if (timer.task_id && timer.start_time) { + try { + // Convert start_time to timestamp + const startTime = moment(timer.start_time); + if (startTime.isValid()) { + const timestamp = startTime.valueOf(); + + // Update the tasks slice activeTimers + dispatch(updateTaskTimeTracking({ + taskId: timer.task_id, + timeTracking: timestamp + })); + + // Update the task-management slice if the task exists + const currentTask = store.getState().taskManagement.entities[timer.task_id]; + if (currentTask) { + const updatedTask: Task = { + ...currentTask, + timeTracking: { + ...currentTask.timeTracking, + activeTimer: timestamp, + }, + updatedAt: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + dispatch(updateTask(updatedTask)); + } + } + } catch (error) { + logger.error(`Error initializing timer for task ${timer.task_id}:`, error); + } + } + }); + + if (runningTimers.length > 0) { + logger.info(`Initialized ${runningTimers.length} running timers from backend`); + } + } + } catch (error) { + logger.error('Error initializing timers from backend:', error); + } + }; + + // Initialize timers when the hook mounts + initializeTimers(); + }, [dispatch]); +}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index 15dd1b2d..30bed530 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -39,6 +39,7 @@ import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanb import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice'; import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; import { useTranslation } from 'react-i18next'; +import { useTimerInitialization } from '@/hooks/useTimerInitialization'; // Import critical components synchronously to avoid suspense interruptions @@ -89,6 +90,9 @@ const ProjectView = React.memo(() => { const [taskid, setTaskId] = useState(urlParams.taskId); const [isInitialized, setIsInitialized] = useState(false); + // Initialize timer state from backend when project view loads + useTimerInitialization(); + // Update local state when URL params change useEffect(() => { setActiveTab(urlParams.tab); From 833879e0e8bfd2410541e3994bd454b57303ec71 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 16:08:07 +0530 Subject: [PATCH 13/14] feat(logout): implement cache cleanup and service worker unregistration on logout - Added a new utility, CacheCleanup, to handle clearing caches and unregistering the service worker during user logout. - Enhanced the LoggingOutPage to utilize CacheCleanup for clearing local session and caches before redirecting to the login page. - Introduced ModuleErrorBoundary to manage module loading errors, providing user feedback and options to retry or reload the application. - Updated App component to include global error handlers for improved error management related to module loading issues. --- worklenz-frontend/public/sw.js | 22 +++ worklenz-frontend/src/App.tsx | 69 +++++++- .../src/components/ModuleErrorBoundary.tsx | 110 ++++++++++++ .../src/pages/auth/logging-out.tsx | 29 +++- worklenz-frontend/src/utils/cache-cleanup.ts | 163 ++++++++++++++++++ 5 files changed, 381 insertions(+), 12 deletions(-) create mode 100644 worklenz-frontend/src/components/ModuleErrorBoundary.tsx create mode 100644 worklenz-frontend/src/utils/cache-cleanup.ts diff --git a/worklenz-frontend/public/sw.js b/worklenz-frontend/public/sw.js index 526541b0..15dbef76 100644 --- a/worklenz-frontend/public/sw.js +++ b/worklenz-frontend/public/sw.js @@ -331,6 +331,13 @@ self.addEventListener('message', event => { }); break; + case 'LOGOUT': + // Special handler for logout - clear all caches and unregister + handleLogout().then(() => { + event.ports[0].postMessage({ success: true }); + }); + break; + default: console.log('Service Worker: Unknown message type', type); } @@ -342,4 +349,19 @@ async function clearAllCaches() { console.log('Service Worker: All caches cleared'); } +async function handleLogout() { + try { + // Clear all caches + await clearAllCaches(); + + // Unregister the service worker to force fresh registration on next visit + await self.registration.unregister(); + + console.log('Service Worker: Logout handled - caches cleared and unregistered'); + } catch (error) { + console.error('Service Worker: Error during logout handling', error); + throw error; + } +} + console.log('Service Worker: Loaded successfully'); \ No newline at end of file diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index aa20e0ed..0f894c3a 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -5,6 +5,7 @@ import i18next from 'i18next'; // Components import ThemeWrapper from './features/theme/ThemeWrapper'; +import ModuleErrorBoundary from './components/ModuleErrorBoundary'; // Routes import router from './app/routes'; @@ -113,6 +114,60 @@ const App: React.FC = memo(() => { }; }, []); + // Global error handlers for module loading issues + useEffect(() => { + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + const error = event.reason; + + // Check if this is a module loading error + if ( + error?.message?.includes('Failed to fetch dynamically imported module') || + error?.message?.includes('Loading chunk') || + error?.name === 'ChunkLoadError' + ) { + console.error('Unhandled module loading error:', error); + event.preventDefault(); // Prevent default browser error handling + + // Clear caches and reload + import('./utils/cache-cleanup').then(({ default: CacheCleanup }) => { + CacheCleanup.clearAllCaches() + .then(() => CacheCleanup.forceReload('/auth/login')) + .catch(() => window.location.reload()); + }); + } + }; + + const handleError = (event: ErrorEvent) => { + const error = event.error; + + // Check if this is a module loading error + if ( + error?.message?.includes('Failed to fetch dynamically imported module') || + error?.message?.includes('Loading chunk') || + error?.name === 'ChunkLoadError' + ) { + console.error('Global module loading error:', error); + event.preventDefault(); // Prevent default browser error handling + + // Clear caches and reload + import('./utils/cache-cleanup').then(({ default: CacheCleanup }) => { + CacheCleanup.clearAllCaches() + .then(() => CacheCleanup.forceReload('/auth/login')) + .catch(() => window.location.reload()); + }); + } + }; + + // Add global error handlers + window.addEventListener('unhandledrejection', handleUnhandledRejection); + window.addEventListener('error', handleError); + + return () => { + window.removeEventListener('unhandledrejection', handleUnhandledRejection); + window.removeEventListener('error', handleError); + }; + }, []); + // Register service worker useEffect(() => { registerSW({ @@ -150,12 +205,14 @@ const App: React.FC = memo(() => { return ( }> - + + + ); diff --git a/worklenz-frontend/src/components/ModuleErrorBoundary.tsx b/worklenz-frontend/src/components/ModuleErrorBoundary.tsx new file mode 100644 index 00000000..64c3809b --- /dev/null +++ b/worklenz-frontend/src/components/ModuleErrorBoundary.tsx @@ -0,0 +1,110 @@ +import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { Button, Result } from 'antd'; +import CacheCleanup from '@/utils/cache-cleanup'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +class ModuleErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + // Check if this is a module loading error + const isModuleError = + error.message.includes('Failed to fetch dynamically imported module') || + error.message.includes('Loading chunk') || + error.message.includes('Loading CSS chunk') || + error.name === 'ChunkLoadError'; + + if (isModuleError) { + return { hasError: true, error }; + } + + // For other errors, let them bubble up + return { hasError: false }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Module Error Boundary caught an error:', error, errorInfo); + + // If this is a module loading error, clear caches and reload + if (this.state.hasError) { + this.handleModuleError(); + } + } + + private async handleModuleError() { + try { + console.log('Handling module loading error - clearing caches...'); + + // Clear all caches + await CacheCleanup.clearAllCaches(); + + // Force reload to login page + CacheCleanup.forceReload('/auth/login'); + } catch (cacheError) { + console.error('Failed to handle module error:', cacheError); + // Fallback: just reload the page + window.location.reload(); + } + } + + private handleRetry = async () => { + try { + await CacheCleanup.clearAllCaches(); + CacheCleanup.forceReload('/auth/login'); + } catch (error) { + console.error('Retry failed:', error); + window.location.reload(); + } + }; + + render() { + if (this.state.hasError) { + return ( +
+ + Retry + , + + ]} + /> +
+ ); + } + + return this.props.children; + } +} + +export default ModuleErrorBoundary; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/auth/logging-out.tsx b/worklenz-frontend/src/pages/auth/logging-out.tsx index f4464b8a..c5e94c25 100644 --- a/worklenz-frontend/src/pages/auth/logging-out.tsx +++ b/worklenz-frontend/src/pages/auth/logging-out.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useAuthService } from '@/hooks/useAuth'; import { useMediaQuery } from 'react-responsive'; import { authApiService } from '@/api/auth/auth.api.service'; +import CacheCleanup from '@/utils/cache-cleanup'; const LoggingOutPage = () => { const navigate = useNavigate(); @@ -14,14 +15,30 @@ const LoggingOutPage = () => { useEffect(() => { const logout = async () => { - await auth.signOut(); - await authApiService.logout(); - setTimeout(() => { - window.location.href = '/'; - }, 1500); + try { + // Clear local session + await auth.signOut(); + + // Call backend logout + await authApiService.logout(); + + // Clear all caches using the utility + await CacheCleanup.clearAllCaches(); + + // Force a hard reload to ensure fresh state + setTimeout(() => { + CacheCleanup.forceReload('/auth/login'); + }, 1000); + + } catch (error) { + console.error('Logout error:', error); + // Fallback: force reload to login page + CacheCleanup.forceReload('/auth/login'); + } }; + void logout(); - }, [auth, navigate]); + }, [auth]); const cardStyles = { width: '100%', diff --git a/worklenz-frontend/src/utils/cache-cleanup.ts b/worklenz-frontend/src/utils/cache-cleanup.ts new file mode 100644 index 00000000..9f1313c6 --- /dev/null +++ b/worklenz-frontend/src/utils/cache-cleanup.ts @@ -0,0 +1,163 @@ +/** + * Cache cleanup utilities for logout operations + * Handles clearing of various caches to prevent stale data issues + */ + +export class CacheCleanup { + /** + * Clear all caches including service worker, browser cache, and storage + */ + static async clearAllCaches(): Promise { + try { + console.log('CacheCleanup: Starting cache clearing process...'); + + // Clear browser caches + if ('caches' in window) { + const cacheNames = await caches.keys(); + console.log('CacheCleanup: Found caches:', cacheNames); + + await Promise.all( + cacheNames.map(async cacheName => { + const deleted = await caches.delete(cacheName); + console.log(`CacheCleanup: Deleted cache "${cacheName}":`, deleted); + return deleted; + }) + ); + console.log('CacheCleanup: Browser caches cleared'); + } else { + console.log('CacheCleanup: Cache API not supported'); + } + + // Clear service worker cache + if ('serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.getRegistration(); + if (registration) { + console.log('CacheCleanup: Found service worker registration'); + + // Send logout message to service worker to clear its caches and unregister + if (registration.active) { + try { + console.log('CacheCleanup: Sending LOGOUT message to service worker...'); + await this.sendMessageToServiceWorker('LOGOUT'); + console.log('CacheCleanup: LOGOUT message sent successfully'); + } catch (error) { + console.warn('CacheCleanup: Failed to send logout message to service worker:', error); + // Fallback: try to clear cache manually + try { + console.log('CacheCleanup: Trying fallback CLEAR_CACHE message...'); + await this.sendMessageToServiceWorker('CLEAR_CACHE'); + console.log('CacheCleanup: CLEAR_CACHE message sent successfully'); + } catch (fallbackError) { + console.warn('CacheCleanup: Failed to clear service worker cache:', fallbackError); + } + } + } + + // If service worker is still registered, unregister it + if (registration.active) { + console.log('CacheCleanup: Unregistering service worker...'); + await registration.unregister(); + console.log('CacheCleanup: Service worker unregistered'); + } + } else { + console.log('CacheCleanup: No service worker registration found'); + } + } else { + console.log('CacheCleanup: Service Worker not supported'); + } + + // Clear localStorage and sessionStorage + const localStorageKeys = Object.keys(localStorage); + const sessionStorageKeys = Object.keys(sessionStorage); + + console.log('CacheCleanup: Clearing localStorage keys:', localStorageKeys); + console.log('CacheCleanup: Clearing sessionStorage keys:', sessionStorageKeys); + + localStorage.clear(); + sessionStorage.clear(); + console.log('CacheCleanup: Local storage cleared'); + + console.log('CacheCleanup: Cache clearing process completed successfully'); + + } catch (error) { + console.error('CacheCleanup: Error clearing caches:', error); + throw error; + } + } + + /** + * Send message to service worker + */ + private static async sendMessageToServiceWorker(type: string, payload?: any): Promise { + if (!('serviceWorker' in navigator)) { + throw new Error('Service Worker not supported'); + } + + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration || !registration.active) { + throw new Error('Service Worker not active'); + } + + return new Promise((resolve, reject) => { + const messageChannel = new MessageChannel(); + + messageChannel.port1.onmessage = (event) => { + if (event.data.error) { + reject(event.data.error); + } else { + resolve(event.data); + } + }; + + registration.active!.postMessage( + { type, payload }, + [messageChannel.port2] + ); + + // Timeout after 5 seconds + setTimeout(() => { + reject(new Error('Service Worker message timeout')); + }, 5000); + }); + } + + /** + * Force reload the page to ensure fresh state + */ + static forceReload(url: string = '/auth/login'): void { + // Use replace to prevent back button issues + window.location.replace(url); + } + + /** + * Clear specific cache types + */ + static async clearSpecificCaches(cacheTypes: string[]): Promise { + if (!('caches' in window)) return; + + const cacheNames = await caches.keys(); + const cachesToDelete = cacheNames.filter(name => + cacheTypes.some(type => name.includes(type)) + ); + + await Promise.all( + cachesToDelete.map(cacheName => caches.delete(cacheName)) + ); + } + + /** + * Clear API cache specifically + */ + static async clearAPICache(): Promise { + await this.clearSpecificCaches(['api', 'dynamic']); + } + + /** + * Clear static asset cache + */ + static async clearStaticCache(): Promise { + await this.clearSpecificCaches(['static', 'images']); + } +} + +export default CacheCleanup; \ No newline at end of file From 737f7cada223483e9c3474605e2ecfabdd5342fa Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Tue, 15 Jul 2025 16:11:30 +0530 Subject: [PATCH 14/14] refactor(App): streamline cache cleanup process on logout - Removed dynamic import of CacheCleanup in the App component, directly utilizing the imported utility for clearing caches and reloading the application on user logout. - This change simplifies the logout process and improves code readability. --- worklenz-frontend/src/App.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/worklenz-frontend/src/App.tsx b/worklenz-frontend/src/App.tsx index 0f894c3a..0f29cdcd 100644 --- a/worklenz-frontend/src/App.tsx +++ b/worklenz-frontend/src/App.tsx @@ -14,6 +14,7 @@ import router from './app/routes'; import { useAppSelector } from './hooks/useAppSelector'; import { initMixpanel } from './utils/mixpanelInit'; import { initializeCsrfToken } from './api/api-client'; +import CacheCleanup from './utils/cache-cleanup'; // Types & Constants import { Language } from './features/i18n/localesSlice'; @@ -129,11 +130,9 @@ const App: React.FC = memo(() => { event.preventDefault(); // Prevent default browser error handling // Clear caches and reload - import('./utils/cache-cleanup').then(({ default: CacheCleanup }) => { - CacheCleanup.clearAllCaches() - .then(() => CacheCleanup.forceReload('/auth/login')) - .catch(() => window.location.reload()); - }); + CacheCleanup.clearAllCaches() + .then(() => CacheCleanup.forceReload('/auth/login')) + .catch(() => window.location.reload()); } }; @@ -150,11 +149,9 @@ const App: React.FC = memo(() => { event.preventDefault(); // Prevent default browser error handling // Clear caches and reload - import('./utils/cache-cleanup').then(({ default: CacheCleanup }) => { - CacheCleanup.clearAllCaches() - .then(() => CacheCleanup.forceReload('/auth/login')) - .catch(() => window.location.reload()); - }); + CacheCleanup.clearAllCaches() + .then(() => CacheCleanup.forceReload('/auth/login')) + .catch(() => window.location.reload()); } };