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 },