diff --git a/src/components/Reports/ReportTableSearchPanel.jsx b/src/components/Reports/ReportTableSearchPanel.jsx index d22ac54113..51939621f9 100644 --- a/src/components/Reports/ReportTableSearchPanel.jsx +++ b/src/components/Reports/ReportTableSearchPanel.jsx @@ -1,14 +1,39 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; +import debounce from 'lodash/debounce'; import { SEARCH } from '../../languages/en/ui'; import styles from './reportsPage.module.css'; +const SEARCH_DEBOUNCE_MS = 300; + /** * The search panel stateless component for Report grid */ function ReportTableSearchPanel({ onSearch, wildCardSearchText, onSearchClick }) { const darkMode = useSelector(state => state.theme.darkMode); + // Keep the input responsive to every keystroke while debouncing the (expensive) + // filter recompute that onSearch triggers upstream in ReportsPage. + const [localSearchText, setLocalSearchText] = useState(wildCardSearchText ?? ''); + const onSearchRef = useRef(onSearch); + onSearchRef.current = onSearch; + + const debouncedSearch = useRef( + debounce(value => onSearchRef.current(value), SEARCH_DEBOUNCE_MS, { + leading: true, + trailing: true, + }), + ).current; + + useEffect(() => () => debouncedSearch.cancel(), [debouncedSearch]); + + // Stay in sync when the search text is reset externally (e.g. "Clear Filters"). + useEffect(() => { + if (wildCardSearchText === '' && localSearchText !== '') { + setLocalSearchText(''); + } + }, [wildCardSearchText]); + const handleSearchClick = () => { // Call the parent's search click handler if provided if (onSearchClick) { @@ -81,9 +106,11 @@ function ReportTableSearchPanel({ onSearch, wildCardSearchText, onSearchClick }) aria-label="Search" placeholder="Search Text" id="team-profiles-wild-card-search" - value={wildCardSearchText} + value={localSearchText} onChange={e => { - onSearch(e.target.value); // Use destructured onSearch directly + const { value } = e.target; + setLocalSearchText(value); + debouncedSearch(value); }} /> diff --git a/src/components/Reports/__tests__/ReportTableSearchPanel.test.jsx b/src/components/Reports/__tests__/ReportTableSearchPanel.test.jsx index 76728f5628..3afcb6130b 100644 --- a/src/components/Reports/__tests__/ReportTableSearchPanel.test.jsx +++ b/src/components/Reports/__tests__/ReportTableSearchPanel.test.jsx @@ -76,7 +76,9 @@ describe('', () => { vi.runAllTimers(); - expect(onSearchMock).toHaveBeenCalledTimes(5); // adjust this if debounce is implemented + // Debounce is leading+trailing: the first keystroke ('h') fires immediately, + // then rapid keystrokes collapse into a single trailing call with the final value. + expect(onSearchMock).toHaveBeenCalledTimes(2); expect(onSearchMock).toHaveBeenCalledWith('hello'); vi.useRealTimers(); }); diff --git a/src/components/Teams/Teams.jsx b/src/components/Teams/Teams.jsx index 680d7b7dab..c2f60f07e1 100644 --- a/src/components/Teams/Teams.jsx +++ b/src/components/Teams/Teams.jsx @@ -8,6 +8,7 @@ import { connect } from 'react-redux'; import { Container } from 'reactstrap'; import { toast } from 'react-toastify'; import isEqual from 'lodash/isEqual'; +import debounce from 'lodash/debounce'; import { searchWithAccent } from '../../utils/search'; import { getAllUserTeams, @@ -61,6 +62,10 @@ class Teams extends React.PureComponent { membersFetching: false, selectedTeamMembers: [], }; + this.onWildCardSearch = debounce(this.onWildCardSearch, 300, { + leading: true, + trailing: true, + }); } componentDidMount() { @@ -68,6 +73,10 @@ class Teams extends React.PureComponent { this.props.getAllUserProfile(); } + componentWillUnmount() { + this.onWildCardSearch.cancel(); + } + componentDidUpdate(prevProps, prevState) { const prevSlice = prevProps.state?.allTeamsData; const currSlice = this.props.state?.allTeamsData; diff --git a/src/components/UserManagement/UserManagement.jsx b/src/components/UserManagement/UserManagement.jsx index 141993a15f..37111a392f 100644 --- a/src/components/UserManagement/UserManagement.jsx +++ b/src/components/UserManagement/UserManagement.jsx @@ -7,6 +7,7 @@ import React from 'react'; import PropTypes from 'prop-types'; +import debounce from 'lodash/debounce'; import { connect } from 'react-redux'; import { Container, Spinner } from 'reactstrap'; import { Table } from 'react-bootstrap'; @@ -55,6 +56,7 @@ class UserManagement extends React.PureComponent { weeklyHrsSearchText: '', emailSearchText: '', wildCardSearchText: '', + rawSearchText: '', selectedPage: props.state.userPagination.pagestats.selectedPage, pageSize: props.state.userPagination.pagestats.pageSize, allSelected: undefined, @@ -86,6 +88,14 @@ class UserManagement extends React.PureComponent { this.onDeleteButtonClick = this.onDeleteButtonClick.bind(this); this.onFinalDayClick = this.onFinalDayClick.bind(this); this.onActiveInactiveClick = this.onActiveInactiveClick.bind(this); + // Debounce committing the wildcard search text to state, since that state + // drives the (expensive) full user-list filter/re-render in componentDidUpdate. + // The input itself stays responsive via rawSearchText, updated on every keystroke. + this.debouncedApplyWildCardSearch = debounce( + searchText => this.setState({ wildCardSearchText: searchText, selectedPage: 1 }), + 300, + { leading: true, trailing: true }, + ); } componentDidMount() { @@ -111,6 +121,7 @@ class UserManagement extends React.PureComponent { componentWillUnmount() { document.body.classList.remove('no-global-theme'); window.removeEventListener('resize', this.handleResize); + this.debouncedApplyWildCardSearch.cancel(); } handleResize = () => { @@ -644,13 +655,12 @@ class UserManagement extends React.PureComponent { }; onWildCardSearch = (searchText) => { - this.setState( - { - wildCardSearchText: searchText, - selectedPage: 1, - }, - () => this.updateGetFilteredData(), - ); + // Update the visible input immediately; debounce the state change that + // actually re-filters/re-renders the (potentially large) user list. + // componentDidUpdate already re-runs getFilteredData when wildCardSearchText + // changes, so the debounced update below doesn't need its own explicit call. + this.setState({ rawSearchText: searchText }); + this.debouncedApplyWildCardSearch(searchText); }; onActiveFilter = (value) => { @@ -794,7 +804,7 @@ class UserManagement extends React.PureComponent { <> { const [isOpen, toggle] = useState(false); @@ -18,6 +21,22 @@ const AddProjectsAutoComplete = React.memo(props => { } }, [props.selectedProject]); + // The input stays fully responsive via props.searchText; only the (re)filtering + // of the suggestion list is debounced so rapid keystrokes don't recompute it every time. + const [debouncedSearchText, setDebouncedSearchText] = useState(props.searchText); + const debouncedSetSearchText = useRef( + debounce(value => setDebouncedSearchText(value), SEARCH_DEBOUNCE_MS, { + leading: true, + trailing: true, + }), + ).current; + + useEffect(() => { + debouncedSetSearchText(props.searchText); + }, [props.searchText, debouncedSetSearchText]); + + useEffect(() => () => debouncedSetSearchText.cancel(), [debouncedSetSearchText]); + return ( { {props.projectsData .filter(project => { if ( - //prettier-ignore - props.formatText(project.projectName).indexOf(props.formatText(props.searchText)) >-1 + props.formatText(project.projectName).includes(props.formatText(debouncedSearchText)) ) { return project; } @@ -75,7 +93,7 @@ const AddProjectsAutoComplete = React.memo(props => { ))} {props.projectsData.every( - item => props.formatText(item.projectName) !== props.formatText(props.searchText), + item => props.formatText(item.projectName) !== props.formatText(debouncedSearchText), ) && ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
{ props.setIsOpenDropdown(true); }} > - Create new project: {props.searchText} + Create new project: {debouncedSearchText}
)} diff --git a/src/components/UserProfile/TeamsAndProjects/AddTeamsAutoComplete.jsx b/src/components/UserProfile/TeamsAndProjects/AddTeamsAutoComplete.jsx index 34d704a5af..4be7851820 100644 --- a/src/components/UserProfile/TeamsAndProjects/AddTeamsAutoComplete.jsx +++ b/src/components/UserProfile/TeamsAndProjects/AddTeamsAutoComplete.jsx @@ -1,11 +1,13 @@ -import React, { useRef, useEffect } from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { Dropdown, Input } from 'reactstrap'; +import debounce from 'lodash/debounce'; import './TeamsAndProjects.module.css'; import { useSelector } from 'react-redux'; import appStyles from '~/App.module.css'; const TEAM_NAME_MAX_LENGTH = 100; +const SEARCH_DEBOUNCE_MS = 300; // eslint-disable-next-line react/display-name const AddTeamsAutoComplete = React.memo((props) => { @@ -27,11 +29,27 @@ const AddTeamsAutoComplete = React.memo((props) => { const normalize = (s) => (s ?? '').toString().toLowerCase().trim().replace(/\s+/g, ' '); + // The input stays fully responsive via searchText; only the (re)filtering of the + // suggestion list is debounced so rapid keystrokes don't recompute it every time. + const [debouncedSearchText, setDebouncedSearchText] = useState(searchText); + const debouncedSetSearchText = useRef( + debounce((value) => setDebouncedSearchText(value), SEARCH_DEBOUNCE_MS, { + leading: true, + trailing: true, + }), + ).current; + + useEffect(() => { + debouncedSetSearchText(searchText); + }, [searchText, debouncedSetSearchText]); + + useEffect(() => () => debouncedSetSearchText.cancel(), [debouncedSetSearchText]); + const suggestions = React.useMemo(() => { - const q = normalize(searchText); + const q = normalize(debouncedSearchText); if (!q) return allTeams; // show all when empty return allTeams.filter((t) => normalize(t.teamName).includes(q)); - }, [allTeams, searchText]); + }, [allTeams, debouncedSearchText]); const handlePick = (team) => { onDropDownSelect(team);