refactor(sort-orders): remove outdated deployment and implementation guides

- Deleted the `DEPLOYMENT_GUIDE_SORT_ORDERS.md` and `SEPARATE_SORT_ORDERS_IMPLEMENTATION.md` files as they are no longer relevant following the recent updates to the sort orders feature.
- Introduced new migration scripts to address duplicate sort orders and ensure data integrity across the updated task sorting system.
- Updated database schema to include new sort order columns and constraints for improved performance and organization.
- Enhanced backend functions and frontend components to support the new sorting logic and maintain user experience during task organization.
This commit is contained in:
chamikaJ
2025-07-15 13:18:51 +05:30
parent 407dc416ec
commit 6d8c475e67
22 changed files with 718 additions and 450 deletions

View File

@@ -39,6 +39,7 @@
"addTaskText": "Shto Detyrë",
"addSubTaskText": "+ Shto Nën-Detyrë",
"noTasksInGroup": "Nuk ka detyra në këtë grup",
"dropTaskHere": "Lëshoje detyrën këtu",
"addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter",
"openButton": "Hap",

View File

@@ -40,6 +40,7 @@
"addSubTaskText": "+ Unteraufgabe hinzufügen",
"addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken",
"noTasksInGroup": "Keine Aufgaben in dieser Gruppe",
"dropTaskHere": "Aufgabe hier ablegen",
"openButton": "Öffnen",
"okButton": "OK",

View File

@@ -40,6 +40,7 @@
"addSubTaskText": "Add Sub Task",
"addTaskInputPlaceholder": "Type your task and hit enter",
"noTasksInGroup": "No tasks in this group",
"dropTaskHere": "Drop task here",
"openButton": "Open",
"okButton": "Ok",

View File

@@ -39,6 +39,7 @@
"addTaskText": "Agregar tarea",
"addSubTaskText": "Agregar subtarea",
"noTasksInGroup": "No hay tareas en este grupo",
"dropTaskHere": "Soltar tarea aquí",
"addTaskInputPlaceholder": "Escribe tu tarea y presiona enter",
"openButton": "Abrir",

View File

@@ -39,6 +39,7 @@
"addTaskText": "Adicionar Tarefa",
"addSubTaskText": "+ Adicionar Subtarefa",
"noTasksInGroup": "Nenhuma tarefa neste grupo",
"dropTaskHere": "Soltar tarefa aqui",
"addTaskInputPlaceholder": "Digite sua tarefa e pressione enter",
"openButton": "Abrir",

