Merge branch 'release/v2.0.4' into refact/board-task-card-performance

This commit is contained in:
Chamika J
2025-06-26 09:50:30 +05:30
committed by GitHub
10 changed files with 381 additions and 199 deletions

View File

@@ -10,6 +10,8 @@ import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth'; import { useAuthService } from '@/hooks/useAuth';
import { Avatar, Button, Checkbox } from '@/components'; import { Avatar, Button, Checkbox } from '@/components';
import { sortTeamMembers } from '@/utils/sort-team-members'; import { sortTeamMembers } from '@/utils/sort-team-members';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
interface AssigneeSelectorProps { interface AssigneeSelectorProps {
task: IProjectTask; task: IProjectTask;
@@ -34,6 +36,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers); const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers);
const currentSession = useAuthService().getCurrentSession(); const currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket(); const { socket } = useSocket();
const dispatch = useAppDispatch();
const filteredMembers = useMemo(() => { const filteredMembers = useMemo(() => {
return teamMembers?.data?.filter(member => return teamMembers?.data?.filter(member =>
@@ -149,6 +152,11 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
return assignees?.includes(memberId) || false; return assignees?.includes(memberId) || false;
}; };
const handleInviteProjectMemberDrawer = () => {
setIsOpen(false); // Close the assignee dropdown first
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
};
return ( return (
<> <>
<button <button
@@ -271,10 +279,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
: 'text-blue-600 hover:bg-blue-50' : 'text-blue-600 hover:bg-blue-50'
} }
`} `}
onClick={() => { onClick={handleInviteProjectMemberDrawer}
// TODO: Implement invite member functionality
console.log('Invite member clicked');
}}
> >
<UserAddOutlined /> <UserAddOutlined />
Invite member Invite member

View File

@@ -1,50 +1,42 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { colors } from '@/styles/colors'; import { Spin } from 'antd';
import { getInitialTheme } from '@/utils/get-initial-theme';
import { ConfigProvider, theme, Layout, Spin } from 'antd';
// Memoized loading component with theme awareness // Lightweight loading component - removed heavy theme calculations
export const SuspenseFallback = memo(() => { export const SuspenseFallback = memo(() => {
const currentTheme = getInitialTheme();
const isDark = currentTheme === 'dark';
// Memoize theme configuration to prevent unnecessary re-renders
const themeConfig = React.useMemo(() => ({
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
components: {
Layout: {
colorBgLayout: isDark ? colors.darkGray : '#fafafa',
},
Spin: {
colorPrimary: isDark ? '#fff' : '#1890ff',
},
},
}), [isDark]);
// Memoize layout style to prevent object recreation
const layoutStyle = React.useMemo(() => ({
position: 'fixed' as const,
width: '100vw',
height: '100vh',
background: 'transparent',
transition: 'none',
}), []);
// Memoize spin style to prevent object recreation
const spinStyle = React.useMemo(() => ({
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
}), []);
return ( return (
<ConfigProvider theme={themeConfig}> <div
<Layout className="app-loading-container" style={layoutStyle}> style={{
<Spin size="large" style={spinStyle} /> position: 'fixed',
</Layout> width: '100vw',
</ConfigProvider> height: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'transparent',
zIndex: 9999,
}}
>
<Spin size="large" />
</div>
);
});
// Lightweight fallback for internal components that doesn't cover the screen
export const InlineSuspenseFallback = memo(() => {
return (
<div
style={{
padding: '40px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
}}
>
<Spin size="large" />
</div>
); );
}); });
SuspenseFallback.displayName = 'SuspenseFallback'; SuspenseFallback.displayName = 'SuspenseFallback';
InlineSuspenseFallback.displayName = 'InlineSuspenseFallback';

View File

@@ -14,33 +14,20 @@ import {
EyeOutlined, EyeOutlined,
InboxOutlined, InboxOutlined,
CheckOutlined, CheckOutlined,
SettingOutlined,
MoreOutlined,
} from './antd-imports'; } from './antd-imports';
import { RootState } from '@/app/store'; import { RootState } from '@/app/store';
import { AppDispatch } from '@/app/store';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
import useTabSearchParam from '@/hooks/useTabSearchParam'; import useTabSearchParam from '@/hooks/useTabSearchParam';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { colors } from '@/styles/colors';
import SingleAvatar from '@components/common/single-avatar/single-avatar';
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader'; import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import { import { toggleField } from '@/features/task-management/taskListFields.slice';
Dropdown,
Checkbox,
Button,
Space,
taskManagementAntdConfig
} from './antd-imports';
import { toggleField, TaskListField } from '@/features/task-management/taskListFields.slice';
// Import Redux actions // Import Redux actions
import { fetchTasksV3, setSelectedPriorities } from '@/features/task-management/task-management.slice'; import { fetchTasksV3, setSearch as setTaskManagementSearch } from '@/features/task-management/task-management.slice';
import { setCurrentGrouping, selectCurrentGrouping } from '@/features/task-management/grouping.slice'; import { setCurrentGrouping, selectCurrentGrouping } from '@/features/task-management/grouping.slice';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { fetchLabelsByProject, fetchTaskAssignees, setMembers, setLabels } from '@/features/tasks/tasks.slice'; import { fetchLabelsByProject, fetchTaskAssignees, setMembers, setLabels, setSearch, setPriorities } from '@/features/tasks/tasks.slice';
import { getTeamMembers } from '@/features/team-members/team-members.slice'; import { getTeamMembers } from '@/features/team-members/team-members.slice';
import { ITaskPriority } from '@/types/tasks/taskPriority.types'; import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { ITaskListColumn } from '@/types/tasks/taskList.types'; import { ITaskListColumn } from '@/types/tasks/taskList.types';
@@ -57,6 +44,11 @@ import {
fetchEnhancedKanbanGroups, fetchEnhancedKanbanGroups,
} from '@/features/enhanced-kanban/enhanced-kanban.slice'; } from '@/features/enhanced-kanban/enhanced-kanban.slice';
// Performance constants
const FILTER_DEBOUNCE_DELAY = 300; // ms
const SEARCH_DEBOUNCE_DELAY = 500; // ms
const MAX_FILTER_OPTIONS = 100; // Limit options to prevent UI lag
// Optimized selectors with proper transformation logic // Optimized selectors with proper transformation logic
const selectFilterData = createSelector( const selectFilterData = createSelector(
[ [
@@ -68,7 +60,6 @@ const selectFilterData = createSelector(
(state: any) => state.taskReducer.taskAssignees, (state: any) => state.taskReducer.taskAssignees,
(state: any) => state.boardReducer.taskAssignees, (state: any) => state.boardReducer.taskAssignees,
(state: any) => state.projectReducer.project, (state: any) => state.projectReducer.project,
(state: any) => state.taskManagement.selectedPriorities,
], ],
( (
priorities, priorities,
@@ -78,8 +69,7 @@ const selectFilterData = createSelector(
boardLabels, boardLabels,
taskAssignees, taskAssignees,
boardAssignees, boardAssignees,
project, project
selectedPriorities
) => ({ ) => ({
priorities: priorities || [], priorities: priorities || [],
taskPriorities: taskPriorities || [], taskPriorities: taskPriorities || [],
@@ -89,7 +79,7 @@ const selectFilterData = createSelector(
taskAssignees: taskAssignees || [], taskAssignees: taskAssignees || [],
boardAssignees: boardAssignees || [], boardAssignees: boardAssignees || [],
project, project,
selectedPriorities: selectedPriorities || [], selectedPriorities: taskPriorities || [], // Use taskReducer.priorities as selected priorities
}) })
); );
@@ -119,6 +109,33 @@ interface ImprovedTaskFiltersProps {
className?: string; className?: string;
} }
// Enhanced debounce with cancellation support
function createDebouncedFunction<T extends (...args: any[]) => void>(
func: T,
delay: number
): T & { cancel: () => void } {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const debouncedFunc = ((...args: any[]) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
func(...args);
timeoutId = null;
}, delay);
}) as T & { cancel: () => void };
debouncedFunc.cancel = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};
return debouncedFunc;
}
// Get real filter data from Redux state // Get real filter data from Redux state
const useFilterData = (position: 'board' | 'list'): FilterSection[] => { const useFilterData = (position: 'board' | 'list'): FilterSection[] => {
const { t } = useTranslation('task-list-filters'); const { t } = useTranslation('task-list-filters');
@@ -267,6 +284,7 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => {
]; ];
} }
}, [isBoard, kanbanState, kanbanProject, filterData, currentProjectView, t, currentGrouping]); }, [isBoard, kanbanState, kanbanProject, filterData, currentProjectView, t, currentGrouping]);
}; };
// Filter Dropdown Component // Filter Dropdown Component
@@ -283,19 +301,23 @@ const FilterDropdown: React.FC<{
const [filteredOptions, setFilteredOptions] = useState(section.options); const [filteredOptions, setFilteredOptions] = useState(section.options);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Filter options based on search term // Memoized filter function to prevent unnecessary recalculations
useEffect(() => { const filteredOptionsMemo = useMemo(() => {
if (!section.searchable || !searchTerm.trim()) { if (!section.searchable || !searchTerm.trim()) {
setFilteredOptions(section.options); return section.options;
return;
} }
const filtered = section.options.filter(option => const searchLower = searchTerm.toLowerCase();
option.label.toLowerCase().includes(searchTerm.toLowerCase()) return section.options.filter(option =>
option.label.toLowerCase().includes(searchLower)
); );
setFilteredOptions(filtered);
}, [searchTerm, section.options, section.searchable]); }, [searchTerm, section.options, section.searchable]);
// Update filtered options when memo changes
useEffect(() => {
setFilteredOptions(filteredOptionsMemo);
}, [filteredOptionsMemo]);
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
@@ -550,35 +572,28 @@ 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 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);
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : []; const fields = Array.isArray(fieldsRaw) ? fieldsRaw : [];
const sortedFields = [...fields].sort((a, b) => a.order - b.order); const sortedFields = useMemo(() => [...fields].sort((a, b) => a.order - b.order), [fields]);
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 // Debounced save to localStorage using enhanced debounce
const debouncedSaveFields = useMemo(() => debounce((fieldsToSave: typeof fields) => { const debouncedSaveFields = useMemo(() =>
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fieldsToSave)); createDebouncedFunction((fieldsToSave: typeof fields) => {
}, 300), []); localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fieldsToSave));
}, 300),
[]);
useEffect(() => { useEffect(() => {
debouncedSaveFields(fields); debouncedSaveFields(fields);
// Cleanup debounce on unmount // Cleanup debounce on unmount
return () => { /* no cancel needed for custom debounce */ }; return () => debouncedSaveFields.cancel();
}, [fields, debouncedSaveFields]); }, [fields, debouncedSaveFields]);
// Close dropdown on outside click // Close dropdown on outside click
@@ -593,7 +608,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
return () => document.removeEventListener('mousedown', handleClick); return () => document.removeEventListener('mousedown', handleClick);
}, [open]); }, [open]);
const visibleCount = sortedFields.filter(field => field.visible).length; const visibleCount = useMemo(() => sortedFields.filter(field => field.visible).length, [sortedFields]);
return ( return (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
@@ -686,13 +701,10 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
}) => { }) => {
const { t } = useTranslation('task-list-filters'); const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { socket, connected } = useSocket();
// Get current state values for filter updates // Get current state values for filter updates
const currentTaskAssignees = useAppSelector(state => state.taskReducer.taskAssignees); const currentTaskAssignees = useAppSelector(state => state.taskReducer.taskAssignees);
const currentBoardAssignees = useAppSelector(state => state.boardReducer.taskAssignees);
const currentTaskLabels = useAppSelector(state => state.taskReducer.labels); const currentTaskLabels = useAppSelector(state => state.taskReducer.labels);
const currentBoardLabels = useAppSelector(state => state.boardReducer.labels);
// Use the filter data loader hook // Use the filter data loader hook
useFilterDataLoader(); useFilterDataLoader();
@@ -703,6 +715,11 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
const [showArchived, setShowArchived] = useState(false); const [showArchived, setShowArchived] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(null); const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const [activeFiltersCount, setActiveFiltersCount] = useState(0); const [activeFiltersCount, setActiveFiltersCount] = useState(0);
const [clearingFilters, setClearingFilters] = useState(false);
// Refs for debounced functions
const debouncedFilterChangeRef = useRef<((projectId: string) => void) & { cancel: () => void } | null>(null);
const debouncedSearchChangeRef = useRef<((projectId: string, value: string) => void) & { cancel: () => void } | null>(null);
// Get real filter data // Get real filter data
const filterSectionsData = useFilterData(position); const filterSectionsData = useFilterData(position);
@@ -717,15 +734,18 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
return filterSectionsData; return filterSectionsData;
}, [filterSectionsData]); }, [filterSectionsData]);
// Only update filter sections if they have actually changed
useEffect(() => { useEffect(() => {
setFilterSections(memoizedFilterSections); const hasChanged = JSON.stringify(filterSections) !== JSON.stringify(memoizedFilterSections);
}, [memoizedFilterSections]); if (hasChanged && memoizedFilterSections.length > 0) {
setFilterSections(memoizedFilterSections);
}
}, [memoizedFilterSections, filterSections]);
// Redux selectors for theme and other state // Redux selectors for theme and other state
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
const { projectId } = useAppSelector(state => state.projectReducer); const { projectId } = useAppSelector(state => state.projectReducer);
const { projectView } = useTabSearchParam(); const { projectView } = useTabSearchParam();
const { columns } = useAppSelector(state => state.taskReducer);
// Theme-aware class names - memoize to prevent unnecessary re-renders // Theme-aware class names - memoize to prevent unnecessary re-renders
const themeClasses = useMemo(() => ({ const themeClasses = useMemo(() => ({
@@ -749,12 +769,47 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
searchText: isDarkMode ? 'text-gray-200' : 'text-gray-900', searchText: isDarkMode ? 'text-gray-200' : 'text-gray-900',
}), [isDarkMode]); }), [isDarkMode]);
// Calculate active filters count // Initialize debounced functions
useEffect(() => { useEffect(() => {
const count = filterSections.reduce((acc, section) => acc + section.selectedValues.length, 0); // Debounced filter change function
setActiveFiltersCount(count + (searchValue ? 1 : 0)); debouncedFilterChangeRef.current = createDebouncedFunction((projectId: string) => {
dispatch(fetchTasksV3(projectId));
}, FILTER_DEBOUNCE_DELAY);
// Debounced search change function
debouncedSearchChangeRef.current = createDebouncedFunction((projectId: string, value: string) => {
// Dispatch search action based on current view
if (projectView === 'list') {
dispatch(setSearch(value));
} else {
dispatch(setBoardSearch(value));
}
// Trigger task refetch with new search value
dispatch(fetchTasksV3(projectId));
}, SEARCH_DEBOUNCE_DELAY);
// Cleanup function
return () => {
debouncedFilterChangeRef.current?.cancel();
debouncedSearchChangeRef.current?.cancel();
};
}, [dispatch, projectView]);
// Calculate active filters count - memoized to prevent unnecessary recalculations
const calculatedActiveFiltersCount = useMemo(() => {
const count = filterSections.reduce((acc, section) =>
section.id === 'groupBy' ? acc : acc + section.selectedValues.length, 0
);
return count + (searchValue ? 1 : 0);
}, [filterSections, searchValue]); }, [filterSections, searchValue]);
useEffect(() => {
if (activeFiltersCount !== calculatedActiveFiltersCount) {
setActiveFiltersCount(calculatedActiveFiltersCount);
}
}, [calculatedActiveFiltersCount, activeFiltersCount]);
// Handlers // Handlers
const handleDropdownToggle = useCallback((sectionId: string) => { const handleDropdownToggle = useCallback((sectionId: string) => {
setOpenDropdown(current => current === sectionId ? null : sectionId); setOpenDropdown(current => current === sectionId ? null : sectionId);
@@ -819,26 +874,96 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
dispatch(fetchTasksV3(projectId)); dispatch(fetchTasksV3(projectId));
return; return;
} }
} }
}, [dispatch, projectId, position, currentTaskAssignees, currentTaskLabels]); }, [dispatch, projectId, position, currentTaskAssignees, currentTaskLabels]);
const handleSearchChange = useCallback((value: string) => { const handleSearchChange = useCallback((value: string) => {
setSearchValue(value); setSearchValue(value);
if (!projectId) return;
if (position === 'board') { if (position === 'board') {
dispatch(setKanbanSearch(value)); dispatch(setKanbanSearch(value));
dispatch(fetchEnhancedKanbanGroups(projectId)); dispatch(fetchEnhancedKanbanGroups(projectId));
} else { } else {
// ... existing logic ... // Use debounced search
// TODO: Implement proper search dispatch for list debouncedSearchChangeRef.current?.(projectId, value);
} }
}, [dispatch, projectId, position]); }, [dispatch, projectId, position]);
const clearAllFilters = useCallback(() => { const clearAllFilters = useCallback(async () => {
// TODO: Implement clear all filters if (!projectId || clearingFilters) return;
console.log('Clear all filters');
setSearchValue(''); // Set loading state to prevent multiple clicks
setShowArchived(false); setClearingFilters(true);
}, []);
try {
// Cancel any pending debounced calls
debouncedFilterChangeRef.current?.cancel();
debouncedSearchChangeRef.current?.cancel();
// Batch all state updates together to prevent multiple re-renders
const batchUpdates = () => {
// Clear local state immediately for UI feedback
setSearchValue('');
setShowArchived(false);
// Update local filter sections state immediately
setFilterSections(prev => prev.map(section => ({
...section,
selectedValues: section.id === 'groupBy' ? section.selectedValues : [] // Keep groupBy, clear others
})));
};
// Execute all local state updates in a batch
batchUpdates();
// Prepare all Redux actions to be dispatched together
const reduxUpdates = () => {
// Clear search based on view
if (projectView === 'list') {
dispatch(setSearch(''));
} else {
dispatch(setBoardSearch(''));
}
// Clear label filters
const clearedLabels = currentTaskLabels.map(label => ({
...label,
selected: false
}));
dispatch(setLabels(clearedLabels));
// Clear assignee filters
const clearedAssignees = currentTaskAssignees.map(member => ({
...member,
selected: false
}));
dispatch(setMembers(clearedAssignees));
// Clear priority filters
dispatch(setPriorities([]));
};
// Execute Redux updates
reduxUpdates();
// Use a short timeout to batch Redux state updates before API call
// This ensures all filter state is updated before the API call
setTimeout(() => {
dispatch(fetchTasksV3(projectId));
// Reset loading state after API call is initiated
setTimeout(() => setClearingFilters(false), 100);
}, 0);
} catch (error) {
console.error('Error clearing filters:', error);
setClearingFilters(false);
}
}, [projectId, projectView, dispatch, currentTaskLabels, currentTaskAssignees, clearingFilters]);
const toggleArchived = useCallback(() => { const toggleArchived = useCallback(() => {
setShowArchived(!showArchived); setShowArchived(!showArchived);
@@ -850,13 +975,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
} }
}, [dispatch, projectId, position, showArchived]); }, [dispatch, projectId, position, showArchived]);
// Show fields dropdown functionality
const handleColumnVisibilityChange = useCallback(async (col: ITaskListColumn) => {
if (!projectId) return;
console.log('Column visibility change:', col);
// TODO: Implement column visibility change
}, [projectId]);
return ( return (
<div className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-3 shadow-sm ${className}`}> <div className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-3 shadow-sm ${className}`}>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -902,11 +1020,16 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
</span> </span>
<button <button
onClick={clearAllFilters} onClick={clearAllFilters}
className={`text-xs text-blue-600 hover:text-blue-700 font-medium transition-colors duration-150 ${ disabled={clearingFilters}
isDarkMode ? 'text-blue-400 hover:text-blue-300' : '' className={`text-xs font-medium transition-colors duration-150 ${
clearingFilters
? 'text-gray-400 cursor-not-allowed'
: isDarkMode
? 'text-blue-400 hover:text-blue-300'
: 'text-blue-600 hover:text-blue-700'
}`} }`}
> >
Clear all {clearingFilters ? 'Clearing...' : 'Clear all'}
</button> </button>
</div> </div>
)} )}
@@ -944,7 +1067,19 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
<SearchOutlined className="w-2.5 h-2.5" /> <SearchOutlined className="w-2.5 h-2.5" />
<span>"{searchValue}"</span> <span>"{searchValue}"</span>
<button <button
onClick={() => setSearchValue('')} onClick={() => {
setSearchValue('');
if (projectId) {
// Cancel pending search and immediately clear
debouncedSearchChangeRef.current?.cancel();
if (projectView === 'list') {
dispatch(setSearch(''));
} else {
dispatch(setBoardSearch(''));
}
dispatch(fetchTasksV3(projectId));
}
}}
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${ className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${
isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200' isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200'
}`} }`}
@@ -975,6 +1110,14 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
<span>{option.label}</span> <span>{option.label}</span>
<button <button
onClick={() => { onClick={() => {
// Update local state immediately for UI feedback
setFilterSections(prev => prev.map(s =>
s.id === section.id
? { ...s, selectedValues: s.selectedValues.filter(v => v !== value) }
: s
));
// Use debounced API call
const newValues = section.selectedValues.filter(v => v !== value); const newValues = section.selectedValues.filter(v => v !== value);
handleSelectionChange(section.id, newValues); handleSelectionChange(section.id, newValues);
}} }}

