Merge pull request #237 from Worklenz/fix/task-drag-and-drop-improvement

Fix/task drag and drop improvement
This commit is contained in:
Chamika J
2025-07-07 02:40:15 +05:30
committed by GitHub
43 changed files with 3527 additions and 850 deletions

View File

@@ -9,12 +9,10 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { Avatar, Button, Checkbox } from '@/components';
import { Avatar, Checkbox } from '@/components';
import { sortTeamMembers } from '@/utils/sort-team-members';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import { updateTask } from '@/features/task-management/task-management.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';

View File

@@ -0,0 +1,341 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
import { sortTeamMembers } from '@/utils/sort-team-members';
import { Avatar, Checkbox } from '@/components';
interface PeopleDropdownProps {
selectedMemberIds: string[];
onMemberToggle: (memberId: string, checked: boolean) => void;
onInviteClick?: () => void;
isDarkMode?: boolean;
className?: string;
buttonClassName?: string;
isLoading?: boolean;
loadMembers?: () => void;
pendingChanges?: Set<string>;
}
const PeopleDropdown: React.FC<PeopleDropdownProps> = ({
selectedMemberIds,
onMemberToggle,
onInviteClick,
isDarkMode = false,
className = '',
buttonClassName = '',
isLoading = false,
loadMembers,
pendingChanges = new Set(),
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const [hasLoadedMembers, setHasLoadedMembers] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const dispatch = useAppDispatch();
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
// Load members on demand when dropdown opens
useEffect(() => {
if (!hasLoadedMembers && loadMembers && isOpen) {
loadMembers();
setHasLoadedMembers(true);
}
}, [hasLoadedMembers, loadMembers, isOpen]);
const filteredMembers = useMemo(() => {
return teamMembers?.data?.filter(member =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [teamMembers, searchQuery]);
// Update dropdown position
const updateDropdownPosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding
// Check if dropdown would go below viewport
const spaceBelow = viewportHeight - rect.bottom;
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
setDropdownPosition({
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
left: rect.left,
});
}
}, []);
// Close dropdown when clicking outside and handle scroll
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleScroll = (event: Event) => {
if (isOpen) {
// Only close dropdown if scrolling happens outside the dropdown
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
};
const handleResize = () => {
if (isOpen) {
updateDropdownPosition();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleResize);
};
} else {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, updateDropdownPosition]);
const handleDropdownToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isOpen) {
updateDropdownPosition();
// Prepare team members data when opening
const membersData = (members?.data || []).map(member => ({
...member,
selected: selectedMemberIds.includes(member.id || ''),
}));
const sortedMembers = sortTeamMembers(membersData);
setTeamMembers({ data: sortedMembers });
setIsOpen(true);
// Focus search input after opening
setTimeout(() => {
searchInputRef.current?.focus();
}, 0);
} else {
setIsOpen(false);
}
};
const handleMemberToggle = (memberId: string, checked: boolean) => {
if (!memberId) return;
onMemberToggle(memberId, checked);
// Update local team members state for dropdown UI
setTeamMembers(prev => ({
...prev,
data: (prev.data || []).map(member =>
member.id === memberId ? { ...member, selected: checked } : member
),
}));
};
const checkMemberSelected = (memberId: string) => {
if (!memberId) return false;
return selectedMemberIds.includes(memberId);
};
const handleInviteProjectMemberDrawer = () => {
setIsOpen(false); // Close the dropdown first
if (onInviteClick) {
onInviteClick();
} else {
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
}
};
return (
<>
<button
ref={buttonRef}
onClick={handleDropdownToggle}
className={`
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
transition-colors duration-200
${buttonClassName}
${
isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: isDarkMode
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
}
`}
>
<PlusOutlined className="text-xs" />
</button>
{isOpen &&
createPortal(
<div
ref={dropdownRef}
onClick={e => e.stopPropagation()}
className={`
fixed w-72 rounded-md shadow-lg border people-dropdown-portal ${className}
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
`}
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
}}
>
{/* Header */}
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search members..."
className={`
w-full px-2 py-1 text-xs rounded border
${
isDarkMode
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
}
focus:outline-none focus:ring-1 focus:ring-blue-500
`}
/>
</div>
{/* Members List */}
<div className="max-h-48 overflow-y-auto">
{filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map(member => (
<div
key={member.id}
className={`
flex items-center gap-2 p-2 cursor-pointer transition-colors
${
member.pending_invitation
? 'opacity-50 cursor-not-allowed'
: isDarkMode
? 'hover:bg-gray-700'
: 'hover:bg-gray-50'
}
`}
onClick={() => {
if (!member.pending_invitation) {
const isSelected = checkMemberSelected(member.id || '');
handleMemberToggle(member.id || '', !isSelected);
}
}}
style={{
// Add visual feedback for immediate response
transition: 'all 0.15s ease-in-out',
}}
>
<div className="relative">
<span onClick={e => e.stopPropagation()}>
<Checkbox
checked={checkMemberSelected(member.id || '')}
onChange={checked => handleMemberToggle(member.id || '', checked)}
disabled={
member.pending_invitation || pendingChanges.has(member.id || '')
}
isDarkMode={isDarkMode}
/>
</span>
{pendingChanges.has(member.id || '') && (
<div
className={`absolute inset-0 flex items-center justify-center ${
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
}`}
>
<div
className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
isDarkMode ? 'border-blue-400' : 'border-blue-600'
}`}
/>
</div>
)}
</div>
<Avatar
src={member.avatar_url}
name={member.name || ''}
size={24}
isDarkMode={isDarkMode}
/>
<div className="flex-1 min-w-0">
<div
className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
>
{member.name}
</div>
<div
className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
>
{member.email}
{member.pending_invitation && (
<span className="text-red-400 ml-1">(Pending)</span>
)}
</div>
</div>
</div>
))
) : (
<div
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
>
<div className="text-xs">
{isLoading ? 'Loading members...' : 'No members found'}
</div>
</div>
)}
</div>
{/* Footer */}
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<button
className={`
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
transition-colors
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
`}
onClick={handleInviteProjectMemberDrawer}
>
<UserAddOutlined />
Invite member
</button>
</div>
</div>,
document.body
)}
</>
);
};
export default PeopleDropdown;

View File

@@ -5,9 +5,11 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '../../../features/projects/status/StatusSlice';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
const CreateStatusButton = () => {
const { t } = useTranslation('task-list-filters');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
@@ -19,9 +21,7 @@ const CreateStatusButton = () => {
onClick={() => dispatch(toggleDrawer())}
icon={
<SettingOutlined
style={{
color: colors.skyBlue,
}}
style={{ color: themeMode === 'dark' ? colors.white : colors.midBlue }}
/>
}
/>

View File

@@ -19,16 +19,16 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
return <div style={baseStyle} />;
case 'taskKey':
return (
<div style={baseStyle} className="flex items-center">
<div className="h-4 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
<div style={baseStyle} className="flex items-center pl-3">
<div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded-md animate-pulse border border-gray-300 dark:border-gray-600" />
</div>
);
case 'title':
return (
<div style={baseStyle} className="flex items-center">
{/* Subtask indentation */}
<div className="w-8" />
<div className="w-8" />
{/* Subtask indentation - tighter spacing */}
<div className="w-4" />
<div className="w-2" />
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
</div>
);

View File

@@ -1,13 +1,25 @@
import React, { useMemo, useCallback } from 'react';
import React, { useMemo, useCallback, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { Checkbox } from 'antd';
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
import { useTranslation } from 'react-i18next';
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';
import { selectGroups, fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { selectCurrentGrouping } from '@/features/task-management/grouping.slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { useAuthService } from '@/hooks/useAuth';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import logger from '@/utils/errorLogger';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
interface TaskGroupHeaderProps {
group: {
@@ -18,12 +30,25 @@ interface TaskGroupHeaderProps {
};
isCollapsed: boolean;
onToggle: () => void;
projectId: string;
}
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle, projectId }) => {
const { t } = useTranslation('task-management');
const dispatch = useAppDispatch();
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
const groups = useAppSelector(selectGroups);
const currentGrouping = useAppSelector(selectCurrentGrouping);
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
const { trackMixpanelEvent } = useMixpanelTracking();
const { isOwnerOrAdmin } = useAuthService();
const [dropdownVisible, setDropdownVisible] = useState(false);
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isChangingCategory, setIsChangingCategory] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [editingName, setEditingName] = useState(group.name);
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
const headerTextColor = getContrastColor(headerBackgroundColor);
@@ -67,6 +92,139 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
}
}, [dispatch, isAllSelected, tasksInGroup]);
// Handle inline name editing
const handleNameSave = useCallback(async () => {
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return;
setIsRenaming(true);
try {
if (currentGrouping === 'status') {
// Extract status ID from group ID (format: "status-{statusId}")
const statusId = group.id.replace('status-', '');
const body: ITaskStatusUpdateModel = {
name: editingName.trim(),
project_id: projectId,
};
await statusApiService.updateNameOfStatus(statusId, body, projectId);
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Status' });
dispatch(fetchStatuses(projectId));
} else if (currentGrouping === 'phase') {
// Extract phase ID from group ID (format: "phase-{phaseId}")
const phaseId = group.id.replace('phase-', '');
const body = { id: phaseId, name: editingName.trim() };
await phasesApiService.updateNameOfPhase(phaseId, body as ITaskPhase, projectId);
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
dispatch(fetchPhasesByProjectId(projectId));
}
// Refresh task list to get updated group names
dispatch(fetchTasksV3(projectId));
setIsEditingName(false);
} catch (error) {
logger.error('Error renaming group:', error);
setEditingName(group.name);
} finally {
setIsRenaming(false);
}
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]);
const handleNameClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (!isOwnerOrAdmin) return;
setIsEditingName(true);
setEditingName(group.name);
}, [group.name, isOwnerOrAdmin]);
const handleNameKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleNameSave();
} else if (e.key === 'Escape') {
setIsEditingName(false);
setEditingName(group.name);
}
e.stopPropagation();
}, [group.name, handleNameSave]);
const handleNameBlur = useCallback(() => {
setIsEditingName(false);
setEditingName(group.name);
}, [group.name]);
// Handle dropdown menu actions
const handleRenameGroup = useCallback(() => {
setDropdownVisible(false);
setIsEditingName(true);
setEditingName(group.name);
}, [group.name]);
const handleChangeCategory = useCallback(() => {
setDropdownVisible(false);
setCategoryModalVisible(true);
}, []);
// Handle category change
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
e?.stopPropagation();
if (isChangingCategory) return;
setIsChangingCategory(true);
try {
// Extract status ID from group ID (format: "status-{statusId}")
const statusId = group.id.replace('status-', '');
await statusApiService.updateStatusCategory(statusId, categoryId, projectId);
trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' });
// Refresh status list and tasks
dispatch(fetchStatuses(projectId));
dispatch(fetchTasksV3(projectId));
setCategoryModalVisible(false);
} catch (error) {
logger.error('Error changing category:', error);
} finally {
setIsChangingCategory(false);
}
}, [group.id, projectId, dispatch, trackMixpanelEvent, isChangingCategory]);
// Create dropdown menu items
const menuItems = useMemo(() => {
if (!isOwnerOrAdmin) return [];
const items = [
{
key: 'rename',
icon: <PencilIcon className="h-4 w-4" />,
label: currentGrouping === 'status' ? t('renameStatus') : currentGrouping === 'phase' ? t('renamePhase') : t('renameGroup'),
onClick: (e: any) => {
e?.domEvent?.stopPropagation();
handleRenameGroup();
},
},
];
// Only show "Change Category" when grouped by status
if (currentGrouping === 'status') {
items.push({
key: 'changeCategory',
icon: <ArrowPathIcon className="h-4 w-4" />,
label: t('changeCategory'),
onClick: (e: any) => {
e?.domEvent?.stopPropagation();
handleChangeCategory();
},
});
}
return items;
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]);
// Make the group header droppable
const { isOver, setNodeRef } = useDroppable({
id: group.id,
@@ -79,7 +237,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
return (
<div
ref={setNodeRef}
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 ${
className={`inline-flex w-max items-center px-1 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' : ''
}`}
style={{
@@ -87,31 +245,38 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
color: headerTextColor,
position: 'sticky',
top: 0,
zIndex: 20 // Higher than sticky columns (zIndex: 1) and column headers (zIndex: 2)
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
height: '36px',
minHeight: '36px',
maxHeight: '36px'
}}
onClick={onToggle}
>
{/* Drag Handle Space */}
<div style={{ width: '32px' }} className="flex items-center justify-center">
{/* Drag Handle Space - ultra minimal width */}
<div style={{ width: '20px' }} className="flex items-center justify-center">
{/* Chevron button */}
<button
className="p-1 rounded-md hover:bg-opacity-20 transition-colors"
style={{ backgroundColor: headerBackgroundColor, color: headerTextColor, borderColor: headerTextColor, border: '1px solid' }}
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
style={{ backgroundColor: 'transparent', color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
>
{isCollapsed ? (
<ChevronRightIcon className="h-4 w-4" style={{ color: headerTextColor }} />
) : (
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
)}
<div
className="transition-transform duration-300 ease-out"
style={{
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transformOrigin: 'center'
}}
>
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
</div>
</button>
</div>
{/* Select All Checkbox Space */}
<div style={{ width: '40px' }} className="flex items-center justify-center">
{/* Select All Checkbox Space - ultra minimal width */}
<div style={{ width: '28px' }} className="flex items-center justify-center">
<Checkbox
checked={isAllSelected}
indeterminate={isPartiallySelected}
@@ -123,17 +288,111 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
/>
</div>
{/* Group indicator and name */}
<div className="ml-2 flex items-center gap-3 flex-1">
{/* Color indicator (removed as full header is colored) */}
{/* Group indicator and name - no gap at all */}
<div className="flex items-center flex-1 ml-1">
{/* Group name and count */}
<div className="flex items-center flex-1">
<span className="text-sm font-medium">
{group.name} ({group.count})
<div className="flex items-center">
{isEditingName && isOwnerOrAdmin ? (
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleNameKeyDown}
onBlur={handleNameBlur}
className="text-sm font-semibold px-2 py-1 rounded-md transition-all duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
style={{
color: headerTextColor,
fontSize: '14px',
fontWeight: 600,
width: `${Math.max(editingName.length * 8 + 16, 80)}px`,
minWidth: '80px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
border: `1px solid ${headerTextColor}40`,
backdropFilter: 'blur(4px)'
}}
styles={{
input: {
color: headerTextColor,
backgroundColor: 'transparent',
border: 'none',
outline: 'none',
boxShadow: 'none',
padding: '0'
}
}}
autoFocus
disabled={isRenaming}
placeholder={t('enterGroupName')}
/>
) : (
<span
className={`text-sm font-semibold ${isOwnerOrAdmin ? 'cursor-pointer hover:bg-black hover:bg-opacity-10 dark:hover:bg-white dark:hover:bg-opacity-10 rounded px-2 py-1 transition-all duration-200 hover:shadow-sm' : ''}`}
onClick={handleNameClick}
style={{ color: headerTextColor }}
title={isOwnerOrAdmin ? t('clickToEditGroupName') : ''}
>
{group.name}
</span>
)}
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
({group.count})
</span>
</div>
{/* Three dots menu */}
<div className="flex items-center justify-center ml-2">
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
open={dropdownVisible}
onOpenChange={setDropdownVisible}
placement="bottomLeft"
>
<button
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-all duration-200 ease-out"
style={{ color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
setDropdownVisible(!dropdownVisible);
}}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</Dropdown>
</div>
</div>
{/* Change Category Modal */}
<Modal
title="Change Category"
open={categoryModalVisible}
onCancel={() => setCategoryModalVisible(false)}
footer={null}
width={400}
>
<div className="py-4">
<div className="space-y-2">
{statusCategories?.map((category) => (
<div
key={category.id}
className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
onClick={(e) => category.id && handleCategoryChange(category.id, e)}
>
<Flex align="center" gap={12}>
<Badge color={category.color_code} />
<span className="font-medium">{category.name}</span>
</Flex>
{isChangingCategory && (
<div className="text-blue-500">
<ArrowPathIcon className="h-4 w-4 animate-spin" />
</div>
)}
</div>
))}
</div>
</div>
</Modal>
</div>
);
};

View File

@@ -1,11 +1,8 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import React, { useCallback, useMemo, useEffect } from 'react';
import { GroupedVirtuoso } from 'react-virtuoso';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
@@ -18,6 +15,13 @@ import {
verticalListSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import { createPortal } from 'react-dom';
import { Skeleton } from 'antd';
import { HolderOutlined } from '@ant-design/icons';
// Redux hooks and selectors
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
@@ -26,11 +30,12 @@ import {
selectGrouping,
selectLoading,
selectError,
selectSelectedPriorities,
selectSearch,
fetchTasksV3,
reorderTasksInGroup,
moveTaskBetweenGroups,
fetchTaskListColumns,
selectColumns,
selectCustomColumns,
selectLoadingColumns,
updateColumnVisibility,
} from '@/features/task-management/task-management.slice';
import {
selectCurrentGrouping,
@@ -40,63 +45,60 @@ import {
import {
selectSelectedTaskIds,
selectLastSelectedTaskId,
selectIsTaskSelected,
selectTask,
deselectTask,
toggleTaskSelection,
selectRange,
clearSelection,
} from '@/features/task-management/selection.slice';
import {
setCustomColumnModalAttributes,
toggleCustomColumnModalOpen,
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
// Components
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
import TaskGroupHeader from './TaskGroupHeader';
import { Task, TaskGroup } from '@/types/task-management.types';
import { RootState } from '@/app/store';
import { TaskListField } from '@/types/task-list-field.types';
import { useParams } from 'react-router-dom';
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar';
import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal';
import AddTaskRow from './components/AddTaskRow';
import {
AddCustomColumnButton,
CustomColumnHeader,
} from './components/CustomColumnComponents';
// Hooks and utilities
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { HolderOutlined } from '@ant-design/icons';
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
import { useSocket } from '@/socket/socketContext';
import { useDragAndDrop } from './hooks/useDragAndDrop';
import { useBulkActions } from './hooks/useBulkActions';
// Base column configuration
const BASE_COLUMNS = [
{ 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: 'title', label: 'Title', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
{ id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
{ id: 'priority', label: 'Priority', width: '120px', key: COLUMN_KEYS.PRIORITY },
{ id: 'dueDate', label: 'Due Date', width: '120px', key: COLUMN_KEYS.DUE_DATE },
{ id: 'progress', label: 'Progress', width: '120px', key: COLUMN_KEYS.PROGRESS },
{ id: 'labels', label: 'Labels', width: 'auto', key: COLUMN_KEYS.LABELS },
{ id: 'phase', label: 'Phase', width: '120px', key: COLUMN_KEYS.PHASE },
{ id: 'timeTracking', label: 'Time Tracking', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
{ id: 'estimation', label: 'Estimation', width: '120px', key: COLUMN_KEYS.ESTIMATION },
{ id: 'startDate', label: 'Start Date', width: '120px', key: COLUMN_KEYS.START_DATE },
{ id: 'dueTime', label: 'Due Time', width: '120px', key: COLUMN_KEYS.DUE_TIME },
{ id: 'completedDate', label: 'Completed Date', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE },
{ id: 'createdDate', label: 'Created Date', width: '120px', key: COLUMN_KEYS.CREATED_DATE },
{ id: 'lastUpdated', label: 'Last Updated', width: '120px', key: COLUMN_KEYS.LAST_UPDATED },
{ id: 'reporter', label: 'Reporter', width: '120px', key: COLUMN_KEYS.REPORTER },
];
type ColumnStyle = {
width: string;
position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed';
left?: number;
backgroundColor?: string;
zIndex?: number;
flexShrink?: number;
};
// Constants and types
import { BASE_COLUMNS, ColumnStyle } from './constants/columns';
import { Task } from '@/types/task-management.types';
import { SocketEvents } from '@/shared/socket-events';
const TaskListV2: React.FC = () => {
const dispatch = useAppDispatch();
const { projectId: urlProjectId } = useParams();
const { t } = useTranslation('task-list-table');
const { socket, connected } = useSocket();
// Drag and drop state
const [activeId, setActiveId] = useState<string | null>(null);
// Redux state selectors
const allTasks = useAppSelector(selectAllTasksArray);
const groups = useAppSelector(selectGroups);
const grouping = useAppSelector(selectGrouping);
const loading = useAppSelector(selectLoading);
const error = useAppSelector(selectError);
const currentGrouping = useAppSelector(selectCurrentGrouping);
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId);
const collapsedGroups = useAppSelector(selectCollapsedGroups);
const fields = useAppSelector(state => state.taskManagementFields) || [];
const columns = useAppSelector(selectColumns);
const customColumns = useAppSelector(selectCustomColumns);
const loadingColumns = useAppSelector(selectLoadingColumns);
// Configure sensors for drag and drop
const sensors = useSensors(
@@ -116,51 +118,110 @@ const TaskListV2: React.FC = () => {
})
);
// Using Redux state for collapsedGroups instead of local state
const collapsedGroups = useAppSelector(selectCollapsedGroups);
// Selectors
const allTasks = useAppSelector(selectAllTasksArray); // Renamed to allTasks for clarity
const groups = useAppSelector(selectGroups);
const grouping = useAppSelector(selectGrouping);
const loading = useAppSelector(selectLoading);
const error = useAppSelector(selectError);
const selectedPriorities = useAppSelector(selectSelectedPriorities);
const searchQuery = useAppSelector(selectSearch);
const currentGrouping = useAppSelector(selectCurrentGrouping);
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId);
const fields = useAppSelector(state => state.taskManagementFields) || [];
// Custom hooks
const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups);
const bulkActions = useBulkActions();
// Enable real-time updates via socket handlers
useTaskSocketHandlers();
// Filter visible columns based on fields
// Filter visible columns based on local fields (primary) and backend columns (fallback)
const visibleColumns = useMemo(() => {
return BASE_COLUMNS.filter(column => {
// Start with base columns
const baseVisibleColumns = BASE_COLUMNS.filter(column => {
// Always show drag handle and title (sticky columns)
if (column.isSticky) return true;
// Check if field is visible for all other columns (including task key)
// Primary: Check local fields configuration
const field = fields.find(f => f.key === column.key);
return field?.visible ?? false;
if (field) {
return field.visible;
}
// Fallback: Check backend column configuration if local field not found
const backendColumn = columns.find(c => c.key === column.key);
if (backendColumn) {
return backendColumn.pinned ?? false;
}
// Default: hide if neither local field nor backend column found
return false;
});
}, [fields]);
// Add visible custom columns
const visibleCustomColumns = customColumns
?.filter(column => column.pinned)
?.map(column => {
// Give selection columns more width for dropdown content
const fieldType = column.custom_column_obj?.fieldType;
let defaultWidth = 160;
if (fieldType === 'selection') {
defaultWidth = 180; // Extra width for selection dropdowns
} else if (fieldType === 'people') {
defaultWidth = 170; // Extra width for people with avatars
}
return {
id: column.key || column.id || 'unknown',
label: column.name || t('customColumns.customColumnHeader'),
width: `${(column as any).width || defaultWidth}px`,
key: column.key || column.id || 'unknown',
custom_column: true,
custom_column_obj: column.custom_column_obj || (column as any).configuration,
isCustom: true,
name: column.name,
uuid: column.id,
};
}) || [];
return [...baseVisibleColumns, ...visibleCustomColumns];
}, [fields, columns, customColumns, t]);
// Sync local field changes with backend column configuration (debounced)
useEffect(() => {
if (!urlProjectId || columns.length === 0 || fields.length === 0) return;
const timeoutId = setTimeout(() => {
const changedFields = fields.filter(field => {
const backendColumn = columns.find(c => c.key === field.key);
if (backendColumn) {
return (backendColumn.pinned ?? false) !== field.visible;
}
return false;
});
changedFields.forEach(field => {
const backendColumn = columns.find(c => c.key === field.key);
if (backendColumn) {
dispatch(updateColumnVisibility({
projectId: urlProjectId,
item: {
...backendColumn,
pinned: field.visible
}
}));
}
});
}, 500);
return () => clearTimeout(timeoutId);
}, [fields, columns, urlProjectId, dispatch]);
// Effects
useEffect(() => {
if (urlProjectId) {
dispatch(fetchTasksV3(urlProjectId));
dispatch(fetchTaskListColumns(urlProjectId));
}
}, [dispatch, urlProjectId]);
// Handlers
// Event handlers
const handleTaskSelect = useCallback(
(taskId: string, event: React.MouseEvent) => {
if (event.ctrlKey || event.metaKey) {
dispatch(toggleTaskSelection(taskId));
} else if (event.shiftKey && lastSelectedTaskId) {
const taskIds = allTasks.map(t => t.id); // Use allTasks here
const taskIds = allTasks.map(t => t.id);
const startIdx = taskIds.indexOf(lastSelectedTaskId);
const endIdx = taskIds.indexOf(taskId);
const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1);
@@ -175,259 +236,98 @@ const TaskListV2: React.FC = () => {
const handleGroupCollapse = useCallback(
(groupId: string) => {
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
dispatch(toggleGroupCollapsed(groupId));
},
[dispatch]
);
// Drag and drop handlers
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) return;
// Check if we're dragging over a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
let targetGroup = overGroup;
if (overTask) {
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
}
if (!activeGroup || !targetGroup) return;
// If dragging to a different group, we need to handle cross-group movement
if (activeGroup.id !== targetGroup.id) {
console.log('Cross-group drag detected:', {
activeTask: activeTask.id,
fromGroup: activeGroup.id,
toGroup: targetGroup.id,
});
}
},
[allTasks, groups]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) {
// Function to update custom column values
const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => {
try {
if (!urlProjectId) {
console.error('Project ID is missing');
return;
}
const activeId = active.id;
const overId = over.id;
const body = {
task_id: taskId,
column_key: columnKey,
value: value,
project_id: urlProjectId,
};
// Find the active task
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) {
console.error('Active task not found:', activeId);
return;
}
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
if (!activeGroup) {
console.error('Could not find active group for task:', activeId);
return;
}
// Check if we're dropping on a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
let targetGroup = overGroup;
let insertIndex = 0;
if (overTask) {
// Dropping on a task
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (targetGroup) {
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
}
} else if (overGroup) {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
}
if (!targetGroup) {
console.error('Could not find target group');
return;
}
const isCrossGroup = activeGroup.id !== targetGroup.id;
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
console.log('Drag operation:', {
activeId,
overId,
activeTask: activeTask.name || activeTask.title,
activeGroup: activeGroup.id,
targetGroup: targetGroup.id,
activeIndex,
insertIndex,
isCrossGroup,
});
if (isCrossGroup) {
// Moving task between groups
console.log('Moving task between groups:', {
task: activeTask.name || activeTask.title,
from: activeGroup.title,
to: targetGroup.title,
newPosition: insertIndex,
});
// Move task to the target group
dispatch(
moveTaskBetweenGroups({
taskId: activeId as string,
sourceGroupId: activeGroup.id,
targetGroupId: targetGroup.id,
})
);
// Reorder task within target group at drop position
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: targetGroup.id,
})
);
if (socket && connected) {
socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body));
} else {
// Reordering within the same group
console.log('Reordering task within same group:', {
task: activeTask.name || activeTask.title,
group: activeGroup.title,
from: activeIndex,
to: insertIndex,
});
if (activeIndex !== insertIndex) {
// Reorder task within same group at drop position
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: activeGroup.id,
})
);
}
console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event');
}
},
[allTasks, groups]
);
} catch (error) {
console.error('Error updating custom column value:', error);
}
}, [urlProjectId, socket, connected]);
// Bulk action handlers
const handleClearSelection = useCallback(() => {
dispatch(clearSelection());
}, [dispatch]);
// Custom column settings handler
const handleCustomColumnSettings = useCallback((columnKey: string) => {
if (!columnKey) return;
const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey);
dispatch(setCustomColumnModalAttributes({
modalType: 'edit',
columnId: columnKey,
columnData: columnData
}));
dispatch(toggleCustomColumnModalOpen(true));
}, [dispatch, visibleColumns]);
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);
// Add callback for task added
const handleTaskAdded = useCallback(() => {
// Task is now added in real-time via socket, no need to refetch
// The global socket handler will handle the real-time update
}, []);
// Memoized values for GroupedVirtuoso
const virtuosoGroups = useMemo(() => {
let currentTaskIndex = 0;
return groups.map(group => {
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
// Order tasks according to group.taskIds array to maintain proper order
const visibleTasksInGroup = isCurrentGroupCollapsed
? []
: group.taskIds
.map(taskId => allTasks.find(task => task.id === taskId))
.filter((task): task is Task => task !== undefined); // Type guard to filter out undefined tasks
.filter((task): task is Task => task !== undefined);
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
...task,
originalIndex: allTasks.indexOf(task),
}));
const itemsWithAddTask = !isCurrentGroupCollapsed ? [
...tasksForVirtuoso,
{
id: `add-task-${group.id}`,
isAddTaskRow: true,
groupId: group.id,
groupType: currentGrouping || 'status',
groupValue: group.id, // Use the actual database ID from backend
projectId: urlProjectId,
}
] : tasksForVirtuoso;
const groupData = {
...group,
tasks: tasksForVirtuoso,
tasks: itemsWithAddTask,
startIndex: currentTaskIndex,
count: tasksForVirtuoso.length,
count: itemsWithAddTask.length,
actualCount: group.taskIds.length,
groupValue: group.groupValue || group.title,
};
currentTaskIndex += tasksForVirtuoso.length;
currentTaskIndex += itemsWithAddTask.length;
return groupData;
});
}, [groups, allTasks, collapsedGroups]);
}, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]);
const virtuosoGroupCounts = useMemo(() => {
return virtuosoGroups.map(group => group.count);
@@ -437,49 +337,12 @@ const TaskListV2: React.FC = () => {
return virtuosoGroups.flatMap(group => group.tasks);
}, [virtuosoGroups]);
// Memoize column headers to prevent unnecessary re-renders
const columnHeaders = useMemo(
() => (
<div className="flex items-center px-4 py-2" style={{ minWidth: 'max-content' }}>
{visibleColumns.map(column => {
const columnStyle: ColumnStyle = {
width: column.width,
flexShrink: 0, // Prevent columns from shrinking
// Add specific styling for labels column with auto width
...(column.id === 'labels' && column.width === 'auto'
? {
minWidth: '200px', // Ensure minimum width for labels
flexGrow: 1, // Allow it to grow
}
: {}),
};
return (
<div
key={column.id}
className="text-xs font-medium text-gray-500 dark:text-gray-400"
style={columnStyle}
>
{column.id === 'dragHandle' ? (
<HolderOutlined className="text-gray-400" />
) : column.id === 'checkbox' ? (
<span></span> // Empty for checkbox column header
) : (
column.label
)}
</div>
);
})}
</div>
),
[visibleColumns]
);
// Render functions
const renderGroup = useCallback(
(groupIndex: number) => {
const group = virtuosoGroups[groupIndex];
const isGroupEmpty = group.count === 0;
const isGroupCollapsed = collapsedGroups.has(group.id);
const isGroupEmpty = group.actualCount === 0;
return (
<div className={groupIndex > 0 ? 'mt-2' : ''}>
@@ -487,40 +350,114 @@ const TaskListV2: React.FC = () => {
group={{
id: group.id,
name: group.title,
count: group.count,
count: group.actualCount,
color: group.color,
}}
isCollapsed={collapsedGroups.has(group.id)}
isCollapsed={isGroupCollapsed}
onToggle={() => handleGroupCollapse(group.id)}
projectId={urlProjectId || ''}
/>
{/* Empty group drop zone */}
{isGroupEmpty && !collapsedGroups.has(group.id) && (
<div className="px-4 py-8 text-center text-gray-400 dark:text-gray-500 border-2 border-dashed border-transparent hover:border-blue-300 transition-colors">
<div className="text-sm">Drop tasks here</div>
{isGroupEmpty && !isGroupCollapsed && (
<div className="relative w-full">
<div className="flex items-center min-w-max px-1 py-3">
{visibleColumns.map((column) => (
<div
key={`empty-${column.id}`}
style={{ width: column.width, flexShrink: 0 }}
/>
))}
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-sm italic text-gray-400 dark:text-gray-500 bg-white dark:bg-gray-900 px-4 py-1 rounded-md border border-gray-200 dark:border-gray-700">
{t('noTasksInGroup')}
</div>
</div>
</div>
)}
</div>
);
},
[virtuosoGroups, collapsedGroups, handleGroupCollapse]
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
);
const renderTask = useCallback(
(taskIndex: number) => {
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
if (!task || !urlProjectId) return null; // Should not happen if logic is correct
const item = virtuosoItems[taskIndex];
if (!item || !urlProjectId) return null;
if ('isAddTaskRow' in item && item.isAddTaskRow) {
return (
<AddTaskRow
groupId={item.groupId}
groupType={item.groupType}
groupValue={item.groupValue}
projectId={urlProjectId}
visibleColumns={visibleColumns}
onTaskAdded={handleTaskAdded}
/>
);
}
return (
<TaskRowWithSubtasks
taskId={task.id}
taskId={item.id}
projectId={urlProjectId}
visibleColumns={visibleColumns}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
);
},
[virtuosoItems, visibleColumns]
[virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue]
);
if (loading) return <div>Loading...</div>;
// Render column headers
const renderColumnHeaders = useCallback(() => (
<div className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center px-1 py-3 w-full" style={{ minWidth: 'max-content', height: '44px' }}>
{visibleColumns.map(column => {
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 }),
};
return (
<div
key={column.id}
className={`text-sm font-semibold text-gray-600 dark:text-gray-300 ${
column.id === 'taskKey' ? 'pl-3' : ''
}`}
style={columnStyle}
>
{column.id === 'dragHandle' || column.id === 'checkbox' ? (
<span></span>
) : (column as any).isCustom ? (
<CustomColumnHeader
column={column}
onSettingsClick={handleCustomColumnSettings}
/>
) : (
t(column.label || '')
)}
</div>
);
})}
<div className="flex items-center justify-center" style={{ width: '60px', flexShrink: 0 }}>
<AddCustomColumnButton />
</div>
</div>
</div>
), [visibleColumns, t, handleCustomColumnSettings]);
// Loading and error states
if (loading || loadingColumns) return <Skeleton active />;
if (error) return <div>Error: {error}</div>;
return (
@@ -531,31 +468,35 @@ const TaskListV2: React.FC = () => {
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
<div className="flex flex-col bg-white dark:bg-gray-900" style={{ height: '100vh', overflow: 'hidden' }}>
{/* Task Filters */}
<div className="flex-none px-4 py-3">
<div className="flex-none px-4 py-3" style={{ height: '66px', flexShrink: 0 }}>
<ImprovedTaskFilters position="list" />
</div>
{/* Table Container with synchronized horizontal scrolling */}
<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">
{columnHeaders}
</div>
{/* Table Container */}
<div
className="flex-1 overflow-auto border border-gray-200 dark:border-gray-700"
style={{
height: '600px',
maxHeight: '600px'
}}
>
<div style={{ minWidth: 'max-content' }}>
{/* Column Headers */}
{renderColumnHeaders()}
{/* Task List - Scrollable content */}
<div className="flex-1">
{/* Task List Content */}
<div className="bg-white dark:bg-gray-900">
<SortableContext
items={virtuosoItems
.filter(task => !task.parent_task_id)
.map(task => task.id)
.filter(item => !('isAddTaskRow' in item) && !item.parent_task_id)
.map(item => item.id)
.filter((id): id is string => id !== undefined)}
strategy={verticalListSortingStrategy}
>
<GroupedVirtuoso
style={{ height: 'calc(100vh - 200px)' }}
style={{ height: '550px' }}
groupCounts={virtuosoGroupCounts}
groupContent={renderGroup}
itemContent={renderTask}
@@ -564,7 +505,7 @@ const TaskListV2: React.FC = () => {
HTMLDivElement,
{ style?: React.CSSProperties; children?: React.ReactNode }
>(({ style, children }, ref) => (
<div ref={ref} style={style || {}} className="virtuoso-list-container">
<div ref={ref} style={style || {}} className="virtuoso-list-container bg-white dark:bg-gray-900">
{children}
</div>
)),
@@ -600,24 +541,29 @@ const TaskListV2: React.FC = () => {
{/* Bulk Action Bar */}
{selectedTaskIds.length > 0 && urlProjectId && (
<OptimizedBulkActionBar
selectedTaskIds={selectedTaskIds}
totalSelected={selectedTaskIds.length}
projectId={urlProjectId}
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 className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
<OptimizedBulkActionBar
selectedTaskIds={selectedTaskIds}
totalSelected={selectedTaskIds.length}
projectId={urlProjectId}
onClearSelection={bulkActions.handleClearSelection}
onBulkStatusChange={(statusId) => bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)}
onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)}
onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)}
onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)}
onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)}
onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)}
onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)}
onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)}
onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)}
onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)}
onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)}
/>
</div>
)}
{/* Custom Column Modal */}
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
</div>
</DndContext>
);

View File

@@ -1,7 +1,7 @@
import React, { memo, useMemo, useCallback, useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons';
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined } from '@ant-design/icons';
import { Checkbox, DatePicker } from 'antd';
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
import { Task } from '@/types/task-management.types';
@@ -25,6 +25,7 @@ import TaskTimeTracking from './TaskTimeTracking';
import { CustomNumberLabel, CustomColordLabel } from '@/components';
import LabelsSelector from '@/components/LabelsSelector';
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
import { CustomColumnCell } from './components/CustomColumnComponents';
interface TaskRowProps {
taskId: string;
@@ -33,8 +34,13 @@ interface TaskRowProps {
id: string;
width: string;
isSticky?: boolean;
key?: string;
custom_column?: boolean;
custom_column_obj?: any;
isCustom?: boolean;
}>;
isSubtask?: boolean;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
}
interface TaskLabelsCellProps {
@@ -91,7 +97,7 @@ const formatDate = (dateString: string): string => {
}
};
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns, isSubtask = false }) => {
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns, isSubtask = false, updateTaskCustomColumnValue }) => {
const dispatch = useAppDispatch();
const task = useAppSelector(state => selectTaskById(state, taskId));
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
@@ -276,8 +282,8 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
case 'taskKey':
return (
<div className="flex items-center" style={baseStyle}>
<span className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
<div className="flex items-center pl-3" style={baseStyle}>
<span className="text-xs font-medium px-2 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 whitespace-nowrap border border-gray-200 dark:border-gray-600">
{task.task_key || 'N/A'}
</span>
</div>
@@ -287,33 +293,33 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
return (
<div className="flex items-center justify-between group" style={baseStyle}>
<div className="flex items-center flex-1">
{/* Indentation for subtasks - increased padding */}
{isSubtask && <div className="w-8" />}
{/* Indentation for subtasks - tighter spacing */}
{isSubtask && <div className="w-4" />}
{/* Expand/Collapse button - only show for parent tasks */}
{!isSubtask && (
<button
onClick={handleToggleExpansion}
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-2 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors ${
task.sub_tasks_count && task.sub_tasks_count > 0
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out ${
task.sub_tasks_count && Number(task.sub_tasks_count) > 0
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
>
{task.sub_tasks_count && task.sub_tasks_count > 0 ? (
task.show_sub_tasks ? (
<DownOutlined className="text-gray-600 dark:text-gray-400" />
) : (
<RightOutlined className="text-gray-600 dark:text-gray-400" />
)
) : (
<div
className="transition-transform duration-300 ease-out"
style={{
transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)',
transformOrigin: 'center'
}}
>
<RightOutlined className="text-gray-600 dark:text-gray-400" />
)}
</div>
</button>
)}
{/* Additional indentation for subtasks after the expand button space */}
{isSubtask && <div className="w-4" />}
{isSubtask && <div className="w-2" />}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
@@ -321,7 +327,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
</span>
{/* Subtask count indicator */}
{!isSubtask && task.sub_tasks_count && task.sub_tasks_count > 0 && (
{!isSubtask && task.sub_tasks_count && Number(task.sub_tasks_count) > 0 && (
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
{task.sub_tasks_count}
@@ -333,13 +339,14 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
</div>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 ml-2 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 border-none bg-transparent cursor-pointer"
className="opacity-0 group-hover:opacity-100 transition-all duration-200 ml-2 mr-2 px-3 py-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-pointer rounded-md shadow-sm hover:shadow-md flex items-center gap-1"
onClick={(e) => {
e.stopPropagation();
dispatch(setSelectedTaskId(task.id));
dispatch(setShowTaskDrawer(true));
}}
>
<ArrowsAltOutlined />
{t('openButton')}
</button>
</div>
@@ -604,6 +611,19 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
);
default:
// Handle custom columns
const column = visibleColumns.find(col => col.id === columnId);
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
return (
<div style={baseStyle}>
<CustomColumnCell
column={column}
task={task}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
</div>
);
}
return null;
}
}, [
@@ -632,19 +652,25 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
// Translation
t,
// Custom columns
visibleColumns,
updateTaskCustomColumnValue,
]);
return (
<div
ref={setNodeRef}
style={style}
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 ${
className={`flex items-center min-w-max px-1 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' : ''
}`}
>
{visibleColumns.map((column, index) =>
renderColumn(column.id, column.width, column.isSticky, index)
)}
{visibleColumns.map((column, index) => (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width, column.isSticky, index)}
</React.Fragment>
))}
</div>
);
});

View File

@@ -10,6 +10,7 @@ import { PlusOutlined } from '@ant-design/icons';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useTranslation } from 'react-i18next';
import { useAuthService } from '@/hooks/useAuth';
interface TaskRowWithSubtasksProps {
taskId: string;
@@ -19,6 +20,7 @@ interface TaskRowWithSubtasksProps {
width: string;
isSticky?: boolean;
}>;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
}
interface AddSubtaskRowProps {
@@ -43,9 +45,12 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
const { socket, connected } = useSocket();
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
// Get session data for reporter_id and team_id
const currentSession = useAuthService().getCurrentSession();
const handleAddSubtask = useCallback(() => {
if (!subtaskName.trim()) return;
if (!subtaskName.trim() || !currentSession) return;
// Create optimistic subtask immediately for better UX
dispatch(createSubtask({
@@ -62,6 +67,8 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
name: subtaskName.trim(),
project_id: projectId,
parent_task_id: parentTaskId,
reporter_id: currentSession.id,
team_id: currentSession.team_id,
})
);
}
@@ -69,7 +76,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
setSubtaskName('');
setIsAdding(false);
onSubtaskAdded();
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, onSubtaskAdded]);
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded]);
const handleCancel = useCallback(() => {
setSubtaskName('');
@@ -90,8 +97,9 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
return (
<div className="flex items-center h-full" style={baseStyle}>
<div className="flex items-center w-full h-full">
{/* Match subtask indentation pattern - same as TaskRow for subtasks */}
<div className="w-8" />
{/* Match subtask indentation pattern - tighter spacing */}
<div className="w-4" />
<div className="w-2" />
{!isAdding ? (
<button
@@ -127,7 +135,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
return (
<div 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 min-h-[42px]">
<div className="flex items-center min-w-max px-1 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
{visibleColumns.map((column) =>
renderColumn(column.id, column.width)
)}
@@ -140,7 +148,8 @@ AddSubtaskRow.displayName = 'AddSubtaskRow';
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
taskId,
projectId,
visibleColumns
visibleColumns,
updateTaskCustomColumnValue
}) => {
const task = useAppSelector(state => selectTaskById(state, taskId));
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
@@ -162,6 +171,7 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
taskId={taskId}
projectId={projectId}
visibleColumns={visibleColumns}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
{/* Subtasks and add subtask row when expanded */}
@@ -182,6 +192,7 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
projectId={projectId}
visibleColumns={visibleColumns}
isSubtask={true}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
</div>
))}

View File

@@ -0,0 +1,146 @@
import React, { useState, useCallback, memo } from 'react';
import { Input } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
interface AddTaskRowProps {
groupId: string;
groupType: string;
groupValue: string;
projectId: string;
visibleColumns: Array<{
id: string;
width: string;
isSticky?: boolean;
}>;
onTaskAdded: () => void;
}
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
groupId,
groupType,
groupValue,
projectId,
visibleColumns,
onTaskAdded
}) => {
const [isAdding, setIsAdding] = useState(false);
const [taskName, setTaskName] = useState('');
const { socket, connected } = useSocket();
const { t } = useTranslation('task-list-table');
// Get session data for reporter_id and team_id
const currentSession = useAuthService().getCurrentSession();
// The global socket handler (useTaskSocketHandlers) will handle task addition
// No need for local socket listener to avoid duplicate additions
const handleAddTask = useCallback(() => {
if (!taskName.trim() || !currentSession) return;
try {
const body: any = {
name: taskName.trim(),
project_id: projectId,
reporter_id: currentSession.id,
team_id: currentSession.team_id,
};
// Map grouping type to correct field name expected by backend
switch (groupType) {
case 'status':
body.status_id = groupValue;
break;
case 'priority':
body.priority_id = groupValue;
break;
case 'phase':
body.phase_id = groupValue;
break;
default:
// For any other grouping types, use the groupType as is
body[groupType] = groupValue;
break;
}
if (socket && connected) {
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
setTaskName('');
setIsAdding(false);
// Task refresh will be handled by socket response listener
} else {
console.warn('Socket not connected, unable to create task');
}
} catch (error) {
console.error('Error creating task:', error);
}
}, [taskName, projectId, groupType, groupValue, socket, connected, currentSession]);
const handleCancel = useCallback(() => {
setTaskName('');
setIsAdding(false);
}, []);
const renderColumn = useCallback((columnId: string, width: string) => {
const baseStyle = { width };
switch (columnId) {
case 'dragHandle':
case 'checkbox':
case 'taskKey':
return <div style={baseStyle} />;
case 'title':
return (
<div className="flex items-center h-full" style={baseStyle}>
<div className="flex items-center w-full h-full">
<div className="w-4 mr-1" />
{!isAdding ? (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
>
<PlusOutlined className="text-xs" />
{t('addTaskText')}
</button>
) : (
<Input
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
onPressEnter={handleAddTask}
onBlur={handleCancel}
placeholder="Type task name and press Enter to save"
className="w-full h-full border-none shadow-none bg-transparent"
style={{
height: '100%',
minHeight: '32px',
padding: '0',
fontSize: '14px'
}}
autoFocus
/>
)}
</div>
</div>
);
default:
return <div style={baseStyle} />;
}
}, [isAdding, taskName, handleAddTask, handleCancel, t]);
return (
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
{visibleColumns.map((column) =>
renderColumn(column.id, column.width)
)}
</div>
);
});
AddTaskRow.displayName = 'AddTaskRow';
export default AddTaskRow;

View File

@@ -0,0 +1,585 @@
import React, { useState, useCallback, useMemo, memo, useEffect } from 'react';
import { Button, Tooltip, Flex, Dropdown, DatePicker, Input } from 'antd';
import { PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
setCustomColumnModalAttributes,
toggleCustomColumnModalOpen,
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import PeopleDropdown from '@/components/common/people-dropdown/PeopleDropdown';
import AvatarGroup from '@/components/AvatarGroup';
import dayjs from 'dayjs';
// Add Custom Column Button Component
export const AddCustomColumnButton: React.FC = memo(() => {
const dispatch = useAppDispatch();
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const { t } = useTranslation('task-list-table');
const handleModalOpen = useCallback(() => {
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
dispatch(toggleCustomColumnModalOpen(true));
}, [dispatch]);
return (
<Tooltip title={t('customColumns.addCustomColumn')} placement="top">
<button
onClick={handleModalOpen}
className={`
group relative w-8 h-8 rounded-lg border-2 border-dashed transition-all duration-200
flex items-center justify-center
${isDarkMode
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
: 'border-gray-300 hover:border-blue-500 hover:bg-blue-50 text-gray-400 hover:text-blue-600'
}
`}
>
<PlusOutlined className="text-xs transition-transform duration-200 group-hover:scale-110" />
{/* Subtle glow effect on hover */}
<div className={`
absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200
${isDarkMode
? 'bg-blue-500/5 shadow-lg shadow-blue-500/20'
: 'bg-blue-500/5 shadow-lg shadow-blue-500/10'
}
`} />
</button>
</Tooltip>
);
});
AddCustomColumnButton.displayName = 'AddCustomColumnButton';
// Custom Column Header Component
export const CustomColumnHeader: React.FC<{
column: any;
onSettingsClick: (columnId: string) => void;
}> = ({ column, onSettingsClick }) => {
const { t } = useTranslation('task-list-table');
const displayName = column.name ||
column.label ||
column.custom_column_obj?.fieldTitle ||
column.custom_column_obj?.field_title ||
t('customColumns.customColumnHeader');
return (
<Flex align="center" justify="space-between" className="w-full px-2">
<span title={displayName}>{displayName}</span>
<Tooltip title={t('customColumns.customColumnSettings')}>
<SettingOutlined
className="cursor-pointer hover:text-primary"
onClick={e => {
e.stopPropagation();
onSettingsClick(column.key || column.id);
}}
/>
</Tooltip>
</Flex>
);
};
// Custom Column Cell Component with Interactive Inputs
export const CustomColumnCell: React.FC<{
column: any;
task: any;
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ column, task, updateTaskCustomColumnValue }) => {
const { t } = useTranslation('task-list-table');
const customValue = task.custom_column_values?.[column.key];
const fieldType = column.custom_column_obj?.fieldType;
if (!fieldType || !column.custom_column) {
return <span className="text-gray-400 text-sm">-</span>;
}
// Render different input types based on field type
switch (fieldType) {
case 'people':
return (
<PeopleCustomColumnCell
task={task}
columnKey={column.key}
customValue={customValue}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
);
case 'date':
return (
<DateCustomColumnCell
task={task}
columnKey={column.key}
customValue={customValue}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
);
case 'number':
return (
<NumberCustomColumnCell
task={task}
columnKey={column.key}
customValue={customValue}
columnObj={column.custom_column_obj}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
);
case 'selection':
return (
<SelectionCustomColumnCell
task={task}
columnKey={column.key}
customValue={customValue}
columnObj={column.custom_column_obj}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
);
default:
return <span className="text-sm text-gray-400 px-2">{t('customColumns.unsupportedField')}</span>;
}
});
CustomColumnCell.displayName = 'CustomColumnCell';
// People Field Cell Component
export const PeopleCustomColumnCell: React.FC<{
task: any;
columnKey: string;
customValue: any;
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
const [isLoading, setIsLoading] = useState(false);
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
const [optimisticSelectedIds, setOptimisticSelectedIds] = useState<string[]>([]);
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDarkMode = themeMode === 'dark';
// Parse selected member IDs from custom value
const selectedMemberIds = useMemo(() => {
try {
return customValue ? JSON.parse(customValue) : [];
} catch (e) {
return [];
}
}, [customValue]);
// Use optimistic updates when there are pending changes, otherwise use actual value
const displayedMemberIds = useMemo(() => {
// If we have pending changes, use optimistic state
if (pendingChanges.size > 0) {
return optimisticSelectedIds;
}
// Otherwise use the actual value from the server
return selectedMemberIds;
}, [pendingChanges.size, optimisticSelectedIds, selectedMemberIds]);
// Initialize optimistic state and update when actual value changes (from socket updates)
useEffect(() => {
// Only update optimistic state if there are no pending changes
// This prevents the socket update from overriding our optimistic state
if (pendingChanges.size === 0) {
setOptimisticSelectedIds(selectedMemberIds);
}
}, [selectedMemberIds, pendingChanges.size]);
const selectedMembers = useMemo(() => {
if (!members?.data || !displayedMemberIds.length) return [];
return members.data.filter(member => displayedMemberIds.includes(member.id));
}, [members, displayedMemberIds]);
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
// Add to pending changes for visual feedback
setPendingChanges(prev => new Set(prev).add(memberId));
const newSelectedIds = checked
? [...selectedMemberIds, memberId]
: selectedMemberIds.filter((id: string) => id !== memberId);
// Update optimistic state immediately for instant UI feedback
setOptimisticSelectedIds(newSelectedIds);
if (task.id) {
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
}
// Remove from pending changes after socket update is processed
// Use a longer timeout to ensure the socket update has been received and processed
setTimeout(() => {
setPendingChanges(prev => {
const newSet = new Set<string>(Array.from(prev));
newSet.delete(memberId);
return newSet;
});
}, 1500); // Even longer delay to ensure socket update is fully processed
}, [selectedMemberIds, task.id, columnKey, updateTaskCustomColumnValue]);
const loadMembers = useCallback(async () => {
if (members?.data?.length === 0) {
setIsLoading(true);
// The members are loaded through Redux, so we just need to wait
setTimeout(() => setIsLoading(false), 500);
}
}, [members]);
return (
<div className="flex items-center gap-1 px-2 relative custom-column-cell">
{selectedMembers.length > 0 && (
<AvatarGroup
members={selectedMembers.map(member => ({
id: member.id,
team_member_id: member.id,
name: member.name,
avatar_url: member.avatar_url,
color_code: member.color_code,
}))}
maxCount={3}
size={24}
isDarkMode={isDarkMode}
/>
)}
<PeopleDropdown
selectedMemberIds={displayedMemberIds}
onMemberToggle={handleMemberToggle}
isDarkMode={isDarkMode}
isLoading={isLoading}
loadMembers={loadMembers}
pendingChanges={pendingChanges}
buttonClassName="w-6 h-6"
/>
</div>
);
});
PeopleCustomColumnCell.displayName = 'PeopleCustomColumnCell';
// Date Field Cell Component
export const DateCustomColumnCell: React.FC<{
task: any;
columnKey: string;
customValue: any;
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
const [isOpen, setIsOpen] = useState(false);
const dateValue = customValue ? dayjs(customValue) : null;
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const handleDateChange = (date: dayjs.Dayjs | null) => {
if (task.id) {
updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : '');
}
setIsOpen(false);
};
return (
<div className={`px-2 relative custom-column-cell ${isOpen ? 'focused' : ''}`}>
<div className="relative">
<DatePicker
open={isOpen}
onOpenChange={setIsOpen}
value={dateValue}
onChange={handleDateChange}
placeholder={dateValue ? "" : "Click to set date"}
format="MMM DD, YYYY"
suffixIcon={null}
size="small"
variant="borderless"
className={`
w-full text-sm transition-colors duration-200 custom-column-date-picker
${isDarkMode ? 'dark-mode' : 'light-mode'}
`}
popupClassName={isDarkMode ? 'dark-date-picker' : 'light-date-picker'}
inputReadOnly
getPopupContainer={(trigger) => trigger.parentElement || document.body}
style={{
backgroundColor: 'transparent',
border: 'none',
boxShadow: 'none',
width: '100%',
}}
/>
</div>
</div>
);
});
DateCustomColumnCell.displayName = 'DateCustomColumnCell';
// Number Field Cell Component
export const NumberCustomColumnCell: React.FC<{
task: any;
columnKey: string;
customValue: any;
columnObj: any;
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
const [inputValue, setInputValue] = useState(String(customValue || ''));
const [isEditing, setIsEditing] = useState(false);
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const numberType = columnObj?.numberType || 'formatted';
const decimals = columnObj?.decimals || 0;
const label = columnObj?.label || '';
const labelPosition = columnObj?.labelPosition || 'left';
// Sync inputValue with customValue to prevent NaN issues
useEffect(() => {
setInputValue(String(customValue || ''));
}, [customValue]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Allow only numbers, decimal point, and minus sign
if (/^-?\d*\.?\d*$/.test(value) || value === '') {
setInputValue(value);
}
};
const handleFocus = () => {
setIsEditing(true);
};
const handleBlur = () => {
setIsEditing(false);
// Only update if there's a valid value and it's different from the current value
if (task.id && inputValue !== customValue) {
// Safely convert inputValue to string to avoid .trim() errors
const stringValue = String(inputValue || '');
// Don't save empty values or invalid numbers
if (stringValue.trim() === '' || isNaN(parseFloat(stringValue))) {
setInputValue(customValue || ''); // Reset to original value
} else {
updateTaskCustomColumnValue(task.id, columnKey, stringValue);
}
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleBlur();
}
if (e.key === 'Escape') {
setInputValue(customValue || '');
setIsEditing(false);
}
};
const getDisplayValue = () => {
if (isEditing) return inputValue;
// Safely convert inputValue to string to avoid .trim() errors
const stringValue = String(inputValue || '');
if (!stringValue || stringValue.trim() === '') return '';
const numValue = parseFloat(stringValue);
if (isNaN(numValue)) return ''; // Return empty string instead of showing NaN
switch (numberType) {
case 'formatted':
return numValue.toFixed(decimals);
case 'percentage':
return `${numValue.toFixed(decimals)}%`;
case 'withLabel':
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
default:
return numValue.toString();
}
};
const addonBefore = numberType === 'withLabel' && labelPosition === 'left' ? label : undefined;
const addonAfter = numberType === 'withLabel' && labelPosition === 'right' ? label : undefined;
return (
<div className="px-2">
<Input
value={getDisplayValue()}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={numberType === 'percentage' ? '0%' : '0'}
size="small"
variant="borderless"
addonBefore={addonBefore}
addonAfter={addonAfter}
style={{
textAlign: 'right',
width: '100%',
minWidth: 0,
}}
className={`
custom-column-number-input
${isDarkMode ? 'dark-mode' : 'light-mode'}
`}
/>
</div>
);
});
NumberCustomColumnCell.displayName = 'NumberCustomColumnCell';
// Selection Field Cell Component
export const SelectionCustomColumnCell: React.FC<{
task: any;
columnKey: string;
customValue: any;
columnObj: any;
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const selectionsList = columnObj?.selectionsList || [];
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
const handleOptionSelect = async (option: any) => {
setIsLoading(true);
try {
if (task.id) {
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
}
setIsDropdownOpen(false);
} finally {
// Small delay to show loading state
setTimeout(() => setIsLoading(false), 200);
}
};
const dropdownContent = (
<div className={`
rounded-lg shadow-xl border min-w-[180px] max-h-64 overflow-y-auto custom-column-dropdown
${isDarkMode
? 'bg-gray-800 border-gray-600'
: 'bg-white border-gray-200'
}
`}>
{/* Header */}
<div className={`
px-3 py-2 border-b text-xs font-medium
${isDarkMode
? 'border-gray-600 text-gray-300 bg-gray-750'
: 'border-gray-200 text-gray-600 bg-gray-50'
}
`}>
Select an option
</div>
{/* Options */}
<div className="p-1">
{selectionsList.map((option: any) => (
<div
key={option.selection_id}
onClick={() => handleOptionSelect(option)}
className={`
flex items-center gap-3 p-2 rounded-md cursor-pointer transition-all duration-200
${selectedOption?.selection_id === option.selection_id
? isDarkMode
? 'bg-blue-900/50 text-blue-200'
: 'bg-blue-50 text-blue-700'
: isDarkMode
? 'hover:bg-gray-700 text-gray-200'
: 'hover:bg-gray-100 text-gray-900'
}
`}
>
<div
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
style={{ backgroundColor: option.selection_color || '#6b7280' }}
/>
<span className="text-sm font-medium flex-1">{option.selection_name}</span>
{selectedOption?.selection_id === option.selection_id && (
<div className={`
w-4 h-4 rounded-full flex items-center justify-center
${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'}
`}>
<svg className="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
))}
{selectionsList.length === 0 && (
<div className={`
text-center py-8 text-sm
${isDarkMode ? 'text-gray-500' : 'text-gray-400'}
`}>
<div className="mb-2">📋</div>
<div>No options available</div>
</div>
)}
</div>
</div>
);
return (
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'focused' : ''}`}>
<Dropdown
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
dropdownRender={() => dropdownContent}
trigger={['click']}
placement="bottomLeft"
overlayClassName="custom-selection-dropdown"
getPopupContainer={(trigger) => trigger.parentElement || document.body}
>
<div className={`
flex items-center gap-2 cursor-pointer rounded-md px-2 py-1 min-h-[28px] transition-all duration-200 relative
${isDropdownOpen
? isDarkMode
? 'bg-gray-700 ring-1 ring-blue-500/50'
: 'bg-gray-100 ring-1 ring-blue-500/50'
: isDarkMode
? 'hover:bg-gray-700/50'
: 'hover:bg-gray-100/50'
}
`}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className={`
w-3 h-3 rounded-full animate-spin border-2 border-transparent
${isDarkMode ? 'border-t-gray-400' : 'border-t-gray-600'}
`} />
<span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Updating...
</span>
</div>
) : selectedOption ? (
<>
<div
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
style={{ backgroundColor: selectedOption.selection_color || '#6b7280' }}
/>
<span className={`text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-900'}`}>
{selectedOption.selection_name}
</span>
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
) : (
<>
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
Select option
</span>
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
)}
</div>
</Dropdown>
</div>
);
});
SelectionCustomColumnCell.displayName = 'SelectionCustomColumnCell';

