Files
worklenz/worklenz-frontend/src/components/task-management/task-row.tsx
chamiakJ f405777463 feat(task-management): enhance task filtering and UI components for improved usability
- Updated AssigneeSelector and LabelsSelector components to include text color adjustments for better visibility in dark mode.
- Introduced ImprovedTaskFilters component for a more efficient task filtering experience, integrating Redux state management for selected priorities and labels.
- Refactored task management slice to support new filtering capabilities, including selected priorities and improved task fetching logic.
- Enhanced TaskGroup and TaskRow components to accommodate new filtering features and improve overall layout consistency.
2025-06-24 21:40:01 +05:30

463 lines
18 KiB
TypeScript

import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useSelector } from 'react-redux';
import { Input, Typography } from 'antd';
import type { InputRef } from 'antd';
import {
HolderOutlined,
MessageOutlined,
PaperClipOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { Task } from '@/types/task-management.types';
import { RootState } from '@/app/store';
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
interface TaskRowProps {
task: Task;
projectId: string;
groupId: string;
currentGrouping: 'status' | 'priority' | 'phase';
isSelected: boolean;
isDragOverlay?: boolean;
index?: number;
onSelect?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
columns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
fixedColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
scrollableColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
}
// Priority and status colors - moved outside component to avoid recreation
const PRIORITY_COLORS = {
critical: '#ff4d4f',
high: '#ff7a45',
medium: '#faad14',
low: '#52c41a',
} as const;
const STATUS_COLORS = {
todo: '#f0f0f0',
doing: '#1890ff',
done: '#52c41a',
} as const;
const TaskRow: React.FC<TaskRowProps> = React.memo(({
task,
projectId,
groupId,
currentGrouping,
isSelected,
isDragOverlay = false,
index,
onSelect,
onToggleSubtasks,
columns,
fixedColumns,
scrollableColumns,
}) => {
const { socket, connected } = useSocket();
// Edit task name state
const [editTaskName, setEditTaskName] = useState(false);
const [taskName, setTaskName] = useState(task.title || '');
const inputRef = useRef<InputRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: task.id,
data: {
type: 'task',
taskId: task.id,
groupId,
},
disabled: isDragOverlay,
});
// Get theme from Redux store
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Click outside detection for edit mode
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
handleTaskNameSave();
}
};
if (editTaskName) {
document.addEventListener('mousedown', handleClickOutside);
inputRef.current?.focus();
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [editTaskName]);
// Handle task name save
const handleTaskNameSave = useCallback(() => {
const newTaskName = inputRef.current?.input?.value;
if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) {
socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
name: newTaskName,
parent_task: null, // Assuming top-level tasks for now
})
);
}
setEditTaskName(false);
}, [connected, socket, task.id, task.title]);
// Memoize style calculations - simplified
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}), [transform, transition, isDragging]);
// Memoize event handlers to prevent unnecessary re-renders
const handleSelectChange = useCallback((checked: boolean) => {
onSelect?.(task.id, checked);
}, [onSelect, task.id]);
const handleToggleSubtasks = useCallback(() => {
onToggleSubtasks?.(task.id);
}, [onToggleSubtasks, task.id]);
// Memoize assignees for AvatarGroup to prevent unnecessary re-renders
const avatarGroupMembers = useMemo(() => {
return task.assignee_names || [];
}, [task.assignee_names]);
// Simplified class name calculations
const containerClasses = useMemo(() => {
const baseClasses = 'border-b transition-all duration-300';
const themeClasses = isDarkMode
? 'border-gray-700 bg-gray-900 hover:bg-gray-800'
: 'border-gray-200 bg-white hover:bg-gray-50';
const selectedClasses = isSelected
? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50')
: '';
const overlayClasses = isDragOverlay
? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}`
: '';
return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`;
}, [isDarkMode, isSelected, isDragOverlay]);
const fixedColumnsClasses = useMemo(() =>
`flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}`,
[isDarkMode]
);
const taskNameClasses = useMemo(() => {
const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300 cursor-pointer';
const themeClasses = isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600';
const completedClasses = task.progress === 100
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
: '';
return `${baseClasses} ${themeClasses} ${completedClasses}`;
}, [isDarkMode, task.progress]);
// Get colors - using constants for better performance
const getPriorityColor = useCallback((priority: string) =>
PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9', []);
const getStatusColor = useCallback((status: string) =>
STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#d9d9d9', []);
// Create adapter for LabelsSelector - memoized
const taskAdapter = useMemo(() => ({
id: task.id,
name: task.title,
parent_task_id: null,
all_labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
labels: task.labels?.map(label => ({
id: label.id,
name: label.name,
color_code: label.color
})) || [],
} as any), [task.id, task.title, task.labels]);
// Create adapter for AssigneeSelector - memoized
const taskAdapterForAssignee = useMemo(() => ({
id: task.id,
name: task.title,
parent_task_id: null,
assignees: task.assignee_names?.map(member => ({
team_member_id: member.team_member_id,
id: member.team_member_id,
project_member_id: member.team_member_id,
name: member.name,
})) || [],
} as any), [task.id, task.title, task.assignee_names]);
// Memoize due date calculation
const dueDate = useMemo(() => {
if (!task.dueDate) return null;
const date = new Date(task.dueDate);
const now = new Date();
const diffTime = date.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) {
return { text: `${Math.abs(diffDays)}d overdue`, color: 'error' };
} else if (diffDays === 0) {
return { text: 'Due today', color: 'warning' };
} else if (diffDays <= 3) {
return { text: `Due in ${diffDays}d`, color: 'warning' };
} else {
return { text: `Due ${date.toLocaleDateString()}`, color: 'default' };
}
}, [task.dueDate]);
return (
<div
ref={setNodeRef}
style={style}
className={containerClasses}
>
<div className="flex h-10 max-h-10 overflow-visible relative">
{/* Fixed Columns */}
<div
className="fixed-columns-row"
style={{
display: 'flex',
background: isDarkMode ? '#1a1a1a' : '#fff',
width: fixedColumns?.reduce((sum, col) => sum + col.width, 0) || 0,
}}
>
{fixedColumns?.map(col => {
switch (col.key) {
case 'drag':
return (
<div key={col.key} className="w-10 flex items-center justify-center px-2 border-r" style={{ width: col.width }}>
<Button
variant="text"
size="small"
icon={<HolderOutlined />}
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
isDarkMode={isDarkMode}
{...attributes}
{...listeners}
/>
</div>
);
case 'select':
return (
<div key={col.key} className="w-10 flex items-center justify-center px-2 border-r" style={{ width: col.width }}>
<Checkbox
checked={isSelected}
onChange={handleSelectChange}
isDarkMode={isDarkMode}
/>
</div>
);
case 'key':
return (
<div key={col.key} className="w-20 flex items-center px-2 border-r" style={{ width: col.width }}>
<Tag
backgroundColor={isDarkMode ? "#374151" : "#f0f0f0"}
color={isDarkMode ? "#d1d5db" : "#666"}
className="truncate whitespace-nowrap max-w-full"
>
{task.task_key}
</Tag>
</div>
);
case 'task':
return (
<div key={col.key} className="flex items-center px-2" style={{ width: col.width }}>
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
<div className="flex items-center gap-2 h-5 overflow-hidden">
<div ref={wrapperRef} className="flex-1 min-w-0">
{!editTaskName ? (
<Typography.Text
ellipsis={{ tooltip: task.title }}
onClick={() => setEditTaskName(true)}
className={taskNameClasses}
style={{ cursor: 'pointer' }}
>
{task.title}
</Typography.Text>
) : (
<Input
ref={inputRef}
variant="borderless"
value={taskName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTaskName(e.target.value)}
onPressEnter={handleTaskNameSave}
className={`${isDarkMode ? 'bg-gray-800 text-gray-100 border-gray-600' : 'bg-white text-gray-900 border-gray-300'}`}
style={{
width: '100%',
padding: '2px 4px',
fontSize: '14px',
fontWeight: 500,
}}
/>
)}
</div>
</div>
</div>
</div>
);
default:
return null;
}
})}
</div>
{/* Scrollable Columns */}
<div className="scrollable-columns-row" style={{ display: 'flex', minWidth: scrollableColumns?.reduce((sum, col) => sum + col.width, 0) || 0 }}>
{scrollableColumns?.map(col => {
switch (col.key) {
case 'progress':
return (
<div key={col.key} className="flex items-center justify-center px-2 border-r" style={{ width: col.width }}>
{task.progress !== undefined && task.progress >= 0 && (
<Progress
type="circle"
percent={task.progress}
size={24}
strokeColor={task.progress === 100 ? '#52c41a' : '#1890ff'}
strokeWidth={2}
showInfo={true}
isDarkMode={isDarkMode}
/>
)}
</div>
);
case 'members':
return (
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
<div className="flex items-center gap-2">
{avatarGroupMembers.length > 0 && (
<AvatarGroup
members={avatarGroupMembers}
size={24}
maxCount={3}
isDarkMode={isDarkMode}
/>
)}
<AssigneeSelector
task={taskAdapterForAssignee}
groupId={groupId}
isDarkMode={isDarkMode}
/>
</div>
</div>
);
case 'labels':
return (
<div key={col.key} className="max-w-[200px] flex items-center px-2 border-r" style={{ width: col.width }}>
<div className="flex items-center gap-1 flex-wrap h-full w-full overflow-visible relative">
{task.labels?.map((label, index) => (
label.end && label.names && label.name ? (
<CustomNumberLabel
key={`${label.id}-${index}`}
labelList={label.names}
namesString={label.name}
isDarkMode={isDarkMode}
/>
) : (
<CustomColordLabel
key={`${label.id}-${index}`}
label={label}
isDarkMode={isDarkMode}
/>
)
))}
<LabelsSelector
task={taskAdapter}
isDarkMode={isDarkMode}
/>
</div>
</div>
);
case 'status':
return (
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
<Tag
backgroundColor={getStatusColor(task.status)}
color="white"
className="text-xs font-medium uppercase"
>
{task.status}
</Tag>
</div>
);
case 'priority':
return (
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: getPriorityColor(task.priority) }}
/>
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
{task.priority}
</span>
</div>
</div>
);
case 'timeTracking':
return (
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
<div className="flex items-center gap-2 h-full overflow-hidden">
{task.timeTracking?.logged && task.timeTracking.logged > 0 && (
<div className="flex items-center gap-1">
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
{typeof task.timeTracking.logged === 'number'
? `${task.timeTracking.logged}h`
: task.timeTracking.logged
}
</span>
</div>
)}
</div>
</div>
);
default:
return null;
}
})}
</div>
</div>
</div>
);
}, (prevProps, nextProps) => {
// Simplified comparison for better performance
return (
prevProps.task.id === nextProps.task.id &&
prevProps.task.title === nextProps.task.title &&
prevProps.task.progress === nextProps.task.progress &&
prevProps.task.status === nextProps.task.status &&
prevProps.task.priority === nextProps.task.priority &&
prevProps.task.labels?.length === nextProps.task.labels?.length &&
prevProps.task.assignee_names?.length === nextProps.task.assignee_names?.length &&
prevProps.isSelected === nextProps.isSelected &&
prevProps.isDragOverlay === nextProps.isDragOverlay &&
prevProps.groupId === nextProps.groupId
);
});
TaskRow.displayName = 'TaskRow';
export default TaskRow;