feat(task-management): enhance task assignment handling and UI feedback in AssigneeSelector
- Introduced optimistic updates for assignee selection to improve UI responsiveness. - Updated AssigneeSelector to initialize optimistic assignees from task data on mount. - Refactored task assignment logic to ensure unique assignee IDs and improved state management. - Enhanced TaskGroupHeader and TaskListV2 to support bulk actions and selection state. - Integrated a new bulk action bar for managing selected tasks efficiently.
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { Checkbox } from 'antd';
|
||||
import { getContrastColor } from '@/utils/colorUtils';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice';
|
||||
import { selectGroups } from '@/features/task-management/task-management.slice';
|
||||
|
||||
interface TaskGroupHeaderProps {
|
||||
group: {
|
||||
@@ -15,9 +20,52 @@ interface TaskGroupHeaderProps {
|
||||
}
|
||||
|
||||
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
||||
const groups = useAppSelector(selectGroups);
|
||||
|
||||
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
|
||||
const headerTextColor = getContrastColor(headerBackgroundColor);
|
||||
|
||||
// Get tasks in this group
|
||||
const currentGroup = useMemo(() => {
|
||||
return groups.find(g => g.id === group.id);
|
||||
}, [groups, group.id]);
|
||||
|
||||
const tasksInGroup = useMemo(() => {
|
||||
return currentGroup?.taskIds || [];
|
||||
}, [currentGroup]);
|
||||
|
||||
// Calculate selection state for this group
|
||||
const { isAllSelected, isPartiallySelected } = useMemo(() => {
|
||||
if (tasksInGroup.length === 0) {
|
||||
return { isAllSelected: false, isPartiallySelected: false };
|
||||
}
|
||||
|
||||
const selectedTasksInGroup = tasksInGroup.filter(taskId => selectedTaskIds.includes(taskId));
|
||||
const allSelected = selectedTasksInGroup.length === tasksInGroup.length;
|
||||
const partiallySelected = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < tasksInGroup.length;
|
||||
|
||||
return { isAllSelected: allSelected, isPartiallySelected: partiallySelected };
|
||||
}, [tasksInGroup, selectedTaskIds]);
|
||||
|
||||
// Handle select all checkbox change
|
||||
const handleSelectAllChange = useCallback((e: any) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (isAllSelected) {
|
||||
// Deselect all tasks in this group
|
||||
tasksInGroup.forEach(taskId => {
|
||||
dispatch(deselectTask(taskId));
|
||||
});
|
||||
} else {
|
||||
// Select all tasks in this group
|
||||
tasksInGroup.forEach(taskId => {
|
||||
dispatch(selectTask(taskId));
|
||||
});
|
||||
}
|
||||
}, [dispatch, isAllSelected, tasksInGroup]);
|
||||
|
||||
// Make the group header droppable
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: group.id,
|
||||
@@ -42,21 +90,37 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
}}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Chevron button */}
|
||||
<button
|
||||
className="p-1 rounded-md hover:bg-opacity-20 transition-colors"
|
||||
style={{ backgroundColor: headerBackgroundColor, color: headerTextColor, borderColor: headerTextColor, border: '1px solid' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||
)}
|
||||
</button>
|
||||
{/* Drag Handle Space */}
|
||||
<div style={{ width: '32px' }} 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' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Select All Checkbox Space */}
|
||||
<div style={{ width: '40px' }} className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
onChange={handleSelectAllChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
color: headerTextColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group indicator and name */}
|
||||
<div className="ml-2 flex items-center gap-3 flex-1">
|
||||
|
||||
@@ -54,6 +54,7 @@ import { RootState } from '@/app/store';
|
||||
import { TaskListField } from '@/types/task-list-field.types';
|
||||
import { 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 { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||
@@ -61,6 +62,7 @@ import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||
// 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: '300px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
|
||||
@@ -86,6 +88,7 @@ type ColumnStyle = {
|
||||
left?: number;
|
||||
backgroundColor?: string;
|
||||
zIndex?: number;
|
||||
flexShrink?: number;
|
||||
};
|
||||
|
||||
interface TaskListV2Props {
|
||||
@@ -336,6 +339,66 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
|
||||
}, [allTasks, groups]);
|
||||
|
||||
// Bulk action handlers
|
||||
const handleClearSelection = useCallback(() => {
|
||||
dispatch(clearSelection());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleBulkStatusChange = useCallback(async (statusId: string) => {
|
||||
// TODO: Implement bulk status change
|
||||
console.log('Bulk status change:', statusId);
|
||||
}, []);
|
||||
|
||||
const handleBulkPriorityChange = useCallback(async (priorityId: string) => {
|
||||
// TODO: Implement bulk priority change
|
||||
console.log('Bulk priority change:', priorityId);
|
||||
}, []);
|
||||
|
||||
const handleBulkPhaseChange = useCallback(async (phaseId: string) => {
|
||||
// TODO: Implement bulk phase change
|
||||
console.log('Bulk phase change:', phaseId);
|
||||
}, []);
|
||||
|
||||
const handleBulkAssignToMe = useCallback(async () => {
|
||||
// TODO: Implement bulk assign to me
|
||||
console.log('Bulk assign to me');
|
||||
}, []);
|
||||
|
||||
const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => {
|
||||
// TODO: Implement bulk assign members
|
||||
console.log('Bulk assign members:', memberIds);
|
||||
}, []);
|
||||
|
||||
const handleBulkAddLabels = useCallback(async (labelIds: string[]) => {
|
||||
// TODO: Implement bulk add labels
|
||||
console.log('Bulk add labels:', labelIds);
|
||||
}, []);
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
// TODO: Implement bulk archive
|
||||
console.log('Bulk archive');
|
||||
}, []);
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
// TODO: Implement bulk delete
|
||||
console.log('Bulk delete');
|
||||
}, []);
|
||||
|
||||
const handleBulkDuplicate = useCallback(async () => {
|
||||
// TODO: Implement bulk duplicate
|
||||
console.log('Bulk duplicate');
|
||||
}, []);
|
||||
|
||||
const handleBulkExport = useCallback(async () => {
|
||||
// TODO: Implement bulk export
|
||||
console.log('Bulk export');
|
||||
}, []);
|
||||
|
||||
const handleBulkSetDueDate = useCallback(async (date: string) => {
|
||||
// TODO: Implement bulk set due date
|
||||
console.log('Bulk set due date:', date);
|
||||
}, []);
|
||||
|
||||
// Memoized values for GroupedVirtuoso
|
||||
const virtuosoGroups = useMemo(() => {
|
||||
let currentTaskIndex = 0;
|
||||
@@ -375,10 +438,11 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
|
||||
// Memoize column headers to prevent unnecessary re-renders
|
||||
const columnHeaders = useMemo(() => (
|
||||
<div className="flex items-center min-w-max px-4 py-2">
|
||||
<div className="flex items-center px-4 py-2" style={{ minWidth: 'max-content' }}>
|
||||
{visibleColumns.map((column) => {
|
||||
const columnStyle: ColumnStyle = {
|
||||
width: column.width,
|
||||
flexShrink: 0, // Prevent columns from shrinking
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -389,6 +453,8 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
>
|
||||
{column.id === 'dragHandle' ? (
|
||||
<HolderOutlined className="text-gray-400" />
|
||||
) : column.id === 'checkbox' ? (
|
||||
<span></span> // Empty for checkbox column header
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
@@ -430,7 +496,7 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
if (!task) return null; // Should not happen if logic is correct
|
||||
return (
|
||||
<TaskRow
|
||||
task={task}
|
||||
taskId={task.id}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
);
|
||||
@@ -453,37 +519,39 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
<ImprovedTaskFilters position="list" />
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
{columnHeaders}
|
||||
</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>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SortableContext
|
||||
items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<GroupedVirtuoso
|
||||
style={{ height: 'calc(100vh - 200px)' }}
|
||||
groupCounts={virtuosoGroupCounts}
|
||||
groupContent={renderGroup}
|
||||
itemContent={renderTask}
|
||||
components={{
|
||||
// Removed custom Group component as TaskGroupHeader now handles stickiness
|
||||
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style || {}}
|
||||
className="virtuoso-list-container" // Add a class for potential debugging/styling
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
}}
|
||||
/>
|
||||
</SortableContext>
|
||||
{/* Task List - Scrollable content */}
|
||||
<div className="flex-1">
|
||||
<SortableContext
|
||||
items={virtuosoItems.map(task => task.id).filter((id): id is string => id !== undefined)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<GroupedVirtuoso
|
||||
style={{ height: 'calc(100vh - 200px)' }}
|
||||
groupCounts={virtuosoGroupCounts}
|
||||
groupContent={renderGroup}
|
||||
itemContent={renderTask}
|
||||
components={{
|
||||
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
style={style || {}}
|
||||
className="virtuoso-list-container"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
}}
|
||||
/>
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -509,6 +577,27 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{selectedTaskIds.length > 0 && (
|
||||
<OptimizedBulkActionBar
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
totalSelected={selectedTaskIds.length}
|
||||
projectId={projectId}
|
||||
onClearSelection={handleClearSelection}
|
||||
onBulkStatusChange={handleBulkStatusChange}
|
||||
onBulkPriorityChange={handleBulkPriorityChange}
|
||||
onBulkPhaseChange={handleBulkPhaseChange}
|
||||
onBulkAssignToMe={handleBulkAssignToMe}
|
||||
onBulkAssignMembers={handleBulkAssignMembers}
|
||||
onBulkAddLabels={handleBulkAddLabels}
|
||||
onBulkArchive={handleBulkArchive}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkDuplicate={handleBulkDuplicate}
|
||||
onBulkExport={handleBulkExport}
|
||||
onBulkSetDueDate={handleBulkSetDueDate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { CheckCircleOutlined, HolderOutlined } from '@ant-design/icons';
|
||||
import { Checkbox } from 'antd';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
import Avatar from '@/components/Avatar';
|
||||
@@ -13,9 +14,12 @@ import AvatarGroup from '../AvatarGroup';
|
||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { selectTaskById } from '@/features/task-management/task-management.slice';
|
||||
import { selectIsTaskSelected, toggleTaskSelection } from '@/features/task-management/selection.slice';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: Task;
|
||||
taskId: string;
|
||||
visibleColumns: Array<{
|
||||
id: string;
|
||||
width: string;
|
||||
@@ -43,7 +47,15 @@ const formatDate = (dateString: string): string => {
|
||||
|
||||
// Memoized date formatter to avoid repeated date parsing
|
||||
|
||||
const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
|
||||
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, visibleColumns }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
||||
|
||||
if (!task) {
|
||||
return null; // Don't render if task is not found in store
|
||||
}
|
||||
|
||||
// Drag and drop functionality
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id,
|
||||
@@ -111,6 +123,17 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
|
||||
[task.updatedAt]
|
||||
);
|
||||
|
||||
// Debugging: Log assignee_names whenever the task prop changes
|
||||
React.useEffect(() => {
|
||||
console.log(`Task ${task.id} assignees:`, task.assignee_names);
|
||||
}, [task.id, task.assignee_names]);
|
||||
|
||||
// Handle checkbox change
|
||||
const handleCheckboxChange = useCallback((e: any) => {
|
||||
e.stopPropagation(); // Prevent row click when clicking checkbox
|
||||
dispatch(toggleTaskSelection(taskId));
|
||||
}, [dispatch, taskId]);
|
||||
|
||||
// Memoize status style
|
||||
const statusStyle = useMemo(() => ({
|
||||
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
|
||||
@@ -152,6 +175,17 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={baseStyle}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={handleCheckboxChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'taskKey':
|
||||
return (
|
||||
<div className="flex items-center" style={baseStyle}>
|
||||
@@ -383,13 +417,15 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ task, visibleColumns }) => {
|
||||
labelsDisplay,
|
||||
isDarkMode,
|
||||
convertedTask,
|
||||
isSelected,
|
||||
handleCheckboxChange,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
className={`flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||
}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user