diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index cf41cc300790e..5f5389cfcb8a9 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -412,7 +412,7 @@ function MoneyReportHeader({ > | null>(null); const {selectedTransactionIDs, removeTransaction, clearSelectedTransactions, currentSearchQueryJSON, currentSearchKey, currentSearchHash, currentSearchResults} = useSearchContext(); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.similarSearchHash, true); + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); const [network] = useOnyx(ONYXKEYS.NETWORK, {canBeMissing: true}); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index a40fc2a75be39..565cc7986bfe8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -289,7 +289,7 @@ function Search({ const suggestedSearches = useMemo(() => getSuggestedSearches(accountID, defaultCardFeed?.id), [defaultCardFeed?.id, accountID]); const searchKey = useMemo(() => Object.values(suggestedSearches).find((search) => search.similarSearchHash === similarSearchHash)?.key, [suggestedSearches, similarSearchHash]); const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); - const shouldCalculateTotals = useSearchShouldCalculateTotals(searchKey, similarSearchHash, offset === 0); + const shouldCalculateTotals = useSearchShouldCalculateTotals(searchKey, hash, offset === 0); const previousReportActions = usePrevious(reportActions); const {translate, localeCompare, formatPhoneNumber} = useLocalize(); @@ -545,6 +545,8 @@ function Search({ setShouldShowFiltersBarLoading(shouldShowLoadingState && lastSearchType !== type); }, [lastSearchType, setShouldShowFiltersBarLoading, shouldShowLoadingState, type]); + const shouldRetrySearchWithTotalsRef = useRef(false); + useEffect(() => { const focusedRoute = findFocusedRoute(navigationRef.getRootState()); const isMigratedModalDisplayed = focusedRoute?.name === NAVIGATORS.MIGRATED_USER_MODAL_NAVIGATOR || focusedRoute?.name === SCREENS.MIGRATED_USER_WELCOME_MODAL.ROOT; @@ -554,12 +556,34 @@ function Search({ return; } + if (searchResults?.search?.isLoading) { + if (shouldCalculateTotals && searchResults?.search?.count === undefined) { + shouldRetrySearchWithTotalsRef.current = true; + } + return; + } + handleSearch({queryJSON, searchKey, offset, shouldCalculateTotals, prevReportsLength: filteredDataLength, isLoading: !!searchResults?.search?.isLoading}); // We don't need to run the effect on change of isFocused. // eslint-disable-next-line react-hooks/exhaustive-deps }, [handleSearch, isOffline, offset, queryJSON, searchKey, shouldCalculateTotals]); + useEffect(() => { + if (!shouldRetrySearchWithTotalsRef.current || searchResults?.search?.isLoading || !shouldCalculateTotals) { + return; + } + + // If count is already present, the latest response already contains totals and we can skip the re-query. + if (searchResults?.search?.count !== undefined) { + shouldRetrySearchWithTotalsRef.current = false; + return; + } + + shouldRetrySearchWithTotalsRef.current = false; + handleSearch({queryJSON, searchKey, offset, shouldCalculateTotals: true, prevReportsLength: filteredDataLength, isLoading: false}); + }, [filteredDataLength, handleSearch, offset, queryJSON, searchKey, searchResults?.search?.count, searchResults?.search?.isLoading, shouldCalculateTotals]); + // When new data load, selectedTransactions is updated in next effect. We use this flag to whether selection is updated const isRefreshingSelection = useRef(false); diff --git a/src/hooks/useSearchShouldCalculateTotals.ts b/src/hooks/useSearchShouldCalculateTotals.ts index c7f10905f1d2c..9909daacd16eb 100644 --- a/src/hooks/useSearchShouldCalculateTotals.ts +++ b/src/hooks/useSearchShouldCalculateTotals.ts @@ -1,11 +1,10 @@ import {useMemo} from 'react'; -import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import type {SearchKey} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import useOnyx from './useOnyx'; -function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, similarSearchHash: number | undefined, enabled: boolean) { +function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, searchHash: number | undefined, enabled: boolean) { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES, {canBeMissing: true}); const shouldCalculateTotals = useMemo(() => { @@ -13,9 +12,7 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, simila return false; } - const savedSearchValues = Object.values(savedSearches ?? {}); - - if (!savedSearchValues.length && !searchKey) { + if (!Object.keys(savedSearches ?? {}).length && !searchKey) { return false; } @@ -30,21 +27,13 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, simila CONST.SEARCH.SEARCH_KEYS.RECONCILIATION, ]; - if (eligibleSearchKeys.includes(searchKey)) { - return true; - } - - for (const savedSearch of savedSearchValues) { - const searchData = buildSearchQueryJSON(savedSearch.query); - if (searchData && searchData.similarSearchHash === similarSearchHash) { - return true; - } - } + const isSuggestedSearchWithTotals = eligibleSearchKeys.includes(searchKey); + const isSavedSearch = searchHash !== undefined && savedSearches && !!savedSearches[searchHash]; - return false; - }, [enabled, savedSearches, searchKey, similarSearchHash]); + return isSuggestedSearchWithTotals || isSavedSearch; + }, [enabled, savedSearches, searchKey, searchHash]); - return shouldCalculateTotals; + return shouldCalculateTotals ?? false; } export default useSearchShouldCalculateTotals; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index b1ac288632540..049312eec9eda 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -33,6 +33,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -109,8 +110,17 @@ function SearchPage({route}: SearchPageProps) { const theme = useTheme(); const {isOffline} = useNetwork(); const {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); - const {selectedTransactions, clearSelectedTransactions, selectedReports, lastSearchType, setLastSearchType, areAllMatchingItemsSelected, selectAllMatchingItems, currentSearchResults} = - useSearchContext(); + const { + selectedTransactions, + clearSelectedTransactions, + selectedReports, + lastSearchType, + setLastSearchType, + areAllMatchingItemsSelected, + selectAllMatchingItems, + currentSearchKey, + currentSearchResults, + } = useSearchContext(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const isMobileSelectionModeEnabled = useMobileSelectionMode(clearSelectedTransactions); const allTransactions = useAllTransactions(); @@ -1175,7 +1185,8 @@ function SearchPage({route}: SearchPageProps) { const {resetVideoPlayerData} = usePlaybackContext(); const metadata = searchResults?.search; - const shouldShowFooter = !!metadata?.count || selectedTransactionsKeys.length > 0; + const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, queryJSON?.hash, true); + const shouldShowFooter = selectedTransactionsKeys.length > 0 || (shouldAllowFooterTotals && !!metadata?.count); // Handles video player cleanup: // 1. On mount: Resets player if navigating from report screen @@ -1217,7 +1228,11 @@ function SearchPage({route}: SearchPageProps) { }, []); const footerData = useMemo(() => { - const shouldUseClientTotal = !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); + if (!shouldAllowFooterTotals && selectedTransactionsKeys.length === 0) { + return {count: undefined, total: undefined, currency: undefined}; + } + + const shouldUseClientTotal = selectedTransactionsKeys.length > 0 || !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency; const numberOfExpense = shouldUseClientTotal @@ -1233,7 +1248,7 @@ function SearchPage({route}: SearchPageProps) { const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? 0), 0) : metadata?.total; return {count: numberOfExpense, total, currency}; - }, [areAllMatchingItemsSelected, metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys]); + }, [areAllMatchingItemsSelected, metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys, shouldAllowFooterTotals]); const onSortPressedCallback = useCallback(() => { setIsSorting(true); @@ -1296,6 +1311,7 @@ function SearchPage({route}: SearchPageProps) { currentSelectedReportID={selectedTransactionReportIDs?.at(0) ?? selectedReportIDs?.at(0)} confirmPayment={onBulkPaySelected} latestBankItems={latestBankItems} + shouldShowFooter={shouldShowFooter} /> void; latestBankItems?: BankAccountMenuItem[] | undefined; + shouldShowFooter: boolean; }; function SearchPageNarrow({ @@ -71,13 +72,14 @@ function SearchPageNarrow({ currentSelectedReportID, latestBankItems, confirmPayment, + shouldShowFooter, }: SearchPageNarrowProps) { const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {clearSelectedTransactions, selectedTransactions} = useSearchContext(); + const {clearSelectedTransactions} = useSearchContext(); const [searchRouterListVisible, setSearchRouterListVisible] = useState(false); const {isOffline} = useNetwork(); // Controls the visibility of the educational tooltip based on user scrolling. @@ -176,7 +178,6 @@ function SearchPageNarrow({ ); } - const shouldShowFooter = !!metadata?.count || Object.keys(selectedTransactions).length > 0; const isDataLoaded = isSearchDataLoaded(searchResults, queryJSON); const shouldShowLoadingState = !isOffline && (!isDataLoaded || !!metadata?.isLoading); diff --git a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts new file mode 100644 index 0000000000000..b0b88c7595001 --- /dev/null +++ b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts @@ -0,0 +1,78 @@ +import {renderHook} from '@testing-library/react-native'; +import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const onyxData: Record = {}; + +const mockUseOnyx = jest.fn( + ( + key: string, + options?: { + selector?: (value: unknown) => unknown; + }, + ) => { + const value = onyxData[key]; + const selectedValue = options?.selector ? options.selector(value as never) : value; + return [selectedValue]; + }, +); + +jest.mock('@hooks/useOnyx', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: (key: string, options?: {selector?: (value: unknown) => unknown}) => mockUseOnyx(key, options), +})); + +describe('useSearchShouldCalculateTotals', () => { + beforeEach(() => { + onyxData[ONYXKEYS.SAVED_SEARCHES] = undefined; + mockUseOnyx.mockClear(); + }); + + it('returns false when disabled', () => { + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, 123, false)); + + expect(result.current).toBe(false); + }); + + it('returns true for eligible suggested searches', () => { + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.SUBMIT, 123, true)); + + expect(result.current).toBe(true); + }); + + it('returns false for non-eligible searches', () => { + const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, true)); + + expect(result.current).toBe(false); + }); + + it('returns true for saved searches that match the hash', () => { + onyxData[ONYXKEYS.SAVED_SEARCHES] = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 456: { + name: 'My search', + query: 'type:expense', + }, + }; + + const {result} = renderHook(() => useSearchShouldCalculateTotals(undefined, 456, true)); + + expect(result.current).toBe(true); + }); + + it('returns false when saved searches do not match the hash', () => { + onyxData[ONYXKEYS.SAVED_SEARCHES] = { + // eslint-disable-next-line @typescript-eslint/naming-convention + 456: { + name: 'My search', + query: 'type:expense', + }, + }; + + const {result} = renderHook(() => useSearchShouldCalculateTotals(undefined, 789, true)); + + expect(result.current).toBe(false); + }); +});