Merge pull request #228 from Worklenz/fix/task-drag-and-drop-improvement
feat(task-management): enhance task assignment handling and UI feedba…
This commit is contained in:
@@ -15,6 +15,8 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|||||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||||
import { updateTask } from '@/features/task-management/task-management.slice';
|
import { updateTask } from '@/features/task-management/task-management.slice';
|
||||||
import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||||
|
import { updateTaskAssignees } from '@/features/task-management/task-management.slice';
|
||||||
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
|
|
||||||
interface AssigneeSelectorProps {
|
interface AssigneeSelectorProps {
|
||||||
task: IProjectTask;
|
task: IProjectTask;
|
||||||
@@ -33,6 +35,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||||
const [optimisticAssignees, setOptimisticAssignees] = useState<string[]>([]); // For optimistic updates
|
const [optimisticAssignees, setOptimisticAssignees] = useState<string[]>([]); // For optimistic updates
|
||||||
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set()); // Track pending member changes
|
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set()); // Track pending member changes
|
||||||
|
|
||||||
|
// Initialize optimistic assignees from task data on mount or when task changes
|
||||||
|
useEffect(() => {
|
||||||
|
const currentAssigneeIds = task?.assignees?.map(a => a.team_member_id) || [];
|
||||||
|
setOptimisticAssignees(currentAssigneeIds);
|
||||||
|
}, [task?.assignees]);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -123,11 +131,14 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
updateDropdownPosition();
|
updateDropdownPosition();
|
||||||
|
|
||||||
// Prepare team members data when opening
|
// Prepare team members data when opening - use optimistic assignees for current state
|
||||||
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
const currentAssigneeIds = optimisticAssignees.length > 0
|
||||||
const membersData = (members?.data || []).map(member => ({
|
? optimisticAssignees
|
||||||
|
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
||||||
|
|
||||||
|
const membersData: (ITeamMembersViewModel & { selected?: boolean })[] = (members?.data || []).map(member => ({
|
||||||
...member,
|
...member,
|
||||||
selected: assignees?.includes(member.id),
|
selected: currentAssigneeIds.includes(member.id),
|
||||||
}));
|
}));
|
||||||
const sortedMembers = sortTeamMembers(membersData);
|
const sortedMembers = sortTeamMembers(membersData);
|
||||||
setTeamMembers({ data: sortedMembers });
|
setTeamMembers({ data: sortedMembers });
|
||||||
@@ -148,16 +159,20 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
// Add to pending changes for visual feedback
|
// Add to pending changes for visual feedback
|
||||||
setPendingChanges(prev => new Set(prev).add(memberId));
|
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||||
|
|
||||||
// OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback
|
// Get the current list of assignees, prioritizing optimistic updates for immediate feedback
|
||||||
const currentAssignees = task?.assignees?.map(a => a.team_member_id) || [];
|
const currentAssigneeIds = optimisticAssignees.length > 0
|
||||||
|
? optimisticAssignees
|
||||||
|
: task?.assignees?.map(a => a.team_member_id) || [];
|
||||||
|
|
||||||
let newAssigneeIds: string[];
|
let newAssigneeIds: string[];
|
||||||
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
// Adding assignee
|
// Adding assignee: ensure no duplicates
|
||||||
newAssigneeIds = [...currentAssignees, memberId];
|
const uniqueIds = new Set([...currentAssigneeIds, memberId]);
|
||||||
|
newAssigneeIds = Array.from(uniqueIds);
|
||||||
} else {
|
} else {
|
||||||
// Removing assignee
|
// Removing assignee
|
||||||
newAssigneeIds = currentAssignees.filter(id => id !== memberId);
|
newAssigneeIds = currentAssigneeIds.filter(id => id !== memberId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update optimistic state for immediate UI feedback in dropdown
|
// Update optimistic state for immediate UI feedback in dropdown
|
||||||
@@ -183,13 +198,31 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
// Emit socket event - the socket handler will update Redux with proper types
|
// Emit socket event - the socket handler will update Redux with proper types
|
||||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||||
socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => {
|
socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => {
|
||||||
dispatch(updateEnhancedKanbanTaskAssignees(data));
|
// Instead of updating enhancedKanbanSlice, update the main taskManagementSlice
|
||||||
|
// Filter members to get the actual InlineMember objects for the new assignees
|
||||||
|
const updatedAssigneeNames: InlineMember[] = (members?.data || [])
|
||||||
|
.filter((member): member is ITeamMemberViewModel & { id: string; name: string } => {
|
||||||
|
return typeof member.id === 'string' && typeof member.name === 'string' && newAssigneeIds.includes(member.id);
|
||||||
|
})
|
||||||
|
.map(member => ({
|
||||||
|
name: member.name || '',
|
||||||
|
id: member.id || '',
|
||||||
|
team_member_id: member.id || '',
|
||||||
|
avatar_url: member.avatar_url || '',
|
||||||
|
color_code: member.color_code || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
dispatch(updateTaskAssignees({
|
||||||
|
taskId: task.id || '',
|
||||||
|
assigneeIds: newAssigneeIds,
|
||||||
|
assigneeNames: updatedAssigneeNames,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove from pending changes after a short delay (optimistic)
|
// Remove from pending changes after a short delay (optimistic)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setPendingChanges(prev => {
|
setPendingChanges(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set<string>(Array.from(prev));
|
||||||
newSet.delete(memberId);
|
newSet.delete(memberId);
|
||||||
return newSet;
|
return newSet;
|
||||||
});
|
});
|
||||||
@@ -198,12 +231,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
|
|
||||||
const checkMemberSelected = (memberId: string) => {
|
const checkMemberSelected = (memberId: string) => {
|
||||||
if (!memberId) return false;
|
if (!memberId) return false;
|
||||||
// Use optimistic assignees if available, otherwise fall back to task assignees
|
// Always use optimistic assignees for dropdown display
|
||||||
const assignees =
|
return optimisticAssignees.includes(memberId);
|
||||||
optimisticAssignees.length > 0
|
|
||||||
? optimisticAssignees
|
|
||||||
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
|
||||||
return assignees.includes(memberId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInviteProjectMemberDrawer = () => {
|
const handleInviteProjectMemberDrawer = () => {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useCallback } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { Checkbox } from 'antd';
|
||||||
import { getContrastColor } from '@/utils/colorUtils';
|
import { getContrastColor } from '@/utils/colorUtils';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice';
|
||||||
|
import { selectGroups } from '@/features/task-management/task-management.slice';
|
||||||
|
|
||||||
interface TaskGroupHeaderProps {
|
interface TaskGroupHeaderProps {
|
||||||
group: {
|
group: {
|
||||||
@@ -15,9 +20,52 @@ interface TaskGroupHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
|
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
||||||
|
const groups = useAppSelector(selectGroups);
|
||||||
|
|
||||||
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
|
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
|
||||||
const headerTextColor = getContrastColor(headerBackgroundColor);
|
const headerTextColor = getContrastColor(headerBackgroundColor);
|
||||||
|
|
||||||
|
// Get tasks in this group
|
||||||
|
const currentGroup = useMemo(() => {
|
||||||
|
return groups.find(g => g.id === group.id);
|
||||||
|
}, [groups, group.id]);
|
||||||
|
|
||||||
|
const tasksInGroup = useMemo(() => {
|
||||||
|
return currentGroup?.taskIds || [];
|
||||||
|
}, [currentGroup]);
|
||||||
|
|
||||||
|
// Calculate selection state for this group
|
||||||
|
const { isAllSelected, isPartiallySelected } = useMemo(() => {
|
||||||
|
if (tasksInGroup.length === 0) {
|
||||||
|
return { isAllSelected: false, isPartiallySelected: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTasksInGroup = tasksInGroup.filter(taskId => selectedTaskIds.includes(taskId));
|
||||||
|
const allSelected = selectedTasksInGroup.length === tasksInGroup.length;
|
||||||
|
const partiallySelected = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < tasksInGroup.length;
|
||||||
|
|
||||||
|
return { isAllSelected: allSelected, isPartiallySelected: partiallySelected };
|
||||||
|
}, [tasksInGroup, selectedTaskIds]);
|
||||||
|
|
||||||
|
// Handle select all checkbox change
|
||||||
|
const handleSelectAllChange = useCallback((e: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (isAllSelected) {
|
||||||
|
// Deselect all tasks in this group
|
||||||
|
tasksInGroup.forEach(taskId => {
|
||||||
|
dispatch(deselectTask(taskId));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Select all tasks in this group
|
||||||
|
tasksInGroup.forEach(taskId => {
|
||||||
|
dispatch(selectTask(taskId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [dispatch, isAllSelected, tasksInGroup]);
|
||||||
|
|
||||||
// Make the group header droppable
|
// Make the group header droppable
|
||||||
const { isOver, setNodeRef } = useDroppable({
|
const { isOver, setNodeRef } = useDroppable({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -42,6 +90,8 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
}}
|
}}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
|
{/* Drag Handle Space */}
|
||||||
|
<div style={{ width: '32px' }} className="flex items-center justify-center">
|
||||||
{/* Chevron button */}
|
{/* Chevron button */}
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded-md hover:bg-opacity-20 transition-colors"
|
className="p-1 rounded-md hover:bg-opacity-20 transition-colors"
|
||||||
@@ -57,6 +107,20 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Select All Checkbox Space */}
|
||||||
|
<div style={{ width: '40px' }} className="flex items-center justify-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
indeterminate={isPartiallySelected}
|
||||||
|
onChange={handleSelectAllChange}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
color: headerTextColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Group indicator and name */}
|
{/* Group indicator and name */}
|
||||||
<div className="ml-2 flex items-center gap-3 flex-1">
|
<div className="ml-2 flex items-center gap-3 flex-1">
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { RootState } from '@/app/store';
|
|||||||
import { TaskListField } from '@/types/task-list-field.types';
|
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 { Bars3Icon } from '@heroicons/react/24/outline';
|
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||||
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';
|
||||||
@@ -61,6 +62,7 @@ import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
|||||||
// Base column configuration
|
// Base column configuration
|
||||||
const BASE_COLUMNS = [
|
const BASE_COLUMNS = [
|
||||||
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
|
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
|
||||||
|
{ id: 'checkbox', label: '', width: '40px', isSticky: true, key: 'checkbox' },
|
||||||
{ id: 'taskKey', label: 'Key', width: '100px', key: COLUMN_KEYS.KEY },
|
{ id: 'taskKey', label: 'Key', width: '100px', key: COLUMN_KEYS.KEY },
|
||||||
{ id: 'title', label: 'Title', width: '300px', isSticky: true, key: COLUMN_KEYS.NAME },
|
{ id: 'title', label: 'Title', width: '300px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||||
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
|
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
|
||||||
@@ -86,6 +88,7 @@ type ColumnStyle = {
|
|||||||
left?: number;
|
left?: number;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
zIndex?: number;
|
zIndex?: number;
|
||||||
|
flexShrink?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TaskListV2Props {
|
interface TaskListV2Props {
|
||||||
@@ -336,6 +339,66 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
|
|
||||||
}, [allTasks, groups]);
|
}, [allTasks, groups]);
|
||||||
|
|
||||||
|
// Bulk action handlers
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
|
dispatch(clearSelection());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleBulkStatusChange = useCallback(async (statusId: string) => {
|
||||||
|
// TODO: Implement bulk status change
|
||||||
|
console.log('Bulk status change:', statusId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkPriorityChange = useCallback(async (priorityId: string) => {
|
||||||
|
// TODO: Implement bulk priority change
|
||||||
|
console.log('Bulk priority change:', priorityId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkPhaseChange = useCallback(async (phaseId: string) => {
|
||||||
|
// TODO: Implement bulk phase change
|
||||||
|
console.log('Bulk phase change:', phaseId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkAssignToMe = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk assign to me
|
||||||
|
console.log('Bulk assign to me');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => {
|
||||||
|
// TODO: Implement bulk assign members
|
||||||
|
console.log('Bulk assign members:', memberIds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkAddLabels = useCallback(async (labelIds: string[]) => {
|
||||||
|
// TODO: Implement bulk add labels
|
||||||
|
console.log('Bulk add labels:', labelIds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkArchive = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk archive
|
||||||
|
console.log('Bulk archive');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkDelete = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk delete
|
||||||
|
console.log('Bulk delete');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkDuplicate = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk duplicate
|
||||||
|
console.log('Bulk duplicate');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkExport = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk export
|
||||||
|
console.log('Bulk export');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkSetDueDate = useCallback(async (date: string) => {
|
||||||
|
// TODO: Implement bulk set due date
|
||||||
|
console.log('Bulk set due date:', date);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Memoized values for GroupedVirtuoso
|
// Memoized values for GroupedVirtuoso
|
||||||
const virtuosoGroups = useMemo(() => {
|
const virtuosoGroups = useMemo(() => {
|
||||||
let currentTaskIndex = 0;
|
let currentTaskIndex = 0;
|
||||||
@@ -375,10 +438,11 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
|
|
||||||
// Memoize column headers to prevent unnecessary re-renders
|
// Memoize column headers to prevent unnecessary re-renders
|
||||||
const columnHeaders = useMemo(() => (
|
const columnHeaders = useMemo(() => (
|
||||||
<div className="flex items-center min-w-max px-4 py-2">
|
<div className="flex items-center px-4 py-2" style={{ minWidth: 'max-content' }}>
|
||||||
{visibleColumns.map((column) => {
|
{visibleColumns.map((column) => {
|
||||||
const columnStyle: ColumnStyle = {
|
const columnStyle: ColumnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
|
flexShrink: 0, // Prevent columns from shrinking
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -389,6 +453,8 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
>
|
>
|
||||||
{column.id === 'dragHandle' ? (
|
{column.id === 'dragHandle' ? (
|
||||||
<HolderOutlined className="text-gray-400" />
|
<HolderOutlined className="text-gray-400" />
|
||||||
|
) : column.id === 'checkbox' ? (
|
||||||
|
<span></span> // Empty for checkbox column header
|
||||||
) : (
|
) : (
|
||||||
column.label
|
column.label
|
||||||
)}
|
)}
|
||||||
@@ -430,7 +496,7 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
if (!task) return null; // Should not happen if logic is correct
|
if (!task) return null; // Should not happen if logic is correct
|
||||||
return (
|
return (
|
||||||
<TaskRow
|
<TaskRow
|
||||||
task={task}
|
taskId={task.id}
|
||||||
visibleColumns={visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -453,14 +519,16 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
<ImprovedTaskFilters position="list" />
|
<ImprovedTaskFilters position="list" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Headers */}
|
{/* Table Container with synchronized horizontal scrolling */}
|
||||||
<div className="overflow-x-auto">
|
<div className="flex-1 overflow-x-auto">
|
||||||
|
<div className="min-w-max flex flex-col h-full">
|
||||||
|
{/* Column Headers - Fixed at top */}
|
||||||
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||||
{columnHeaders}
|
{columnHeaders}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task List */}
|
{/* Task List - Scrollable content */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1">
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
|
items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
@@ -471,12 +539,11 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
groupContent={renderGroup}
|
groupContent={renderGroup}
|
||||||
itemContent={renderTask}
|
itemContent={renderTask}
|
||||||
components={{
|
components={{
|
||||||
// Removed custom Group component as TaskGroupHeader now handles stickiness
|
|
||||||
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
|
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
style={style || {}}
|
style={style || {}}
|
||||||
className="virtuoso-list-container" // Add a class for potential debugging/styling
|
className="virtuoso-list-container"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
@@ -486,6 +553,7 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Drag Overlay */}
|
{/* Drag Overlay */}
|
||||||
<DragOverlay dropAnimation={null}>
|
<DragOverlay dropAnimation={null}>
|
||||||
@@ -509,6 +577,27 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
|
|
||||||
|
{/* Bulk Action Bar */}
|
||||||
|
{selectedTaskIds.length > 0 && (
|
||||||
|
<OptimizedBulkActionBar
|
||||||
|
selectedTaskIds={selectedTaskIds}
|
||||||
|
totalSelected={selectedTaskIds.length}
|
||||||
|
projectId={projectId}
|
||||||
|
onClearSelection={handleClearSelection}
|
||||||
|
onBulkStatusChange={handleBulkStatusChange}
|
||||||
|
onBulkPriorityChange={handleBulkPriorityChange}
|
||||||
|
onBulkPhaseChange={handleBulkPhaseChange}
|
||||||
|
onBulkAssignToMe={handleBulkAssignToMe}
|
||||||
|
onBulkAssignMembers={handleBulkAssignMembers}
|
||||||
|
onBulkAddLabels={handleBulkAddLabels}
|
||||||
|
onBulkArchive={handleBulkArchive}
|
||||||
|
onBulkDelete={handleBulkDelete}
|
||||||
|
onBulkDuplicate={handleBulkDuplicate}
|
||||||
|
onBulkExport={handleBulkExport}
|
||||||
|
onBulkSetDueDate={handleBulkSetDueDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { memo, useMemo, useCallback } from 'react';
|
|||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { CheckCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
import { CheckCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||||
|
import { Checkbox } from 'antd';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
import Avatar from '@/components/Avatar';
|
import Avatar from '@/components/Avatar';
|
||||||
@@ -13,9 +14,12 @@ 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 { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { selectTaskById } from '@/features/task-management/task-management.slice';
|
||||||
|
import { selectIsTaskSelected, toggleTaskSelection } from '@/features/task-management/selection.slice';
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
task: Task;
|
taskId: string;
|
||||||
visibleColumns: Array<{
|
visibleColumns: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
width: string;
|
width: string;
|
||||||
@@ -43,7 +47,15 @@ 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(({ task, visibleColumns }) => {
|
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, visibleColumns }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||||
|
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return null; // Don't render if task is not found in store
|
||||||
|
}
|
||||||
|
|
||||||
// Drag and drop functionality
|
// Drag and drop functionality
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
@@ -111,6 +123,17 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
|
|||||||
[task.updatedAt]
|
[task.updatedAt]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Debugging: Log assignee_names whenever the task prop changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log(`Task ${task.id} assignees:`, task.assignee_names);
|
||||||
|
}, [task.id, task.assignee_names]);
|
||||||
|
|
||||||
|
// Handle checkbox change
|
||||||
|
const handleCheckboxChange = useCallback((e: any) => {
|
||||||
|
e.stopPropagation(); // Prevent row click when clicking checkbox
|
||||||
|
dispatch(toggleTaskSelection(taskId));
|
||||||
|
}, [dispatch, taskId]);
|
||||||
|
|
||||||
// Memoize status style
|
// Memoize status style
|
||||||
const statusStyle = useMemo(() => ({
|
const statusStyle = useMemo(() => ({
|
||||||
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
|
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
|
||||||
@@ -152,6 +175,17 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'checkbox':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center" style={baseStyle}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
case 'taskKey':
|
case 'taskKey':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center" style={baseStyle}>
|
<div className="flex items-center" style={baseStyle}>
|
||||||
@@ -383,13 +417,15 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
|
|||||||
labelsDisplay,
|
labelsDisplay,
|
||||||
isDarkMode,
|
isDarkMode,
|
||||||
convertedTask,
|
convertedTask,
|
||||||
|
isSelected,
|
||||||
|
handleCheckboxChange,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
className={`flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '@/api/tasks/tasks.api.service';
|
} from '@/api/tasks/tasks.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||||
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
|
|
||||||
// Helper function to safely convert time values
|
// Helper function to safely convert time values
|
||||||
const convertTimeValue = (value: any): number => {
|
const convertTimeValue = (value: any): number => {
|
||||||
@@ -163,6 +164,19 @@ export const fetchTasks = createAsyncThunk(
|
|||||||
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: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
||||||
|
// Ensure all Task properties are mapped, even if undefined in API response
|
||||||
|
sub_tasks: task.sub_tasks || [],
|
||||||
|
sub_tasks_count: task.sub_tasks_count || 0,
|
||||||
|
show_sub_tasks: task.show_sub_tasks || false,
|
||||||
|
parent_task_id: task.parent_task_id || undefined,
|
||||||
|
weight: task.weight || 0,
|
||||||
|
color: task.color || undefined,
|
||||||
|
statusColor: task.statusColor || undefined,
|
||||||
|
priorityColor: task.priorityColor || undefined,
|
||||||
|
comments_count: task.comments_count || 0,
|
||||||
|
attachments_count: task.attachments_count || 0,
|
||||||
|
has_dependencies: task.has_dependencies || false,
|
||||||
|
schedule_id: task.schedule_id || null,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -226,11 +240,9 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
console.log('Task key from backend:', response.body.allTasks?.[0]?.task_key);
|
console.log('Task key from backend:', response.body.allTasks?.[0]?.task_key);
|
||||||
|
|
||||||
// Ensure tasks are properly normalized
|
// Ensure tasks are properly normalized
|
||||||
const tasks = response.body.allTasks.map((task: any) => {
|
const tasks: Task[] = response.body.allTasks.map((task: any) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
task_key: task.task_key || task.key || '',
|
task_key: task.task_key || task.key || '',
|
||||||
@@ -249,59 +261,33 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
end: l.end,
|
end: l.end,
|
||||||
names: l.names,
|
names: l.names,
|
||||||
})) || [],
|
})) || [],
|
||||||
due_date: task.end_date || '',
|
dueDate: task.end_date,
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
estimated: convertTimeValue(task.total_time),
|
estimated: convertTimeValue(task.total_time),
|
||||||
logged: convertTimeValue(task.time_spent),
|
logged: convertTimeValue(task.time_spent),
|
||||||
},
|
},
|
||||||
created_at: task.created_at || now,
|
customFields: {},
|
||||||
updated_at: task.updated_at || now,
|
createdAt: task.created_at || now,
|
||||||
|
updatedAt: task.updated_at || now,
|
||||||
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
||||||
sub_tasks: task.sub_tasks || [],
|
sub_tasks: task.sub_tasks || [],
|
||||||
sub_tasks_count: task.sub_tasks_count || 0,
|
sub_tasks_count: task.sub_tasks_count || 0,
|
||||||
show_sub_tasks: task.show_sub_tasks || false,
|
show_sub_tasks: task.show_sub_tasks || false,
|
||||||
parent_task_id: task.parent_task_id || '',
|
parent_task_id: task.parent_task_id || undefined,
|
||||||
weight: task.weight || 0,
|
weight: task.weight || 0,
|
||||||
color: task.color || '',
|
color: task.color || undefined,
|
||||||
statusColor: task.status_color || '',
|
statusColor: task.statusColor || undefined,
|
||||||
priorityColor: task.priority_color || '',
|
priorityColor: task.priorityColor || undefined,
|
||||||
comments_count: task.comments_count || 0,
|
comments_count: task.comments_count || 0,
|
||||||
attachments_count: task.attachments_count || 0,
|
attachments_count: task.attachments_count || 0,
|
||||||
has_dependencies: !!task.has_dependencies,
|
has_dependencies: task.has_dependencies || false,
|
||||||
schedule_id: task.schedule_id || null,
|
schedule_id: task.schedule_id || null,
|
||||||
} as Task;
|
};
|
||||||
});
|
|
||||||
|
|
||||||
// Map groups to match TaskGroup interface
|
|
||||||
const mappedGroups = response.body.groups.map((group: any) => ({
|
|
||||||
id: group.id,
|
|
||||||
title: group.title,
|
|
||||||
taskIds: group.taskIds || [],
|
|
||||||
type: group.groupType as 'status' | 'priority' | 'phase' | 'members',
|
|
||||||
color: group.color,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Log normalized data for debugging
|
|
||||||
console.log('Normalized data:', {
|
|
||||||
tasks,
|
|
||||||
groups: mappedGroups,
|
|
||||||
grouping: response.body.grouping,
|
|
||||||
totalTasks: response.body.totalTasks,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify task IDs match group taskIds
|
|
||||||
const taskIds = new Set(tasks.map(t => t.id));
|
|
||||||
const groupTaskIds = new Set(mappedGroups.flatMap(g => g.taskIds));
|
|
||||||
console.log('Task ID verification:', {
|
|
||||||
taskIds: Array.from(taskIds),
|
|
||||||
groupTaskIds: Array.from(groupTaskIds),
|
|
||||||
allTaskIdsInGroups: Array.from(groupTaskIds).every(id => taskIds.has(id)),
|
|
||||||
allGroupTaskIdsInTasks: Array.from(taskIds).every(id => groupTaskIds.has(id)),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tasks: tasks,
|
allTasks: tasks,
|
||||||
groups: mappedGroups,
|
groups: response.body.groups,
|
||||||
grouping: response.body.grouping,
|
grouping: response.body.grouping,
|
||||||
totalTasks: response.body.totalTasks,
|
totalTasks: response.body.totalTasks,
|
||||||
};
|
};
|
||||||
@@ -310,7 +296,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return rejectWithValue(error.message);
|
return rejectWithValue(error.message);
|
||||||
}
|
}
|
||||||
return rejectWithValue('Failed to fetch tasks');
|
return rejectWithValue('Failed to fetch tasks V3');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -471,8 +457,24 @@ const taskManagementSlice = createSlice({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateTask: (state, action: PayloadAction<Task>) => {
|
updateTask: (state, action: PayloadAction<Task>) => {
|
||||||
const task = action.payload;
|
tasksAdapter.upsertOne(state as EntityState<Task, string>, action.payload);
|
||||||
state.entities[task.id] = task;
|
// Additionally, update the task within its group if necessary (e.g., if status changed)
|
||||||
|
const updatedTask = action.payload;
|
||||||
|
const oldTask = state.entities[updatedTask.id];
|
||||||
|
|
||||||
|
if (oldTask && state.grouping?.id === IGroupBy.STATUS && oldTask.status !== updatedTask.status) {
|
||||||
|
// Remove from old status group
|
||||||
|
const oldGroup = state.groups.find(group => group.id === oldTask.status);
|
||||||
|
if (oldGroup) {
|
||||||
|
oldGroup.taskIds = oldGroup.taskIds.filter(id => id !== updatedTask.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to new status group
|
||||||
|
const newGroup = state.groups.find(group => group.id === updatedTask.status);
|
||||||
|
if (newGroup) {
|
||||||
|
newGroup.taskIds.push(updatedTask.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
deleteTask: (state, action: PayloadAction<string>) => {
|
deleteTask: (state, action: PayloadAction<string>) => {
|
||||||
const taskId = action.payload;
|
const taskId = action.payload;
|
||||||
@@ -556,13 +558,101 @@ const taskManagementSlice = createSlice({
|
|||||||
},
|
},
|
||||||
reorderTasksInGroup: (
|
reorderTasksInGroup: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{ taskIds: string[]; groupId: string }>
|
action: PayloadAction<{
|
||||||
|
sourceTaskId: string;
|
||||||
|
destinationTaskId: string;
|
||||||
|
sourceGroupId: string;
|
||||||
|
destinationGroupId: string;
|
||||||
|
}>
|
||||||
) => {
|
) => {
|
||||||
const { taskIds, groupId } = action.payload;
|
const { sourceTaskId, destinationTaskId, sourceGroupId, destinationGroupId } = action.payload;
|
||||||
const group = state.groups.find(g => g.id === groupId);
|
|
||||||
|
// Get a mutable copy of entities for updates
|
||||||
|
const newEntities = { ...state.entities };
|
||||||
|
|
||||||
|
const sourceTask = newEntities[sourceTaskId];
|
||||||
|
const destinationTask = newEntities[destinationTaskId];
|
||||||
|
|
||||||
|
if (!sourceTask || !destinationTask) return;
|
||||||
|
|
||||||
|
if (sourceGroupId === destinationGroupId) {
|
||||||
|
// Reordering within the same group
|
||||||
|
const group = state.groups.find(g => g.id === sourceGroupId);
|
||||||
if (group) {
|
if (group) {
|
||||||
group.taskIds = taskIds;
|
const newTasks = Array.from(group.taskIds);
|
||||||
|
const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1);
|
||||||
|
newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed);
|
||||||
|
group.taskIds = newTasks;
|
||||||
|
|
||||||
|
// Update order for affected tasks. Assuming simple reordering affects order.
|
||||||
|
// This might need more sophisticated logic based on how `order` is used.
|
||||||
|
newTasks.forEach((id, index) => {
|
||||||
|
if (newEntities[id]) {
|
||||||
|
newEntities[id] = { ...newEntities[id], order: index };
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Moving between different groups
|
||||||
|
const sourceGroup = state.groups.find(g => g.id === sourceGroupId);
|
||||||
|
const destinationGroup = state.groups.find(g => g.id === destinationGroupId);
|
||||||
|
|
||||||
|
if (sourceGroup && destinationGroup) {
|
||||||
|
// Remove from source group
|
||||||
|
sourceGroup.taskIds = sourceGroup.taskIds.filter(id => id !== sourceTaskId);
|
||||||
|
|
||||||
|
// Add to destination group at the correct position relative to destinationTask
|
||||||
|
const destinationIndex = destinationGroup.taskIds.indexOf(destinationTaskId);
|
||||||
|
if (destinationIndex !== -1) {
|
||||||
|
destinationGroup.taskIds.splice(destinationIndex, 0, sourceTaskId);
|
||||||
|
} else {
|
||||||
|
destinationGroup.taskIds.push(sourceTaskId); // Add to end if destination task not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update task's grouping field to reflect new group (e.g., status, priority, phase)
|
||||||
|
// This assumes the group ID directly corresponds to the task's field value
|
||||||
|
if (sourceTask) {
|
||||||
|
let updatedTask = { ...sourceTask };
|
||||||
|
switch (state.grouping?.id) {
|
||||||
|
case IGroupBy.STATUS:
|
||||||
|
updatedTask.status = destinationGroup.id;
|
||||||
|
break;
|
||||||
|
case IGroupBy.PRIORITY:
|
||||||
|
updatedTask.priority = destinationGroup.id;
|
||||||
|
break;
|
||||||
|
case IGroupBy.PHASE:
|
||||||
|
updatedTask.phase = destinationGroup.id;
|
||||||
|
break;
|
||||||
|
case IGroupBy.MEMBERS:
|
||||||
|
// If moving to a member group, ensure task is assigned to that member
|
||||||
|
// This assumes the group ID is the member ID
|
||||||
|
if (!updatedTask.assignees) {
|
||||||
|
updatedTask.assignees = [];
|
||||||
|
}
|
||||||
|
if (!updatedTask.assignees.includes(destinationGroup.id)) {
|
||||||
|
updatedTask.assignees.push(destinationGroup.id);
|
||||||
|
}
|
||||||
|
// If moving from a member group, and the task is no longer in any member group,
|
||||||
|
// consider removing the assignment (more complex logic might be needed here)
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
newEntities[sourceTaskId] = updatedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update order for affected tasks in both groups if necessary
|
||||||
|
sourceGroup.taskIds.forEach((id, index) => {
|
||||||
|
if (newEntities[id]) newEntities[id] = { ...newEntities[id], order: index };
|
||||||
|
});
|
||||||
|
destinationGroup.taskIds.forEach((id, index) => {
|
||||||
|
if (newEntities[id]) newEntities[id] = { ...newEntities[id], order: index };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the state's entities after all modifications
|
||||||
|
state.entities = newEntities;
|
||||||
},
|
},
|
||||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.loading = action.payload;
|
state.loading = action.payload;
|
||||||
@@ -608,6 +698,22 @@ const taskManagementSlice = createSlice({
|
|||||||
parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
|
parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
updateTaskAssignees: (state, action: PayloadAction<{
|
||||||
|
taskId: string;
|
||||||
|
assigneeIds: string[];
|
||||||
|
assigneeNames: InlineMember[];
|
||||||
|
}>) => {
|
||||||
|
const { taskId, assigneeIds, assigneeNames } = action.payload;
|
||||||
|
const existingTask = state.entities[taskId];
|
||||||
|
|
||||||
|
if (existingTask) {
|
||||||
|
state.entities[taskId] = {
|
||||||
|
...existingTask,
|
||||||
|
assignees: assigneeIds,
|
||||||
|
assignee_names: assigneeNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
@@ -617,46 +723,17 @@ const taskManagementSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchTasksV3.fulfilled, (state, action) => {
|
.addCase(fetchTasksV3.fulfilled, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.error = null;
|
const { allTasks, groups, grouping } = action.payload;
|
||||||
|
tasksAdapter.setAll(state as EntityState<Task, string>, allTasks || []); // Ensure allTasks is an array
|
||||||
// Ensure we have tasks before updating state
|
state.ids = (allTasks || []).map(task => task.id); // Also update ids
|
||||||
if (action.payload.tasks && action.payload.tasks.length > 0) {
|
state.groups = groups;
|
||||||
// Update tasks
|
state.grouping = grouping;
|
||||||
const tasks = action.payload.tasks;
|
|
||||||
state.ids = tasks.map(task => task.id);
|
|
||||||
state.entities = tasks.reduce((acc, task) => {
|
|
||||||
acc[task.id] = task;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, Task>);
|
|
||||||
|
|
||||||
// Update groups
|
|
||||||
state.groups = action.payload.groups;
|
|
||||||
state.grouping = action.payload.grouping;
|
|
||||||
|
|
||||||
// Verify task IDs match group taskIds
|
|
||||||
const taskIds = new Set(Object.keys(state.entities));
|
|
||||||
const groupTaskIds = new Set(state.groups.flatMap(g => g.taskIds));
|
|
||||||
|
|
||||||
// Ensure all tasks have IDs and all group taskIds exist
|
|
||||||
const validTaskIds = new Set(Object.keys(state.entities));
|
|
||||||
state.groups = state.groups.map((group: TaskGroup) => ({
|
|
||||||
...group,
|
|
||||||
taskIds: group.taskIds.filter((id: string) => validTaskIds.has(id)),
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// Set empty state but don't show error
|
|
||||||
state.ids = [];
|
|
||||||
state.entities = {} as Record<string, Task>;
|
|
||||||
state.groups = [];
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.addCase(fetchTasksV3.rejected, (state, action) => {
|
.addCase(fetchTasksV3.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
// Provide a more descriptive error message
|
state.error = action.error?.message || (action.payload as string) || 'Failed to load tasks (V3)';
|
||||||
state.error = action.error.message || action.payload || 'An error occurred while fetching tasks. Please try again.';
|
|
||||||
// Clear task data on error to prevent stale state
|
|
||||||
state.ids = [];
|
state.ids = [];
|
||||||
state.entities = {} as Record<string, Task>;
|
state.entities = {};
|
||||||
state.groups = [];
|
state.groups = [];
|
||||||
})
|
})
|
||||||
.addCase(fetchSubTasks.pending, (state, action) => {
|
.addCase(fetchSubTasks.pending, (state, action) => {
|
||||||
@@ -675,6 +752,24 @@ const taskManagementSlice = createSlice({
|
|||||||
.addCase(fetchSubTasks.rejected, (state, action) => {
|
.addCase(fetchSubTasks.rejected, (state, action) => {
|
||||||
// Set error but don't clear task data
|
// Set error but don't clear task data
|
||||||
state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.';
|
state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.';
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
tasksAdapter.setAll(state as EntityState<Task, string>, action.payload || []); // Ensure payload is an array
|
||||||
|
state.ids = (action.payload || []).map(task => task.id); // Also update ids
|
||||||
|
state.groups = []; // Assuming no groups when using old fetchTasks
|
||||||
|
state.grouping = undefined; // Assuming no grouping when using old fetchTasks
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.error?.message || (action.payload as string) || 'Failed to load tasks';
|
||||||
|
state.ids = [];
|
||||||
|
state.entities = {};
|
||||||
|
state.groups = [];
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -700,6 +795,7 @@ export const {
|
|||||||
resetTaskManagement,
|
resetTaskManagement,
|
||||||
toggleTaskExpansion,
|
toggleTaskExpansion,
|
||||||
addSubtaskToParent,
|
addSubtaskToParent,
|
||||||
|
updateTaskAssignees,
|
||||||
} = taskManagementSlice.actions;
|
} = taskManagementSlice.actions;
|
||||||
|
|
||||||
// Export the selectors
|
// Export the selectors
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface Task {
|
|||||||
priority: string;
|
priority: string;
|
||||||
phase?: string;
|
phase?: string;
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
|
assignees?: string[]; // Array of assigned member IDs
|
||||||
assignee_names?: InlineMember[]; // Array of assigned members
|
assignee_names?: InlineMember[]; // Array of assigned members
|
||||||
names?: InlineMember[]; // Alternative names field
|
names?: InlineMember[]; // Alternative names field
|
||||||
due_date?: string;
|
due_date?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user