View File

@@ -0,0 +1,35 @@
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
export type ColumnStyle = {
width: string;
position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed';
left?: number;
backgroundColor?: string;
zIndex?: number;
flexShrink?: number;
minWidth?: string;
maxWidth?: string;
};
// Base column configuration
export const BASE_COLUMNS = [
{ id: 'dragHandle', label: '', width: '20px', isSticky: true, key: 'dragHandle' },
{ id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' },
{ id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' },
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
{ id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS },
{ id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
{ id: 'dueDate', label: 'dueDateColumn', width: '120px', key: COLUMN_KEYS.DUE_DATE },
{ id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS },
{ id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS },
{ id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE },
{ id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
{ id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },
{ id: 'startDate', label: 'startDateColumn', width: '120px', key: COLUMN_KEYS.START_DATE },
{ id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME },
{ id: 'completedDate', label: 'completedDateColumn', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE },
{ id: 'createdDate', label: 'createdDateColumn', width: '120px', key: COLUMN_KEYS.CREATED_DATE },
{ id: 'lastUpdated', label: 'lastUpdatedColumn', width: '120px', key: COLUMN_KEYS.LAST_UPDATED },
{ id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER },
];

View File

@@ -0,0 +1,356 @@
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { clearSelection } from '@/features/task-management/selection.slice';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import {
evt_project_task_list_bulk_archive,
evt_project_task_list_bulk_assign_me,
evt_project_task_list_bulk_assign_members,
evt_project_task_list_bulk_change_phase,
evt_project_task_list_bulk_change_priority,
evt_project_task_list_bulk_change_status,
evt_project_task_list_bulk_delete,
evt_project_task_list_bulk_update_labels,
} from '@/shared/worklenz-analytics-events';
import {
IBulkTasksLabelsRequest,
IBulkTasksPhaseChangeRequest,
IBulkTasksPriorityChangeRequest,
IBulkTasksStatusChangeRequest,
} from '@/types/tasks/bulk-action-bar.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import { ITaskAssignee } from '@/types/tasks/task.types';
export const useBulkActions = () => {
const dispatch = useAppDispatch();
const { projectId } = useParams();
const { trackMixpanelEvent } = useMixpanelTracking();
const archived = useAppSelector(state => state.taskReducer.archived);
// Loading states for individual actions
const [loadingStates, setLoadingStates] = useState({
status: false,
priority: false,
phase: false,
assignToMe: false,
assignMembers: false,
labels: false,
archive: false,
delete: false,
duplicate: false,
export: false,
dueDate: false,
});
// Helper function to update loading state
const updateLoadingState = useCallback((action: keyof typeof loadingStates, loading: boolean) => {
setLoadingStates(prev => ({ ...prev, [action]: loading }));
}, []);
// Helper function to refetch tasks after bulk action
const refetchTasks = useCallback(() => {
if (projectId) {
dispatch(fetchTasksV3(projectId));
}
}, [dispatch, projectId]);
const handleClearSelection = useCallback(() => {
dispatch(clearSelection());
}, [dispatch]);
const handleBulkStatusChange = useCallback(async (statusId: string, selectedTaskIds: string[]) => {
if (!statusId || !projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('status', true);
// Check task dependencies before proceeding
for (const taskId of selectedTaskIds) {
const canContinue = await checkTaskDependencyStatus(taskId, statusId);
if (!canContinue) {
if (selectedTaskIds.length > 1) {
alertService.warning(
'Incomplete Dependencies!',
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
);
} else {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
}
return;
}
}
const body: IBulkTasksStatusChangeRequest = {
tasks: selectedTaskIds,
status_id: statusId,
};
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
dispatch(clearSelection());
refetchTasks();
}
} catch (error) {
logger.error('Error changing status:', error);
} finally {
updateLoadingState('status', false);
}
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
const handleBulkPriorityChange = useCallback(async (priorityId: string, selectedTaskIds: string[]) => {
if (!priorityId || !projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('priority', true);
const body: IBulkTasksPriorityChangeRequest = {
tasks: selectedTaskIds,
priority_id: priorityId,
};
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
dispatch(clearSelection());
refetchTasks();
}
} catch (error) {
logger.error('Error changing priority:', error);
} finally {
updateLoadingState('priority', false);
}
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
const handleBulkPhaseChange = useCallback(async (phaseId: string, selectedTaskIds: string[]) => {
if (!phaseId || !projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('phase', true);
const body: IBulkTasksPhaseChangeRequest = {
tasks: selectedTaskIds,
phase_id: phaseId,
};
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
dispatch(clearSelection());
refetchTasks();
}
} catch (error) {
logger.error('Error changing phase:', error);
} finally {
updateLoadingState('phase', false);
}
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
const handleBulkAssignToMe = useCallback(async (selectedTaskIds: string[]) => {
if (!projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('assignToMe', true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
dispatch(clearSelection());
refetchTasks();
}
} catch (error) {
logger.error('Error assigning to me:', error);
} finally {
updateLoadingState('assignToMe', false);
}
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
const handleBulkAssignMembers = useCallback(async (memberIds: string[], selectedTaskIds: string[]) => {
if (!projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('assignMembers', true);
// Convert memberIds to member objects - this would need to be handled by the component
// For now, we'll just pass the IDs and let the API handle it
const body = {
tasks: selectedTaskIds,
project_id: projectId,
members: memberIds.map(id => ({
id: id,
name: '',
team_member_id: id,
project_member_id: id,
})) as ITaskAssignee[],
};
const res = await taskListBulkActionsApiService.assignTasks(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
dispatch(clearSelection());
refetchTasks();
}
} catch (error) {
logger.error('Error assigning tasks:', error);
} finally {
updateLoadingState('assignMembers', false);
}
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
const handleBulkAddLabels = useCallback(async (labelIds: string[], selectedTaskIds: string[]) => {
if (!projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('labels', true);
// Convert labelIds to label objects - this would need to be handled by the component
// For now, we'll just pass the IDs and let the API handle it
const body: IBulkTasksLabelsRequest = {
tasks: selectedTaskIds,
labels: labelIds.map(id => ({ id, name: '', color: '' })) as ITaskLabel[],
text: null,
};
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
dispatch(clearSelection());
dispatch(fetchLabels()); // Refetch labels in case new ones were created
refetchTasks();
}
} catch (error) {
logger.error('Error updating labels:', error);
} finally {
updateLoadingState('labels', false);
}
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
const handleBulkArchive = useCallback(async (selectedTaskIds: string[]) => {
if (!projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('archive', true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_archive);
dispatch(clearSelection());
refetchTasks();
}
} catch (error) {
logger.error('Error archiving tasks:', error);
} finally {
updateLoadingState('archive', false);
}
}, [projectId, archived, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
const handleBulkDelete = useCallback(async (selectedTaskIds: string[]) => {
if (!projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('delete', true);
const body = {
tasks: selectedTaskIds,
project_id: projectId,
};
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_bulk_delete);
dispatch(clearSelection());
refetchTasks();
}
} catch (error) {
logger.error('Error deleting tasks:', error);
} finally {
updateLoadingState('delete', false);
}
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
const handleBulkDuplicate = useCallback(async (selectedTaskIds: string[]) => {
if (!projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('duplicate', true);
// TODO: Implement bulk duplicate API call when available
console.log('Bulk duplicate:', selectedTaskIds);
// For now, just clear selection and refetch
dispatch(clearSelection());
refetchTasks();
} catch (error) {
logger.error('Error duplicating tasks:', error);
} finally {
updateLoadingState('duplicate', false);
}
}, [projectId, dispatch, refetchTasks, updateLoadingState]);
const handleBulkExport = useCallback(async (selectedTaskIds: string[]) => {
if (!projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('export', true);
// TODO: Implement bulk export API call when available
console.log('Bulk export:', selectedTaskIds);
} catch (error) {
logger.error('Error exporting tasks:', error);
} finally {
updateLoadingState('export', false);
}
}, [projectId, updateLoadingState]);
const handleBulkSetDueDate = useCallback(async (date: string, selectedTaskIds: string[]) => {
if (!projectId || !selectedTaskIds.length) return;
try {
updateLoadingState('dueDate', true);
// TODO: Implement bulk set due date API call when available
console.log('Bulk set due date:', date, selectedTaskIds);
// For now, just clear selection and refetch
dispatch(clearSelection());
refetchTasks();
} catch (error) {
logger.error('Error setting due date:', error);
} finally {
updateLoadingState('dueDate', false);
}
}, [projectId, dispatch, refetchTasks, updateLoadingState]);
return {
handleClearSelection,
handleBulkStatusChange,
handleBulkPriorityChange,
handleBulkPhaseChange,
handleBulkAssignToMe,
handleBulkAssignMembers,
handleBulkAddLabels,
handleBulkArchive,
handleBulkDelete,
handleBulkDuplicate,
handleBulkExport,
handleBulkSetDueDate,
loadingStates,
};
};

View File

@@ -0,0 +1,176 @@
import { useState, useCallback } from 'react';
import { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { reorderTasksInGroup, moveTaskBetweenGroups } from '@/features/task-management/task-management.slice';
import { Task, TaskGroup } from '@/types/task-management.types';
export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const dispatch = useAppDispatch();
const [activeId, setActiveId] = useState<string | null>(null);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id as string);
}, []);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) return;
// Check if we're dragging over a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
let targetGroup = overGroup;
if (overTask) {
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
}
if (!activeGroup || !targetGroup) return;
// If dragging to a different group, we need to handle cross-group movement
if (activeGroup.id !== targetGroup.id) {
console.log('Cross-group drag detected:', {
activeTask: activeTask.id,
fromGroup: activeGroup.id,
toGroup: targetGroup.id,
});
}
},
[allTasks, groups]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) {
return;
}
const activeId = active.id;
const overId = over.id;
// Find the active task
const activeTask = allTasks.find(task => task.id === activeId);
if (!activeTask) {
console.error('Active task not found:', activeId);
return;
}
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
if (!activeGroup) {
console.error('Could not find active group for task:', activeId);
return;
}
// Check if we're dropping on a task or a group
const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId);
let targetGroup = overGroup;
let insertIndex = 0;
if (overTask) {
// Dropping on a task
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (targetGroup) {
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
}
} else if (overGroup) {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
}
if (!targetGroup) {
console.error('Could not find target group');
return;
}
const isCrossGroup = activeGroup.id !== targetGroup.id;
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
console.log('Drag operation:', {
activeId,
overId,
activeTask: activeTask.name || activeTask.title,
activeGroup: activeGroup.id,
targetGroup: targetGroup.id,
activeIndex,
insertIndex,
isCrossGroup,
});
if (isCrossGroup) {
// Moving task between groups
console.log('Moving task between groups:', {
task: activeTask.name || activeTask.title,
from: activeGroup.title,
to: targetGroup.title,
newPosition: insertIndex,
});
// Move task to the target group
dispatch(
moveTaskBetweenGroups({
taskId: activeId as string,
sourceGroupId: activeGroup.id,
targetGroupId: targetGroup.id,
})
);
// Reorder task within target group at drop position
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: targetGroup.id,
})
);
} else {
// Reordering within the same group
console.log('Reordering task within same group:', {
task: activeTask.name || activeTask.title,
group: activeGroup.title,
from: activeIndex,
to: insertIndex,
});
if (activeIndex !== insertIndex) {
// Reorder task within same group at drop position
dispatch(
reorderTasksInGroup({
sourceTaskId: activeId as string,
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: activeGroup.id,
})
);
}
}
},
[allTasks, groups, dispatch]
);
return {
activeId,
handleDragStart,
handleDragOver,
handleDragEnd,
};
};

View File

@@ -26,6 +26,9 @@ import { toggleField } from '@/features/task-management/taskListFields.slice';
import {
fetchTasksV3,
setSearch as setTaskManagementSearch,
setArchived as setTaskManagementArchived,
toggleArchived as toggleTaskManagementArchived,
selectArchived,
} from '@/features/task-management/task-management.slice';
import {
setCurrentGrouping,
@@ -443,11 +446,11 @@ const FilterDropdown: React.FC<{
${
selectedCount > 0
? isDarkMode
? 'bg-blue-600 text-white border-blue-500'
? 'bg-gray-600 text-white border-gray-500'
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
}
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
`}
aria-expanded={isOpen}
@@ -456,7 +459,7 @@ const FilterDropdown: React.FC<{
<IconComponent className="w-3.5 h-3.5" />
<span>{section.label}</span>
{selectedCount > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full">
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-gray-500 rounded-full">
{selectedCount}
</span>
)}
@@ -518,7 +521,7 @@ const FilterDropdown: React.FC<{
${
isSelected
? isDarkMode
? 'bg-blue-600 text-white'
? 'bg-gray-600 text-white'
: 'bg-blue-50 text-blue-800 font-semibold'
: `${themeClasses.optionText} ${themeClasses.optionHover}`
}
@@ -530,7 +533,7 @@ const FilterDropdown: React.FC<{
flex items-center justify-center w-3.5 h-3.5 border rounded
${
isSelected
? 'bg-blue-500 border-blue-500 text-white'
? 'bg-gray-600 border-gray-800 text-white'
: 'border-gray-300 dark:border-gray-600'
}
`}
@@ -730,7 +733,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
${
visibleCount > 0
? isDarkMode
? 'bg-blue-600 text-white border-blue-500'
? 'bg-gray-600 text-white border-gray-500'
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
}
@@ -743,7 +746,9 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
<EyeOutlined className="w-3.5 h-3.5" />
<span>Fields</span>
{visibleCount > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full">
<span
className={`inline-flex items-center justify-center w-4 h-4 text-xs font-bold ${isDarkMode ? 'text-white bg-gray-500' : 'text-gray-800 bg-gray-300'} rounded-full`}
>
{visibleCount}
</span>
)}
@@ -778,8 +783,8 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
${
isSelected
? isDarkMode
? 'bg-blue-600 text-white'
: 'bg-blue-50 text-blue-800 font-semibold'
? 'text-white font-semibold'
: 'text-gray-800 font-semibold'
: `${themeClasses.optionText} ${themeClasses.optionHover}`
}
`}
@@ -790,7 +795,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
flex items-center justify-center w-3.5 h-3.5 border rounded
${
isSelected
? 'bg-blue-500 border-blue-500 text-white'
? 'bg-gray-600 border-gray-600 text-white'
: 'border-gray-300 dark:border-gray-600'
}
`}
@@ -826,13 +831,17 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
// Enhanced Kanban state
const kanbanState = useAppSelector((state: RootState) => state.enhancedKanbanReducer);
// Get archived state from the appropriate slice based on position
const taskManagementArchived = useAppSelector(selectArchived);
const taskReducerArchived = useAppSelector(state => state.taskReducer.archived);
const showArchived = position === 'list' ? taskManagementArchived : taskReducerArchived;
// Use the filter data loader hook
useFilterDataLoader();
// Local state for filter sections
const [filterSections, setFilterSections] = useState<FilterSection[]>([]);
const [searchValue, setSearchValue] = useState('');
const [showArchived, setShowArchived] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
const [clearingFilters, setClearingFilters] = useState(false);
@@ -1077,7 +1086,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
const batchUpdates = () => {
// Clear local state immediately for UI feedback
setSearchValue('');
setShowArchived(false);
// Update local filter sections state immediately
setFilterSections(prev =>
@@ -1116,6 +1124,13 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
// Clear priority filters
dispatch(setPriorities([]));
// Clear archived state based on position
if (position === 'list') {
dispatch(setTaskManagementArchived(false));
} else {
dispatch(setKanbanArchived(false));
}
};
// Execute Redux updates
@@ -1137,14 +1152,17 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
}, [projectId, projectView, dispatch, currentTaskLabels, currentTaskAssignees, clearingFilters]);
const toggleArchived = useCallback(() => {
setShowArchived(!showArchived);
if (position === 'board') {
dispatch(setKanbanArchived(!showArchived));
if (projectId) {
dispatch(fetchEnhancedKanbanGroups(projectId));
}
} else {
// ... existing logic ...
// For TaskListV2, use the task management slice
dispatch(toggleTaskManagementArchived());
if (projectId) {
dispatch(fetchTasksV3(projectId));
}
}
}, [dispatch, projectId, position, showArchived]);

