From 8c02ad9291b3a52fd87f67b611a275eef76f3bb8 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Thu, 26 Jun 2025 00:07:19 +0530 Subject: [PATCH] feat(task-filters): enhance performance and debounce functionality in task filters - Introduced performance constants to limit filter options and improve UI responsiveness. - Implemented an enhanced debounced function with cancellation support for filter and search changes, reducing unnecessary API calls. - Optimized filter data retrieval and state updates using memoization to prevent redundant calculations. - Improved the clear all filters functionality to batch state updates and prevent multiple re-renders, enhancing user experience. - Updated the handling of search input to immediately clear and dispatch actions, ensuring efficient task fetching. --- .../task-management/improved-task-filters.tsx | 305 ++++++++++++------ 1 file changed, 211 insertions(+), 94 deletions(-) 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 716de798..54a6025d 100644 --- a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -28,6 +28,11 @@ import { setCurrentGrouping, selectCurrentGrouping } from '@/features/task-manag import { setMembers, setLabels, setSearch, setPriorities } from '@/features/tasks/tasks.slice'; import { setBoardSearch } from '@/features/board/board-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 const selectFilterData = createSelector( [ @@ -88,6 +93,33 @@ interface ImprovedTaskFiltersProps { className?: string; } +// Enhanced debounce with cancellation support +function createDebouncedFunction void>( + func: T, + delay: number +): T & { cancel: () => void } { + let timeoutId: ReturnType | 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 const useFilterData = (): FilterSection[] => { const { t } = useTranslation('task-list-filters'); @@ -111,7 +143,7 @@ const useFilterData = (): FilterSection[] => { { id: 'priority', label: 'Priority', - options: filterData.priorities.map((p: any) => ({ + options: filterData.priorities.slice(0, MAX_FILTER_OPTIONS).map((p: any) => ({ value: p.id, label: p.name, color: p.color_code, @@ -128,7 +160,7 @@ const useFilterData = (): FilterSection[] => { multiSelect: true, searchable: true, selectedValues: currentAssignees.filter((m: any) => m.selected && m.id).map((m: any) => m.id || ''), - options: currentAssignees.map((assignee: any) => ({ + options: currentAssignees.slice(0, MAX_FILTER_OPTIONS).map((assignee: any) => ({ id: assignee.id || '', label: assignee.name || '', value: assignee.id || '', @@ -143,7 +175,7 @@ const useFilterData = (): FilterSection[] => { multiSelect: true, searchable: true, selectedValues: currentLabels.filter((l: any) => l.selected && l.id).map((l: any) => l.id || ''), - options: currentLabels.map((label: any) => ({ + options: currentLabels.slice(0, MAX_FILTER_OPTIONS).map((label: any) => ({ id: label.id || '', label: label.name || '', value: label.id || '', @@ -187,19 +219,23 @@ const FilterDropdown: React.FC<{ const [filteredOptions, setFilteredOptions] = useState(section.options); const dropdownRef = useRef(null); - // Filter options based on search term - useEffect(() => { + // Memoized filter function to prevent unnecessary recalculations + const filteredOptionsMemo = useMemo(() => { if (!section.searchable || !searchTerm.trim()) { - setFilteredOptions(section.options); - return; + return section.options; } - const filtered = section.options.filter(option => - option.label.toLowerCase().includes(searchTerm.toLowerCase()) + const searchLower = searchTerm.toLowerCase(); + return section.options.filter(option => + option.label.toLowerCase().includes(searchLower) ); - setFilteredOptions(filtered); }, [searchTerm, section.options, section.searchable]); + // Update filtered options when memo changes + useEffect(() => { + setFilteredOptions(filteredOptionsMemo); + }, [filteredOptionsMemo]); + // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -454,35 +490,28 @@ 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); 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 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), []); + // Debounced save to localStorage using enhanced debounce + const debouncedSaveFields = useMemo(() => + createDebouncedFunction((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 */ }; + return () => debouncedSaveFields.cancel(); }, [fields, debouncedSaveFields]); // Close dropdown on outside click @@ -497,7 +526,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ return () => document.removeEventListener('mousedown', handleClick); }, [open]); - const visibleCount = sortedFields.filter(field => field.visible).length; + const visibleCount = useMemo(() => sortedFields.filter(field => field.visible).length, [sortedFields]); return (
@@ -604,6 +633,11 @@ const ImprovedTaskFilters: React.FC = ({ const [showArchived, setShowArchived] = useState(false); const [openDropdown, setOpenDropdown] = useState(null); 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 const filterSectionsData = useFilterData(); @@ -618,9 +652,13 @@ const ImprovedTaskFilters: React.FC = ({ return filterSectionsData; }, [filterSectionsData]); + // Only update filter sections if they have actually changed useEffect(() => { - setFilterSections(memoizedFilterSections); - }, [memoizedFilterSections]); + const hasChanged = JSON.stringify(filterSections) !== JSON.stringify(memoizedFilterSections); + if (hasChanged && memoizedFilterSections.length > 0) { + setFilterSections(memoizedFilterSections); + } + }, [memoizedFilterSections, filterSections]); // Redux selectors for theme and other state const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); @@ -649,12 +687,47 @@ const ImprovedTaskFilters: React.FC = ({ searchText: isDarkMode ? 'text-gray-200' : 'text-gray-900', }), [isDarkMode]); - // Calculate active filters count + // Initialize debounced functions useEffect(() => { - const count = filterSections.reduce((acc, section) => acc + section.selectedValues.length, 0); - setActiveFiltersCount(count + (searchValue ? 1 : 0)); + // Debounced filter change function + 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]); + useEffect(() => { + if (activeFiltersCount !== calculatedActiveFiltersCount) { + setActiveFiltersCount(calculatedActiveFiltersCount); + } + }, [calculatedActiveFiltersCount, activeFiltersCount]); + // Handlers const handleDropdownToggle = useCallback((sectionId: string) => { setOpenDropdown(current => current === sectionId ? null : sectionId); @@ -668,49 +741,46 @@ const ImprovedTaskFilters: React.FC = ({ return; // Do nothing } - // Update local state first + // Update local state first for immediate UI feedback setFilterSections(prev => prev.map(section => section.id === sectionId ? { ...section, selectedValues: values } : section )); - // Use task management slices for groupBy + // Use task management slices for groupBy (immediate, no debounce) if (sectionId === 'groupBy' && values.length > 0) { dispatch(setCurrentGrouping(values[0] as 'status' | 'priority' | 'phase')); dispatch(fetchTasksV3(projectId)); return; } - // Handle priorities + // Handle priorities (with debounce) if (sectionId === 'priority') { - console.log('Priority selection changed:', { sectionId, values, projectId }); dispatch(setPriorities(values)); - dispatch(fetchTasksV3(projectId)); + debouncedFilterChangeRef.current?.(projectId); return; } - // Handle assignees (members) + // Handle assignees (members) (with debounce) if (sectionId === 'assignees') { - // Update selected property for each assignee const updatedAssignees = currentTaskAssignees.map(member => ({ ...member, selected: values.includes(member.id || '') })); dispatch(setMembers(updatedAssignees)); - dispatch(fetchTasksV3(projectId)); + debouncedFilterChangeRef.current?.(projectId); return; } - // Handle labels + // Handle labels (with debounce) if (sectionId === 'labels') { - // Update selected property for each label const updatedLabels = currentTaskLabels.map(label => ({ ...label, selected: values.includes(label.id || '') })); dispatch(setLabels(updatedLabels)); - dispatch(fetchTasksV3(projectId)); + debouncedFilterChangeRef.current?.(projectId); return; } }, [dispatch, projectId, currentTaskAssignees, currentTaskLabels]); @@ -720,57 +790,79 @@ const ImprovedTaskFilters: React.FC = ({ if (!projectId) return; - // Dispatch search action based on current view - if (projectView === 'list') { - // For list view, use the tasks slice - dispatch(setSearch(value)); - } else { - // For board view, use the board slice - dispatch(setBoardSearch(value)); - } - - console.log('Search change:', value, { projectView, projectId }); - - // Trigger task refetch with new search value - dispatch(fetchTasksV3(projectId)); - }, [projectView, projectId, dispatch]); + // Use debounced search + debouncedSearchChangeRef.current?.(projectId, value); + }, [projectId]); - const clearAllFilters = useCallback(() => { - if (!projectId) return; + const clearAllFilters = useCallback(async () => { + if (!projectId || clearingFilters) return; - // Clear search - setSearchValue(''); - if (projectView === 'list') { - dispatch(setSearch('')); - } else { - dispatch(setBoardSearch('')); + // Set loading state to prevent multiple clicks + 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); } - - // 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([])); - - // Clear other filters - setShowArchived(false); - - // Trigger task refetch with cleared filters - dispatch(fetchTasksV3(projectId)); - - console.log('Clear all filters'); - }, [projectId, projectView, dispatch, currentTaskLabels, currentTaskAssignees]); + }, [projectId, projectView, dispatch, currentTaskLabels, currentTaskAssignees, clearingFilters]); const toggleArchived = useCallback(() => { setShowArchived(!showArchived); @@ -823,11 +915,16 @@ const ImprovedTaskFilters: React.FC = ({
)} @@ -865,7 +962,19 @@ const ImprovedTaskFilters: React.FC = ({ "{searchValue}"