View File

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

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { Tooltip } from 'antd';
import { Label } from '@/types/task-management.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ALPHA_CHANNEL } from '@/shared/constants';
interface CustomColordLabelProps {
label: Label | ITaskLabel;
@@ -14,36 +15,21 @@ const CustomColordLabel = React.forwardRef<HTMLSpanElement, CustomColordLabelPro
label.name && label.name.length > 10 ? `${label.name.substring(0, 10)}...` : label.name;
// Handle different color property names for different types
const backgroundColor = (label as Label).color || (label as ITaskLabel).color_code || '#6b7280'; // Default to gray-500 if no color
const baseColor = (label as Label).color || (label as ITaskLabel).color_code || '#6b7280'; // Default to gray-500 if no color
// Function to determine if we should use white or black text based on background color
const getTextColor = (bgColor: string): string => {
// Remove # if present
const color = bgColor.replace('#', '');
// Convert to RGB
const r = parseInt(color.substr(0, 2), 16);
const g = parseInt(color.substr(2, 2), 16);
const b = parseInt(color.substr(4, 2), 16);
// Calculate luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Return white for dark backgrounds, black for light backgrounds
return luminance > 0.5 ? '#000000' : '#ffffff';
};
const textColor = getTextColor(backgroundColor);
// Add alpha channel to the base color
const backgroundColor = baseColor + ALPHA_CHANNEL;
const textColor = baseColor;
return (
<Tooltip title={label.name}>
<span
ref={ref}
className="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium shrink-0 max-w-[120px]"
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium shrink-0 max-w-[100px]"
style={{
backgroundColor,
color: textColor,
border: `1px solid ${backgroundColor}`,
border: `1px solid ${baseColor}`,
}}
>
<span className="truncate">{truncatedName}</span>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Tooltip } from 'antd';
import { NumbersColorMap } from '@/shared/constants';
import { NumbersColorMap, ALPHA_CHANNEL } from '@/shared/constants';
interface CustomNumberLabelProps {
labelList: string[];
@@ -12,17 +12,24 @@ interface CustomNumberLabelProps {
const CustomNumberLabel = React.forwardRef<HTMLSpanElement, CustomNumberLabelProps>(
({ labelList, namesString, isDarkMode = false, color }, ref) => {
// Use provided color, or fall back to NumbersColorMap based on first digit
const backgroundColor = color || (() => {
const baseColor = color || (() => {
const firstDigit = namesString.match(/\d/)?.[0] || '0';
return NumbersColorMap[firstDigit] || NumbersColorMap['0'];
})();
// Add alpha channel to the base color
const backgroundColor = baseColor + ALPHA_CHANNEL;
return (
<Tooltip title={labelList.join(', ')}>
<span
ref={ref}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white cursor-help"
style={{ backgroundColor }}
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium cursor-help"
style={{
backgroundColor,
color: baseColor,
border: `1px solid ${baseColor}`,
}}
>
{namesString}
</span>

View File

@@ -9,6 +9,7 @@ import {
KeyboardSensor,
TouchSensor,
closestCenter,
useDroppable,
} from '@dnd-kit/core';
import {
SortableContext,
@@ -67,6 +68,101 @@ import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomCo
import TaskListSkeleton from './components/TaskListSkeleton';
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
// Empty Group Drop Zone Component
const EmptyGroupDropZone: React.FC<{
groupId: string;
visibleColumns: any[];
t: (key: string) => string;
}> = ({ groupId, visibleColumns, t }) => {
const { setNodeRef, isOver, active } = useDroppable({
id: `empty-group-${groupId}`,
data: {
type: 'group',
groupId: groupId,
isEmpty: true,
},
});
return (
<div
ref={setNodeRef}
className={`relative w-full transition-colors duration-200 ${
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div className="flex items-center min-w-max px-1 py-6">
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
};
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</div>
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 flex items-center">
<div
className={`text-sm px-4 py-3 rounded-md border shadow-sm transition-colors duration-200 ${
isOver && active
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/30 border-blue-300 dark:border-blue-600'
: 'text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700'
}`}
>
{isOver && active ? t('dropTaskHere') || 'Drop task here' : t('noTasksInGroup')}
</div>
</div>
{isOver && active && (
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
)}
</div>
);
};
// Placeholder Drop Indicator Component
const PlaceholderDropIndicator: React.FC<{
isVisible: boolean;
visibleColumns: any[];
}> = ({ isVisible, visibleColumns }) => {
if (!isVisible) return null;
return (
<div
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out"
style={{ minWidth: 'max-content', height: '40px' }}
>
{visibleColumns.map((column, index) => {
const columnStyle = {
width: column.width,
flexShrink: 0,
};
return (
<div
key={`placeholder-${column.id}`}
className="flex items-center justify-center h-full"
style={columnStyle}
>
{/* Show "Drop task here" message in the title column */}
{column.id === 'title' && (
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
Drop task here
</div>
)}
{/* Show subtle placeholder content in other columns */}
{column.id !== 'title' && column.id !== 'dragHandle' && (
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
)}
</div>
);
})}
</div>
);
};
// Hooks and utilities
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useSocket } from '@/socket/socketContext';
@@ -127,7 +223,7 @@ const TaskListV2Section: React.FC = () => {
);
// Custom hooks
const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
allTasks,
groups
);
@@ -465,31 +561,11 @@ const TaskListV2Section: React.FC = () => {
projectId={urlProjectId || ''}
/>
{isGroupEmpty && !isGroupCollapsed && (
<div className="relative w-full">
<div className="flex items-center min-w-max px-1 py-6">
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
...(column.id === 'labels' && column.width === 'auto'
? { minWidth: '200px', flexGrow: 1 }
: {}),
};
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</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>
<EmptyGroupDropZone
groupId={group.id}
visibleColumns={visibleColumns}
t={t}
/>
)}
</div>
);
@@ -546,12 +622,6 @@ const TaskListV2Section: React.FC = () => {
const columnStyle: ColumnStyle = {
width: column.width,
flexShrink: 0,
...(column.id === 'labels' && column.width === 'auto'
? {
minWidth: '200px',
flexGrow: 1,
}
: {}),
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
};
@@ -687,8 +757,9 @@ const TaskListV2Section: React.FC = () => {
{renderGroup(groupIndex)}
{/* Group Tasks */}
{!collapsedGroups.has(group.id) &&
group.tasks.map((task, taskIndex) => {
{!collapsedGroups.has(group.id) && (
group.tasks.length > 0 ? (
group.tasks.map((task, taskIndex) => {
const globalTaskIndex =
virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) +
taskIndex;
@@ -696,12 +767,41 @@ const TaskListV2Section: React.FC = () => {
// Check if this is the first actual task in the group (not AddTaskRow)
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
// Check if we should show drop indicators
const isTaskBeingDraggedOver = overId === task.id;
const isGroupBeingDraggedOver = overId === group.id;
const isFirstTaskInGroupBeingDraggedOver = isFirstTaskInGroup && isTaskBeingDraggedOver;
return (
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
{/* Placeholder drop indicator before first task in group */}
{isFirstTaskInGroupBeingDraggedOver && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{/* Placeholder drop indicator between tasks */}
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
{/* Placeholder drop indicator at end of group when dragging over group */}
{isGroupBeingDraggedOver && taskIndex === group.tasks.length - 1 && (
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
)}
</div>
);
})}
})
) : (
// Handle empty groups with placeholder drop indicator
overId === group.id && (
<div style={{ minWidth: 'max-content' }}>
<PlaceholderDropIndicator isVisible={true} visibleColumns={visibleColumns} />
</div>
)
)
)}
</div>
))}
</div>
@@ -710,12 +810,12 @@ const TaskListV2Section: React.FC = () => {
</div>
{/* Drag Overlay */}
<DragOverlay dropAnimation={null}>
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}>
{activeId ? (
<div className="bg-white dark:bg-gray-800 shadow-xl rounded-md border-2 border-blue-400 opacity-95">
<div className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 scale-105">
<div className="px-4 py-3">
<div className="flex items-center gap-3">
<HolderOutlined className="text-blue-500" />
<HolderOutlined className="text-blue-500 dark:text-blue-400" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{allTasks.find(task => task.id === activeId)?.name ||

View File

@@ -113,7 +113,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
}), [transform, transition, isDragging]);
return (

View File

@@ -252,10 +252,9 @@ interface LabelsColumnProps {
}
export const LabelsColumn: React.FC<LabelsColumnProps> = memo(({ width, task, labelsAdapter, isDarkMode, visibleColumns }) => {
const labelsColumn = visibleColumns.find(col => col.id === 'labels');
const labelsStyle = {
width,
...(labelsColumn?.width === 'auto' ? { minWidth: '200px', flexGrow: 1 } : {})
flexShrink: 0
};
return (

View File

@@ -19,10 +19,10 @@ export const BASE_COLUMNS = [
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
{ id: 'description', label: 'descriptionColumn', width: '260px', key: COLUMN_KEYS.DESCRIPTION },
{ id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS },
{ id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
{ id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS },
{ id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE },
{ id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS },
{ id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
{ id: 'labels', label: 'labelsColumn', width: '250px', key: COLUMN_KEYS.LABELS },
{ id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE },
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
{ id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
{ id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },

View File

@@ -17,6 +17,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const currentGrouping = useAppSelector(selectCurrentGrouping);
const currentSession = useAuthService().getCurrentSession();
const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
// Helper function to emit socket event for persistence
const emitTaskSortChange = useCallback(
@@ -35,35 +36,67 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
// Get team_id from current session
const teamId = currentSession?.team_id || '';
// Calculate sort orders for socket emission using the appropriate sort field
const sortField = getSortOrderField(currentGrouping);
const fromIndex = (task as any)[sortField] || task.order || 0;
let toIndex = 0;
let toLastIndex = false;
if (targetGroup.taskIds.length === 0) {
toIndex = 0;
toLastIndex = true;
} else if (insertIndex >= targetGroup.taskIds.length) {
// Dropping at the end
const lastTask = allTasks.find(t => t.id === targetGroup.taskIds[targetGroup.taskIds.length - 1]);
toIndex = ((lastTask as any)?.[sortField] || lastTask?.order || 0) + 1;
toLastIndex = true;
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates
const taskUpdates = [];
// Create a copy of all groups and perform the move operation
const updatedGroups = groups.map(group => ({
...group,
taskIds: [...group.taskIds]
}));
// Find the source and target groups in our copy
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!;
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
if (sourceGroup.id === targetGroup.id) {
// Same group - reorder within the group
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
// Remove task from old position
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
// Insert at new position
sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId);
} else {
// Dropping at specific position
const targetTask = allTasks.find(t => t.id === targetGroup.taskIds[insertIndex]);
toIndex = (targetTask as any)?.[sortField] || targetTask?.order || insertIndex;
toLastIndex = false;
// Different groups - move task between groups
// Remove from source group
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
// Add to target group
targetGroupCopy.taskIds.splice(insertIndex, 0, taskId);
}
// Now assign sequential sort orders to ALL tasks across ALL groups
let currentSortOrder = 0;
updatedGroups.forEach(group => {
group.taskIds.forEach(id => {
const update: any = {
task_id: id,
sort_order: currentSortOrder
};
// Add group-specific fields for the moved task if it changed groups
if (id === taskId && sourceGroup.id !== targetGroup.id) {
if (currentGrouping === 'status') {
update.status_id = targetGroup.id;
} else if (currentGrouping === 'priority') {
update.priority_id = targetGroup.id;
} else if (currentGrouping === 'phase') {
update.phase_id = targetGroup.id;
}
}
taskUpdates.push(update);
currentSortOrder++;
});
});
const socketData = {
project_id: projectId,
from_index: fromIndex,
to_index: toIndex,
to_last_index: toLastIndex,
group_by: currentGrouping || 'status',
task_updates: taskUpdates,
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: currentGrouping || 'status',
task: {
id: task.id,
project_id: projectId,
@@ -76,7 +109,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
console.log('Emitting TASK_SORT_ORDER_CHANGE:', socketData);
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
},
[socket, connected, projectId, allTasks, currentGrouping, currentSession]
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
);
const handleDragStart = useCallback((event: DragStartEvent) => {
@@ -87,11 +120,17 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
if (!over) {
setOverId(null);
return;
}
const activeId = active.id;
const overId = over.id;
// Set the overId for drop indicators
setOverId(overId as string);
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) return;
@@ -126,6 +165,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
setOverId(null);
if (!over || active.id === over.id) {
return;
@@ -148,11 +188,16 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
return;
}
// Check if we're dropping on a task or a group
// Check if we're dropping on a task, group, or empty group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Check if dropping on empty group drop zone
const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-');
const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null;
const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null;
let targetGroup = overGroup;
let targetGroup = overGroup || emptyGroup;
let insertIndex = 0;
if (overTask) {
@@ -165,6 +210,10 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
} else if (emptyGroup) {
// Dropping on an empty group
targetGroup = emptyGroup;
insertIndex = 0; // First position in empty group
}
if (!targetGroup) {
@@ -238,6 +287,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
return {
activeId,
overId,
handleDragStart,
handleDragOver,
handleDragEnd,

View File

@@ -14,10 +14,10 @@ const DEFAULT_FIELDS: TaskListField[] = [
{ key: 'KEY', label: 'Key', visible: false, order: 1 },
{ key: 'DESCRIPTION', label: 'Description', visible: false, order: 2 },
{ key: 'PROGRESS', label: 'Progress', visible: true, order: 3 },
{ key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 4 },
{ key: 'LABELS', label: 'Labels', visible: true, order: 5 },
{ key: 'PHASE', label: 'Phase', visible: true, order: 6 },
{ key: 'STATUS', label: 'Status', visible: true, order: 7 },
{ key: 'STATUS', label: 'Status', visible: true, order: 4 },
{ key: 'ASSIGNEES', label: 'Assignees', visible: true, order: 5 },
{ key: 'LABELS', label: 'Labels', visible: true, order: 6 },
{ key: 'PHASE', label: 'Phase', visible: true, order: 7 },
{ key: 'PRIORITY', label: 'Priority', visible: true, order: 8 },
{ key: 'TIME_TRACKING', label: 'Time Tracking', visible: true, order: 9 },
{ key: 'ESTIMATION', label: 'Estimation', visible: false, order: 10 },