Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Enable drag-and-drop task reordering within status columns 🔄 #156

Merged
merged 1 commit into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}
}
Loading