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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user