View File

@@ -17,6 +17,7 @@ const initialState: TaskManagementState = {
groups: [], groups: [],
grouping: null, grouping: null,
selectedPriorities: [], selectedPriorities: [],
search: '',
}; };
// Async thunk to fetch tasks from API // Async thunk to fetch tasks from API
@@ -140,20 +141,24 @@ export const fetchTasksV3 = createAsyncThunk(
// Get selected labels from taskReducer // Get selected labels from taskReducer
const selectedLabels = state.taskReducer.labels const selectedLabels = state.taskReducer.labels
? state.taskReducer.labels.filter(l => l.selected).map(l => l.id).join(',') ? state.taskReducer.labels.filter(l => l.selected).map(l => l.id).join(' ')
: ''; : '';
// Get selected assignees from taskReducer // Get selected assignees from taskReducer
const selectedAssignees = state.taskReducer.taskAssignees const selectedAssignees = state.taskReducer.taskAssignees
? state.taskReducer.taskAssignees.filter(m => m.selected).map(m => m.id).join(',') ? state.taskReducer.taskAssignees.filter(m => m.selected).map(m => m.id).join(' ')
: ''; : '';
// Get selected priorities from taskManagement slice // Get selected priorities from taskReducer (consistent with other slices)
const selectedPriorities = state.taskManagement.selectedPriorities const selectedPriorities = state.taskReducer.priorities
? state.taskManagement.selectedPriorities.join(',') ? state.taskReducer.priorities.join(' ')
: ''; : '';
// Get search value from taskReducer
const searchValue = state.taskReducer.search || '';
console.log('fetchTasksV3 - selectedPriorities:', selectedPriorities); console.log('fetchTasksV3 - selectedPriorities:', selectedPriorities);
console.log('fetchTasksV3 - searchValue:', searchValue);
const config: ITaskListConfigV2 = { const config: ITaskListConfigV2 = {
id: projectId, id: projectId,
@@ -161,7 +166,7 @@ export const fetchTasksV3 = createAsyncThunk(
group: currentGrouping, group: currentGrouping,
field: '', field: '',
order: '', order: '',
search: '', search: searchValue,
statuses: '', statuses: '',
members: selectedAssignees, members: selectedAssignees,
projects: '', projects: '',
@@ -328,6 +333,11 @@ const taskManagementSlice = createSlice({
setSelectedPriorities: (state, action: PayloadAction<string[]>) => { setSelectedPriorities: (state, action: PayloadAction<string[]>) => {
state.selectedPriorities = action.payload; state.selectedPriorities = action.payload;
}, },
// Search action
setSearch: (state, action: PayloadAction<string>) => {
state.search = action.payload;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
@@ -387,6 +397,7 @@ export const {
setLoading, setLoading,
setError, setError,
setSelectedPriorities, setSelectedPriorities,
setSearch,
} = taskManagementSlice.actions; } = taskManagementSlice.actions;
export default taskManagementSlice.reducer; export default taskManagementSlice.reducer;

View File

@@ -1,12 +1,15 @@
import React, { ReactNode } from 'react'; import React, { ReactNode, Suspense } from 'react';
import { InlineSuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
// Lazy load all project view components to reduce initial bundle size // Import core components synchronously to avoid suspense in main tabs
import ProjectViewEnhancedTasks from '@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks';
import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board';
// Lazy load less critical components
const ProjectViewInsights = React.lazy(() => import('@/pages/projects/projectView/insights/project-view-insights')); const ProjectViewInsights = React.lazy(() => import('@/pages/projects/projectView/insights/project-view-insights'));
const ProjectViewFiles = React.lazy(() => import('@/pages/projects/projectView/files/project-view-files')); const ProjectViewFiles = React.lazy(() => import('@/pages/projects/projectView/files/project-view-files'));
const ProjectViewMembers = React.lazy(() => import('@/pages/projects/projectView/members/project-view-members')); const ProjectViewMembers = React.lazy(() => import('@/pages/projects/projectView/members/project-view-members'));
const ProjectViewUpdates = React.lazy(() => import('@/pages/projects/project-view-1/updates/project-view-updates')); const ProjectViewUpdates = React.lazy(() => import('@/pages/projects/project-view-1/updates/project-view-updates'));
const ProjectViewEnhancedTasks = React.lazy(() => import('@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks'));
const ProjectViewEnhancedBoard = React.lazy(() => import('@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board'));
// type of a tab items // type of a tab items
type TabItems = { type TabItems = {
@@ -37,24 +40,36 @@ export const tabItems: TabItems[] = [
index: 2, index: 2,
key: 'project-insights-member-overview', key: 'project-insights-member-overview',
label: 'Insights', label: 'Insights',
element: React.createElement(ProjectViewInsights), element: React.createElement(Suspense,
{ fallback: React.createElement(InlineSuspenseFallback) },
React.createElement(ProjectViewInsights)
),
}, },
{ {
index: 3, index: 3,
key: 'all-attachments', key: 'all-attachments',
label: 'Files', label: 'Files',
element: React.createElement(ProjectViewFiles), element: React.createElement(Suspense,
{ fallback: React.createElement(InlineSuspenseFallback) },
React.createElement(ProjectViewFiles)
),
}, },
{ {
index: 4, index: 4,
key: 'members', key: 'members',
label: 'Members', label: 'Members',
element: React.createElement(ProjectViewMembers), element: React.createElement(Suspense,
{ fallback: React.createElement(InlineSuspenseFallback) },
React.createElement(ProjectViewMembers)
),
}, },
{ {
index: 5, index: 5,
key: 'updates', key: 'updates',
label: 'Updates', label: 'Updates',
element: React.createElement(ProjectViewUpdates), element: React.createElement(Suspense,
{ fallback: React.createElement(InlineSuspenseFallback) },
React.createElement(ProjectViewUpdates)
),
}, },
]; ];

View File

@@ -1,7 +1,7 @@
import { useEffect, useState, useRef, useMemo, useCallback, lazy, Suspense } from 'react'; import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
const TaskListFilters = lazy(() => import('../taskList/task-list-filters/task-list-filters')); import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
import { Flex, Skeleton } from 'antd'; import { Flex, Skeleton } from 'antd';
import BoardSectionCardContainer from './board-section/board-section-container'; import BoardSectionCardContainer from './board-section/board-section-container';
import { import {
@@ -44,6 +44,7 @@ import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types'; import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice'; import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice';
interface DroppableContainer { interface DroppableContainer {
id: UniqueIdentifier; id: UniqueIdentifier;
data: { data: {
@@ -554,9 +555,7 @@ const ProjectViewBoard = () => {
return ( return (
<Flex vertical gap={16}> <Flex vertical gap={16}>
<Suspense fallback={<div>Loading filters...</div>}> <TaskListFilters position={'board'} />
<TaskListFilters position={'board'} />
</Suspense>
<Skeleton active loading={isLoading} className='mt-4 p-4'> <Skeleton active loading={isLoading} className='mt-4 p-4'>
<DndContext <DndContext
sensors={sensors} sensors={sensors}

View File

@@ -54,6 +54,7 @@ import useTabSearchParam from '@/hooks/useTabSearchParam';
import { addTaskCardToTheTop, fetchBoardTaskGroups } from '@/features/board/board-slice'; import { addTaskCardToTheTop, fetchBoardTaskGroups } from '@/features/board/board-slice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
const ProjectViewHeader = () => { const ProjectViewHeader = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -83,6 +84,8 @@ const ProjectViewHeader = () => {
dispatch(fetchTaskListColumns(projectId)); dispatch(fetchTaskListColumns(projectId));
dispatch(fetchPhasesByProjectId(projectId)) dispatch(fetchPhasesByProjectId(projectId))
dispatch(fetchTaskGroups(projectId)); dispatch(fetchTaskGroups(projectId));
// Also refresh the enhanced tasks data
dispatch(fetchTasksV3(projectId));
break; break;
case 'board': case 'board':
// dispatch(fetchBoardTaskGroups(projectId)); // dispatch(fetchBoardTaskGroups(projectId));
@@ -166,7 +169,7 @@ const ProjectViewHeader = () => {
try { try {
setCreatingTask(true); setCreatingTask(true);
const body: ITaskCreateRequest = { const body: Partial<ITaskCreateRequest> = {
name: DEFAULT_TASK_NAME, name: DEFAULT_TASK_NAME,
project_id: selectedProject?.id, project_id: selectedProject?.id,
reporter_id: currentSession?.id, reporter_id: currentSession?.id,

View File

@@ -1,10 +1,10 @@
import { useEffect, useState, useMemo, lazy, Suspense } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { Empty } from '@/shared/antd-imports'; import { Empty } from '@/shared/antd-imports';
import Flex from 'antd/es/flex'; import Flex from 'antd/es/flex';
import Skeleton from 'antd/es/skeleton'; import Skeleton from 'antd/es/skeleton';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
const TaskListFilters = lazy(() => import('./task-list-filters/task-list-filters')); import TaskListFilters from './task-list-filters/task-list-filters';
import TaskGroupWrapperOptimized from './task-group-wrapper-optimized'; import TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -17,7 +17,7 @@ const ProjectViewTaskList = () => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam(); const { projectView } = useTabSearchParam();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [initialLoadComplete, setInitialLoadComplete] = useState(false); const [coreDataLoaded, setCoreDataLoaded] = useState(false);
// Split selectors to prevent unnecessary rerenders // Split selectors to prevent unnecessary rerenders
const projectId = useAppSelector(state => state.projectReducer.projectId); const projectId = useAppSelector(state => state.projectReducer.projectId);
@@ -33,11 +33,11 @@ const ProjectViewTaskList = () => {
const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases); const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases);
// Single source of truth for loading state - EXCLUDE labels loading from skeleton // Simplified loading state - only wait for essential data
// Labels loading should not block the main task list display // Remove dependency on phases and status categories for initial render
const isLoading = useMemo(() => const isLoading = useMemo(() =>
loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete, loadingGroups || !coreDataLoaded,
[loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete] [loadingGroups, coreDataLoaded]
); );
// Memoize the empty state check // Memoize the empty state check
@@ -56,53 +56,63 @@ const ProjectViewTaskList = () => {
} }
}, [projectView, setSearchParams, searchParams]); }, [projectView, setSearchParams, searchParams]);
// Batch initial data fetching - core data only // Optimized parallel data fetching - don't wait for everything
useEffect(() => { useEffect(() => {
const fetchInitialData = async () => { const fetchCoreData = async () => {
if (!projectId || !groupBy || initialLoadComplete) return; if (!projectId || !groupBy || coreDataLoaded) return;
try { try {
// Batch only essential API calls for initial load // Start all requests in parallel, but only wait for task columns
// Filter data (labels, assignees, etc.) will load separately and not block the UI // Other data can load in background without blocking UI
await Promise.allSettled([ const corePromises = [
dispatch(fetchTaskListColumns(projectId)), dispatch(fetchTaskListColumns(projectId)),
dispatch(fetchPhasesByProjectId(projectId)), dispatch(fetchTaskGroups(projectId)), // Start immediately
dispatch(fetchStatusesCategories()), ];
]);
setInitialLoadComplete(true); // Background data - don't wait for these
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchStatusesCategories());
// Only wait for essential data
await Promise.allSettled(corePromises);
setCoreDataLoaded(true);
} catch (error) { } catch (error) {
console.error('Error fetching initial data:', error); console.error('Error fetching core data:', error);
setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading setCoreDataLoaded(true); // Still mark as complete to prevent infinite loading
} }
}; };
fetchInitialData(); fetchCoreData();
}, [projectId, groupBy, dispatch, initialLoadComplete]); }, [projectId, groupBy, dispatch, coreDataLoaded]);
// Fetch task groups with dependency on initial load completion // Optimized task groups fetching - remove initialLoadComplete dependency
useEffect(() => { useEffect(() => {
const fetchTasks = async () => { const fetchTasks = async () => {
if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return; if (!projectId || !groupBy || projectView !== 'list') return;
try { try {
await dispatch(fetchTaskGroups(projectId)); // Only refetch if filters change, not on initial load
if (coreDataLoaded) {
await dispatch(fetchTaskGroups(projectId));
}
} catch (error) { } catch (error) {
console.error('Error fetching task groups:', error); console.error('Error fetching task groups:', error);
} }
}; };
fetchTasks(); // Only refetch when filters change
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]); if (coreDataLoaded) {
fetchTasks();
}
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, coreDataLoaded]);
// Memoize the task groups to prevent unnecessary re-renders // Memoize the task groups to prevent unnecessary re-renders
const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]); const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
return ( return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}> <Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
{/* Filters load independently and don't block the main content */} {/* Filters load synchronously - no suspense boundary */}
<Suspense fallback={<div>Loading filters...</div>}> <TaskListFilters position="list" />
<TaskListFilters position="list" />
</Suspense>
{isEmptyState ? ( {isEmptyState ? (
<Empty description="No tasks group found" /> <Empty description="No tasks group found" />

View File

@@ -16,13 +16,14 @@ import {
import { getTeamMembers } from '@/features/team-members/team-members.slice'; import { getTeamMembers } from '@/features/team-members/team-members.slice';
import useTabSearchParam from '@/hooks/useTabSearchParam'; import useTabSearchParam from '@/hooks/useTabSearchParam';
const SearchDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/search-dropdown')); // Import filter components synchronously for better performance
const SortFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/sort-filter-dropdown')); import SearchDropdown from '@components/project-task-filters/filter-dropdowns/search-dropdown';
const LabelsFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/labels-filter-dropdown')); import SortFilterDropdown from '@components/project-task-filters/filter-dropdowns/sort-filter-dropdown';
const MembersFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/members-filter-dropdown')); import LabelsFilterDropdown from '@components/project-task-filters/filter-dropdowns/labels-filter-dropdown';
const GroupByFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/group-by-filter-dropdown')); import MembersFilterDropdown from '@components/project-task-filters/filter-dropdowns/members-filter-dropdown';
const ShowFieldsFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/show-fields-filter-dropdown')); import GroupByFilterDropdown from '@components/project-task-filters/filter-dropdowns/group-by-filter-dropdown';
const PriorityFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/priority-filter-dropdown')); import ShowFieldsFilterDropdown from '@components/project-task-filters/filter-dropdowns/show-fields-filter-dropdown';
import PriorityFilterDropdown from '@components/project-task-filters/filter-dropdowns/priority-filter-dropdown';
interface TaskListFiltersProps { interface TaskListFiltersProps {
position: 'board' | 'list'; position: 'board' | 'list';
@@ -39,44 +40,46 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
const handleShowArchivedChange = () => dispatch(toggleArchived()); const handleShowArchivedChange = () => dispatch(toggleArchived());
// Load filter data asynchronously and non-blocking // Optimized filter data loading - staggered and non-blocking
// This runs independently of the main task list loading
useEffect(() => { useEffect(() => {
const loadFilterData = async () => { const loadFilterData = () => {
try { try {
// Load priorities first (usually cached/fast) // Load priorities first (usually cached/fast) - immediate
if (!priorities.length) { if (!priorities.length) {
dispatch(fetchPriorities()); dispatch(fetchPriorities());
} }
// Load project-specific filter data in parallel, but don't await
// This allows the main task list to load while filters are still loading
if (projectId) { if (projectId) {
// Fire and forget - these will update the UI when ready // Stagger the loading to prevent overwhelming the server
dispatch(fetchLabelsByProject(projectId)); // Load project-specific data with delays
dispatch(fetchTaskAssignees(projectId)); setTimeout(() => {
} dispatch(fetchLabelsByProject(projectId));
}, 100);
// Load team members (usually needed for member filters) setTimeout(() => {
dispatch(getTeamMembers({ dispatch(fetchTaskAssignees(projectId));
index: 0, }, 200);
size: 100,
field: null, // Load team members last (heaviest query)
order: null, setTimeout(() => {
search: null, dispatch(getTeamMembers({
all: true index: 0,
})); size: 50, // Reduce initial load size
field: null,
order: null,
search: null,
all: false // Don't load all at once
}));
}, 300);
}
} catch (error) { } catch (error) {
console.error('Error loading filter data:', error); console.error('Error loading filter data:', error);
// Don't throw - filter loading errors shouldn't break the main UI // Don't throw - filter loading errors shouldn't break the main UI
} }
}; };
// Use setTimeout to ensure this runs after the main component render // Load immediately without setTimeout to avoid additional delay
// This prevents filter loading from blocking the initial render loadFilterData();
const timeoutId = setTimeout(loadFilterData, 0);
return () => clearTimeout(timeoutId);
}, [dispatch, priorities.length, projectId]); }, [dispatch, priorities.length, projectId]);
return ( return (

View File

@@ -76,6 +76,7 @@ export interface TaskManagementState {
groups: TaskGroup[]; // Pre-processed groups from V3 API groups: TaskGroup[]; // Pre-processed groups from V3 API
grouping: string | null; // Current grouping from V3 API grouping: string | null; // Current grouping from V3 API
selectedPriorities: string[]; // Selected priority filters selectedPriorities: string[]; // Selected priority filters
search: string; // Search query for filtering tasks
} }
export interface TaskGroupsState { export interface TaskGroupsState {