feat(task-management): enhance Checkbox component and task selection functionality

- Added `indeterminate` state to Checkbox component for better visual representation of partial selections.
- Updated TaskGroup and VirtualizedTaskList components to utilize the new Checkbox features, allowing for group selection with indeterminate states.
- Implemented custom debounce function for saving task fields to localStorage, improving performance during user interactions.
- Enhanced task row styling for better visibility and user experience, particularly in dark mode.
This commit is contained in:
chamikaJ
2025-06-25 10:48:01 +05:30
parent a25fcf209a
commit cf5919a3a0
7 changed files with 367 additions and 120 deletions

View File

@@ -6,6 +6,7 @@ interface CheckboxProps {
isDarkMode?: boolean; isDarkMode?: boolean;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
indeterminate?: boolean;
} }
const Checkbox: React.FC<CheckboxProps> = ({ const Checkbox: React.FC<CheckboxProps> = ({
@@ -13,7 +14,8 @@ const Checkbox: React.FC<CheckboxProps> = ({
onChange, onChange,
isDarkMode = false, isDarkMode = false,
className = '', className = '',
disabled = false disabled = false,
indeterminate = false
}) => { }) => {
return ( return (
<label className={`inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}> <label className={`inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
@@ -25,15 +27,20 @@ const Checkbox: React.FC<CheckboxProps> = ({
className="sr-only" className="sr-only"
/> />
<div className={`w-4 h-4 border-2 rounded transition-all duration-200 flex items-center justify-center ${ <div className={`w-4 h-4 border-2 rounded transition-all duration-200 flex items-center justify-center ${
checked checked || indeterminate
? `${isDarkMode ? 'bg-blue-600 border-blue-600' : 'bg-blue-500 border-blue-500'}` ? `${isDarkMode ? 'bg-blue-600 border-blue-600' : 'bg-blue-500 border-blue-500'}`
: `${isDarkMode ? 'bg-gray-800 border-gray-600 hover:border-gray-500' : 'bg-white border-gray-300 hover:border-gray-400'}` : `${isDarkMode ? 'bg-gray-800 border-gray-600 hover:border-gray-500' : 'bg-white border-gray-300 hover:border-gray-400'}`
} ${disabled ? 'cursor-not-allowed' : ''}`}> } ${disabled ? 'cursor-not-allowed' : ''}`}>
{checked && ( {checked && !indeterminate && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" /> <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg> </svg>
)} )}
{indeterminate && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
)}
</div> </div>
</label> </label>
); );

View File

@@ -509,6 +509,17 @@ const SearchFilter: React.FC<{
); );
}; };
// Custom debounce implementation
function debounce(func: (...args: any[]) => void, wait: number) {
let timeout: ReturnType<typeof setTimeout>;
return (...args: any[]) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ themeClasses, isDarkMode }) => { const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ themeClasses, isDarkMode }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields); const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
@@ -518,6 +529,17 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Debounced save to localStorage using custom debounce
const debouncedSaveFields = useMemo(() => debounce((fieldsToSave: typeof fields) => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fieldsToSave));
}, 300), []);
useEffect(() => {
debouncedSaveFields(fields);
// Cleanup debounce on unmount
return () => { /* no cancel needed for custom debounce */ };
}, [fields, debouncedSaveFields]);
// Close dropdown on outside click // Close dropdown on outside click
React.useEffect(() => { React.useEffect(() => {
if (!open) return; if (!open) return;

View File

@@ -10,6 +10,7 @@ import { RootState } from '@/app/store';
import TaskRow from './task-row'; import TaskRow from './task-row';
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
import { TaskListField } from '@/features/task-management/taskListFields.slice'; import { TaskListField } from '@/features/task-management/taskListFields.slice';
import { Checkbox } from '@/components';
const { Text } = Typography; const { Text } = Typography;
@@ -136,6 +137,19 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
}; };
}, [groupTasks]); }, [groupTasks]);
// Calculate selection state for the group checkbox
const { isAllSelected, isIndeterminate } = useMemo(() => {
if (groupTasks.length === 0) {
return { isAllSelected: false, isIndeterminate: false };
}
const selectedTasksInGroup = groupTasks.filter(task => selectedTaskIds.includes(task.id));
const isAllSelected = selectedTasksInGroup.length === groupTasks.length;
const isIndeterminate = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < groupTasks.length;
return { isAllSelected, isIndeterminate };
}, [groupTasks, selectedTaskIds]);
// Get group color based on grouping type - memoized // Get group color based on grouping type - memoized
const groupColor = useMemo(() => { const groupColor = useMemo(() => {
if (group.color) return group.color; if (group.color) return group.color;
@@ -163,6 +177,25 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
onAddTask?.(group.id); onAddTask?.(group.id);
}, [onAddTask, group.id]); }, [onAddTask, group.id]);
// Handle select all tasks in group
const handleSelectAllInGroup = useCallback((checked: boolean) => {
if (checked) {
// Select all tasks in the group
groupTasks.forEach(task => {
if (!selectedTaskIds.includes(task.id)) {
onSelectTask?.(task.id, true);
}
});
} else {
// Deselect all tasks in the group
groupTasks.forEach(task => {
if (selectedTaskIds.includes(task.id)) {
onSelectTask?.(task.id, false);
}
});
}
}, [groupTasks, selectedTaskIds, onSelectTask]);
// Memoized style object // Memoized style object
const containerStyle = useMemo(() => ({ const containerStyle = useMemo(() => ({
backgroundColor: isOver backgroundColor: isOver
@@ -212,7 +245,18 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
className="task-table-cell task-table-header-cell" className="task-table-cell task-table-header-cell"
style={{ width: col.width }} style={{ width: col.width }}
> >
{col.label && <Text className="column-header-text">{col.label}</Text>} {col.key === 'select' ? (
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isAllSelected}
onChange={handleSelectAllInGroup}
isDarkMode={isDarkMode}
indeterminate={isIndeterminate}
/>
</div>
) : (
col.label && <Text className="column-header-text">{col.label}</Text>
)}
</div> </div>
))} ))}
</div> </div>
@@ -451,6 +495,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
align-items: center; align-items: center;
padding: 0 12px; padding: 0 12px;
border-right: 1px solid var(--task-border-secondary, #f0f0f0); border-right: 1px solid var(--task-border-secondary, #f0f0f0);
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
height: 40px; height: 40px;
@@ -465,6 +510,49 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
border-right: none; border-right: none;
} }
/* Add row border styling for task rows */
.task-group-tasks > div {
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
transition: border-color 0.3s ease;
}
.task-group-tasks > div:last-child {
border-bottom: none;
}
/* Ensure fixed columns also have bottom borders */
.fixed-columns-row > div {
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
transition: border-color 0.3s ease;
}
.scrollable-columns-row > div {
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
transition: border-color 0.3s ease;
}
/* Dark mode border adjustments */
.dark .task-table-cell,
[data-theme="dark"] .task-table-cell {
border-right-color: var(--task-border-secondary, #374151);
border-bottom-color: var(--task-border-secondary, #374151);
}
.dark .task-group-tasks > div,
[data-theme="dark"] .task-group-tasks > div {
border-bottom-color: var(--task-border-secondary, #374151);
}
.dark .fixed-columns-row > div,
[data-theme="dark"] .fixed-columns-row > div {
border-bottom-color: var(--task-border-secondary, #374151);
}
.dark .scrollable-columns-row > div,
[data-theme="dark"] .scrollable-columns-row > div {
border-bottom-color: var(--task-border-secondary, #374151);
}
.drag-over { .drag-over {
background-color: var(--task-drag-over-bg, #f0f8ff) !important; background-color: var(--task-drag-over-bg, #f0f8ff) !important;
border-color: var(--task-drag-over-border, #40a9ff) !important; border-color: var(--task-drag-over-border, #40a9ff) !important;

View File

@@ -65,7 +65,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
// Edit task name state // Edit task name state
const [editTaskName, setEditTaskName] = useState(false); const [editTaskName, setEditTaskName] = useState(false);
const [taskName, setTaskName] = useState(task.title || ''); const [taskName, setTaskName] = useState(task.title || '');
const inputRef = useRef<InputRef>(null); const inputRef = useRef<HTMLInputElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const { const {
@@ -108,7 +108,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
// Handle task name save // Handle task name save
const handleTaskNameSave = useCallback(() => { const handleTaskNameSave = useCallback(() => {
const newTaskName = inputRef.current?.input?.value; const newTaskName = inputRef.current?.value;
if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) { if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) {
socket?.emit( socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(), SocketEvents.TASK_NAME_CHANGE.toString(),
@@ -284,12 +284,41 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
</div> </div>
); );
case 'task': 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 ( return (
<div key={col.key} className="flex items-center px-2" style={{ width: col.width }}> <div
key={col.key}
className={`flex items-center px-2${editTaskName ? ' task-name-edit-active' : ''}`}
style={cellStyle}
>
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden"> <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 className="flex items-center gap-2 h-5 overflow-hidden">
<div ref={wrapperRef} className="flex-1 min-w-0"> <div ref={wrapperRef} className="flex-1 min-w-0">
{!editTaskName ? ( {editTaskName ? (
<input
ref={inputRef}
className="task-name-input"
value={taskName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTaskName(e.target.value)}
onBlur={handleTaskNameSave}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleTaskNameSave();
}
}}
style={{
background: 'transparent',
border: 'none',
outline: 'none',
width: '100%',
color: isDarkMode ? '#ffffff' : '#262626'
}}
autoFocus
/>
) : (
<Typography.Text <Typography.Text
ellipsis={{ tooltip: task.title }} ellipsis={{ tooltip: task.title }}
onClick={() => setEditTaskName(true)} onClick={() => setEditTaskName(true)}
@@ -298,21 +327,6 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
> >
{task.title} {task.title}
</Typography.Text> </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>
@@ -459,4 +473,50 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
TaskRow.displayName = 'TaskRow'; TaskRow.displayName = 'TaskRow';
// Add styles for better border visibility
const taskRowStyles = `
.task-row-container {
border-bottom: 1px solid #f0f0f0;
transition: border-color 0.3s ease;
}
.dark .task-row-container,
[data-theme="dark"] .task-row-container {
border-bottom-color: #374151;
}
.task-row-container:hover {
border-bottom-color: #e8e8e8;
}
.dark .task-row-container:hover,
[data-theme="dark"] .task-row-container:hover {
border-bottom-color: #4b5563;
}
.fixed-columns-row > div,
.scrollable-columns-row > div {
border-bottom: 1px solid #f0f0f0;
transition: border-color 0.3s ease;
}
.dark .fixed-columns-row > div,
.dark .scrollable-columns-row > div,
[data-theme="dark"] .fixed-columns-row > div,
[data-theme="dark"] .scrollable-columns-row > div {
border-bottom-color: #374151;
}
`;
// Inject styles
if (typeof document !== 'undefined') {
const styleId = 'task-row-styles';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = taskRowStyles;
document.head.appendChild(style);
}
}
export default TaskRow; export default TaskRow;

View File

@@ -8,6 +8,7 @@ import TaskRow from './task-row';
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
import { RootState } from '@/app/store'; import { RootState } from '@/app/store';
import { TaskListField } from '@/features/task-management/taskListFields.slice'; import { TaskListField } from '@/features/task-management/taskListFields.slice';
import { Checkbox } from '@/components';
interface VirtualizedTaskListProps { interface VirtualizedTaskListProps {
group: any; group: any;
@@ -32,6 +33,9 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
}) => { }) => {
const allTasks = useSelector(taskManagementSelectors.selectAll); const allTasks = useSelector(taskManagementSelectors.selectAll);
// Get theme from Redux store
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
// Get field visibility from taskListFields slice // Get field visibility from taskListFields slice
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[]; const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
@@ -51,98 +55,99 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
.filter((task: Task | undefined): task is Task => task !== undefined); .filter((task: Task | undefined): task is Task => task !== undefined);
}, [group.taskIds, allTasks]); }, [group.taskIds, allTasks]);
// Calculate selection state for the group checkbox
const { isAllSelected, isIndeterminate } = useMemo(() => {
if (groupTasks.length === 0) {
return { isAllSelected: false, isIndeterminate: false };
}
const selectedTasksInGroup = groupTasks.filter(task => selectedTaskIds.includes(task.id));
const isAllSelected = selectedTasksInGroup.length === groupTasks.length;
const isIndeterminate = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < groupTasks.length;
return { isAllSelected, isIndeterminate };
}, [groupTasks, selectedTaskIds]);
// Handle select all tasks in group
const handleSelectAllInGroup = useCallback((checked: boolean) => {
if (checked) {
// Select all tasks in the group
groupTasks.forEach(task => {
if (!selectedTaskIds.includes(task.id)) {
onSelectTask(task.id, true);
}
});
} else {
// Deselect all tasks in the group
groupTasks.forEach(task => {
if (selectedTaskIds.includes(task.id)) {
onSelectTask(task.id, false);
}
});
}
}, [groupTasks, selectedTaskIds, onSelectTask]);
const TASK_ROW_HEIGHT = 40; const TASK_ROW_HEIGHT = 40;
const HEADER_HEIGHT = 40; const HEADER_HEIGHT = 40;
const COLUMN_HEADER_HEIGHT = 40; const COLUMN_HEADER_HEIGHT = 40;
const ADD_TASK_ROW_HEIGHT = 40;
// Calculate the actual height needed for the virtualized list // Calculate dynamic height for the group
const actualContentHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT); const taskRowsHeight = groupTasks.length * TASK_ROW_HEIGHT;
const listHeight = Math.min(height - 40, actualContentHeight); const groupHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + taskRowsHeight + ADD_TASK_ROW_HEIGHT;
// Calculate item count - only include actual content
const getItemCount = () => {
return groupTasks.length + 2; // +2 for header and column headers only
};
// Debug logging
useEffect(() => {
console.log('VirtualizedTaskList:', {
groupId: group.id,
groupTasks: groupTasks.length,
height,
listHeight,
itemCount: getItemCount(),
isVirtualized: groupTasks.length > 10, // Show if virtualization should be active
minHeight: 300,
maxHeight: 600
});
}, [group.id, groupTasks.length, height, listHeight]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const headerScrollRef = useRef<HTMLDivElement>(null);
// Synchronize header scroll with body scroll
useEffect(() => {
const handleScroll = () => {
if (headerScrollRef.current && scrollContainerRef.current) {
headerScrollRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
}
};
const scrollDiv = scrollContainerRef.current;
if (scrollDiv) {
scrollDiv.addEventListener('scroll', handleScroll);
}
return () => {
if (scrollDiv) {
scrollDiv.removeEventListener('scroll', handleScroll);
}
};
}, []);
// Define all possible columns // Define all possible columns
const allColumns = [ const allFixedColumns = [
{ key: 'drag', label: '', width: 40, fixed: true, alwaysVisible: true }, { key: 'drag', label: '', width: 40, alwaysVisible: true },
{ key: 'select', label: '', width: 40, fixed: true, alwaysVisible: true }, { key: 'select', label: '', width: 40, alwaysVisible: true },
{ key: 'key', label: 'KEY', width: 80, fixed: true, fieldKey: 'KEY' }, { key: 'key', label: 'KEY', width: 80, fieldKey: 'KEY' },
{ key: 'task', label: 'TASK', width: 475, fixed: true, alwaysVisible: true }, { key: 'task', label: 'TASK', width: 474, alwaysVisible: true },
{ key: 'progress', label: 'PROGRESS', width: 90, fieldKey: 'PROGRESS' }, ];
{ key: 'members', label: 'MEMBERS', width: 150, fieldKey: 'ASSIGNEES' },
{ key: 'labels', label: 'LABELS', width: 200, fieldKey: 'LABELS' }, const allScrollableColumns = [
{ key: 'status', label: 'STATUS', width: 100, fieldKey: 'STATUS' }, { key: 'progress', label: 'Progress', width: 90, fieldKey: 'PROGRESS' },
{ key: 'priority', label: 'PRIORITY', width: 100, fieldKey: 'PRIORITY' }, { key: 'members', label: 'Members', width: 150, fieldKey: 'ASSIGNEES' },
{ key: 'timeTracking', label: 'TIME TRACKING', width: 120, fieldKey: 'TIME_TRACKING' }, { key: 'labels', label: 'Labels', width: 200, fieldKey: 'LABELS' },
{ 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' },
]; ];
// Filter columns based on field visibility // Filter columns based on field visibility
const visibleColumns = useMemo(() => { const fixedColumns = useMemo(() => {
const filtered = allColumns.filter(col => { return allFixedColumns.filter(col => {
// Always show columns marked as alwaysVisible // Always show columns marked as alwaysVisible
if (col.alwaysVisible) return true; if (col.alwaysVisible) return true;
// For other columns, check field visibility // For other columns, check field visibility
if (col.fieldKey) { if (col.fieldKey) {
const field = taskListFields.find(f => f.key === col.fieldKey); const field = taskListFields.find(f => f.key === col.fieldKey);
const isVisible = field?.visible ?? false; return field?.visible ?? false;
console.log(`Column ${col.key} (fieldKey: ${col.fieldKey}):`, { field, isVisible });
return isVisible;
} }
return false; return false;
}); });
}, [taskListFields, allFixedColumns]);
console.log('Visible columns after filtering:', filtered.map(c => ({ key: c.key, fieldKey: c.fieldKey })));
return filtered; const scrollableColumns = useMemo(() => {
}, [taskListFields, allColumns]); return allScrollableColumns.filter(col => {
// For scrollable columns, check field visibility
if (col.fieldKey) {
const field = taskListFields.find(f => f.key === col.fieldKey);
return field?.visible ?? false;
}
return false;
});
}, [taskListFields, allScrollableColumns]);
const fixedColumns = visibleColumns.filter(col => col.fixed);
const scrollableColumns = visibleColumns.filter(col => !col.fixed);
const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0); const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0);
const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0); const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0);
const totalTableWidth = fixedWidth + scrollableWidth; const totalTableWidth = fixedWidth + scrollableWidth;
// Row renderer for virtualization (remove header/column header rows) // Row renderer for virtualization (only task rows)
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => { const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
const task = groupTasks[index]; const task: Task | undefined = groupTasks[index];
if (!task) return null; if (!task) return null;
return ( return (
<div <div
@@ -168,10 +173,34 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
); );
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks, fixedColumns, scrollableColumns]); }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks, fixedColumns, scrollableColumns]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const headerScrollRef = useRef<HTMLDivElement>(null);
// Synchronize header scroll with body scroll
useEffect(() => {
const handleScroll = () => {
if (headerScrollRef.current && scrollContainerRef.current) {
headerScrollRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
}
};
const scrollDiv = scrollContainerRef.current;
if (scrollDiv) {
scrollDiv.addEventListener('scroll', handleScroll);
}
return () => {
if (scrollDiv) {
scrollDiv.removeEventListener('scroll', handleScroll);
}
};
}, []);
const VIRTUALIZATION_THRESHOLD = 20;
const shouldVirtualize = groupTasks.length > VIRTUALIZATION_THRESHOLD;
return ( return (
<div className="virtualized-task-list" style={{ height: height }}> <div className="virtualized-task-list" style={{ height: groupHeight }}>
{/* Group Header */} {/* Group Header */}
<div className="task-group-header"> <div className="task-group-header" style={{ height: HEADER_HEIGHT }}>
<div className="task-group-header-row"> <div className="task-group-header-row">
<div <div
className="task-group-header-content" className="task-group-header-content"
@@ -190,7 +219,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
<div <div
className="task-group-column-headers-scroll" className="task-group-column-headers-scroll"
ref={headerScrollRef} ref={headerScrollRef}
style={{ overflowX: 'auto', overflowY: 'hidden' }} style={{ overflowX: 'auto', overflowY: 'hidden', height: COLUMN_HEADER_HEIGHT }}
> >
<div <div
className="task-group-column-headers" className="task-group-column-headers"
@@ -203,7 +232,18 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
className="task-table-cell task-table-header-cell fixed-column" className="task-table-cell task-table-header-cell fixed-column"
style={{ width: col.width }} style={{ width: col.width }}
> >
<span className="column-header-text">{col.label}</span> {col.key === 'select' ? (
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isAllSelected}
onChange={handleSelectAllInGroup}
isDarkMode={isDarkMode}
indeterminate={isIndeterminate}
/>
</div>
) : (
<span className="column-header-text">{col.label}</span>
)}
</div> </div>
))} ))}
</div> </div>
@@ -220,30 +260,62 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
</div> </div>
</div> </div>
</div> </div>
{/* Scrollable List */} {/* Scrollable List - only task rows */}
<div <div
className="task-list-scroll-container" className="task-list-scroll-container"
ref={scrollContainerRef} ref={scrollContainerRef}
style={{ overflowX: 'auto', overflowY: 'hidden', width: '100%', minWidth: totalTableWidth }} style={{
overflowX: 'auto',
overflowY: 'auto',
width: '100%',
minWidth: totalTableWidth,
height: groupTasks.length > 0 ? taskRowsHeight : 'auto',
}}
> >
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}> <SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
<List {shouldVirtualize ? (
height={listHeight} <List
width={width} height={taskRowsHeight}
itemCount={groupTasks.length} width={width}
itemSize={TASK_ROW_HEIGHT} itemCount={groupTasks.length}
overscanCount={15} itemSize={TASK_ROW_HEIGHT}
className="react-window-list" overscanCount={50}
style={{ minWidth: totalTableWidth }} className="react-window-list"
> style={{ minWidth: totalTableWidth }}
{Row} >
</List> {Row}
</List>
) : (
groupTasks.map((task: Task, index: number) => (
<div
key={task.id}
className="task-row-container"
style={{
height: TASK_ROW_HEIGHT,
'--group-color': group.color || '#f0f0f0',
} as React.CSSProperties}
>
<TaskRow
task={task}
projectId={projectId}
groupId={group.id}
currentGrouping={currentGrouping}
isSelected={selectedTaskIds.includes(task.id)}
index={index}
onSelect={onSelectTask}
onToggleSubtasks={onToggleSubtasks}
fixedColumns={fixedColumns}
scrollableColumns={scrollableColumns}
/>
</div>
))
)}
</SortableContext> </SortableContext>
</div> </div>
{/* Add Task Row - Always show at the bottom */} {/* Add Task Row - Always show at the bottom */}
<div <div
className="task-group-add-task" className="task-group-add-task"
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}` }} style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}`, height: ADD_TASK_ROW_HEIGHT }}
> >
<AddTaskListRow groupId={group.id} /> <AddTaskListRow groupId={group.id} />
</div> </div>
@@ -275,7 +347,10 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
min-width: 1200px; min-width: 1200px;
} }
.task-list-scroll-container { .task-list-scroll-container {
width: 100%; scrollbar-width: none; /* Firefox */
}
.task-list-scroll-container::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
} }
.react-window-list { .react-window-list {
outline: none; outline: none;
@@ -312,7 +387,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
/* Task group header styles */ /* Task group header styles */
.task-group-header-row { .task-group-header-row {
display: inline-flex; display: inline-flex;
height: auto; height: inherit;
max-height: none; max-height: none;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;

View File

@@ -69,15 +69,12 @@ const taskListFieldsSlice = createSlice({
const field = state.find(f => f.key === action.payload); const field = state.find(f => f.key === action.payload);
if (field) { if (field) {
field.visible = !field.visible; field.visible = !field.visible;
saveFields(state);
} }
}, },
setFields(state, action: PayloadAction<TaskListField[]>) { setFields(state, action: PayloadAction<TaskListField[]>) {
saveFields(action.payload);
return action.payload; return action.payload;
}, },
resetFields() { resetFields() {
saveFields(DEFAULT_FIELDS);
return DEFAULT_FIELDS; return DEFAULT_FIELDS;
}, },
}, },

View File

@@ -1,4 +1,4 @@
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback, useMemo } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
@@ -15,13 +15,11 @@ import { getTeamMembers } from '@/features/team-members/team-members.slice';
export const useFilterDataLoader = () => { export const useFilterDataLoader = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { priorities } = useAppSelector(state => ({ // Memoize the priorities selector to prevent unnecessary re-renders
priorities: state.priorityReducer.priorities, const priorities = useAppSelector(state => state.priorityReducer.priorities);
}));
const { projectId } = useAppSelector(state => ({ // Memoize the projectId selector to prevent unnecessary re-renders
projectId: state.projectReducer.projectId, const projectId = useAppSelector(state => state.projectReducer.projectId);
}));
// Load filter data asynchronously // Load filter data asynchronously
const loadFilterData = useCallback(async () => { const loadFilterData = useCallback(async () => {