Files
worklenz/worklenz-frontend/src/components/task-management/task-group.tsx
chamikaJ 2064c0833c feat(task-management): enhance task details and subtask handling
- Added subtask-related properties to the Task interface for better management of subtasks.
- Implemented functionality to show and add subtasks directly within the task list, improving user interaction.
- Updated task rendering logic to accommodate new subtask features, enhancing overall task management experience.
- Removed unused components and optimized imports across various task management files for cleaner code.
2025-07-02 12:38:24 +05:30

642 lines
22 KiB
TypeScript

import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useSelector } from 'react-redux';
import {
Button,
Typography,
taskManagementAntdConfig,
PlusOutlined,
RightOutlined,
DownOutlined
} from './antd-imports';
import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types';
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
import { RootState } from '@/app/store';
import TaskRow from './task-row';
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
import { TaskListField } from '@/features/task-management/taskListFields.slice';
import { Checkbox } from '@/components';
const { Text } = Typography;
interface TaskGroupProps {
group: TaskGroupType;
projectId: string;
currentGrouping: 'status' | 'priority' | 'phase';
selectedTaskIds: string[];
onAddTask?: (groupId: string) => void;
onToggleCollapse?: (groupId: string) => void;
onSelectTask?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
}
// Group color mapping - moved outside component for better performance
const GROUP_COLORS = {
status: {
todo: '#faad14',
doing: '#1890ff',
done: '#52c41a',
},
priority: {
high: '#fa8c16',
medium: '#faad14',
low: '#52c41a',
},
phase: '#722ed1',
default: '#d9d9d9',
} as const;
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
group,
projectId,
currentGrouping,
selectedTaskIds,
onAddTask,
onToggleCollapse,
onSelectTask,
onToggleSubtasks,
}) => {
const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false);
const { setNodeRef, isOver } = useDroppable({
id: group.id,
data: {
type: 'group',
groupId: group.id,
},
});
// Get all tasks from the store
const allTasks = useSelector(taskManagementSelectors.selectAll);
// Get theme from Redux store
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Get field visibility from taskListFields slice
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
// Define all possible columns
const allFixedColumns = [
{ key: 'drag', label: '', width: 40, alwaysVisible: true },
{ key: 'select', label: '', width: 40, alwaysVisible: true },
{ key: 'key', label: 'KEY', width: 80, fieldKey: 'KEY' },
{ key: 'task', label: 'TASK', width: 220, alwaysVisible: true },
];
const allScrollableColumns = [
{ key: 'description', label: 'Description', width: 200, fieldKey: 'DESCRIPTION' },
{ key: 'progress', label: 'Progress', width: 90, fieldKey: 'PROGRESS' },
{ key: 'status', label: 'Status', width: 100, fieldKey: 'STATUS' },
{ key: 'members', label: 'Members', width: 150, fieldKey: 'ASSIGNEES' },
{ key: 'labels', label: 'Labels', width: 200, fieldKey: 'LABELS' },
{ key: 'phase', label: 'Phase', width: 100, fieldKey: 'PHASE' },
{ key: 'priority', label: 'Priority', width: 100, fieldKey: 'PRIORITY' },
{ key: 'timeTracking', label: 'Time Tracking', width: 120, fieldKey: 'TIME_TRACKING' },
{ key: 'estimation', label: 'Estimation', width: 100, fieldKey: 'ESTIMATION' },
{ key: 'startDate', label: 'Start Date', width: 120, fieldKey: 'START_DATE' },
{ key: 'dueDate', label: 'Due Date', width: 120, fieldKey: 'DUE_DATE' },
{ key: 'dueTime', label: 'Due Time', width: 100, fieldKey: 'DUE_TIME' },
{ key: 'completedDate', label: 'Completed Date', width: 130, fieldKey: 'COMPLETED_DATE' },
{ key: 'createdDate', label: 'Created Date', width: 120, fieldKey: 'CREATED_DATE' },
{ key: 'lastUpdated', label: 'Last Updated', width: 130, fieldKey: 'LAST_UPDATED' },
{ key: 'reporter', label: 'Reporter', width: 100, fieldKey: 'REPORTER' },
];
// Filter columns based on field visibility
const visibleFixedColumns = useMemo(() => {
return allFixedColumns.filter(col => {
// Always show columns marked as alwaysVisible
if (col.alwaysVisible) return true;
// For other columns, check field visibility
if (col.fieldKey) {
const field = taskListFields.find(f => f.key === col.fieldKey);
return field?.visible ?? false;
}
return false;
});
}, [taskListFields, allFixedColumns]);
const visibleScrollableColumns = useMemo(() => {
return allScrollableColumns.filter(col => {
// For scrollable columns, check field visibility
if (col.fieldKey) {
const field = taskListFields.find(f => f.key === col.fieldKey);
return field?.visible ?? false;
}
return false;
});
}, [taskListFields, allScrollableColumns]);
// Get tasks for this group using memoization for performance
const groupTasks = useMemo(() => {
return group.taskIds
.map(taskId => allTasks.find(task => task.id === taskId))
.filter((task): task is Task => task !== undefined);
}, [group.taskIds, allTasks]);
// Calculate group statistics - memoized
const { completedTasks, totalTasks, completionRate } = useMemo(() => {
const completed = groupTasks.filter(task => task.progress === 100).length;
const total = groupTasks.length;
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
return {
completedTasks: completed,
totalTasks: total,
completionRate: rate,
};
}, [groupTasks]);
// Calculate selection state for the group checkbox
const { isAllSelected, isIndeterminate } = useMemo(() => {
if (groupTasks.length === 0) {
return { isAllSelected: false, isIndeterminate: false };
}
const selectedTasksInGroup = groupTasks.filter(task => selectedTaskIds.includes(task.id));
const isAllSelected = selectedTasksInGroup.length === groupTasks.length;
const isIndeterminate = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < groupTasks.length;
return { isAllSelected, isIndeterminate };
}, [groupTasks, selectedTaskIds]);
// Get group color based on grouping type - memoized
const groupColor = useMemo(() => {
if (group.color) return group.color;
// Fallback colors based on group value
switch (currentGrouping) {
case 'status':
return GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] || GROUP_COLORS.default;
case 'priority':
return GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] || GROUP_COLORS.default;
case 'phase':
return GROUP_COLORS.phase;
default:
return GROUP_COLORS.default;
}
}, [group.color, group.groupValue, currentGrouping]);
// Memoized event handlers
const handleToggleCollapse = useCallback(() => {
setIsCollapsed(!isCollapsed);
onToggleCollapse?.(group.id);
}, [isCollapsed, onToggleCollapse, group.id]);
const handleAddTask = useCallback(() => {
onAddTask?.(group.id);
}, [onAddTask, group.id]);
// Handle select all tasks in group
const handleSelectAllInGroup = useCallback((checked: boolean) => {
if (checked) {
// Select all tasks in the group
groupTasks.forEach(task => {
if (!selectedTaskIds.includes(task.id)) {
onSelectTask?.(task.id, true);
}
});
} else {
// Deselect all tasks in the group
groupTasks.forEach(task => {
if (selectedTaskIds.includes(task.id)) {
onSelectTask?.(task.id, false);
}
});
}
}, [groupTasks, selectedTaskIds, onSelectTask]);
// Memoized style object
const containerStyle = useMemo(() => ({
backgroundColor: isOver
? (isDarkMode ? '#1a2332' : '#f0f8ff')
: undefined,
}), [isOver, isDarkMode]);
return (
<div
className={`task-group`}
style={{ ...containerStyle, overflowX: 'unset' }}
>
<div className="task-group-scroll-wrapper" style={{ overflowX: 'auto', width: '100%' }}>
<div style={{ minWidth: visibleFixedColumns.reduce((sum, col) => sum + col.width, 0) + visibleScrollableColumns.reduce((sum, col) => sum + col.width, 0) }}>
{/* Group Header Row */}
<div className="task-group-header">
<div className="task-group-header-row">
<div
className="task-group-header-content"
style={{ backgroundColor: groupColor }}
>
<Button
{...taskManagementAntdConfig.taskButtonDefaults}
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
onClick={handleToggleCollapse}
className="task-group-header-button"
/>
<Text strong className="task-group-header-text">
{group.title} ({totalTasks})
</Text>
</div>
</div>
</div>
{/* Column Headers */}
{!isCollapsed && totalTasks > 0 && (
<div
className="task-group-column-headers"
style={{ borderLeft: `4px solid ${groupColor}` }}
>
<div className="task-group-column-headers-row">
<div className="task-table-fixed-columns">
{visibleFixedColumns.map(col => (
<div
key={col.key}
className="task-table-cell task-table-header-cell"
style={{ width: col.width }}
>
{col.key === 'select' ? (
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isAllSelected}
onChange={handleSelectAllInGroup}
isDarkMode={isDarkMode}
indeterminate={isIndeterminate}
/>
</div>
) : (
col.label && <Text className="column-header-text">{col.label}</Text>
)}
</div>
))}
</div>
<div className="task-table-scrollable-columns">
{visibleScrollableColumns.map(col => (
<div
key={col.key}
className="task-table-cell task-table-header-cell"
style={{ width: col.width }}
>
<Text className="column-header-text">{col.label}</Text>
</div>
))}
</div>
</div>
</div>
)}
{/* Tasks List */}
{!isCollapsed && (
<div
className="task-group-body"
style={{ borderLeft: `4px solid ${groupColor}` }}
>
{groupTasks.length === 0 ? (
<div className="task-group-empty">
<div className="task-table-fixed-columns">
<div style={{ width: '380px', padding: '20px 12px' }}>
<div className="text-center text-gray-500">
<Text type="secondary">No tasks in this group</Text>
<br />
<Button
{...taskManagementAntdConfig.taskButtonDefaults}
icon={<PlusOutlined />}
onClick={handleAddTask}
className="mt-2"
>
Add first task
</Button>
</div>
</div>
</div>
</div>
) : (
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
<div className="task-group-tasks">
{groupTasks.map((task, index) => (
<TaskRow
key={task.id}
task={task}
projectId={projectId}
groupId={group.id}
currentGrouping={currentGrouping}
isSelected={selectedTaskIds.includes(task.id)}
index={index}
onSelect={onSelectTask}
onToggleSubtasks={onToggleSubtasks}
fixedColumns={visibleFixedColumns}
scrollableColumns={visibleScrollableColumns}
/>
))}
</div>
</SortableContext>
)}
{/* Add Task Row - Always show when not collapsed */}
<div className="task-group-add-task">
<AddTaskListRow groupId={group.id} />
</div>
</div>
)}
</div>
</div>
<style>{`
.task-group {
border: 1px solid var(--task-border-primary, #e8e8e8);
border-radius: 8px;
margin-bottom: 16px;
background: var(--task-bg-primary, white);
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
overflow-y: visible;
transition: all 0.3s ease;
position: relative;
}
.task-group:last-child {
margin-bottom: 0;
}
.task-group-header {
background: var(--task-bg-primary, white);
transition: background-color 0.3s ease;
}
.task-group-header-row {
display: flex;
height: auto;
max-height: none;
overflow: hidden;
}
.task-group-header-content {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 6px 6px 0 0;
background-color: #f0f0f0;
color: white;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.task-group-header-button {
color: white !important;
padding: 0 !important;
width: 16px !important;
height: 16px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin-right: 8px !important;
border: none !important;
background: transparent !important;
}
.task-group-header-button:hover {
background: rgba(255, 255, 255, 0.2) !important;
border-radius: 2px !important;
}
.task-group-header-text {
color: white !important;
font-size: 14px !important;
font-weight: 600 !important;
margin: 0 !important;
}
.task-group-progress {
display: flex;
height: 20px;
align-items: center;
background: var(--task-bg-tertiary, #f8f9fa);
border-bottom: 1px solid var(--task-border-primary, #e8e8e8);
transition: background-color 0.3s ease;
}
.task-group-column-headers {
background: var(--task-bg-secondary, #f5f5f5);
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
transition: background-color 0.3s ease;
}
.task-group-column-headers-row {
display: flex;
height: 40px;
max-height: 40px;
overflow: visible;
position: relative;
}
.task-table-header-cell {
background: var(--task-bg-secondary, #f5f5f5);
font-weight: 600;
color: var(--task-text-secondary, #595959);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
height: 32px;
max-height: 32px;
overflow: hidden;
transition: all 0.3s ease;
}
.column-header-text {
font-size: 11px;
font-weight: 600;
color: var(--task-text-secondary, #595959);
text-transform: uppercase;
letter-spacing: 0.5px;
transition: color 0.3s ease;
}
.task-group-body {
background: var(--task-bg-primary, white);
transition: background-color 0.3s ease;
overflow: visible;
position: relative;
}
.task-group-empty {
display: flex;
height: 80px;
align-items: center;
background: var(--task-bg-primary, white);
transition: background-color 0.3s ease;
}
.task-group-tasks {
background: var(--task-bg-primary, white);
transition: background-color 0.3s ease;
overflow: visible;
position: relative;
}
.task-group-add-task {
background: var(--task-bg-primary, white);
border-top: 1px solid var(--task-border-secondary, #f0f0f0);
transition: all 0.3s ease;
padding: 0 12px;
width: 100%;
max-width: 500px; /* Fixed maximum width */
min-width: 300px; /* Minimum width for mobile */
min-height: 40px;
display: flex;
align-items: center;
border-radius: 0 0 6px 6px;
margin-left: 0;
position: relative;
}
.task-group-add-task:hover {
background: var(--task-hover-bg, #fafafa);
transform: translateX(2px);
}
/* Responsive adjustments for add task row */
@media (max-width: 768px) {
.task-group-add-task {
max-width: 400px;
min-width: 280px;
}
}
@media (max-width: 480px) {
.task-group-add-task {
max-width: calc(100vw - 40px);
min-width: 250px;
}
}
@media (min-width: 1200px) {
.task-group-add-task {
max-width: 600px;
}
}
.task-table-fixed-columns {
display: flex;
background: var(--task-bg-secondary, #f5f5f5);
border-right: 2px solid var(--task-border-primary, #e8e8e8);
transition: all 0.3s ease;
}
.task-table-scrollable-columns {
display: flex;
flex: 1;
min-width: 0;
}
.task-table-cell {
display: flex;
align-items: center;
padding: 0 12px;
border-right: 1px solid var(--task-border-secondary, #f0f0f0);
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
font-size: 12px;
white-space: nowrap;
height: 40px;
max-height: 40px;
min-height: 40px;
overflow: hidden;
color: var(--task-text-primary, #262626);
transition: all 0.3s ease;
}
.task-table-cell:last-child {
border-right: none;
}
/* Add row border styling for task rows */
.task-group-tasks > div {
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
transition: border-color 0.3s ease;
}
.task-group-tasks > div:last-child {
border-bottom: none;
}
/* Ensure fixed columns also have bottom borders */
.fixed-columns-row > div {
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
transition: border-color 0.3s ease;
}
.scrollable-columns-row > div {
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
transition: border-color 0.3s ease;
}
/* Dark mode border adjustments */
.dark .task-table-cell,
[data-theme="dark"] .task-table-cell {
border-right-color: var(--task-border-secondary, #374151);
border-bottom-color: var(--task-border-secondary, #374151);
}
.dark .task-group-tasks > div,
[data-theme="dark"] .task-group-tasks > div {
border-bottom-color: var(--task-border-secondary, #374151);
}
.dark .fixed-columns-row > div,
[data-theme="dark"] .fixed-columns-row > div {
border-bottom-color: var(--task-border-secondary, #374151);
}
.dark .scrollable-columns-row > div,
[data-theme="dark"] .scrollable-columns-row > div {
border-bottom-color: var(--task-border-secondary, #374151);
}
.drag-over {
background-color: var(--task-drag-over-bg, #f0f8ff) !important;
border-color: var(--task-drag-over-border, #40a9ff) !important;
}
/* Ensure buttons and components fit within row height */
.task-group .ant-btn {
height: auto;
max-height: 32px;
line-height: 1.2;
}
.task-group .ant-badge {
height: auto;
line-height: 1.2;
}
/* Dark mode specific adjustments */
.dark .task-group,
[data-theme="dark"] .task-group {
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.3));
}
`}</style>
</div>
);
}, (prevProps, nextProps) => {
// More comprehensive comparison to detect task movements
return (
prevProps.group.id === nextProps.group.id &&
prevProps.group.taskIds.length === nextProps.group.taskIds.length &&
prevProps.group.taskIds.every((id, index) => id === nextProps.group.taskIds[index]) &&
prevProps.group.collapsed === nextProps.group.collapsed &&
prevProps.selectedTaskIds.length === nextProps.selectedTaskIds.length &&
prevProps.currentGrouping === nextProps.currentGrouping
);
});
TaskGroup.displayName = 'TaskGroup';
export default TaskGroup;