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:
11
.claude/settings.local.json
Normal file
11
.claude/settings.local.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(npm run type-check:*)",
|
||||||
|
"Bash(npm run:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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'
|
|
||||||
```
|
|
||||||
@@ -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.
|
||||||
|
*/
|
||||||
@@ -1391,27 +1391,30 @@ ALTER TABLE task_work_log
|
|||||||
CHECK (time_spent >= (0)::NUMERIC);
|
CHECK (time_spent >= (0)::NUMERIC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
id UUID DEFAULT uuid_generate_v4() NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
done BOOLEAN DEFAULT FALSE NOT NULL,
|
done BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
total_minutes NUMERIC DEFAULT 0 NOT NULL,
|
total_minutes NUMERIC DEFAULT 0 NOT NULL,
|
||||||
archived BOOLEAN DEFAULT FALSE NOT NULL,
|
archived BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
task_no BIGINT NOT NULL,
|
task_no BIGINT NOT NULL,
|
||||||
start_date TIMESTAMP WITH TIME ZONE,
|
start_date TIMESTAMP WITH TIME ZONE,
|
||||||
end_date TIMESTAMP WITH TIME ZONE,
|
end_date TIMESTAMP WITH TIME ZONE,
|
||||||
priority_id UUID NOT NULL,
|
priority_id UUID NOT NULL,
|
||||||
project_id UUID NOT NULL,
|
project_id UUID NOT NULL,
|
||||||
reporter_id UUID NOT NULL,
|
reporter_id UUID NOT NULL,
|
||||||
parent_task_id UUID,
|
parent_task_id UUID,
|
||||||
status_id UUID NOT NULL,
|
status_id UUID NOT NULL,
|
||||||
completed_at TIMESTAMP WITH TIME ZONE,
|
completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
updated_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,
|
sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||||
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
|
roadmap_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||||
billable BOOLEAN DEFAULT TRUE,
|
status_sort_order INTEGER DEFAULT 0 NOT NULL,
|
||||||
schedule_id UUID
|
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
|
ALTER TABLE tasks
|
||||||
@@ -1499,6 +1502,21 @@ ALTER TABLE tasks
|
|||||||
ADD CONSTRAINT tasks_total_minutes_check
|
ADD CONSTRAINT tasks_total_minutes_check
|
||||||
CHECK ((total_minutes >= (0)::NUMERIC) AND (total_minutes <= (999999)::NUMERIC));
|
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 (
|
CREATE TABLE IF NOT EXISTS tasks_assignees (
|
||||||
task_id UUID NOT NULL,
|
task_id UUID NOT NULL,
|
||||||
project_member_id UUID NOT NULL,
|
project_member_id UUID NOT NULL,
|
||||||
|
|||||||
@@ -4608,31 +4608,31 @@ BEGIN
|
|||||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
||||||
VALUES (_project_id, 'Progress', 'PROGRESS', 3, TRUE);
|
VALUES (_project_id, 'Progress', 'PROGRESS', 3, TRUE);
|
||||||
INSERT INTO project_task_list_cols (project_id, name, key, index, pinned)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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)
|
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
|
END
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
@@ -6585,3 +6585,66 @@ BEGIN
|
|||||||
END LOOP;
|
END LOOP;
|
||||||
END
|
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;
|
||||||
|
$$;
|
||||||
|
|||||||
@@ -109,12 +109,29 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static getQuery(userId: string, options: ParsedQs) {
|
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 { searchQuery, sortField } = TasksControllerV2.toPaginationOptions(options, searchField);
|
||||||
|
|
||||||
const isSubTasks = !!options.parent_task;
|
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
|
// Filter tasks by statuses
|
||||||
const statusesFilter = TasksControllerV2.getFilterByStatusWhereClosure(options.statuses as string);
|
const statusesFilter = TasksControllerV2.getFilterByStatusWhereClosure(options.statuses as string);
|
||||||
@@ -196,6 +213,9 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
t.archived,
|
t.archived,
|
||||||
t.description,
|
t.description,
|
||||||
t.sort_order,
|
t.sort_order,
|
||||||
|
t.status_sort_order,
|
||||||
|
t.priority_sort_order,
|
||||||
|
t.phase_sort_order,
|
||||||
t.progress_value,
|
t.progress_value,
|
||||||
t.manual_progress,
|
t.manual_progress,
|
||||||
t.weight,
|
t.weight,
|
||||||
@@ -1088,7 +1108,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
custom_column_values: task.custom_column_values || {}, // Include custom column values
|
custom_column_values: task.custom_column_values || {}, // Include custom column values
|
||||||
createdAt: task.created_at || new Date().toISOString(),
|
createdAt: task.created_at || new Date().toISOString(),
|
||||||
updatedAt: task.updated_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
|
// Additional metadata for frontend
|
||||||
originalStatusId: task.status,
|
originalStatusId: task.status,
|
||||||
originalPriorityId: task.priority,
|
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 {
|
private static getDefaultGroupColor(groupBy: string, groupValue: string): string {
|
||||||
const colorMaps: Record<string, Record<string, string>> = {
|
const colorMaps: Record<string, Record<string, string>> = {
|
||||||
[GroupBy.STATUS]: {
|
[GroupBy.STATUS]: {
|
||||||
|
|||||||
@@ -54,22 +54,19 @@ function notifyStatusChange(socket: Socket, config: Config) {
|
|||||||
|
|
||||||
async function emitSortOrderChange(data: ChangeRequest, socket: Socket) {
|
async function emitSortOrderChange(data: ChangeRequest, socket: Socket) {
|
||||||
// Determine which sort column to use based on group_by
|
// Determine which sort column to use based on group_by
|
||||||
let sortColumn = 'sort_order';
|
let sortColumn = "sort_order";
|
||||||
switch (data.group_by) {
|
switch (data.group_by) {
|
||||||
case 'status':
|
case "status":
|
||||||
sortColumn = 'status_sort_order';
|
sortColumn = "status_sort_order";
|
||||||
break;
|
break;
|
||||||
case 'priority':
|
case "priority":
|
||||||
sortColumn = 'priority_sort_order';
|
sortColumn = "priority_sort_order";
|
||||||
break;
|
break;
|
||||||
case 'phase':
|
case "phase":
|
||||||
sortColumn = 'phase_sort_order';
|
sortColumn = "phase_sort_order";
|
||||||
break;
|
|
||||||
case 'members':
|
|
||||||
sortColumn = 'member_sort_order';
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
sortColumn = 'sort_order';
|
sortColumn = "sort_order";
|
||||||
}
|
}
|
||||||
|
|
||||||
const q = `
|
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
|
// Use the simple bulk update function with group_by parameter
|
||||||
const q = `SELECT update_task_sort_orders_bulk($1, $2);`;
|
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);
|
await emitSortOrderChange(data, socket);
|
||||||
|
|
||||||
// Handle notifications and logging
|
// Handle notifications and logging
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"addTaskText": "Shto Detyrë",
|
"addTaskText": "Shto Detyrë",
|
||||||
"addSubTaskText": "+ Shto Nën-Detyrë",
|
"addSubTaskText": "+ Shto Nën-Detyrë",
|
||||||
"noTasksInGroup": "Nuk ka detyra në këtë grup",
|
"noTasksInGroup": "Nuk ka detyra në këtë grup",
|
||||||
|
"dropTaskHere": "Lëshoje detyrën këtu",
|
||||||
"addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter",
|
"addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter",
|
||||||
|
|
||||||
"openButton": "Hap",
|
"openButton": "Hap",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"addSubTaskText": "+ Unteraufgabe hinzufügen",
|
"addSubTaskText": "+ Unteraufgabe hinzufügen",
|
||||||
"addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken",
|
"addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken",
|
||||||
"noTasksInGroup": "Keine Aufgaben in dieser Gruppe",
|
"noTasksInGroup": "Keine Aufgaben in dieser Gruppe",
|
||||||
|
"dropTaskHere": "Aufgabe hier ablegen",
|
||||||
|
|
||||||
"openButton": "Öffnen",
|
"openButton": "Öffnen",
|
||||||
"okButton": "OK",
|
"okButton": "OK",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"addSubTaskText": "Add Sub Task",
|
"addSubTaskText": "Add Sub Task",
|
||||||
"addTaskInputPlaceholder": "Type your task and hit enter",
|
"addTaskInputPlaceholder": "Type your task and hit enter",
|
||||||
"noTasksInGroup": "No tasks in this group",
|
"noTasksInGroup": "No tasks in this group",
|
||||||
|
"dropTaskHere": "Drop task here",
|
||||||
|
|
||||||
"openButton": "Open",
|
"openButton": "Open",
|
||||||
"okButton": "Ok",
|
"okButton": "Ok",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"addTaskText": "Agregar tarea",
|
"addTaskText": "Agregar tarea",
|
||||||
"addSubTaskText": "Agregar subtarea",
|
"addSubTaskText": "Agregar subtarea",
|
||||||
"noTasksInGroup": "No hay tareas en este grupo",
|
"noTasksInGroup": "No hay tareas en este grupo",
|
||||||
|
"dropTaskHere": "Soltar tarea aquí",
|
||||||
"addTaskInputPlaceholder": "Escribe tu tarea y presiona enter",
|
"addTaskInputPlaceholder": "Escribe tu tarea y presiona enter",
|
||||||
|
|
||||||
"openButton": "Abrir",
|
"openButton": "Abrir",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"addTaskText": "Adicionar Tarefa",
|
"addTaskText": "Adicionar Tarefa",
|
||||||
"addSubTaskText": "+ Adicionar Subtarefa",
|
"addSubTaskText": "+ Adicionar Subtarefa",
|
||||||
"noTasksInGroup": "Nenhuma tarefa neste grupo",
|
"noTasksInGroup": "Nenhuma tarefa neste grupo",
|
||||||
|
"dropTaskHere": "Soltar tarefa aqui",
|
||||||
"addTaskInputPlaceholder": "Digite sua tarefa e pressione enter",
|
"addTaskInputPlaceholder": "Digite sua tarefa e pressione enter",
|
||||||
|
|
||||||
"openButton": "Abrir",
|
"openButton": "Abrir",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"addSubTaskText": "+ 添加子任务",
|
"addSubTaskText": "+ 添加子任务",
|
||||||
"addTaskInputPlaceholder": "输入任务并按回车键",
|
"addTaskInputPlaceholder": "输入任务并按回车键",
|
||||||
"noTasksInGroup": "此组中没有任务",
|
"noTasksInGroup": "此组中没有任务",
|
||||||
|
"dropTaskHere": "将任务拖到这里",
|
||||||
"openButton": "打开",
|
"openButton": "打开",
|
||||||
"okButton": "确定",
|
"okButton": "确定",
|
||||||
"noLabelsFound": "未找到标签",
|
"noLabelsFound": "未找到标签",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from 'antd';
|
||||||
import { Label } from '@/types/task-management.types';
|
import { Label } from '@/types/task-management.types';
|
||||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
|
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||||
|
|
||||||
interface CustomColordLabelProps {
|
interface CustomColordLabelProps {
|
||||||
label: Label | ITaskLabel;
|
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;
|
label.name && label.name.length > 10 ? `${label.name.substring(0, 10)}...` : label.name;
|
||||||
|
|
||||||
// Handle different color property names for different types
|
// 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
|
// Add alpha channel to the base color
|
||||||
const getTextColor = (bgColor: string): string => {
|
const backgroundColor = baseColor + ALPHA_CHANNEL;
|
||||||
// Remove # if present
|
const textColor = baseColor;
|
||||||
const color = bgColor.replace('#', '');
|
|
||||||
|
|
||||||
// Convert to RGB
|
|
||||||
const r = parseInt(color.substr(0, 2), 16);
|
|
||||||
const g = parseInt(color.substr(2, 2), 16);
|
|
||||||
const b = parseInt(color.substr(4, 2), 16);
|
|
||||||
|
|
||||||
// Calculate luminance
|
|
||||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
||||||
|
|
||||||
// Return white for dark backgrounds, black for light backgrounds
|
|
||||||
return luminance > 0.5 ? '#000000' : '#ffffff';
|
|
||||||
};
|
|
||||||
|
|
||||||
const textColor = getTextColor(backgroundColor);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={label.name}>
|
<Tooltip title={label.name}>
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
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={{
|
style={{
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
border: `1px solid ${backgroundColor}`,
|
border: `1px solid ${baseColor}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="truncate">{truncatedName}</span>
|
<span className="truncate">{truncatedName}</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Tooltip } from 'antd';
|
import { Tooltip } from 'antd';
|
||||||
import { NumbersColorMap } from '@/shared/constants';
|
import { NumbersColorMap, ALPHA_CHANNEL } from '@/shared/constants';
|
||||||
|
|
||||||
interface CustomNumberLabelProps {
|
interface CustomNumberLabelProps {
|
||||||
labelList: string[];
|
labelList: string[];
|
||||||
@@ -12,17 +12,24 @@ interface CustomNumberLabelProps {
|
|||||||
const CustomNumberLabel = React.forwardRef<HTMLSpanElement, CustomNumberLabelProps>(
|
const CustomNumberLabel = React.forwardRef<HTMLSpanElement, CustomNumberLabelProps>(
|
||||||
({ labelList, namesString, isDarkMode = false, color }, ref) => {
|
({ labelList, namesString, isDarkMode = false, color }, ref) => {
|
||||||
// Use provided color, or fall back to NumbersColorMap based on first digit
|
// 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';
|
const firstDigit = namesString.match(/\d/)?.[0] || '0';
|
||||||
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
|
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Add alpha channel to the base color
|
||||||
|
const backgroundColor = baseColor + ALPHA_CHANNEL;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={labelList.join(', ')}>
|
<Tooltip title={labelList.join(', ')}>
|
||||||
<span
|
<span
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white cursor-help"
|
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium cursor-help"
|
||||||
style={{ backgroundColor }}
|
style={{
|
||||||
|
backgroundColor,
|
||||||
|
color: baseColor,
|
||||||
|
border: `1px solid ${baseColor}`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{namesString}
|
{namesString}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
useDroppable,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
@@ -67,6 +68,101 @@ import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomCo
|
|||||||
import TaskListSkeleton from './components/TaskListSkeleton';
|
import TaskListSkeleton from './components/TaskListSkeleton';
|
||||||
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
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
|
// Hooks and utilities
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
@@ -127,7 +223,7 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
|
||||||
allTasks,
|
allTasks,
|
||||||
groups
|
groups
|
||||||
);
|
);
|
||||||
@@ -465,31 +561,11 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
projectId={urlProjectId || ''}
|
projectId={urlProjectId || ''}
|
||||||
/>
|
/>
|
||||||
{isGroupEmpty && !isGroupCollapsed && (
|
{isGroupEmpty && !isGroupCollapsed && (
|
||||||
<div className="relative w-full">
|
<EmptyGroupDropZone
|
||||||
<div className="flex items-center min-w-max px-1 py-6">
|
groupId={group.id}
|
||||||
{visibleColumns.map((column, index) => {
|
visibleColumns={visibleColumns}
|
||||||
const emptyColumnStyle = {
|
t={t}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -546,12 +622,6 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
const columnStyle: ColumnStyle = {
|
const columnStyle: ColumnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...(column.id === 'labels' && column.width === 'auto'
|
|
||||||
? {
|
|
||||||
minWidth: '200px',
|
|
||||||
flexGrow: 1,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
||||||
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
||||||
};
|
};
|
||||||
@@ -687,8 +757,9 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
{renderGroup(groupIndex)}
|
{renderGroup(groupIndex)}
|
||||||
|
|
||||||
{/* Group Tasks */}
|
{/* Group Tasks */}
|
||||||
{!collapsedGroups.has(group.id) &&
|
{!collapsedGroups.has(group.id) && (
|
||||||
group.tasks.map((task, taskIndex) => {
|
group.tasks.length > 0 ? (
|
||||||
|
group.tasks.map((task, taskIndex) => {
|
||||||
const globalTaskIndex =
|
const globalTaskIndex =
|
||||||
virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) +
|
virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) +
|
||||||
taskIndex;
|
taskIndex;
|
||||||
@@ -696,12 +767,41 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
// Check if this is the first actual task in the group (not AddTaskRow)
|
// Check if this is the first actual task in the group (not AddTaskRow)
|
||||||
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
|
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 (
|
return (
|
||||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
<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)}
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
) : (
|
||||||
|
// Handle empty groups with placeholder drop indicator
|
||||||
|
overId === group.id && (
|
||||||
|
<div style={{ minWidth: 'max-content' }}>
|
||||||
|
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -710,12 +810,12 @@ const TaskListV2Section: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag Overlay */}
|
{/* Drag Overlay */}
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
|
||||||
{activeId ? (
|
{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="px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<HolderOutlined className="text-blue-500" />
|
<HolderOutlined className="text-blue-500 dark:text-blue-400" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{allTasks.find(task => task.id === activeId)?.name ||
|
{allTasks.find(task => task.id === activeId)?.name ||
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
|
|||||||
const style = useMemo(() => ({
|
const style = useMemo(() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
|
||||||
}), [transform, transition, isDragging]);
|
}), [transform, transition, isDragging]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -252,10 +252,9 @@ interface LabelsColumnProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LabelsColumn: React.FC<LabelsColumnProps> = memo(({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => {
|
export const LabelsColumn: React.FC<LabelsColumnProps> = memo(({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => {
|
||||||
const labelsColumn = visibleColumns.find(col => col.id === 'labels');
|
|
||||||
const labelsStyle = {
|
const labelsStyle = {
|
||||||
width,
|
width,
|
||||||
...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {})
|
flexShrink: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ export const BASE_COLUMNS = [
|
|||||||
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||||
{ id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION },
|
{ id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION },
|
||||||
{ id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
{ 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: '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: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||||
{ id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
{ id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
||||||
{ id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
{ id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
||||||
const currentSession = useAuthService().getCurrentSession();
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [overId, setOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Helper function to emit socket event for persistence
|
// Helper function to emit socket event for persistence
|
||||||
const emitTaskSortChange = useCallback(
|
const emitTaskSortChange = useCallback(
|
||||||
@@ -35,35 +36,67 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
// Get team_id from current session
|
// Get team_id from current session
|
||||||
const teamId = currentSession?.team_id || '';
|
const teamId = currentSession?.team_id || '';
|
||||||
|
|
||||||
// Calculate sort orders for socket emission using the appropriate sort field
|
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
|
||||||
const sortField = getSortOrderField(currentGrouping);
|
const taskUpdates = [];
|
||||||
const fromIndex = (task as any)[sortField] || task.order || 0;
|
|
||||||
let toIndex = 0;
|
// Create a copy of all groups and perform the move operation
|
||||||
let toLastIndex = false;
|
const updatedGroups = groups.map(group => ({
|
||||||
|
...group,
|
||||||
if (targetGroup.taskIds.length === 0) {
|
taskIds: [...group.taskIds]
|
||||||
toIndex = 0;
|
}));
|
||||||
toLastIndex = true;
|
|
||||||
} else if (insertIndex >= targetGroup.taskIds.length) {
|
// Find the source and target groups in our copy
|
||||||
// Dropping at the end
|
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
|
||||||
const lastTask = allTasks.find(t => t.id === targetGroup.taskIds[targetGroup.taskIds.length - 1]);
|
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
|
||||||
toIndex = ((lastTask as any)?.[sortField] || lastTask?.order || 0) + 1;
|
|
||||||
toLastIndex = true;
|
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 {
|
} else {
|
||||||
// Dropping at specific position
|
// Different groups - move task between groups
|
||||||
const targetTask = allTasks.find(t => t.id === targetGroup.taskIds[insertIndex]);
|
// Remove from source group
|
||||||
toIndex = (targetTask as any)?.[sortField] || targetTask?.order || insertIndex;
|
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
|
||||||
toLastIndex = false;
|
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 = {
|
const socketData = {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
from_index: fromIndex,
|
group_by: currentGrouping || 'status',
|
||||||
to_index: toIndex,
|
task_updates: taskUpdates,
|
||||||
to_last_index: toLastIndex,
|
|
||||||
from_group: sourceGroup.id,
|
from_group: sourceGroup.id,
|
||||||
to_group: targetGroup.id,
|
to_group: targetGroup.id,
|
||||||
group_by: currentGrouping || 'status',
|
|
||||||
task: {
|
task: {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
@@ -76,7 +109,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
console.log('Emitting TASK_SORT_ORDER_CHANGE:', socketData);
|
console.log('Emitting TASK_SORT_ORDER_CHANGE:', socketData);
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), 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) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
@@ -87,11 +120,17 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
(event: DragOverEvent) => {
|
(event: DragOverEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
if (!over) return;
|
if (!over) {
|
||||||
|
setOverId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const activeId = active.id;
|
const activeId = active.id;
|
||||||
const overId = over.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
|
// Find the active task and the item being dragged over
|
||||||
const activeTask = allTasks.find(task => task.id === activeId);
|
const activeTask = allTasks.find(task => task.id === activeId);
|
||||||
if (!activeTask) return;
|
if (!activeTask) return;
|
||||||
@@ -126,6 +165,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
|
setOverId(null);
|
||||||
|
|
||||||
if (!over || active.id === over.id) {
|
if (!over || active.id === over.id) {
|
||||||
return;
|
return;
|
||||||
@@ -148,11 +188,16 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
return;
|
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 overTask = allTasks.find(task => task.id === overId);
|
||||||
const overGroup = groups.find(group => group.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;
|
let insertIndex = 0;
|
||||||
|
|
||||||
if (overTask) {
|
if (overTask) {
|
||||||
@@ -165,6 +210,10 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
// Dropping on a group (at the end)
|
// Dropping on a group (at the end)
|
||||||
targetGroup = overGroup;
|
targetGroup = overGroup;
|
||||||
insertIndex = targetGroup.taskIds.length;
|
insertIndex = targetGroup.taskIds.length;
|
||||||
|
} else if (emptyGroup) {
|
||||||
|
// Dropping on an empty group
|
||||||
|
targetGroup = emptyGroup;
|
||||||
|
insertIndex = 0; // First position in empty group
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetGroup) {
|
if (!targetGroup) {
|
||||||
@@ -238,6 +287,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
activeId,
|
activeId,
|
||||||
|
overId,
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
handleDragEnd,
|
handleDragEnd,
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ const DEFAULT_FIELDS: TaskListField[] = [
|
|||||||
{ key: 'KEY', label: 'Key', visible: false, order: 1 },
|
{ key: 'KEY', label: 'Key', visible: false, order: 1 },
|
||||||
{ key: 'DESCRIPTION', label: 'Description', visible: false, order: 2 },
|
{ key: 'DESCRIPTION', label: 'Description', visible: false, order: 2 },
|
||||||
{ key: 'PROGRESS', label: 'Progress', visible: true, order: 3 },
|
{ key: 'PROGRESS', label: 'Progress', visible: true, order: 3 },
|
||||||
{ key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 4 },
|
{ key: 'STATUS', label: 'Status', visible: true, order: 4 },
|
||||||
{ key: 'LABELS', label: 'Labels', visible: true, order: 5 },
|
{ key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 5 },
|
||||||
{ key: 'PHASE', label: 'Phase', visible: true, order: 6 },
|
{ key: 'LABELS', label: 'Labels', visible: true, order: 6 },
|
||||||
{ key: 'STATUS', label: 'Status', visible: true, order: 7 },
|
{ key: 'PHASE', label: 'Phase', visible: true, order: 7 },
|
||||||
{ key: 'PRIORITY', label: 'Priority', visible: true, order: 8 },
|
{ key: 'PRIORITY', label: 'Priority', visible: true, order: 8 },
|
||||||
{ key: 'TIME_TRACKING', label: 'Time Tracking', visible: true, order: 9 },
|
{ key: 'TIME_TRACKING', label: 'Time Tracking', visible: true, order: 9 },
|
||||||
{ key: 'ESTIMATION', label: 'Estimation', visible: false, order: 10 },
|
{ key: 'ESTIMATION', label: 'Estimation', visible: false, order: 10 },
|
||||||
|
|||||||
Reference in New Issue
Block a user