feat(task-management): enhance task components with performance optimizations and new status field
- Rearranged the order of task fields in the dropdown for better usability. - Introduced a new CSS file for task row optimizations, improving rendering performance and responsiveness. - Added utility functions for date formatting and performance monitoring to enhance task management efficiency. - Updated TaskRow and TaskStatusDropdown components to improve rendering and user experience, including better handling of status display and dark mode support. - Integrated new status field into various task management components, ensuring consistent visibility and functionality across the application.
This commit is contained in:
@@ -32,10 +32,10 @@ const DEFAULT_COLUMN_CONFIG: ColumnConfig[] = [
|
||||
{ key: 'TASK', label: 'Task', showInDropdown: false, order: 2, category: 'basic' }, // Always visible, not in dropdown
|
||||
{ key: 'DESCRIPTION', label: 'Description', showInDropdown: true, order: 3, category: 'basic' },
|
||||
{ key: 'PROGRESS', label: 'Progress', showInDropdown: true, order: 4, category: 'basic' },
|
||||
{ key: 'ASSIGNEES', label: 'Assignees', showInDropdown: true, order: 5, category: 'basic' },
|
||||
{ key: 'LABELS', label: 'Labels', showInDropdown: true, order: 6, category: 'basic' },
|
||||
{ key: 'PHASE', label: 'Phase', showInDropdown: true, order: 7, category: 'basic' },
|
||||
{ key: 'STATUS', label: 'Status', showInDropdown: true, order: 8, category: 'basic' },
|
||||
{ key: 'STATUS', label: 'Status', showInDropdown: true, order: 5, category: 'basic' },
|
||||
{ key: 'ASSIGNEES', label: 'Assignees', showInDropdown: true, order: 6, category: 'basic' },
|
||||
{ key: 'LABELS', label: 'Labels', showInDropdown: true, order: 7, category: 'basic' },
|
||||
{ key: 'PHASE', label: 'Phase', showInDropdown: true, order: 8, category: 'basic' },
|
||||
{ key: 'PRIORITY', label: 'Priority', showInDropdown: true, order: 9, category: 'basic' },
|
||||
{ key: 'TIME_TRACKING', label: 'Time Tracking', showInDropdown: true, order: 10, category: 'time' },
|
||||
{ key: 'ESTIMATION', label: 'Estimation', showInDropdown: true, order: 11, category: 'time' },
|
||||
|
||||
@@ -91,10 +91,10 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||
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: 'status', label: 'Status', width: 100, fieldKey: 'STATUS' },
|
||||
{ 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' },
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/* TaskRow Performance Optimizations CSS */
|
||||
|
||||
.task-row-optimized {
|
||||
contain: layout style;
|
||||
will-change: transform;
|
||||
transform: translateZ(0); /* Force GPU acceleration */
|
||||
}
|
||||
|
||||
.task-row-optimized:hover {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.task-row-optimized.task-row-dragging {
|
||||
contain: layout;
|
||||
will-change: transform;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.task-row-optimized.task-row-virtualized {
|
||||
contain: strict;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.task-row-optimized.task-row-selected {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.task-name-edit-active {
|
||||
contain: none; /* Disable containment during editing for proper focus */
|
||||
}
|
||||
|
||||
.task-name-input {
|
||||
contain: layout;
|
||||
will-change: contents;
|
||||
}
|
||||
|
||||
/* Performance optimizations for drag and drop */
|
||||
.task-row-optimized [data-dnd-draggable] {
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.task-row-optimized [data-dnd-drag-handle] {
|
||||
cursor: grab;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.task-row-optimized [data-dnd-drag-handle]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Virtualization specific optimizations */
|
||||
.task-row-virtualized {
|
||||
contain: strict;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.task-row-virtualized * {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* Smooth scrolling for virtualized lists */
|
||||
.virtualized-task-container {
|
||||
scroll-behavior: smooth;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
/* Optimize frequent updates */
|
||||
.task-progress-circle {
|
||||
contain: layout style paint;
|
||||
will-change: stroke-dasharray;
|
||||
}
|
||||
|
||||
.task-status-dropdown {
|
||||
contain: layout style;
|
||||
will-change: background-color;
|
||||
}
|
||||
|
||||
.task-assignee-avatar {
|
||||
contain: layout style paint;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Reduce layout thrashing */
|
||||
.task-cell {
|
||||
contain: layout;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
.task-cell:hover {
|
||||
will-change: background-color;
|
||||
}
|
||||
|
||||
/* Dark mode optimizations */
|
||||
.dark .task-row-optimized {
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.dark .task-row-optimized:hover {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
/* Animation performance */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.task-row-optimized {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* High DPI display optimizations */
|
||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
|
||||
.task-row-optimized {
|
||||
contain: layout style paint;
|
||||
}
|
||||
}
|
||||
|
||||
/* Memory optimization for large lists */
|
||||
.task-list-container {
|
||||
contain: layout style;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-list-container.large-list {
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
/* Intersection observer optimizations */
|
||||
.task-row-optimized.intersection-observed {
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.task-row-optimized.intersection-observed.visible {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.task-row-optimized.intersection-observed.hidden {
|
||||
will-change: auto;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
/* Performance debugging */
|
||||
.task-row-debug {
|
||||
outline: 1px solid red !important;
|
||||
}
|
||||
|
||||
.task-row-debug::before {
|
||||
content: "DEBUG";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: red;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 4px;
|
||||
z-index: 9999;
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { dayjs } from './antd-imports';
|
||||
|
||||
// Performance constants
|
||||
export const PERFORMANCE_CONSTANTS = {
|
||||
CACHE_CLEAR_INTERVAL: 300000, // 5 minutes
|
||||
VIRTUALIZATION_THRESHOLD: 50,
|
||||
DRAG_THROTTLE_MS: 50,
|
||||
RENDER_TIMEOUT_MS: 16, // 60fps target
|
||||
MAX_CACHE_SIZE: 1000,
|
||||
} as const;
|
||||
|
||||
// Priority and status color constants
|
||||
export const PRIORITY_COLORS = {
|
||||
critical: '#ff4d4f',
|
||||
high: '#ff7a45',
|
||||
medium: '#faad14',
|
||||
low: '#52c41a',
|
||||
} as const;
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
todo: '#f0f0f0',
|
||||
doing: '#1890ff',
|
||||
done: '#52c41a',
|
||||
} as const;
|
||||
|
||||
// Cache management for date formatting
|
||||
class DateFormatCache {
|
||||
private cache = new Map<string, string>();
|
||||
private maxSize: number;
|
||||
|
||||
constructor(maxSize: number = PERFORMANCE_CONSTANTS.MAX_CACHE_SIZE) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
get(key: string): string | undefined {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
// LRU eviction when cache is full
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const firstKey = this.cache.keys().next().value as string;
|
||||
if (firstKey) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, value);
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
return this.cache.has(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.cache.size;
|
||||
}
|
||||
}
|
||||
|
||||
// Global cache instances
|
||||
export const formatDateCache = new DateFormatCache();
|
||||
export const formatDateTimeCache = new DateFormatCache();
|
||||
|
||||
// Optimized date formatters with caching
|
||||
export const formatDate = (dateString?: string): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
if (formatDateCache.has(dateString)) {
|
||||
return formatDateCache.get(dateString)!;
|
||||
}
|
||||
|
||||
const formatted = dayjs(dateString).format('MMM DD, YYYY');
|
||||
formatDateCache.set(dateString, formatted);
|
||||
return formatted;
|
||||
};
|
||||
|
||||
export const formatDateTime = (dateString?: string): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
if (formatDateTimeCache.has(dateString)) {
|
||||
return formatDateTimeCache.get(dateString)!;
|
||||
}
|
||||
|
||||
const formatted = dayjs(dateString).format('MMM DD, YYYY HH:mm');
|
||||
formatDateTimeCache.set(dateString, formatted);
|
||||
return formatted;
|
||||
};
|
||||
|
||||
// Performance monitoring utilities
|
||||
export class PerformanceMonitor {
|
||||
private static instance: PerformanceMonitor;
|
||||
private metrics: Map<string, number[]> = new Map();
|
||||
|
||||
static getInstance(): PerformanceMonitor {
|
||||
if (!PerformanceMonitor.instance) {
|
||||
PerformanceMonitor.instance = new PerformanceMonitor();
|
||||
}
|
||||
return PerformanceMonitor.instance;
|
||||
}
|
||||
|
||||
startTiming(operation: string): () => void {
|
||||
const startTime = performance.now();
|
||||
|
||||
return () => {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
if (!this.metrics.has(operation)) {
|
||||
this.metrics.set(operation, []);
|
||||
}
|
||||
|
||||
const operationMetrics = this.metrics.get(operation)!;
|
||||
operationMetrics.push(duration);
|
||||
|
||||
// Keep only last 100 measurements
|
||||
if (operationMetrics.length > 100) {
|
||||
operationMetrics.shift();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getAverageTime(operation: string): number {
|
||||
const times = this.metrics.get(operation);
|
||||
if (!times || times.length === 0) return 0;
|
||||
|
||||
const sum = times.reduce((acc, time) => acc + time, 0);
|
||||
return sum / times.length;
|
||||
}
|
||||
|
||||
getMetrics(): Record<string, { average: number; count: number; latest: number }> {
|
||||
const result: Record<string, { average: number; count: number; latest: number }> = {};
|
||||
|
||||
this.metrics.forEach((times, operation) => {
|
||||
if (times.length > 0) {
|
||||
result[operation] = {
|
||||
average: this.getAverageTime(operation),
|
||||
count: times.length,
|
||||
latest: times[times.length - 1],
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
logMetrics(): void {
|
||||
const metrics = this.getMetrics();
|
||||
console.table(metrics);
|
||||
}
|
||||
|
||||
clearMetrics(): void {
|
||||
this.metrics.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Task comparison utilities for React.memo
|
||||
export const taskPropsEqual = (prevTask: Task, nextTask: Task): boolean => {
|
||||
// Quick identity check
|
||||
if (prevTask === nextTask) return true;
|
||||
if (prevTask.id !== nextTask.id) return false;
|
||||
|
||||
// Check commonly changing properties
|
||||
const criticalProps: (keyof Task)[] = [
|
||||
'title', 'progress', 'status', 'priority', 'description',
|
||||
'startDate', 'dueDate', 'updatedAt'
|
||||
];
|
||||
|
||||
for (const prop of criticalProps) {
|
||||
if (prevTask[prop] !== nextTask[prop]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check array lengths (fast path)
|
||||
if (prevTask.labels?.length !== nextTask.labels?.length) return false;
|
||||
if (prevTask.assignee_names?.length !== nextTask.assignee_names?.length) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Throttle utility for drag operations
|
||||
export const throttle = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let lastExecTime = 0;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
const currentTime = Date.now();
|
||||
|
||||
const execute = () => {
|
||||
lastExecTime = currentTime;
|
||||
func(...args);
|
||||
};
|
||||
|
||||
if (currentTime - lastExecTime > delay) {
|
||||
execute();
|
||||
} else {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(execute, delay - (currentTime - lastExecTime));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Debounce utility for input operations
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
};
|
||||
|
||||
// Virtualization utilities
|
||||
export const shouldVirtualize = (itemCount: number): boolean => {
|
||||
return itemCount > PERFORMANCE_CONSTANTS.VIRTUALIZATION_THRESHOLD;
|
||||
};
|
||||
|
||||
export const calculateVirtualizedHeight = (
|
||||
itemCount: number,
|
||||
itemHeight: number,
|
||||
maxHeight: number = 600
|
||||
): number => {
|
||||
const totalHeight = itemCount * itemHeight;
|
||||
return Math.min(totalHeight, maxHeight);
|
||||
};
|
||||
|
||||
// CSS class utilities for performance
|
||||
export const getOptimizedClasses = (
|
||||
isDragging: boolean,
|
||||
isVirtualized: boolean,
|
||||
isSelected: boolean
|
||||
): string => {
|
||||
const classes = ['task-row-optimized'];
|
||||
|
||||
if (isDragging) {
|
||||
classes.push('task-row-dragging');
|
||||
}
|
||||
|
||||
if (isVirtualized) {
|
||||
classes.push('task-row-virtualized');
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
classes.push('task-row-selected');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
// Memory management
|
||||
export const clearAllCaches = (): void => {
|
||||
formatDateCache.clear();
|
||||
formatDateTimeCache.clear();
|
||||
PerformanceMonitor.getInstance().clearMetrics();
|
||||
};
|
||||
|
||||
// Performance debugging helpers
|
||||
export const logPerformanceWarning = (operation: string, duration: number): void => {
|
||||
if (duration > PERFORMANCE_CONSTANTS.RENDER_TIMEOUT_MS) {
|
||||
console.warn(
|
||||
`Performance warning: ${operation} took ${duration.toFixed(2)}ms (target: ${PERFORMANCE_CONSTANTS.RENDER_TIMEOUT_MS}ms)`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-cleanup setup
|
||||
let cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
export const setupAutoCleanup = (): void => {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
}
|
||||
|
||||
cleanupInterval = setInterval(() => {
|
||||
clearAllCaches();
|
||||
}, PERFORMANCE_CONSTANTS.CACHE_CLEAR_INTERVAL);
|
||||
};
|
||||
|
||||
export const teardownAutoCleanup = (): void => {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
cleanupInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize auto-cleanup
|
||||
if (typeof window !== 'undefined') {
|
||||
setupAutoCleanup();
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', teardownAutoCleanup);
|
||||
}
|
||||
|
||||
// Export performance monitor instance
|
||||
export const performanceMonitor = PerformanceMonitor.getInstance();
|
||||
|
||||
// Task adapter utilities
|
||||
export const createLabelsAdapter = (task: Task) => ({
|
||||
id: task.id,
|
||||
name: task.title,
|
||||
parent_task_id: undefined,
|
||||
manual_progress: false,
|
||||
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);
|
||||
|
||||
export const createAssigneeAdapter = (task: Task) => ({
|
||||
id: task.id,
|
||||
name: task.title,
|
||||
parent_task_id: undefined,
|
||||
manual_progress: false,
|
||||
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);
|
||||
|
||||
// Color utilities
|
||||
export const getPriorityColor = (priority: string): string => {
|
||||
return PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9';
|
||||
};
|
||||
|
||||
export const getStatusColor = (status: string): string => {
|
||||
return STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#d9d9d9';
|
||||
};
|
||||
@@ -21,6 +21,16 @@ import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLa
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import TaskStatusDropdown from './task-status-dropdown';
|
||||
import {
|
||||
formatDate as utilFormatDate,
|
||||
formatDateTime as utilFormatDateTime,
|
||||
createLabelsAdapter,
|
||||
createAssigneeAdapter,
|
||||
PRIORITY_COLORS as UTIL_PRIORITY_COLORS,
|
||||
performanceMonitor,
|
||||
taskPropsEqual
|
||||
} from './task-row-utils';
|
||||
import './task-row-optimized.css';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: Task;
|
||||
@@ -51,6 +61,98 @@ const STATUS_COLORS = {
|
||||
done: '#52c41a',
|
||||
} as const;
|
||||
|
||||
// Memoized sub-components for better performance
|
||||
const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => (
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
icon={<HolderOutlined />}
|
||||
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
||||
isDarkMode={isDarkMode}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
));
|
||||
|
||||
const TaskKey = React.memo<{ taskKey: string; isDarkMode: boolean }>(({ taskKey, isDarkMode }) => (
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded truncate whitespace-nowrap max-w-full ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-300'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{taskKey}
|
||||
</span>
|
||||
));
|
||||
|
||||
const TaskDescription = React.memo<{ description?: string; isDarkMode: boolean }>(({ description, isDarkMode }) => (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{
|
||||
expandable: false,
|
||||
rows: 1,
|
||||
tooltip: description,
|
||||
}}
|
||||
className={`w-full mb-0 text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
|
||||
>
|
||||
{description || ''}
|
||||
</Typography.Paragraph>
|
||||
));
|
||||
|
||||
const TaskProgress = React.memo<{ progress: number; isDarkMode: boolean }>(({ progress, isDarkMode }) => (
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={progress}
|
||||
size={24}
|
||||
strokeColor={progress === 100 ? '#52c41a' : '#1890ff'}
|
||||
strokeWidth={2}
|
||||
showInfo={true}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
));
|
||||
|
||||
const TaskPriority = React.memo<{ priority: string; isDarkMode: boolean }>(({ priority, isDarkMode }) => {
|
||||
const color = PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{priority}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TaskTimeTracking = React.memo<{ timeTracking?: { logged?: number | string }; isDarkMode: boolean }>(({ timeTracking, isDarkMode }) => {
|
||||
if (!timeTracking?.logged || timeTracking.logged === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`} />
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{typeof timeTracking.logged === 'number'
|
||||
? `${timeTracking.logged}h`
|
||||
: timeTracking.logged
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TaskReporter = React.memo<{ reporter?: string; isDarkMode: boolean }>(({ reporter, isDarkMode }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserOutlined className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`} />
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{reporter || '-'}
|
||||
</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
|
||||
|
||||
const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
task,
|
||||
projectId,
|
||||
@@ -73,6 +175,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Optimized drag and drop setup with better performance
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -88,53 +191,62 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
groupId,
|
||||
},
|
||||
disabled: isDragOverlay,
|
||||
// Optimize animation performance
|
||||
animateLayoutChanges: () => false, // Disable layout animations for better performance
|
||||
});
|
||||
|
||||
// Get theme from Redux store
|
||||
// Get theme from Redux store - memoized selector
|
||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||
|
||||
// Click outside detection for edit mode
|
||||
// Optimized click outside detection
|
||||
useEffect(() => {
|
||||
if (!editTaskName) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
handleTaskNameSave();
|
||||
}
|
||||
};
|
||||
|
||||
if (editTaskName) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside, { passive: true });
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [editTaskName]);
|
||||
|
||||
// Handle task name save
|
||||
// Optimized task name save handler
|
||||
const handleTaskNameSave = useCallback(() => {
|
||||
const newTaskName = inputRef.current?.value;
|
||||
if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) {
|
||||
const newTaskName = inputRef.current?.value?.trim();
|
||||
if (newTaskName && 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
|
||||
parent_task: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
setEditTaskName(false);
|
||||
}, [connected, socket, task.id, task.title]);
|
||||
|
||||
// Memoize style calculations - simplified
|
||||
const style = useMemo(() => ({
|
||||
// Optimized style calculations with better memoization
|
||||
const dragStyle = useMemo(() => {
|
||||
if (!isDragging && !transform) return {};
|
||||
|
||||
return {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}), [transform, transition, isDragging]);
|
||||
zIndex: isDragging ? 1000 : 'auto',
|
||||
// Add GPU acceleration for better performance
|
||||
willChange: isDragging ? 'transform' : 'auto',
|
||||
};
|
||||
}, [transform, transition, isDragging]);
|
||||
|
||||
// Memoize event handlers to prevent unnecessary re-renders
|
||||
// Memoized event handlers with better dependency tracking
|
||||
const handleSelectChange = useCallback((checked: boolean) => {
|
||||
onSelect?.(task.id, checked);
|
||||
}, [onSelect, task.id]);
|
||||
@@ -143,127 +255,12 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
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]);
|
||||
// Optimized date handling with better memoization
|
||||
const dateValues = useMemo(() => ({
|
||||
start: task.startDate ? dayjs(task.startDate) : undefined,
|
||||
due: task.dueDate ? dayjs(task.dueDate) : undefined,
|
||||
}), [task.startDate, task.dueDate]);
|
||||
|
||||
// Simplified class name calculations
|
||||
const containerClasses = useMemo(() => {
|
||||
const baseClasses = 'border-b transition-all duration-300';
|
||||
const themeClasses = isDarkMode
|
||||
? 'border-gray-600 hover:bg-gray-800'
|
||||
: 'border-gray-300 hover:bg-gray-50';
|
||||
const backgroundClasses = isDarkMode ? 'bg-[#18181b]' : 'bg-white';
|
||||
const selectedClasses = isSelected
|
||||
? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50')
|
||||
: '';
|
||||
const overlayClasses = isDragOverlay
|
||||
? `rounded shadow-lg border-2 ${isDarkMode ? 'border-gray-600 shadow-2xl' : 'border-gray-300 shadow-2xl'}`
|
||||
: '';
|
||||
return `${baseClasses} ${themeClasses} ${backgroundClasses} ${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-600' : 'bg-white border-gray-300'}`,
|
||||
[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', []);
|
||||
|
||||
// Memoize date values for performance optimization
|
||||
const startDateValue = useMemo(() =>
|
||||
task.startDate ? dayjs(task.startDate) : undefined,
|
||||
[task.startDate]
|
||||
);
|
||||
|
||||
const dueDateValue = useMemo(() =>
|
||||
task.dueDate ? dayjs(task.dueDate) : undefined,
|
||||
[task.dueDate]
|
||||
);
|
||||
|
||||
// Memoize DatePicker configuration
|
||||
const datePickerProps = useMemo(() => ({
|
||||
...taskManagementAntdConfig.datePickerDefaults,
|
||||
className: "w-full bg-transparent border-none shadow-none"
|
||||
}), []);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Memoize date formatting functions
|
||||
const formatDate = useCallback((dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
return dayjs(dateString).format('MMM DD, YYYY');
|
||||
}, []);
|
||||
|
||||
const formatDateTime = useCallback((dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
return dayjs(dateString).format('MMM DD, YYYY HH:mm');
|
||||
}, []);
|
||||
|
||||
// Handle date changes
|
||||
const handleDateChange = useCallback((date: dayjs.Dayjs | null, field: 'startDate' | 'dueDate') => {
|
||||
if (!connected || !socket) return;
|
||||
|
||||
@@ -281,41 +278,50 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
);
|
||||
}, [connected, socket, task.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={containerClasses}
|
||||
>
|
||||
<div className="flex h-10 max-h-10 overflow-visible relative">
|
||||
{/* Fixed Columns */}
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
width: fixedColumns?.reduce((sum, col) => sum + col.width, 0) || 0,
|
||||
}}
|
||||
>
|
||||
{fixedColumns?.map((col, colIdx) => {
|
||||
const isLastFixed = colIdx === fixedColumns.length - 1;
|
||||
const borderClasses = `${isLastFixed ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
// Optimized class name calculations with better memoization
|
||||
const styleClasses = useMemo(() => {
|
||||
const base = 'border-b transition-all duration-200'; // Reduced duration for better performance
|
||||
const theme = isDarkMode
|
||||
? 'border-gray-600 hover:bg-gray-800'
|
||||
: 'border-gray-300 hover:bg-gray-50';
|
||||
const background = isDarkMode ? 'bg-[#18181b]' : 'bg-white';
|
||||
const selected = isSelected
|
||||
? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50')
|
||||
: '';
|
||||
const overlay = isDragOverlay
|
||||
? `rounded shadow-lg border-2 ${isDarkMode ? 'border-gray-600 shadow-2xl' : 'border-gray-300 shadow-2xl'}`
|
||||
: '';
|
||||
|
||||
return {
|
||||
container: `${base} ${theme} ${background} ${selected} ${overlay}`,
|
||||
taskName: `text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-200 cursor-pointer ${
|
||||
isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600'
|
||||
} ${task.progress === 100 ? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}` : ''}`,
|
||||
};
|
||||
}, [isDarkMode, isSelected, isDragOverlay, task.progress]);
|
||||
|
||||
// Memoized adapters for better performance
|
||||
const adapters = useMemo(() => ({
|
||||
labels: createLabelsAdapter(task),
|
||||
assignee: createAssigneeAdapter(task),
|
||||
}), [task]);
|
||||
|
||||
// Optimized column rendering with better performance
|
||||
const renderColumn = useCallback((col: { key: string; width: number }, isFixed: boolean, index: number, totalColumns: number) => {
|
||||
const isLast = index === totalColumns - 1;
|
||||
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
switch (col.key) {
|
||||
case 'drag':
|
||||
return (
|
||||
<div key={col.key} className={`w-10 flex items-center justify-center px-2 ${borderClasses}`} 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 key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<DragHandle isDarkMode={isDarkMode} attributes={attributes} listeners={listeners} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div key={col.key} className={`w-10 flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={handleSelectChange}
|
||||
@@ -323,25 +329,19 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'key':
|
||||
return (
|
||||
<div key={col.key} className={`w-20 flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded truncate whitespace-nowrap max-w-full ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 text-gray-300'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{task.task_key}
|
||||
</span>
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<TaskKey taskKey={task.task_key} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'task':
|
||||
// Compute the style for the cell
|
||||
const cellStyle = editTaskName
|
||||
? { width: col.width, border: '1px solid #1890ff', background: isDarkMode ? '#232b3a' : '#f0f7ff', transition: 'border 0.2s' }
|
||||
: { width: col.width };
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
@@ -356,9 +356,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
ref={inputRef}
|
||||
className="task-name-input w-full bg-transparent border-none outline-none text-sm"
|
||||
value={taskName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTaskName(e.target.value)}
|
||||
onChange={(e) => setTaskName(e.target.value)}
|
||||
onBlur={handleTaskNameSave}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleTaskNameSave();
|
||||
}
|
||||
@@ -372,7 +372,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: task.title }}
|
||||
onClick={() => setEditTaskName(true)}
|
||||
className={taskNameClasses}
|
||||
className={styleClasses.taskName}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{task.title}
|
||||
@@ -383,68 +383,44 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{/* Scrollable Columns */}
|
||||
<div className="overflow-visible" style={{ display: 'flex', minWidth: scrollableColumns?.reduce((sum, col) => sum + col.width, 0) || 0 }}>
|
||||
{scrollableColumns?.map((col, colIdx) => {
|
||||
const isLastScrollable = colIdx === scrollableColumns.length - 1;
|
||||
const borderClasses = `${isLastScrollable ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
switch (col.key) {
|
||||
|
||||
case 'description':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<Typography.Paragraph
|
||||
ellipsis={{
|
||||
expandable: false,
|
||||
rows: 1,
|
||||
tooltip: task.description,
|
||||
}}
|
||||
className={`w-full mb-0 text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
|
||||
>
|
||||
{task.description || ''}
|
||||
</Typography.Paragraph>
|
||||
<TaskDescription description={task.description} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center justify-center px-2 ${borderClasses}`} 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}
|
||||
/>
|
||||
<TaskProgress progress={task.progress} isDarkMode={isDarkMode} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'members':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{avatarGroupMembers.length > 0 && (
|
||||
{task.assignee_names && task.assignee_names.length > 0 && (
|
||||
<AvatarGroup
|
||||
members={avatarGroupMembers}
|
||||
members={task.assignee_names}
|
||||
size={24}
|
||||
maxCount={3}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
<AssigneeSelector
|
||||
task={taskAdapterForAssignee}
|
||||
task={adapters.assignee}
|
||||
groupId={groupId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'labels':
|
||||
return (
|
||||
<div key={col.key} className={`max-w-[200px] flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
@@ -466,12 +442,13 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
)
|
||||
))}
|
||||
<LabelsSelector
|
||||
task={taskAdapter}
|
||||
task={adapters.labels}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'phase':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
@@ -480,6 +457,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses} overflow-visible`} style={{ width: col.width }}>
|
||||
@@ -490,38 +468,21 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} 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-300' : 'text-gray-700'}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
<TaskPriority priority={task.priority} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'timeTracking':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} 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-300' : 'text-gray-700'}`} />
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{typeof task.timeTracking.logged === 'number'
|
||||
? `${task.timeTracking.logged}h`
|
||||
: task.timeTracking.logged
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TaskTimeTracking timeTracking={task.timeTracking} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'estimation':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
@@ -530,28 +491,33 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'startDate':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<DatePicker
|
||||
{...datePickerProps}
|
||||
value={startDateValue}
|
||||
{...taskManagementAntdConfig.datePickerDefaults}
|
||||
className="w-full bg-transparent border-none shadow-none"
|
||||
value={dateValues.start}
|
||||
onChange={(date) => handleDateChange(date, 'startDate')}
|
||||
placeholder="Start Date"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'dueDate':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<DatePicker
|
||||
{...datePickerProps}
|
||||
value={dueDateValue}
|
||||
{...taskManagementAntdConfig.datePickerDefaults}
|
||||
className="w-full bg-transparent border-none shadow-none"
|
||||
value={dateValues.due}
|
||||
onChange={(date) => handleDateChange(date, 'dueDate')}
|
||||
placeholder="Due Date"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'dueTime':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
@@ -560,76 +526,114 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'completedDate':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{task.completedAt ? formatDate(task.completedAt) : '-'}
|
||||
{task.completedAt ? utilFormatDate(task.completedAt) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'createdDate':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{task.createdAt ? formatDate(task.createdAt) : '-'}
|
||||
{task.createdAt ? utilFormatDate(task.createdAt) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'lastUpdated':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{task.updatedAt ? formatDateTime(task.updatedAt) : '-'}
|
||||
{task.updatedAt ? utilFormatDateTime(task.updatedAt) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'reporter':
|
||||
return (
|
||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserOutlined className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`} />
|
||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{task.reporter || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<TaskReporter reporter={task.reporter} isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
}, [
|
||||
isDarkMode, task, isSelected, editTaskName, taskName, adapters, groupId, projectId,
|
||||
attributes, listeners, handleSelectChange, handleTaskNameSave, handleDateChange,
|
||||
dateValues, styleClasses
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={dragStyle}
|
||||
className={`${styleClasses.container} task-row-optimized`}
|
||||
// Add CSS containment for better performance
|
||||
data-task-id={task.id}
|
||||
>
|
||||
<div className="flex h-10 max-h-10 overflow-visible relative">
|
||||
{/* Fixed Columns */}
|
||||
{fixedColumns && fixedColumns.length > 0 && (
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
||||
}}
|
||||
>
|
||||
{fixedColumns.map((col, index) => renderColumn(col, true, index, fixedColumns.length))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable Columns */}
|
||||
{scrollableColumns && scrollableColumns.length > 0 && (
|
||||
<div
|
||||
className="overflow-visible"
|
||||
style={{
|
||||
display: 'flex',
|
||||
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
|
||||
}}
|
||||
>
|
||||
{scrollableColumns.map((col, index) => renderColumn(col, false, index, scrollableColumns.length))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Simplified comparison for better performance
|
||||
const taskPropsEqual = (
|
||||
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
|
||||
);
|
||||
// Optimized comparison function for better performance
|
||||
// Only compare essential props that affect rendering
|
||||
if (prevProps.task.id !== nextProps.task.id) return false;
|
||||
if (prevProps.isSelected !== nextProps.isSelected) return false;
|
||||
if (prevProps.isDragOverlay !== nextProps.isDragOverlay) return false;
|
||||
if (prevProps.groupId !== nextProps.groupId) return false;
|
||||
|
||||
const otherPropsEqual = (
|
||||
prevProps.isSelected === nextProps.isSelected &&
|
||||
prevProps.isDragOverlay === nextProps.isDragOverlay &&
|
||||
prevProps.groupId === nextProps.groupId
|
||||
);
|
||||
// Deep comparison for task properties that commonly change
|
||||
const taskProps = ['title', 'progress', 'status', 'priority', 'description', 'startDate', 'dueDate'];
|
||||
for (const prop of taskProps) {
|
||||
if (prevProps.task[prop as keyof Task] !== nextProps.task[prop as keyof Task]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check column props - these are critical for re-rendering when columns change
|
||||
const columnPropsEqual = (
|
||||
prevProps.fixedColumns?.length === nextProps.fixedColumns?.length &&
|
||||
prevProps.scrollableColumns?.length === nextProps.scrollableColumns?.length &&
|
||||
JSON.stringify(prevProps.fixedColumns?.map(c => c.key)) === JSON.stringify(nextProps.fixedColumns?.map(c => c.key)) &&
|
||||
JSON.stringify(prevProps.scrollableColumns?.map(c => c.key)) === JSON.stringify(nextProps.scrollableColumns?.map(c => c.key))
|
||||
);
|
||||
// Compare arrays by length first (fast path)
|
||||
if (prevProps.task.labels?.length !== nextProps.task.labels?.length) return false;
|
||||
if (prevProps.task.assignee_names?.length !== nextProps.task.assignee_names?.length) return false;
|
||||
|
||||
return taskPropsEqual && otherPropsEqual && columnPropsEqual;
|
||||
// Compare column configurations
|
||||
if (prevProps.fixedColumns?.length !== nextProps.fixedColumns?.length) return false;
|
||||
if (prevProps.scrollableColumns?.length !== nextProps.scrollableColumns?.length) return false;
|
||||
|
||||
// If we reach here, props are effectively equal
|
||||
return true;
|
||||
});
|
||||
|
||||
TaskRow.displayName = 'TaskRow';
|
||||
|
||||
@@ -24,6 +24,13 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
|
||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||
|
||||
// Debug log only when statusList changes, not on every render
|
||||
useEffect(() => {
|
||||
if (statusList.length > 0) {
|
||||
console.log('Status list loaded:', statusList.length, 'statuses');
|
||||
}
|
||||
}, [statusList]);
|
||||
|
||||
// Find current status details
|
||||
const currentStatus = useMemo(() => {
|
||||
return statusList.find(status =>
|
||||
@@ -76,32 +83,39 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Get status color
|
||||
// Get status color - enhanced dark mode support
|
||||
const getStatusColor = useCallback((status: any) => {
|
||||
if (isDarkMode) {
|
||||
return status?.color_code_dark || status?.color_code || '#6b7280';
|
||||
return status?.color_code_dark || status?.color_code || '#4b5563';
|
||||
}
|
||||
return status?.color_code || '#6b7280';
|
||||
}, [isDarkMode]);
|
||||
|
||||
|
||||
|
||||
// Status display name
|
||||
// Status display name - format status names by replacing underscores with spaces
|
||||
const getStatusDisplayName = useCallback((status: string) => {
|
||||
return status.charAt(0).toUpperCase() + status.slice(1);
|
||||
return status
|
||||
.replace(/_/g, ' ') // Replace underscores with spaces
|
||||
.replace(/\b\w/g, char => char.toUpperCase()); // Capitalize first letter of each word
|
||||
}, []);
|
||||
|
||||
// Format status name for display
|
||||
const formatStatusName = useCallback((name: string) => {
|
||||
if (!name) return name;
|
||||
return name.replace(/_/g, ' '); // Replace underscores with spaces
|
||||
}, []);
|
||||
|
||||
if (!task.status) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Status Button */}
|
||||
{/* Status Button - Rounded Pill Design */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log('Status dropdown clicked, current isOpen:', isOpen);
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className={`
|
||||
@@ -109,11 +123,11 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
transition-all duration-200 hover:opacity-80 border-0 min-w-[70px] justify-center
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: currentStatus ? getStatusColor(currentStatus) : (isDarkMode ? '#6b7280' : '#9ca3af'),
|
||||
backgroundColor: currentStatus ? getStatusColor(currentStatus) : (isDarkMode ? '#4b5563' : '#9ca3af'),
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<span>{currentStatus?.name || getStatusDisplayName(task.status)}</span>
|
||||
<span>{currentStatus ? formatStatusName(currentStatus.name || '') : getStatusDisplayName(task.status)}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
@@ -124,62 +138,110 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu - Rendered in Portal */}
|
||||
{/* Dropdown Menu - Redesigned */}
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
fixed min-w-[120px] max-w-[180px]
|
||||
rounded-lg shadow-xl border z-[9999]
|
||||
fixed min-w-[160px] max-w-[220px]
|
||||
rounded border backdrop-blur-sm z-[9999]
|
||||
${isDarkMode
|
||||
? 'bg-gray-800 border-gray-600'
|
||||
: 'bg-white border-gray-200'
|
||||
? 'bg-gray-900/95 border-gray-600 shadow-2xl shadow-black/50'
|
||||
: 'bg-white/95 border-gray-200 shadow-2xl shadow-gray-500/20'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
zIndex: 9999
|
||||
zIndex: 9999,
|
||||
animation: 'fadeInScale 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
<div className="py-1">
|
||||
{statusList.map((status) => (
|
||||
{/* Status Options */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{statusList.map((status, index) => {
|
||||
const isSelected = status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status.id}
|
||||
onClick={() => handleStatusChange(status.id!, status.name!)}
|
||||
className={`
|
||||
w-full px-3 py-2 text-left text-xs font-medium flex items-center gap-2
|
||||
transition-colors duration-150 rounded-md mx-1
|
||||
w-full px-3 py-2.5 text-left text-xs font-medium flex items-center gap-3
|
||||
transition-all duration-150 hover:scale-[1.02] active:scale-[0.98]
|
||||
${isDarkMode
|
||||
? 'hover:bg-gray-700 text-gray-200'
|
||||
: 'hover:bg-gray-50 text-gray-900'
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
}
|
||||
${(status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status)
|
||||
? (isDarkMode ? 'bg-gray-700' : 'bg-gray-50')
|
||||
${isSelected
|
||||
? (isDarkMode ? 'bg-gray-700/60 ring-1 ring-blue-400/40' : 'bg-blue-50/50 ring-1 ring-blue-200')
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${index * 30}ms`,
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Status Pill Preview */}
|
||||
{/* Status Color Indicator */}
|
||||
<div
|
||||
className="px-2 py-0.5 rounded-full text-white text-xs min-w-[50px] text-center"
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: getStatusColor(status) }}
|
||||
>
|
||||
{status.name}
|
||||
</div>
|
||||
/>
|
||||
|
||||
{/* Current Status Indicator */}
|
||||
{(status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status) && (
|
||||
<div className="ml-auto">
|
||||
{/* Status Name */}
|
||||
<span className="flex-1 truncate">
|
||||
{formatStatusName(status.name || '')}
|
||||
</span>
|
||||
|
||||
{/* Current Status Badge */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`} />
|
||||
<span className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* CSS Animations - Injected as style tag */}
|
||||
{isOpen && createPortal(
|
||||
<style>
|
||||
{`
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInFromLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>,
|
||||
document.head
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -83,15 +83,15 @@ const VirtualizedTaskGroup: React.FC<VirtualizedTaskGroupProps> = React.memo(({
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
||||
<span className="column-header-text">Progress</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Status</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||
<span className="column-header-text">Members</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
||||
<span className="column-header-text">Labels</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Status</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Priority</span>
|
||||
</div>
|
||||
|
||||
@@ -100,10 +100,10 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
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: 'status', label: 'Status', width: 100, fieldKey: 'STATUS' },
|
||||
{ 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' },
|
||||
|
||||
@@ -48,6 +48,13 @@ const initialState: projectViewTaskListColumnsState = {
|
||||
width: 60,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
name: 'status',
|
||||
columnHeader: 'status',
|
||||
width: 120,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
name: 'members',
|
||||
@@ -69,13 +76,6 @@ const initialState: projectViewTaskListColumnsState = {
|
||||
width: 150,
|
||||
isVisible: false,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
name: 'status',
|
||||
columnHeader: 'status',
|
||||
width: 120,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
name: 'priority',
|
||||
|
||||
@@ -111,6 +111,24 @@ export const createColumns = ({
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('status', {
|
||||
header: 'Status',
|
||||
id: COLUMN_KEYS.STATUS,
|
||||
size: 120,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<StatusDropdown
|
||||
key={`${row.original.id}-status`}
|
||||
statusList={statuses}
|
||||
task={row.original}
|
||||
teamId={getCurrentSession()?.team_id || ''}
|
||||
onChange={statusId => {
|
||||
console.log('Status changed:', statusId);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('names', {
|
||||
header: 'Assignees',
|
||||
id: COLUMN_KEYS.ASSIGNEES,
|
||||
@@ -163,24 +181,6 @@ export const createColumns = ({
|
||||
cell: ({ row }) => <TaskRowDueTime dueTime={row.original.due_time || ''} />,
|
||||
}),
|
||||
|
||||
columnHelper.accessor('status', {
|
||||
header: 'Status',
|
||||
id: COLUMN_KEYS.STATUS,
|
||||
size: 120,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<StatusDropdown
|
||||
key={`${row.original.id}-status`}
|
||||
statusList={statuses}
|
||||
task={row.original}
|
||||
teamId={getCurrentSession()?.team_id || ''}
|
||||
onChange={statusId => {
|
||||
console.log('Status changed:', statusId);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('labels', {
|
||||
header: 'Labels',
|
||||
id: COLUMN_KEYS.LABELS,
|
||||
|
||||
@@ -22,6 +22,11 @@ export const columnList: CustomTableColumnsType[] = [
|
||||
columnHeader: 'progress',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
columnHeader: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
columnHeader: 'members',
|
||||
@@ -37,11 +42,6 @@ export const columnList: CustomTableColumnsType[] = [
|
||||
columnHeader: phaseHeader,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
columnHeader: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
columnHeader: 'priority',
|
||||
|
||||
Reference in New Issue
Block a user