View File

@@ -18,43 +18,21 @@ import { Card, Spin, Empty, Alert } from 'antd';
import { RootState } from '@/app/store';
import {
selectAllTasks,
selectGroups,
selectGrouping,
selectLoading,
selectError,
selectSelectedPriorities,
selectSearch,
reorderTasks,
moveTaskToGroup,
moveTaskBetweenGroups,
optimisticTaskMove,
reorderTasksInGroup,
setLoading,
setError,
setSelectedPriorities,
setSearch,
resetTaskManagement,
toggleTaskExpansion,
addSubtaskToParent,
fetchTasksV3,
selectTaskGroupsV3,
fetchSubTasks,
} from '@/features/task-management/task-management.slice';
import {
selectCurrentGrouping,
selectCollapsedGroups,
selectIsGroupCollapsed,
toggleGroupCollapsed,
expandAllGroups,
collapseAllGroups,
} from '@/features/task-management/grouping.slice';
import {
selectSelectedTaskIds,
selectLastSelectedTaskId,
selectIsTaskSelected,
selectTask,
deselectTask,
toggleTaskSelection,
selectRange,
clearSelection,
selectTask,
} from '@/features/task-management/selection.slice';
import {
selectTasks,
@@ -89,18 +67,11 @@ import {
IBulkTasksPriorityChangeRequest,
IBulkTasksStatusChangeRequest,
} from '@/types/tasks/bulk-action-bar.types';
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import alertService from '@/services/alerts/alertService';
import logger from '@/utils/errorLogger';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { performanceMonitor } from '@/utils/performance-monitor';
import debugPerformance from '@/utils/debug-performance';
// Import the improved TaskListFilters component synchronously to avoid suspense
import ImprovedTaskFilters from './improved-task-filters';
@@ -173,18 +144,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
// Redux selectors using V3 API (pre-processed data, minimal loops)
const tasks = useSelector(selectAllTasks);
const groups = useSelector(selectGroups);
const grouping = useSelector(selectGrouping);
const loading = useSelector(selectLoading);
const error = useSelector(selectError);
const selectedPriorities = useSelector(selectSelectedPriorities);
const searchQuery = useSelector(selectSearch);
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
const currentGrouping = useSelector(selectCurrentGrouping);
const collapsedGroups = useSelector(selectCollapsedGroups);
const selectedTaskIds = useSelector(selectSelectedTaskIds);
const lastSelectedTaskId = useSelector(selectLastSelectedTaskId);
const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks);
// Bulk action selectors
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
@@ -202,9 +166,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
const tasksById = useMemo(() => {
const map: Record<string, Task> = {};
// Cache all tasks for full functionality - performance optimizations are handled at the virtualization level
tasks.forEach(task => {
map[task.id] = task;
});
if (Array.isArray(tasks)) {
tasks.forEach((task: Task) => {
map[task.id] = task;
});
}
return map;
}, [tasks]);
@@ -262,14 +228,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
const hasAnyTasks = useMemo(() => totalTasks > 0, [totalTasks]);
// Memoized handlers for better performance
const handleGroupingChange = useCallback(
(newGroupBy: 'status' | 'priority' | 'phase') => {
dispatch(setCurrentGrouping(newGroupBy));
},
[dispatch]
);
// Add isDragging state
const [isDragging, setIsDragging] = useState(false);
@@ -280,7 +238,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
const taskId = active.id as string;
// Find the task and its group
const activeTask = tasks.find(t => t.id === taskId) || null;
const activeTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === taskId) || null : null;
let activeGroupId: string | null = null;
if (activeTask) {
@@ -312,7 +270,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
const overId = over.id as string;
// Check if we're hovering over a task or a group container
const targetTask = tasks.find(t => t.id === overId);
const targetTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === overId) : undefined;
let targetGroupId = overId;
if (targetTask) {
@@ -362,7 +320,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
let targetIndex = -1;
// Check if dropping on a task or a group
const targetTask = tasks.find(t => t.id === overId);
const targetTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === overId) : undefined;
if (targetTask) {
// Dropping on a task, find which group contains this task
for (const group of taskGroups) {
@@ -398,13 +356,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
// Use the new reorderTasksInGroup action that properly handles group arrays
dispatch(
reorderTasksInGroup({
taskId: activeTaskId,
fromGroupId: currentDragState.activeGroupId,
toGroupId: targetGroupId,
fromIndex: sourceIndex,
toIndex: finalTargetIndex,
groupType: targetGroup.groupType,
groupValue: targetGroup.groupValue,
sourceTaskId: activeTaskId,
destinationTaskId: targetTask?.id || '',
sourceGroupId: currentDragState.activeGroupId,
destinationGroupId: targetGroupId,
})
);
@@ -448,10 +403,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
const newSelectedIds = Array.from(currentSelectedIds);
// Map selected tasks to the required format
const newSelectedTasks = tasks
.filter((t) => newSelectedIds.includes(t.id))
const newSelectedTasks = Array.isArray(tasks) ? tasks
.filter((t: Task) => newSelectedIds.includes(t.id))
.map(
(task): IProjectTask => ({
(task: Task): IProjectTask => ({
id: task.id,
name: task.title,
task_key: task.task_key,
@@ -463,11 +418,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
description: task.description,
start_date: task.startDate,
end_date: task.dueDate,
total_hours: task.timeTracking.estimated || 0,
total_minutes: task.timeTracking.logged || 0,
total_hours: task.timeTracking?.estimated || 0,
total_minutes: task.timeTracking?.logged || 0,
progress: task.progress,
sub_tasks_count: task.sub_tasks_count || 0,
assignees: task.assignees.map((assigneeId) => ({
assignees: task.assignees?.map((assigneeId: string) => ({
id: assigneeId,
name: '',
email: '',
@@ -477,15 +432,16 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
})),
labels: task.labels,
manual_progress: false,
created_at: task.createdAt,
updated_at: task.updatedAt,
created_at: (task as any).createdAt || (task as any).created_at,
updated_at: (task as any).updatedAt || (task as any).updated_at,
sort_order: task.order,
})
);
) : [];
// Dispatch both actions to update the Redux state
dispatch(selectTasks(newSelectedTasks));
dispatch(selectTaskIds(newSelectedIds));
// Update selection state with the new task IDs
newSelectedIds.forEach(taskId => dispatch(selectTask(taskId)));
},
[dispatch, selectedTaskIds, tasks]
);