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.
This commit is contained in:
chamikaJ
2025-07-15 13:18:51 +05:30
parent 407dc416ec
commit 6d8c475e67
22 changed files with 718 additions and 450 deletions

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(npm run build:*)",
"Bash(npm run type-check:*)",
"Bash(npm run:*)"
],
"deny": []
}
}

View File

@@ -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

View File

@@ -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'
```

View File

@@ -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.
*/

View File

@@ -1410,6 +1410,9 @@ CREATE TABLE IF NOT EXISTS tasks (
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
);
@@ -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,

View File

@@ -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;
$$;

View File

@@ -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<string, Record<string, string>> = {
[GroupBy.STATUS]: {

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -37,6 +37,7 @@
"addSubTaskText": "+ 添加子任务",
"addTaskInputPlaceholder": "输入任务并按回车键",
"noTasksInGroup": "此组中没有任务",
"dropTaskHere": "将任务拖到这里",
"openButton": "打开",
"okButton": "确定",
"noLabelsFound": "未找到标签",

View File

@@ -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<HTMLSpanElement, CustomColordLabelPro
label.name && label.name.length > 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 (
<Tooltip title={label.name}>
<span
ref={ref}
className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium shrink-0 max-w-[120px]"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium shrink-0 max-w-[100px]"
style={{
backgroundColor,
color: textColor,
border: `1px solid ${backgroundColor}`,
border: `1px solid ${baseColor}`,
}}
>
<span className="truncate">{truncatedName}</span>

View File

@@ -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<HTMLSpanElement, CustomNumberLabelProps>(
({ 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 (
<Tooltip title={labelList.join(', ')}>
<span
ref={ref}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white cursor-help"
style={{ backgroundColor }}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium cursor-help"
style={{
backgroundColor,
color: baseColor,
border: `1px solid ${baseColor}`,
}}
>
{namesString}
</span>

View File

@@ -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 (
<div
ref={setNodeRef}
className={`relative w-full transition-colors duration-200 ${
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div className="flex items-center min-w-max px-1 py-6">
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
};
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</div>
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 flex items-center">
<div
className={`text-sm px-4 py-3 rounded-md border shadow-sm transition-colors duration-200 ${
isOver && active
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-600'
: 'text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700'
}`}
>
{isOver && active ? t('dropTaskHere') || 'Drop task here' : t('noTasksInGroup')}
</div>
</div>
{isOver && active && (
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
)}
</div>
);
};
// Placeholder Drop Indicator Component
const PlaceholderDropIndicator: React.FC<{
isVisible: boolean;
visibleColumns: any[];
}> = ({ isVisible, visibleColumns }) => {
if (!isVisible) return null;
return (
<div
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
style={{ minWidth: 'max-content', height: '40px' }}
>
{visibleColumns.map((column, index) => {
const columnStyle = {
width: column.width,
flexShrink: 0,
};
return (
<div
key={`placeholder-${column.id}`}
className="flex items-center justify-center h-full"
style={columnStyle}
>
{/* Show "Drop task here" message in the title column */}
{column.id === 'title' && (
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
Drop task here
</div>
)}
{/* Show subtle placeholder content in other columns */}
{column.id !== 'title' && column.id !== 'dragHandle' && (
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
)}
</div>
);
})}
</div>
);
};
// 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 && (
<div className="relative w-full">
<div className="flex items-center min-w-max px-1 py-6">
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
...(column.id === 'labels' && column.width === 'auto'
? { minWidth: '200px', flexGrow: 1 }
: {}),
};
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
<EmptyGroupDropZone
groupId={group.id}
visibleColumns={visibleColumns}
t={t}
/>
);
})}
</div>
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 flex items-center">
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-4 py-3 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
{t('noTasksInGroup')}
</div>
</div>
</div>
)}
</div>
);
@@ -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,7 +757,8 @@ const TaskListV2Section: React.FC = () => {
{renderGroup(groupIndex)}
{/* Group Tasks */}
{!collapsedGroups.has(group.id) &&
{!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) +
@@ -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 (
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
{/* Placeholder drop indicator before first task in group */}
{isFirstTaskInGroupBeingDraggedOver && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{/* Placeholder drop indicator between tasks */}
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
{/* Placeholder drop indicator at end of group when dragging over group */}
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
</div>
);
})}
})
) : (
// Handle empty groups with placeholder drop indicator
overId === group.id && (
<div style={{ minWidth: 'max-content' }}>
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
</div>
)
)
)}
</div>
))}
</div>
@@ -710,12 +810,12 @@ const TaskListV2Section: React.FC = () => {
</div>
{/* Drag Overlay */}
<DragOverlay dropAnimation={null}>
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
{activeId ? (
<div className="bg-white dark:bg-gray-800 shadow-xl rounded-md border-2 border-blue-400 opacity-95">
<div className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 scale-105">
<div className="px-4 py-3">
<div className="flex items-center gap-3">
<HolderOutlined className="text-blue-500" />
<HolderOutlined className="text-blue-500 dark:text-blue-400" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{allTasks.find(task => task.id === activeId)?.name ||

View File

@@ -113,7 +113,7 @@ const TaskRow: React.FC<TaskRowProps> = 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 (

View File

@@ -252,10 +252,9 @@ interface LabelsColumnProps {
}
export const LabelsColumn: React.FC<LabelsColumnProps> = 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 (

View File

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

View File

@@ -17,6 +17,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const currentGrouping = useAppSelector(selectCurrentGrouping);
const currentSession = useAuthService().getCurrentSession();
const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(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;
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
const taskUpdates = [];
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;
// 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);
let targetGroup = overGroup;
// 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 || 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,

View File

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