diff --git a/worklenz-frontend/src/components/Checkbox.tsx b/worklenz-frontend/src/components/Checkbox.tsx index 6141331a..4ed89018 100644 --- a/worklenz-frontend/src/components/Checkbox.tsx +++ b/worklenz-frontend/src/components/Checkbox.tsx @@ -6,6 +6,7 @@ interface CheckboxProps { isDarkMode?: boolean; className?: string; disabled?: boolean; + indeterminate?: boolean; } const Checkbox: React.FC = ({ @@ -13,7 +14,8 @@ const Checkbox: React.FC = ({ onChange, isDarkMode = false, className = '', - disabled = false + disabled = false, + indeterminate = false }) => { return ( ); diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx index cd533b86..dd376ac7 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -509,6 +509,17 @@ const SearchFilter: React.FC<{ ); }; +// Custom debounce implementation +function debounce(func: (...args: any[]) => void, wait: number) { + let timeout: ReturnType; + 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 dispatch = useDispatch(); 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 dropdownRef = useRef(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 React.useEffect(() => { if (!open) return; diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index 2d5953c9..43e13d23 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -10,6 +10,7 @@ import { RootState } from '@/app/store'; import TaskRow from './task-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 { Checkbox } from '@/components'; const { Text } = Typography; @@ -136,6 +137,19 @@ const TaskGroup: React.FC = React.memo(({ }; }, [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 const groupColor = useMemo(() => { if (group.color) return group.color; @@ -163,6 +177,25 @@ const TaskGroup: React.FC = React.memo(({ 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 const containerStyle = useMemo(() => ({ backgroundColor: isOver @@ -212,7 +245,18 @@ const TaskGroup: React.FC = React.memo(({ className="task-table-cell task-table-header-cell" style={{ width: col.width }} > - {col.label && {col.label}} + {col.key === 'select' ? ( +
+ +
+ ) : ( + col.label && {col.label} + )} ))} @@ -451,6 +495,7 @@ const TaskGroup: React.FC = React.memo(({ align-items: center; padding: 0 12px; border-right: 1px solid var(--task-border-secondary, #f0f0f0); + border-bottom: 1px solid var(--task-border-secondary, #f0f0f0); font-size: 12px; white-space: nowrap; height: 40px; @@ -465,6 +510,49 @@ const TaskGroup: React.FC = React.memo(({ 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 { background-color: var(--task-drag-over-bg, #f0f8ff) !important; border-color: var(--task-drag-over-border, #40a9ff) !important; diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 668fe248..d21b08e4 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -65,7 +65,7 @@ const TaskRow: React.FC = React.memo(({ // Edit task name state const [editTaskName, setEditTaskName] = useState(false); const [taskName, setTaskName] = useState(task.title || ''); - const inputRef = useRef(null); + const inputRef = useRef(null); const wrapperRef = useRef(null); const { @@ -108,7 +108,7 @@ const TaskRow: React.FC = React.memo(({ // Handle task name save const handleTaskNameSave = useCallback(() => { - const newTaskName = inputRef.current?.input?.value; + const newTaskName = inputRef.current?.value; if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) { socket?.emit( SocketEvents.TASK_NAME_CHANGE.toString(), @@ -284,12 +284,41 @@ const TaskRow: React.FC = React.memo(({ ); 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 ( -
+
- {!editTaskName ? ( + {editTaskName ? ( + ) => setTaskName(e.target.value)} + onBlur={handleTaskNameSave} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleTaskNameSave(); + } + }} + style={{ + background: 'transparent', + border: 'none', + outline: 'none', + width: '100%', + color: isDarkMode ? '#ffffff' : '#262626' + }} + autoFocus + /> + ) : ( setEditTaskName(true)} @@ -298,21 +327,6 @@ const TaskRow: React.FC = React.memo(({ > {task.title} - ) : ( - ) => 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, - }} - /> )}
@@ -459,4 +473,50 @@ const TaskRow: React.FC = React.memo(({ 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; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx index 3b18ab1c..b583079d 100644 --- a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -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 { RootState } from '@/app/store'; import { TaskListField } from '@/features/task-management/taskListFields.slice'; +import { Checkbox } from '@/components'; interface VirtualizedTaskListProps { group: any; @@ -32,6 +33,9 @@ const VirtualizedTaskList: React.FC = React.memo(({ }) => { 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 const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[]; @@ -51,98 +55,99 @@ const VirtualizedTaskList: React.FC = React.memo(({ .filter((task: Task | undefined): task is Task => task !== undefined); }, [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 HEADER_HEIGHT = 40; const COLUMN_HEADER_HEIGHT = 40; + const ADD_TASK_ROW_HEIGHT = 40; - // Calculate the actual height needed for the virtualized list - const actualContentHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT); - const listHeight = Math.min(height - 40, actualContentHeight); - - // 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(null); - const headerScrollRef = useRef(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); - } - }; - }, []); + // Calculate dynamic height for the group + const taskRowsHeight = groupTasks.length * TASK_ROW_HEIGHT; + const groupHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + taskRowsHeight + ADD_TASK_ROW_HEIGHT; // Define all possible columns - const allColumns = [ - { key: 'drag', label: '', width: 40, fixed: true, alwaysVisible: true }, - { key: 'select', label: '', width: 40, fixed: true, alwaysVisible: true }, - { key: 'key', label: 'KEY', width: 80, fixed: true, fieldKey: 'KEY' }, - { key: 'task', label: 'TASK', width: 475, fixed: true, 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' }, - { 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' }, + const allFixedColumns = [ + { key: 'drag', label: '', width: 40, alwaysVisible: true }, + { key: 'select', label: '', width: 40, alwaysVisible: true }, + { key: 'key', label: 'KEY', width: 80, fieldKey: 'KEY' }, + { key: 'task', label: 'TASK', width: 474, alwaysVisible: true }, + ]; + + const allScrollableColumns = [ + { key: 'progress', label: 'Progress', width: 90, fieldKey: 'PROGRESS' }, + { key: 'members', label: 'Members', width: 150, fieldKey: 'ASSIGNEES' }, + { 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 - const visibleColumns = useMemo(() => { - const filtered = allColumns.filter(col => { + const fixedColumns = useMemo(() => { + return allFixedColumns.filter(col => { // Always show columns marked as alwaysVisible if (col.alwaysVisible) return true; // For other columns, check field visibility if (col.fieldKey) { const field = taskListFields.find(f => f.key === col.fieldKey); - const isVisible = field?.visible ?? false; - console.log(`Column ${col.key} (fieldKey: ${col.fieldKey}):`, { field, isVisible }); - return isVisible; + return field?.visible ?? false; } return false; }); - - console.log('Visible columns after filtering:', filtered.map(c => ({ key: c.key, fieldKey: c.fieldKey }))); - return filtered; - }, [taskListFields, allColumns]); + }, [taskListFields, allFixedColumns]); + + const scrollableColumns = useMemo(() => { + 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 scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0); 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 task = groupTasks[index]; + const task: Task | undefined = groupTasks[index]; if (!task) return null; return (
= React.memo(({ ); }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks, fixedColumns, scrollableColumns]); + const scrollContainerRef = useRef(null); + const headerScrollRef = useRef(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 ( -
+
{/* Group Header */} -
+
= React.memo(({
= React.memo(({ className="task-table-cell task-table-header-cell fixed-column" style={{ width: col.width }} > - {col.label} + {col.key === 'select' ? ( +
+ +
+ ) : ( + {col.label} + )}
))}
@@ -220,30 +260,62 @@ const VirtualizedTaskList: React.FC = React.memo(({
- {/* Scrollable List */} + {/* Scrollable List - only task rows */}
0 ? taskRowsHeight : 'auto', + }} > - - {Row} - + {shouldVirtualize ? ( + + {Row} + + ) : ( + groupTasks.map((task: Task, index: number) => ( +
+ +
+ )) + )}
{/* Add Task Row - Always show at the bottom */}
@@ -275,7 +347,10 @@ const VirtualizedTaskList: React.FC = React.memo(({ min-width: 1200px; } .task-list-scroll-container { - width: 100%; + scrollbar-width: none; /* Firefox */ + } + .task-list-scroll-container::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ } .react-window-list { outline: none; @@ -312,7 +387,7 @@ const VirtualizedTaskList: React.FC = React.memo(({ /* Task group header styles */ .task-group-header-row { display: inline-flex; - height: auto; + height: inherit; max-height: none; overflow: hidden; margin: 0; diff --git a/worklenz-frontend/src/features/task-management/taskListFields.slice.ts b/worklenz-frontend/src/features/task-management/taskListFields.slice.ts index 873dd037..78c8c9b1 100644 --- a/worklenz-frontend/src/features/task-management/taskListFields.slice.ts +++ b/worklenz-frontend/src/features/task-management/taskListFields.slice.ts @@ -69,15 +69,12 @@ const taskListFieldsSlice = createSlice({ const field = state.find(f => f.key === action.payload); if (field) { field.visible = !field.visible; - saveFields(state); } }, setFields(state, action: PayloadAction) { - saveFields(action.payload); return action.payload; }, resetFields() { - saveFields(DEFAULT_FIELDS); return DEFAULT_FIELDS; }, }, diff --git a/worklenz-frontend/src/hooks/useFilterDataLoader.ts b/worklenz-frontend/src/hooks/useFilterDataLoader.ts index e3aa4f41..4ec5f13f 100644 --- a/worklenz-frontend/src/hooks/useFilterDataLoader.ts +++ b/worklenz-frontend/src/hooks/useFilterDataLoader.ts @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useMemo } from 'react'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppSelector } from '@/hooks/useAppSelector'; import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; @@ -15,13 +15,11 @@ import { getTeamMembers } from '@/features/team-members/team-members.slice'; export const useFilterDataLoader = () => { const dispatch = useAppDispatch(); - const { priorities } = useAppSelector(state => ({ - priorities: state.priorityReducer.priorities, - })); + // Memoize the priorities selector to prevent unnecessary re-renders + const priorities = useAppSelector(state => state.priorityReducer.priorities); - const { projectId } = useAppSelector(state => ({ - projectId: state.projectReducer.projectId, - })); + // Memoize the projectId selector to prevent unnecessary re-renders + const projectId = useAppSelector(state => state.projectReducer.projectId); // Load filter data asynchronously const loadFilterData = useCallback(async () => {