feat(task-management): enhance task list with new components and improved state management
- Introduced TaskListV2 and TaskGroupHeader components for a more organized task display. - Implemented virtualized rendering using react-virtuoso for efficient task list handling. - Updated Redux state management to include new selectors and improved task grouping logic. - Added task filtering capabilities with TaskListFilters component for better user experience. - Enhanced task selection handling and integrated drag-and-drop functionality for task rows. - Updated package dependencies to include new libraries for icons and forms.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface TaskGroupHeaderProps {
|
||||
group: {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
color?: string; // Color for the group indicator
|
||||
};
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center px-4 py-2 bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700"
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Chevron button */}
|
||||
<button
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Group indicator and name */}
|
||||
<div className="ml-2 flex items-center gap-3 flex-1">
|
||||
{/* Color indicator */}
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm"
|
||||
style={{ backgroundColor: group.color || '#94A3B8' }}
|
||||
/>
|
||||
|
||||
{/* Group name and count */}
|
||||
<div className="flex items-center justify-between flex-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full">
|
||||
{group.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroupHeader;
|
||||
264
worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx
Normal file
264
worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { GroupedVirtuoso } from 'react-virtuoso';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
selectAllTasksArray,
|
||||
selectGroups,
|
||||
selectGrouping,
|
||||
selectLoading,
|
||||
selectError,
|
||||
selectSelectedPriorities,
|
||||
selectSearch,
|
||||
fetchTasksV3,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
selectCurrentGrouping,
|
||||
selectCollapsedGroups,
|
||||
selectIsGroupCollapsed,
|
||||
toggleGroupCollapsed,
|
||||
} from '@/features/task-management/grouping.slice';
|
||||
import {
|
||||
selectSelectedTaskIds,
|
||||
selectLastSelectedTaskId,
|
||||
selectIsTaskSelected,
|
||||
selectTask,
|
||||
deselectTask,
|
||||
toggleTaskSelection,
|
||||
selectRange,
|
||||
clearSelection,
|
||||
} from '@/features/task-management/selection.slice';
|
||||
import TaskRow from './TaskRow';
|
||||
import TaskGroupHeader from './TaskGroupHeader';
|
||||
import { Task, TaskGroup } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
import { TaskListField } from '@/types/task-list-field.types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||
|
||||
// Base column configuration
|
||||
const BASE_COLUMNS = [
|
||||
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
|
||||
{ id: 'taskKey', label: 'Key', width: '100px', isSticky: true, 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 },
|
||||
{ id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
||||
{ id: 'priority', label: 'Priority', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||
{ id: 'dueDate', label: 'Due Date', width: '120px', key: COLUMN_KEYS.DUE_DATE },
|
||||
{ id: 'progress', label: 'Progress', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
||||
{ id: 'labels', label: 'Labels', width: '150px', key: COLUMN_KEYS.LABELS },
|
||||
{ id: 'phase', label: 'Phase', width: '120px', key: COLUMN_KEYS.PHASE },
|
||||
{ id: 'timeTracking', label: 'Time Tracking', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
||||
{ id: 'estimation', label: 'Estimation', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
||||
{ id: 'startDate', label: 'Start Date', width: '120px', key: COLUMN_KEYS.START_DATE },
|
||||
{ id: 'dueTime', label: 'Due Time', width: '120px', key: COLUMN_KEYS.DUE_TIME },
|
||||
{ id: 'completedDate', label: 'Completed Date', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE },
|
||||
{ id: 'createdDate', label: 'Created Date', width: '120px', key: COLUMN_KEYS.CREATED_DATE },
|
||||
{ id: 'lastUpdated', label: 'Last Updated', width: '120px', key: COLUMN_KEYS.LAST_UPDATED },
|
||||
{ id: 'reporter', label: 'Reporter', width: '120px', key: COLUMN_KEYS.REPORTER },
|
||||
];
|
||||
|
||||
type ColumnStyle = {
|
||||
width: string;
|
||||
position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed';
|
||||
left?: number;
|
||||
backgroundColor?: string;
|
||||
zIndex?: number;
|
||||
};
|
||||
|
||||
interface TaskListV2Props {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectId: urlProjectId } = useParams();
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// Selectors
|
||||
const tasks = useAppSelector(selectAllTasksArray);
|
||||
const groups = useAppSelector(selectGroups);
|
||||
const grouping = useAppSelector(selectGrouping);
|
||||
const loading = useAppSelector(selectLoading);
|
||||
const error = useAppSelector(selectError);
|
||||
const selectedPriorities = useAppSelector(selectSelectedPriorities);
|
||||
const searchQuery = useAppSelector(selectSearch);
|
||||
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
||||
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
||||
const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId);
|
||||
|
||||
const fields = useAppSelector(state => state.taskManagementFields) || [];
|
||||
|
||||
// Filter visible columns based on fields
|
||||
const visibleColumns = useMemo(() => {
|
||||
return BASE_COLUMNS.filter(column => {
|
||||
// Always show drag handle, task key, and title
|
||||
if (column.isSticky) return true;
|
||||
// Check if field is visible
|
||||
const field = fields.find(f => f.key === column.key);
|
||||
return field?.visible ?? false;
|
||||
});
|
||||
}, [fields]);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
if (urlProjectId) {
|
||||
dispatch(fetchTasksV3(urlProjectId));
|
||||
}
|
||||
}, [dispatch, urlProjectId]);
|
||||
|
||||
// Handlers
|
||||
const handleTaskSelect = useCallback((taskId: string, event: React.MouseEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
dispatch(toggleTaskSelection(taskId));
|
||||
} else if (event.shiftKey && lastSelectedTaskId) {
|
||||
const taskIds = tasks.map(t => t.id);
|
||||
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
||||
const endIdx = taskIds.indexOf(taskId);
|
||||
const rangeIds = taskIds.slice(
|
||||
Math.min(startIdx, endIdx),
|
||||
Math.max(startIdx, endIdx) + 1
|
||||
);
|
||||
dispatch(selectRange(rangeIds));
|
||||
} else {
|
||||
dispatch(clearSelection());
|
||||
dispatch(selectTask(taskId));
|
||||
}
|
||||
}, [dispatch, lastSelectedTaskId, tasks]);
|
||||
|
||||
const handleGroupCollapse = useCallback((groupId: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Memoized values
|
||||
const groupCounts = useMemo(() => {
|
||||
return groups.map(group => {
|
||||
const visibleTasks = tasks.filter(task => group.taskIds.includes(task.id));
|
||||
return visibleTasks.length;
|
||||
});
|
||||
}, [groups, tasks]);
|
||||
|
||||
const visibleGroups = useMemo(() => {
|
||||
return groups.filter(group => !collapsedGroups.has(group.id));
|
||||
}, [groups, collapsedGroups]);
|
||||
|
||||
// Render functions
|
||||
const renderGroup = useCallback((groupIndex: number) => {
|
||||
const group = groups[groupIndex];
|
||||
return (
|
||||
<TaskGroupHeader
|
||||
group={{
|
||||
id: group.id,
|
||||
name: group.title,
|
||||
count: groupCounts[groupIndex],
|
||||
color: group.color,
|
||||
}}
|
||||
isCollapsed={collapsedGroups.has(group.id)}
|
||||
onToggle={() => handleGroupCollapse(group.id)}
|
||||
/>
|
||||
);
|
||||
}, [groups, groupCounts, collapsedGroups, handleGroupCollapse]);
|
||||
|
||||
const renderTask = useCallback((taskIndex: number) => {
|
||||
const task = tasks[taskIndex];
|
||||
return (
|
||||
<TaskRow
|
||||
task={task}
|
||||
visibleColumns={visibleColumns}
|
||||
/>
|
||||
);
|
||||
}, [tasks, visibleColumns]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error}</div>;
|
||||
|
||||
// Log data for debugging
|
||||
console.log('Rendering with:', {
|
||||
groups,
|
||||
tasks,
|
||||
groupCounts
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
|
||||
{/* Task Filters */}
|
||||
<div className="flex-none px-4 py-3">
|
||||
<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">
|
||||
<div className="flex items-center min-w-max px-4 py-2">
|
||||
{visibleColumns.map((column, index) => {
|
||||
const columnStyle: ColumnStyle = {
|
||||
width: column.width,
|
||||
...(column.isSticky ? {
|
||||
position: 'sticky',
|
||||
left: index === 0 ? 0 : index === 1 ? 32 : 132,
|
||||
backgroundColor: 'inherit',
|
||||
zIndex: 2,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
className="text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
style={columnStyle}
|
||||
>
|
||||
{column.id === 'dragHandle' ? (
|
||||
<Bars3Icon className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<GroupedVirtuoso
|
||||
style={{ height: 'calc(100vh - 200px)' }}
|
||||
groupCounts={groupCounts}
|
||||
groupContent={renderGroup}
|
||||
itemContent={renderTask}
|
||||
components={{
|
||||
Group: ({ children, ...props }) => (
|
||||
<div
|
||||
{...props}
|
||||
className="sticky top-0 z-10 bg-white dark:bg-gray-800"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
List: React.forwardRef(({ style, children }, ref) => (
|
||||
<div
|
||||
ref={ref as any}
|
||||
style={style}
|
||||
className="divide-y divide-gray-200 dark:divide-gray-700"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListV2;
|
||||
261
worklenz-frontend/src/components/task-list-v2/TaskRow.tsx
Normal file
261
worklenz-frontend/src/components/task-list-v2/TaskRow.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React from 'react';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import Avatar from '@/components/Avatar';
|
||||
import { format } from 'date-fns';
|
||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||
import { ClockIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface TaskRowProps {
|
||||
task: Task;
|
||||
visibleColumns: Array<{
|
||||
id: string;
|
||||
width: string;
|
||||
isSticky?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||
const renderColumn = (columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||
const baseStyle = {
|
||||
width,
|
||||
...(isSticky ? {
|
||||
position: 'sticky' as const,
|
||||
left: index === 0 ? 0 : index === 1 ? 32 : 132,
|
||||
backgroundColor: 'inherit',
|
||||
zIndex: 1,
|
||||
} : {}),
|
||||
};
|
||||
|
||||
switch (columnId) {
|
||||
case 'dragHandle':
|
||||
return (
|
||||
<div
|
||||
className="cursor-move flex items-center justify-center"
|
||||
style={baseStyle}
|
||||
>
|
||||
<Bars3Icon className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'taskKey':
|
||||
return (
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={baseStyle}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||
{task.task_key}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'title':
|
||||
return (
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={baseStyle}
|
||||
>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||
{task.title || task.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'status':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
|
||||
color: task.statusColor || 'rgb(31, 41, 55)',
|
||||
}}
|
||||
>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'assignees':
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={baseStyle}>
|
||||
{task.assignee_names?.slice(0, 3).map((assignee, index) => (
|
||||
<Avatar
|
||||
key={index}
|
||||
name={assignee.name || ''}
|
||||
size="small"
|
||||
className="ring-2 ring-white dark:ring-gray-900"
|
||||
/>
|
||||
))}
|
||||
{(task.assignee_names?.length || 0) > 3 && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
|
||||
+{task.assignee_names!.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'priority':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: task.priorityColor ? `${task.priorityColor}20` : 'rgb(229, 231, 235)',
|
||||
color: task.priorityColor || 'rgb(31, 41, 55)',
|
||||
}}
|
||||
>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'dueDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.dueDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.dueDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 dark:bg-gray-700">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${task.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'labels':
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={baseStyle}>
|
||||
{task.labels?.slice(0, 2).map((label, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: label.color ? `${label.color}20` : 'rgb(229, 231, 235)',
|
||||
color: label.color || 'rgb(31, 41, 55)',
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
{(task.labels?.length || 0) > 2 && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
+{task.labels!.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'phase':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
|
||||
{task.phase}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'timeTracking':
|
||||
return (
|
||||
<div className="flex items-center gap-1" style={baseStyle}>
|
||||
<ClockIcon className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{task.timeTracking?.logged || 0}h
|
||||
</span>
|
||||
{task.timeTracking?.estimated && (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">
|
||||
/{task.timeTracking.estimated}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'estimation':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.timeTracking.estimated && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{task.timeTracking.estimated}h
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'startDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.startDate && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.startDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'completedDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.completedAt && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.completedAt), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'createdDate':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.createdAt && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.createdAt), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'lastUpdated':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.updatedAt && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.updatedAt), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'reporter':
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{task.reporter && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{task.reporter}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{visibleColumns.map((column, index) => renderColumn(column.id, column.width, column.isSticky, index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRow;
|
||||
Reference in New Issue
Block a user