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.
This commit is contained in:
chamiakJ
2025-06-26 00:07:19 +05:30
parent 4c34a01729
commit 8c02ad9291

View File

@@ -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<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
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<HTMLDivElement>(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<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 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<HTMLDivElement>(null);
// Debounced save to localStorage using custom debounce
const debouncedSaveFields = useMemo(() => debounce((fieldsToSave: typeof fields) => {
// Debounced save to localStorage using enhanced debounce
const debouncedSaveFields = useMemo(() =>
createDebouncedFunction((fieldsToSave: typeof fields) => {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fieldsToSave));
}, 300), []);
}, 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 (
<div className="relative" ref={dropdownRef}>
@@ -604,6 +633,11 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
const [showArchived, setShowArchived] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(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<ImprovedTaskFiltersProps> = ({
return filterSectionsData;
}, [filterSectionsData]);
// Only update filter sections if they have actually changed
useEffect(() => {
const hasChanged = JSON.stringify(filterSections) !== JSON.stringify(memoizedFilterSections);
if (hasChanged && memoizedFilterSections.length > 0) {
setFilterSections(memoizedFilterSections);
}, [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<ImprovedTaskFiltersProps> = ({
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<ImprovedTaskFiltersProps> = ({
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,26 +790,40 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
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));
}
// Use debounced search
debouncedSearchChangeRef.current?.(projectId, value);
}, [projectId]);
console.log('Search change:', value, { projectView, projectId });
const clearAllFilters = useCallback(async () => {
if (!projectId || clearingFilters) return;
// Trigger task refetch with new search value
dispatch(fetchTasksV3(projectId));
}, [projectView, projectId, dispatch]);
// Set loading state to prevent multiple clicks
setClearingFilters(true);
const clearAllFilters = useCallback(() => {
if (!projectId) return;
try {
// Cancel any pending debounced calls
debouncedFilterChangeRef.current?.cancel();
debouncedSearchChangeRef.current?.cancel();
// Clear search
// 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 {
@@ -762,15 +846,23 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
// Clear priority filters
dispatch(setPriorities([]));
};
// Clear other filters
setShowArchived(false);
// Execute Redux updates
reduxUpdates();
// Trigger task refetch with cleared filters
// 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));
console.log('Clear all filters');
}, [projectId, projectView, dispatch, currentTaskLabels, currentTaskAssignees]);
// 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(() => {
setShowArchived(!showArchived);
@@ -823,11 +915,16 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
</span>
<button
onClick={clearAllFilters}
className={`text-xs text-blue-600 hover:text-blue-700 font-medium transition-colors duration-150 ${
isDarkMode ? 'text-blue-400 hover:text-blue-300' : ''
disabled={clearingFilters}
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>
</div>
)}
@@ -865,7 +962,19 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
<SearchOutlined className="w-2.5 h-2.5" />
<span>"{searchValue}"</span>
<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 ${
isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200'
}`}
@@ -896,6 +1005,14 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
<span>{option.label}</span>
<button
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);
handleSelectionChange(section.id, newValues);
}}