Skip to content

Commit

Permalink
feat: Enable drag-and-drop task reordering within status columns 🔄 (#156
Browse files Browse the repository at this point in the history
)

This update introduces the ability to reorder tasks within status columns using drag-and-drop functionality. Tasks now maintain their order through WBS codes, with visual indicators showing drop positions. A database migration removes the unique constraint on WBS codes to support this feature.

Key changes:
- Add reorderTasksInStatus server action
- Implement drag-and-drop reordering UI with visual guides
- Remove unique constraint on WBS codes
- Sort tasks by WBS code in status columns

🧙‍♀️ "Dorothy would be proud - we're not in static task orders anymore! Now our tasks can dance down the yellow brick road in any order they choose ✨"
  • Loading branch information
RobertAtNineMinds authored Jan 3, 2025
1 parent 4684624 commit dc50b2b
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Remove unique constraint on wbs_code in project_tasks table
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function(knex) {
await knex.schema.alterTable('project_tasks', table => {
table.dropUnique(['tenant', 'phase_id', 'wbs_code'], 'project_tasks_tenant_phase_id_wbs_code_unique');
});
};

/**
* Restore unique constraint on wbs_code in project_tasks table
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function(knex) {
await knex.schema.alterTable('project_tasks', table => {
table.unique(['tenant', 'phase_id', 'wbs_code'], 'project_tasks_tenant_phase_id_wbs_code_unique');
});
};
3 changes: 3 additions & 0 deletions server/src/components/projects/KanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface KanbanBoardProps {
onAssigneeChange: (taskId: string, newAssigneeId: string) => void;
onDragStart: (e: React.DragEvent, taskId: string) => void;
onDragEnd: (e: React.DragEvent) => void;
onReorderTasks: (updates: { taskId: string, newWbsCode: string }[]) => void;
}

const statusIcons: { [key: string]: React.ReactNode } = {
Expand Down Expand Up @@ -46,6 +47,7 @@ export const KanbanBoard: React.FC<KanbanBoardProps> = ({
onAssigneeChange,
onDragStart,
onDragEnd,
onReorderTasks,
}) => {
return (
<div className={styles.kanbanBoard}>
Expand Down Expand Up @@ -74,6 +76,7 @@ export const KanbanBoard: React.FC<KanbanBoardProps> = ({
onAssigneeChange={onAssigneeChange}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onReorderTasks={onReorderTasks}
/>
);
})}
Expand Down
36 changes: 30 additions & 6 deletions server/src/components/projects/ProjectDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import PhaseQuickAdd from './PhaseQuickAdd';
import {
updateTaskStatus,
getProjectTaskStatuses,
reorderTasksInStatus,
updatePhase,
moveTaskToPhase,
updateTaskWithChecklist,
Expand Down Expand Up @@ -422,13 +423,35 @@ export default function ProjectDetail({
setSelectedPhase(phase);
setCurrentPhase(phase);
};

const handleDeletePhaseClick = (phase: IProjectPhase) => {
setDeletePhaseConfirmation({
phaseId: phase.phase_id,
phaseName: phase.phase_name
const handleDeletePhaseClick = (phase: IProjectPhase) => {
setDeletePhaseConfirmation({
phaseId: phase.phase_id,
phaseName: phase.phase_name
});
};

const handleReorderTasks = async (updates: { taskId: string, newWbsCode: string }[]) => {
try {
await reorderTasksInStatus(updates);
// Update local state to reflect the new order
const updatedTasks = [...projectTasks];
updates.forEach(({taskId, newWbsCode}) => {
const taskIndex = updatedTasks.findIndex(t => t.task_id === taskId);
if (taskIndex !== -1) {
updatedTasks[taskIndex] = {
...updatedTasks[taskIndex],
wbs_code: newWbsCode
};
}
});
};
setProjectTasks(updatedTasks);
toast.success('Tasks reordered successfully');
} catch (error) {
console.error('Error reordering tasks:', error);
toast.error('Failed to reorder tasks');
}
};


const renderContent = () => {
if (!selectedPhase) {
Expand Down Expand Up @@ -466,6 +489,7 @@ export default function ProjectDetail({
onAssigneeChange={handleAssigneeChange}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onReorderTasks={handleReorderTasks}
/>
</div>
</div>
Expand Down
129 changes: 116 additions & 13 deletions server/src/components/projects/StatusColumn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Circle, Plus } from 'lucide-react';
import TaskCard from './TaskCard';
import styles from './ProjectDetail.module.css';
import { IUserWithRoles } from '@/interfaces/auth.interfaces';
import { useState } from 'react';
import { useState, useRef } from 'react';

interface StatusColumnProps {
status: ProjectStatus;
Expand All @@ -25,6 +25,7 @@ interface StatusColumnProps {
onAssigneeChange: (taskId: string, newAssigneeId: string, newTaskName?: string) => void;
onDragStart: (e: React.DragEvent, taskId: string) => void;
onDragEnd: (e: React.DragEvent) => void;
onReorderTasks: (updates: { taskId: string, newWbsCode: string }[]) => void;
}

export const StatusColumn: React.FC<StatusColumnProps> = ({
Expand All @@ -44,27 +45,122 @@ export const StatusColumn: React.FC<StatusColumnProps> = ({
onAssigneeChange,
onDragStart,
onDragEnd,
onReorderTasks,
}) => {
const [isDraggedOver, setIsDraggedOver] = useState(false);
const [dragOverTaskId, setDragOverTaskId] = useState<string | null>(null);
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
const tasksRef = useRef<HTMLDivElement>(null);

const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (!isDraggedOver) {
setIsDraggedOver(true);
}

// Find the task being dragged over
const taskElement = findClosestTask(e);
if (taskElement) {
const taskId = taskElement.getAttribute('data-task-id');
const rect = taskElement.getBoundingClientRect();
const position = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';

setDragOverTaskId(taskId);
setDropPosition(position);
} else {
setDragOverTaskId(null);
setDropPosition(null);
}

onDragOver(e);
};

const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDraggedOver(false);
setDragOverTaskId(null);
setDropPosition(null);
};

const findClosestTask = (e: React.DragEvent): HTMLElement | null => {
if (!tasksRef.current) return null;

const taskElements = Array.from(tasksRef.current.children) as HTMLElement[];
let closestTask: HTMLElement | null = null;
let closestDistance = Infinity;

taskElements.forEach(taskElement => {
const rect = taskElement.getBoundingClientRect();
const taskMiddle = rect.top + rect.height / 2;
const distance = Math.abs(e.clientY - taskMiddle);

if (distance < closestDistance) {
closestDistance = distance;
closestTask = taskElement;
}
});

return closestTask;
};

const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDraggedOver(false);
onDrop(e, status.project_status_mapping_id);
setDragOverTaskId(null);
setDropPosition(null);

const draggedTaskId = e.dataTransfer.getData('text');
const draggedTask = tasks.find(t => t.task_id === draggedTaskId);

if (!draggedTask) return;

// If the task is from a different status, handle status change
if (draggedTask.project_status_mapping_id !== status.project_status_mapping_id) {
onDrop(e, status.project_status_mapping_id);
return;
}

// Handle reordering within the same status
const taskElement = findClosestTask(e);
if (!taskElement) return;

const targetTaskId = taskElement.getAttribute('data-task-id');
if (!targetTaskId || targetTaskId === draggedTaskId) return;

const targetTask = tasks.find(t => t.task_id === targetTaskId);
if (!targetTask) return;

// Calculate new WBS codes for reordering
const orderedTasks = [...tasks].sort((a, b) =>
a.wbs_code.localeCompare(b.wbs_code)
);

const draggedIndex = orderedTasks.findIndex(t => t.task_id === draggedTaskId);
const targetIndex = orderedTasks.findIndex(t => t.task_id === targetTaskId);

// Remove dragged task
orderedTasks.splice(draggedIndex, 1);

// Insert at new position
const insertIndex = e.clientY < taskElement.getBoundingClientRect().top + taskElement.getBoundingClientRect().height / 2
? targetIndex
: targetIndex + 1;

orderedTasks.splice(insertIndex, 0, draggedTask);

// Update WBS codes
const updates = orderedTasks.map((task, index) => ({
taskId: task.task_id,
newWbsCode: task.wbs_code.split('.').slice(0, -1).concat(String(index + 1)).join('.')
}));

// Call parent handler to update WBS codes
onReorderTasks(updates);
};

// Sort tasks by WBS code
const sortedTasks = [...tasks].sort((a, b) => a.wbs_code.localeCompare(b.wbs_code));

return (
<div
className={`${styles.kanbanColumn} ${backgroundColor} rounded-lg border-2 border-solid transition-all duration-200 ${
Expand Down Expand Up @@ -96,17 +192,24 @@ export const StatusColumn: React.FC<StatusColumnProps> = ({
</div>
</div>
</div>
<div className={styles.kanbanTasks}>
{tasks.map((task): JSX.Element => (
<TaskCard
key={task.task_id}
task={task}
users={users}
onTaskSelected={onTaskSelected}
onAssigneeChange={onAssigneeChange}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
<div className={styles.kanbanTasks} ref={tasksRef}>
{sortedTasks.map((task): JSX.Element => (
<div key={task.task_id} data-task-id={task.task_id} className="relative">
{dragOverTaskId === task.task_id && dropPosition === 'before' && (
<div className="absolute -top-1 left-0 right-0 h-0.5 bg-purple-500 rounded-full" />
)}
<TaskCard
task={task}
users={users}
onTaskSelected={onTaskSelected}
onAssigneeChange={onAssigneeChange}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
/>
{dragOverTaskId === task.task_id && dropPosition === 'after' && (
<div className="absolute -bottom-1 left-0 right-0 h-0.5 bg-purple-500 rounded-full" />
)}
</div>
))}
</div>
</div>
Expand Down
43 changes: 43 additions & 0 deletions server/src/lib/actions/projectActions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
'use server';

import { Knex } from 'knex';
import ProjectModel from '../models/project';
import { IProject, IProjectPhase, IProjectTask, IProjectTicketLink, IStatus, IProjectStatusMapping, IStandardStatus, ItemType, ITaskChecklistItem, IProjectTicketLinkWithDetails, ProjectStatus } from '@/interfaces/project.interfaces';
import { getCurrentUser } from '@/lib/actions/user-actions/userActions';
import { IUser, IUserWithRoles } from '@/interfaces/auth.interfaces';
import { hasPermission } from '@/lib/auth/rbac';
import { getAllUsers } from './user-actions/userActions';
import { validateData, validateArray } from '../utils/validation';
import { createTenantKnex } from '@/lib/db';
import {
createProjectSchema,
updateProjectSchema,
Expand Down Expand Up @@ -965,3 +967,44 @@ export async function deleteTaskTicketLinkAction(linkId: string): Promise<void>
throw error;
}
}

export async function reorderTasksInStatus(tasks: { taskId: string, newWbsCode: string }[]): Promise<void> {
try {
const currentUser = await getCurrentUser();
if (!currentUser) {
throw new Error("user not found");
}

await checkPermission(currentUser, 'project', 'update');

const {knex: db} = await createTenantKnex();
await db.transaction(async (trx: Knex.Transaction) => {
// Verify tasks exist and are in the same phase
const taskRecords = await trx('project_tasks')
.whereIn('task_id', tasks.map(t => t.taskId))
.select('task_id', 'phase_id');

if (taskRecords.length !== tasks.length) {
throw new Error('Some tasks not found');
}

const phaseId = taskRecords[0].phase_id;
if (!taskRecords.every(t => t.phase_id === phaseId)) {
throw new Error('All tasks must be in the same phase');
}

// Update all tasks with their new WBS codes
await Promise.all(tasks.map(({taskId, newWbsCode}) =>
trx('project_tasks')
.where('task_id', taskId)
.update({
wbs_code: newWbsCode,
updated_at: trx.fn.now()
})
));
});
} catch (error) {
console.error('Error reordering tasks:', error);
throw error;
}
}

0 comments on commit dc50b2b

Please sign in to comment.