Merge pull request #230 from Worklenz/fix/task-drag-and-drop-improvement
Fix/task drag and drop improvement
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import React, { useMemo, useCallback } from 'react';
|
import React, { useMemo, useCallback } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
// @ts-ignore: Heroicons module types
|
||||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
import { Checkbox } from 'antd';
|
import { Checkbox } from 'antd';
|
||||||
import { getContrastColor } from '@/utils/colorUtils';
|
import { getContrastColor } from '@/utils/colorUtils';
|
||||||
@@ -78,7 +79,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={`flex items-center px-4 py-2 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700 ${
|
className={`inline-flex w-max items-center px-4 py-2 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700 rounded-t-md ${
|
||||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -127,15 +128,9 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
{/* Color indicator (removed as full header is colored) */}
|
{/* Color indicator (removed as full header is colored) */}
|
||||||
|
|
||||||
{/* Group name and count */}
|
{/* Group name and count */}
|
||||||
<div className="flex items-center justify-between flex-1">
|
<div className="flex items-center flex-1">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{group.name}
|
{group.name} ({group.count})
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
|
||||||
style={{ backgroundColor: getContrastColor(headerTextColor) === '#000000' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.2)', color: headerTextColor }}
|
|
||||||
>
|
|
||||||
{group.count}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import { TaskListField } from '@/types/task-list-field.types';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
||||||
import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar';
|
import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar';
|
||||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import { HolderOutlined } from '@ant-design/icons';
|
import { HolderOutlined } from '@ant-design/icons';
|
||||||
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||||
|
|
||||||
@@ -137,6 +137,9 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
|
|
||||||
const fields = useAppSelector(state => state.taskManagementFields) || [];
|
const fields = useAppSelector(state => state.taskManagementFields) || [];
|
||||||
|
|
||||||
|
// Enable real-time updates via socket handlers
|
||||||
|
useTaskSocketHandlers();
|
||||||
|
|
||||||
// Filter visible columns based on fields
|
// Filter visible columns based on fields
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
return BASE_COLUMNS.filter(column => {
|
return BASE_COLUMNS.filter(column => {
|
||||||
@@ -298,22 +301,13 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
targetGroupId: targetGroup.id,
|
targetGroupId: targetGroup.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// If we need to insert at a specific position (not at the end)
|
// Reorder task within target group at drop position
|
||||||
if (insertIndex < targetGroup.taskIds.length) {
|
|
||||||
const newTaskIds = [...targetGroup.taskIds];
|
|
||||||
// Remove the task if it was already added at the end
|
|
||||||
const taskIndex = newTaskIds.indexOf(activeId as string);
|
|
||||||
if (taskIndex > -1) {
|
|
||||||
newTaskIds.splice(taskIndex, 1);
|
|
||||||
}
|
|
||||||
// Insert at the correct position
|
|
||||||
newTaskIds.splice(insertIndex, 0, activeId as string);
|
|
||||||
|
|
||||||
dispatch(reorderTasksInGroup({
|
dispatch(reorderTasksInGroup({
|
||||||
taskIds: newTaskIds,
|
sourceTaskId: activeId as string,
|
||||||
groupId: targetGroup.id,
|
destinationTaskId: over.id as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
destinationGroupId: targetGroup.id,
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Reordering within the same group
|
// Reordering within the same group
|
||||||
console.log('Reordering task within same group:', {
|
console.log('Reordering task within same group:', {
|
||||||
@@ -324,15 +318,12 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (activeIndex !== insertIndex) {
|
if (activeIndex !== insertIndex) {
|
||||||
const newTaskIds = [...activeGroup.taskIds];
|
// Reorder task within same group at drop position
|
||||||
// Remove task from old position
|
|
||||||
newTaskIds.splice(activeIndex, 1);
|
|
||||||
// Insert at new position
|
|
||||||
newTaskIds.splice(insertIndex, 0, activeId as string);
|
|
||||||
|
|
||||||
dispatch(reorderTasksInGroup({
|
dispatch(reorderTasksInGroup({
|
||||||
taskIds: newTaskIds,
|
sourceTaskId: activeId as string,
|
||||||
groupId: activeGroup.id,
|
destinationTaskId: over.id as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
destinationGroupId: activeGroup.id,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,7 +461,7 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
const isGroupEmpty = group.count === 0;
|
const isGroupEmpty = group.count === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
||||||
<TaskGroupHeader
|
<TaskGroupHeader
|
||||||
group={{
|
group={{
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -497,6 +488,7 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
return (
|
return (
|
||||||
<TaskRow
|
<TaskRow
|
||||||
taskId={task.id}
|
taskId={task.id}
|
||||||
|
projectId={projectId}
|
||||||
visibleColumns={visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { ClockIcon } from '@heroicons/react/24/outline';
|
|||||||
import AvatarGroup from '../AvatarGroup';
|
import AvatarGroup from '../AvatarGroup';
|
||||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||||
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
||||||
|
import TaskStatusDropdown from '@/components/task-management/task-status-dropdown';
|
||||||
|
import TaskPriorityDropdown from '@/components/task-management/task-priority-dropdown';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { selectTaskById } from '@/features/task-management/task-management.slice';
|
import { selectTaskById } from '@/features/task-management/task-management.slice';
|
||||||
@@ -20,6 +22,7 @@ import { selectIsTaskSelected, toggleTaskSelection } from '@/features/task-manag
|
|||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
projectId: string;
|
||||||
visibleColumns: Array<{
|
visibleColumns: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
width: string;
|
width: string;
|
||||||
@@ -47,7 +50,7 @@ const formatDate = (dateString: string): string => {
|
|||||||
|
|
||||||
// Memoized date formatter to avoid repeated date parsing
|
// Memoized date formatter to avoid repeated date parsing
|
||||||
|
|
||||||
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, visibleColumns }) => {
|
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||||
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
||||||
@@ -207,12 +210,11 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, visibleColumns }) => {
|
|||||||
case 'status':
|
case 'status':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
<span
|
<TaskStatusDropdown
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
task={task}
|
||||||
style={statusStyle}
|
projectId={projectId}
|
||||||
>
|
isDarkMode={isDarkMode}
|
||||||
{task.status}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -236,12 +238,11 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, visibleColumns }) => {
|
|||||||
case 'priority':
|
case 'priority':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
<span
|
<TaskPriorityDropdown
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
task={task}
|
||||||
style={priorityStyle}
|
projectId={projectId}
|
||||||
>
|
isDarkMode={isDarkMode}
|
||||||
{task.priority}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,18 @@ const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isOpen && buttonRef.current) {
|
if (isOpen && buttonRef.current) {
|
||||||
// Calculate position
|
// Calculate position with better handling of scrollable containers
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const dropdownHeight = 200; // Estimated dropdown height
|
||||||
|
|
||||||
|
// Check if dropdown would go below viewport
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom;
|
||||||
|
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||||
|
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top: rect.bottom + window.scrollY + 4,
|
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||||
left: rect.left + window.scrollX,
|
left: rect.left,
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|||||||
@@ -73,11 +73,18 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (isOpen && buttonRef.current) {
|
if (isOpen && buttonRef.current) {
|
||||||
// Calculate position
|
// Calculate position with better handling of scrollable containers
|
||||||
const rect = buttonRef.current.getBoundingClientRect();
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const dropdownHeight = 200; // Estimated dropdown height
|
||||||
|
|
||||||
|
// Check if dropdown would go below viewport
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom;
|
||||||
|
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||||
|
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top: rect.bottom + window.scrollY + 4,
|
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||||
left: rect.left + window.scrollX,
|
left: rect.left,
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|||||||
8
worklenz-frontend/src/types/heroicons-react.d.ts
vendored
Normal file
8
worklenz-frontend/src/types/heroicons-react.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { ComponentType, SVGProps } from 'react';
|
||||||
|
|
||||||
|
declare module '@heroicons/react/24/outline' {
|
||||||
|
export const ChevronDownIcon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
export const ChevronRightIcon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
export const Bars3Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
export const ClockIcon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user