diff --git a/src/components/Search/SearchBulkActionsButton.tsx b/src/components/Search/SearchBulkActionsButton.tsx index 02254fe1a283..aab9ce0f2b68 100644 --- a/src/components/Search/SearchBulkActionsButton.tsx +++ b/src/components/Search/SearchBulkActionsButton.tsx @@ -26,12 +26,13 @@ import shouldPopoverUseScrollView from '@libs/shouldPopoverUseScrollView'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import {isUserValidatedSelector} from '@selectors/Account'; import React, {useContext, useMemo, useRef} from 'react'; import {View} from 'react-native'; -import type {BulkPaySelectionData, SearchQueryJSON} from './types'; +import type {BulkPaySelectionData, SearchQueryJSON, SelectedTransactions} from './types'; import BulkDuplicateHandler from './BulkDuplicateHandler'; import BulkDuplicateReportHandler from './BulkDuplicateReportHandler'; @@ -48,7 +49,7 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { // We need isSmallScreenWidth (not just shouldUseNarrowLayout) because DecisionModal requires it for correct modal type // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); - const {selectedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const kycWallRef = useContext(KYCWallContext); const {isAccountLocked} = useLockedAccountState(); @@ -99,38 +100,43 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) { const pendingPaymentAdditionalDataRef = useRef(undefined); const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const isExpenseType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const isExpenseReportType = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; const popoverUseScrollView = shouldPopoverUseScrollView(headerButtonsOptions); - const selectedItemsCount = useMemo(() => { - if (!selectedTransactions) { - return 0; - } + const {selectedItemsCount, excludedItemsCount} = useMemo(() => { + const getItemsCount = (transactionsToCount: typeof selectedTransactions) => { + if (isExpenseReportType) { + const reportIDs = new Set( + Object.values(transactionsToCount) + .map((transaction) => transaction?.reportID) + .filter((reportID): reportID is string => !!reportID), + ); + return reportIDs.size; + } - if (isExpenseReportType) { - const reportIDs = new Set( - Object.values(selectedTransactions) - .map((transaction) => transaction?.reportID) - .filter((reportID): reportID is string => !!reportID), - ); - return reportIDs.size; - } + return Object.keys(transactionsToCount).reduce((count, key) => { + if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { + const group = searchData?.[key as keyof typeof searchData] as {count?: number} | undefined; + return count + (group?.count ?? 0); + } + return count + 1; + }, 0); + }; - return selectedTransactionsKeys.reduce((count, key) => { - if (key.startsWith(CONST.SEARCH.GROUP_PREFIX)) { - const group = searchData?.[key as keyof typeof searchData] as {count?: number} | undefined; - return count + (group?.count ?? 0); - } - return count + 1; - }, 0); - }, [selectedTransactions, selectedTransactionsKeys, isExpenseReportType, searchData]); + return { + selectedItemsCount: getItemsCount(selectedTransactions), + excludedItemsCount: getItemsCount(excludedTransactions), + }; + }, [excludedTransactions, selectedTransactions, isExpenseReportType, searchData]); const allMatchingItemsCount = currentSearchResults?.search?.count; + const selectedAllMatchingItemsCount = typeof allMatchingItemsCount === 'number' ? Math.max(allMatchingItemsCount - excludedItemsCount, 0) : undefined; const isAllMatchingItemsCountLoading = areAllMatchingItemsSelected && typeof allMatchingItemsCount !== 'number' && !isOffline && !!currentSearchResults?.search?.isLoading; let selectionButtonText: string; if (areAllMatchingItemsSelected) { - selectionButtonText = - typeof allMatchingItemsCount !== 'number' ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: allMatchingItemsCount}); + const count = isExpenseType ? selectedAllMatchingItemsCount : allMatchingItemsCount; + selectionButtonText = typeof count !== 'number' ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count}); } else { selectionButtonText = translate('workspace.common.selected', {count: selectedItemsCount}); } diff --git a/src/components/Search/SearchContextDefinitions.ts b/src/components/Search/SearchContextDefinitions.ts index 9f32d0e75525..92e59e9cb3d4 100644 --- a/src/components/Search/SearchContextDefinitions.ts +++ b/src/components/Search/SearchContextDefinitions.ts @@ -48,6 +48,7 @@ const defaultSearchResultsActions: SearchResultsActionsValue = { const defaultSearchSelectionContext: SearchSelectionContextValue = { currentSelectedTransactionReportID: undefined, selectedTransactions: {}, + excludedTransactions: {}, selectedTransactionIDs: [], selectedReports: [], shouldTurnOffSelectionMode: false, diff --git a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx index 7d0306f6ed3e..368349a7e75b 100644 --- a/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx +++ b/src/components/Search/SearchList/ListItem/TransactionGroupListItem.tsx @@ -228,7 +228,7 @@ function TransactionGroupListItemImpl({ const StyleUtils = useStyleUtils(); const {isSelected: liveRowSelected} = useRowSelection(item?.keyForList); - const isItemSelected = isSelectAllChecked || liveRowSelected; + const isItemSelected = isSelectAllChecked || (liveRowSelected && (isExpenseReportType || transactionsWithoutPendingDelete.length === 0)); const animatedHighlightStyle = useAnimatedHighlightStyle({ shouldHighlight: item?.shouldAnimateInHighlight ?? false, @@ -306,7 +306,8 @@ function TransactionGroupListItemImpl({ }; const onPress = (event?: ModifiedMouseEvent) => { - if (isExpenseReportType || transactions.length === 0) { + const isEmptyGroupWithoutTransactionsQuery = transactions.length === 0 && !groupItem.transactionsQueryJSON; + if (isExpenseReportType || isEmptyGroupWithoutTransactionsQuery) { onSelectRow(item, transactionPreviewData, event); } if (!isExpenseReportType) { diff --git a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx index 399fd47c856a..da53a25662a9 100644 --- a/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx +++ b/src/components/Search/SearchPageHeader/SearchActionsBarWide.tsx @@ -1,9 +1,11 @@ import SearchBulkActionsButton from '@components/Search/SearchBulkActionsButton'; +import {useSearchSelectionContext} from '@components/Search/SearchContext'; import {useSelectionCounts} from '@components/Search/SearchSelectionProvider'; import type {SearchQueryJSON} from '@components/Search/types'; import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; import type {SearchResults} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; @@ -26,12 +28,13 @@ type SearchActionsBarWideProps = { function SearchActionsBarWide({queryJSON, searchResults, onSort}: SearchActionsBarWideProps) { const styles = useThemeStyles(); + const {hasSelectedTransactions} = useSearchSelectionContext(); const {selected} = useSelectionCounts(); - const hasSelectedItems = selected > 0; + const shouldShowBulkActions = queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE ? hasSelectedTransactions : selected > 0; return ( - {hasSelectedItems ? ( + {shouldShowBulkActions ? ( diff --git a/src/components/Search/SearchSelectionFooter.tsx b/src/components/Search/SearchSelectionFooter.tsx index 7c40a8c30c0a..a8749f1a6e4e 100644 --- a/src/components/Search/SearchSelectionFooter.tsx +++ b/src/components/Search/SearchSelectionFooter.tsx @@ -4,11 +4,14 @@ import {isGroupEntry} from '@libs/SearchUIUtils'; import CONST from '@src/CONST'; import type {SearchResults} from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; import React from 'react'; +import type {SelectedTransactions} from './types'; + import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionContext} from './SearchContext'; import SearchPageFooter from './SearchPageFooter'; @@ -20,13 +23,15 @@ type SearchSelectionFooterProps = { // Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this // footer — not SearchPage and the list it contains. function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { - const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext(); const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true, areAllMatchingItemsSelected); const metadata = searchResults?.search; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const excludedTransactionsKeys = Object.keys(excludedTransactions); + const isExpenseType = currentSearchQueryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const shouldShowFooter = (!areAllMatchingItemsSelected && selectedTransactionsKeys.length > 0) || (shouldAllowFooterTotals && !!metadata?.count); if (!shouldShowFooter) { @@ -36,20 +41,29 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) { const shouldUseClientTotal = !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency; - const count = shouldUseClientTotal - ? selectedTransactionsKeys.reduce((acc, key) => { - if (isGroupEntry(key)) { - const group = currentSearchResults?.data?.[key]; - return acc + (group?.count ?? 0); - } - const item = selectedTransactions[key]; - if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { - return acc; - } - return acc + 1; - }, 0) - : metadata?.count; - const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total; + const getTransactionCount = (transactionKeys: string[], transactions: typeof selectedTransactions) => { + return transactionKeys.reduce((acc, key) => { + if (isGroupEntry(key)) { + const group = currentSearchResults?.data?.[key]; + return acc + (group?.count ?? 0); + } + const item = transactions[key]; + if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { + return acc; + } + return acc + 1; + }, 0); + }; + const getTransactionTotal = (transactions: typeof selectedTransactionItems) => + transactions.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0); + const excludedCount = isExpenseType ? getTransactionCount(excludedTransactionsKeys, excludedTransactions) : 0; + const count = shouldUseClientTotal ? getTransactionCount(selectedTransactionsKeys, selectedTransactions) : Math.max((metadata?.count ?? 0) - excludedCount, 0); + let total: number | undefined; + if (shouldUseClientTotal) { + total = getTransactionTotal(selectedTransactionItems); + } else if (metadata?.total !== undefined) { + total = isExpenseType ? metadata.total - getTransactionTotal(Object.values(excludedTransactions)) : metadata.total; + } return ( (defaultSelectionState); @@ -78,7 +82,8 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { // Read-modify-write the selection atomically. The updater receives the previous map so write actions never // need to close over (and re-render on) selection state. `totalSelectableItemsCount` unchecks select-all when // the new selection no longer covers every item; omitting it (e.g. during data reconcile) leaves select-all - // untouched, which is what the former `isRefreshingSelection` flag protected. + // untouched, which is what the former `isRefreshingSelection` flag protected. Expense row toggles preserve + // an all-matching selection and record their removed entries as explicit exclusions. const applySelection: SearchSelectionActionsValue['applySelection'] = (updater, options) => { setSelectionState((prevState) => { const selectedTransactions = updater(prevState.selectedTransactions); @@ -87,12 +92,31 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { } const totalSelectableItemsCount = options?.totalSelectableItemsCount; - const areAllMatchingItemsSelected = + let areAllMatchingItemsSelected = totalSelectableItemsCount && totalSelectableItemsCount !== Object.keys(selectedTransactions).length ? false : prevState.areAllMatchingItemsSelected; + let excludedTransactions = prevState.excludedTransactions; + + if (prevState.areAllMatchingItemsSelected && options?.shouldPreserveAllMatchingSelection) { + areAllMatchingItemsSelected = true; + excludedTransactions = {...prevState.excludedTransactions}; + for (const [key, transaction] of Object.entries(prevState.selectedTransactions)) { + if (!Object.hasOwn(selectedTransactions, key)) { + excludedTransactions[key] = transaction; + } + } + for (const key of Object.keys(selectedTransactions)) { + if (!Object.hasOwn(prevState.selectedTransactions, key) && Object.hasOwn(excludedTransactions, key)) { + delete excludedTransactions[key]; + } + } + } else if (!areAllMatchingItemsSelected) { + excludedTransactions = {}; + } return { ...prevState, selectedTransactions, + excludedTransactions, areAllMatchingItemsSelected, selectedReports: options?.data ? deriveSelectedReports(selectedTransactions, options.data) : prevState.selectedReports, shouldTurnOffSelectionMode: false, @@ -126,12 +150,13 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { const selectAllMatchingItems: SearchSelectionActionsValue['selectAllMatchingItems'] = (shouldSelectAll) => { setSelectionState((prevState) => { - if (prevState.areAllMatchingItemsSelected === shouldSelectAll) { + if (prevState.areAllMatchingItemsSelected === shouldSelectAll && isEmptyObject(prevState.excludedTransactions)) { return prevState; } return { ...prevState, areAllMatchingItemsSelected: shouldSelectAll, + excludedTransactions: {}, }; }); }; @@ -147,13 +172,20 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { } setSelectionState((prevState) => { - if (prevState.selectedReports.length === 0 && isEmptyObject(prevState.selectedTransactions) && !prevState.shouldTurnOffSelectionMode && !prevState.areAllMatchingItemsSelected) { + if ( + prevState.selectedReports.length === 0 && + isEmptyObject(prevState.selectedTransactions) && + isEmptyObject(prevState.excludedTransactions) && + !prevState.shouldTurnOffSelectionMode && + !prevState.areAllMatchingItemsSelected + ) { return prevState; } return { ...prevState, shouldTurnOffSelectionMode, selectedTransactions: {}, + excludedTransactions: {}, selectedReports: [], areAllMatchingItemsSelected: false, }; @@ -167,9 +199,10 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { setSelectionState((prevState) => { const hasSelectedTransactions = !isEmptyObject(prevState.selectedTransactions); + const hasExcludedTransactions = !isEmptyObject(prevState.excludedTransactions); const hasSelectedIDs = prevState.selectedTransactionIDs.length > 0; - if (!hasSelectedTransactions && !hasSelectedIDs) { + if (!hasSelectedTransactions && !hasExcludedTransactions && !hasSelectedIDs) { return prevState; } @@ -184,6 +217,11 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { }, {} as SelectedTransactions); newState.selectedTransactions = newSelectedTransactions; } + if (hasExcludedTransactions) { + const newExcludedTransactions = {...prevState.excludedTransactions}; + delete newExcludedTransactions[transactionID]; + newState.excludedTransactions = newExcludedTransactions; + } if (hasSelectedIDs) { newState.selectedTransactionIDs = prevState.selectedTransactionIDs.filter((ID) => transactionID !== ID); } @@ -191,7 +229,10 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) { }); }; - const hasSelectedTransactions = selectionState.selectedTransactionIDs.length > 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); + const hasSelectedTransactions = + (isExpenseSearch && selectionState.areAllMatchingItemsSelected) || + selectionState.selectedTransactionIDs.length > 0 || + Object.values(selectionState.selectedTransactions).some((t) => t.isSelected); const selectionValue: SearchSelectionContextValue = { ...selectionState, @@ -242,11 +283,11 @@ function useSyncSelectedReports(data: SearchData) { /** Narrow per-row selection read: whether the row for `keyForList` is selected (or covered by select-all). */ function useRowSelection(keyForList: string | undefined): {isSelected: boolean} { - const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected} = useSearchSelectionContext(); if (!keyForList) { return {isSelected: false}; } - return {isSelected: areAllMatchingItemsSelected || !!selectedTransactions[keyForList]?.isSelected}; + return {isSelected: (areAllMatchingItemsSelected && !Object.hasOwn(excludedTransactions, keyForList)) || !!selectedTransactions[keyForList]?.isSelected}; } /** Aggregate count of currently-selected transactions, for the selection top bar. */ diff --git a/src/components/Search/SearchWriteActionsProvider.tsx b/src/components/Search/SearchWriteActionsProvider.tsx index 16c43bf91fca..d389df266214 100644 --- a/src/components/Search/SearchWriteActionsProvider.tsx +++ b/src/components/Search/SearchWriteActionsProvider.tsx @@ -13,7 +13,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OutstandingReportsByPolicyIDDerivedValue, Report, ReportNameValuePairs, SearchResults, Transaction} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -127,7 +127,7 @@ function useReconcileSelectionWithData({ reportNameValuePairs, outstandingReportsByPolicyID, }: ReconcileSelectionParams) { - const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), areAllMatchingItemsSelected} = useSearchSelectionContext(); const {applySelection} = useSearchSelectionActions(); useEffect(() => { @@ -150,7 +150,7 @@ function useReconcileSelectionWithData({ if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { continue; } - if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { + if (reportKey && !Object.hasOwn(excludedTransactions, reportKey) && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); newTransactionList[reportKey] = { ...emptyReportSelection, @@ -172,10 +172,11 @@ function useReconcileSelectionWithData({ for (const transactionItem of transactionGroup.transactions) { const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + const isExcluded = Object.hasOwn(excludedTransactions, listKey) || Object.hasOwn(excludedTransactions, transactionItem.transactionID); const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions; // Include transaction if: already individually selected, part of select-all, or group-level propagation (expense report / empty group expanded) - const shouldInclude = isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows; + const shouldInclude = !isExcluded && (isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows); if (!shouldInclude) { continue; } @@ -205,7 +206,7 @@ function useReconcileSelectionWithData({ newTransactionList[listKey] = { ...baseEntry, - isSelected: areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows, + isSelected: !isExcluded && (areAllMatchingItemsSelected || !!previousSelection?.isSelected || propagateSelectionToAllRows), canReject: currentUserEmail && transactionItem.report ? canRejectReportAction(currentUserEmail, transactionItem.report) : false, policyID: transactionItem.report?.policyID, groupKey: previousSelection?.groupKey ?? (propagateSelectionToAllRows && !isExpenseReportType ? reportKey : undefined), @@ -218,6 +219,10 @@ function useReconcileSelectionWithData({ continue; } const listKey = transactionItem.keyForList ?? transactionItem.transactionID; + const isExcluded = Object.hasOwn(excludedTransactions, listKey) || Object.hasOwn(excludedTransactions, transactionItem.transactionID); + if (isExcluded) { + continue; + } if (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { continue; } @@ -266,9 +271,9 @@ function useReconcileSelectionWithData({ // so `selectedReports` is derived atomically and a stale `useSyncSelectedReports` derivation can't briefly // clear it (which would close screens like SearchChangeApproverPage that dismiss on empty `selectedReports`). applySelection(() => newTransactionList, {data: filteredData}); - // `selectedTransactions` is intentionally omitted from the deps and read from closure instead (see the - // hook doc above): including it would re-run this reconcile on every checkbox press. We only want it to - // run when the underlying data, focus, or select-all state changes. + // `selectedTransactions` and `excludedTransactions` are intentionally omitted from the deps and read from + // closure instead (see the hook doc above): including them would re-run this reconcile on every checkbox + // press. We only want it to run when the underlying data, focus, or select-all state changes. // eslint-disable-next-line react-hooks/exhaustive-deps }, [filteredData, applySelection, areAllMatchingItemsSelected, isFocused, outstandingReportsByPolicyID, isExpenseReportType]); } @@ -392,7 +397,7 @@ function SearchWriteActionsProvider({ return updatedTransactions; }, - {totalSelectableItemsCount}, + {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE}, ); return; } @@ -456,7 +461,7 @@ function SearchWriteActionsProvider({ ), }; }, - {totalSelectableItemsCount}, + {totalSelectableItemsCount, shouldPreserveAllMatchingSelection: type === CONST.SEARCH.DATA_TYPES.EXPENSE}, ); }; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d18222eb8a4d..64752fcc4871 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -15,7 +15,7 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll'; -import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -181,7 +181,21 @@ function Search({ const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); - const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0, areAllMatchingItemsSelected); + const isExpenseAllMatchingSelection = type === CONST.SEARCH.DATA_TYPES.EXPENSE && areAllMatchingItemsSelected; + const isAllMatchingItemsCountMissing = isExpenseAllMatchingSelection && typeof searchResults?.search?.count !== 'number'; + const shouldCalculateExpenseTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, offset === 0 || isAllMatchingItemsCountMissing, isExpenseAllMatchingSelection); + const shouldCalculateTotals = (areAllMatchingItemsSelected && !isExpenseAllMatchingSelection) || shouldCalculateExpenseTotals; + const previousShouldCalculateTotals = usePrevious(shouldCalculateTotals); + const searchRequestOffset = getSearchRequestOffsetForMissingAllMatchingCount(offset, searchResults?.search?.offset, isAllMatchingItemsCountMissing); + + useEffect(() => { + if (searchRequestOffset === offset) { + return; + } + // Defer so this effect does not synchronously chain another render from setState (eslint react-hooks/set-state-in-effect). + const timeoutID = setTimeout(() => setOffset(searchRequestOffset), 0); + return () => clearTimeout(timeoutID); + }, [offset, searchRequestOffset]); const previousReportActions = usePrevious(reportActions); const {translate} = useLocalize(); @@ -400,10 +414,28 @@ function Search({ return; } + // Once a totals request for an already-loaded page completes, `shouldCalculateTotals` switches + // back to false. Do not immediately repeat that same page request without totals; the next real + // pagination offset change will trigger the appropriate request. + const didJustFinishAllMatchingTotalsRequest = + isExpenseAllMatchingSelection && + previousShouldCalculateTotals === true && + !shouldCalculateTotals && + typeof searchResults?.search?.count === 'number' && + searchResults.search.offset === offset; + if (didJustFinishAllMatchingTotalsRequest) { + return; + } + const didEnableAllMatchingTotalsWithExistingCount = + isExpenseAllMatchingSelection && previousShouldCalculateTotals === false && shouldCalculateTotals && typeof searchResults?.search?.count === 'number'; + if (didEnableAllMatchingTotalsWithExistingCount) { + return; + } + handleSearch({ queryJSON, searchKey: currentSearchKey, - offset, + offset: searchRequestOffset, shouldCalculateTotals, prevReportsLength: filteredDataLength, isLoading: !!searchResults?.search?.isLoading, @@ -411,7 +443,7 @@ function Search({ // We don't need to run the effect on change of isFocused. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSearch, hasErrors, isOffline, offset, queryJSON, currentSearchKey, shouldCalculateTotals, validGroupBy]); + }, [handleSearch, hasErrors, isOffline, offset, queryJSON, currentSearchKey, shouldCalculateTotals, validGroupBy, searchRequestOffset]); useEffect(() => { if (!shouldRetrySearchWithTotalsOrGroupedRef.current || searchResults?.search?.isLoading || (!shouldCalculateTotals && !validGroupBy)) { @@ -429,12 +461,22 @@ function Search({ handleSearch({ queryJSON, searchKey: currentSearchKey, - offset, + offset: searchRequestOffset, shouldCalculateTotals: true, prevReportsLength: filteredDataLength, isLoading: false, }); - }, [filteredDataLength, handleSearch, offset, queryJSON, currentSearchKey, searchResults?.search?.count, searchResults?.search?.isLoading, shouldCalculateTotals, validGroupBy]); + }, [ + filteredDataLength, + handleSearch, + queryJSON, + currentSearchKey, + searchResults?.search?.count, + searchResults?.search?.isLoading, + shouldCalculateTotals, + validGroupBy, + searchRequestOffset, + ]); useEffect(() => { if (!isSearchResultsEmpty || prevIsSearchResultEmpty) { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 489594ab7e5a..6a91e6a2070a 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -211,6 +211,8 @@ type SearchResultsActionsValue = { type SearchSelectionContextValue = { currentSelectedTransactionReportID: string | undefined; selectedTransactions: SelectedTransactions; + /** Loaded transactions explicitly excluded from an all-matching selection. */ + excludedTransactions: SelectedTransactions; selectedTransactionIDs: string[]; selectedReports: SelectedReports[]; shouldTurnOffSelectionMode: boolean; @@ -234,8 +236,12 @@ type SearchSelectionActionsValue = { * next one, so callers (e.g. the screen-level write actions) can read-modify-write without subscribing to — and * thus re-rendering on — selection state. Passing `data` derives `selectedReports` in the same commit; passing * `totalSelectableItemsCount` unchecks "select all matching" when the new selection no longer covers every item. + * `shouldPreserveAllMatchingSelection` keeps that mode active for row toggles and records removed rows as exclusions. */ - applySelection: (updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions, options?: {data?: SearchData; totalSelectableItemsCount?: number}) => void; + applySelection: ( + updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions, + options?: {data?: SearchData; totalSelectableItemsCount?: number; shouldPreserveAllMatchingSelection?: boolean}, + ) => void; setSelectedReports: (reports: SelectedReports[]) => void; setCurrentSelectedTransactionReportID: (reportID: string | undefined) => void; /** If you want to clear `selectedTransactionIDs`, pass `true` as the first argument */ diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index ce43f5f9e5e8..9e43fde167b5 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -94,6 +94,7 @@ import {doesPersonalDetailExistSelector} from '@src/selectors/PersonalDetails'; import type {BillingGraceEndPeriod, Policy, Report, ReportAction, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -357,7 +358,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {isProduction} = useEnvironment(); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); - const {selectedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); + const {selectedTransactions, excludedTransactions = getEmptyObject(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext(); const {currentSearchResults} = useSearchResultsContext(); const {currentSearchKey} = useSearchQueryContext(); const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions(); @@ -558,7 +559,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); const {hash} = queryJSON ?? {}; + const isExpenseType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + const excludedTransactionIDs = isExpenseType ? Object.keys(excludedTransactions) : []; const firstTransactionID = selectedTransactionsKeys.at(0); const firstTransaction = (firstTransactionID ? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${firstTransactionID}`] : undefined) ?? @@ -724,7 +727,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportName = translate(isBasicExport ? 'export.basicExport' : 'export.currentView'); if (areAllMatchingItemsSelected) { - if (selectedTransactionsKeys.length === 0 || !hash) { + if ((!isExpenseType && selectedTransactionsKeys.length === 0) || !hash) { return; } const reportIDList = selectedReports?.map((report) => report?.reportID).filter((reportID) => reportID !== undefined) ?? []; @@ -733,6 +736,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { jsonQuery: exportParameters.jsonQuery, reportIDList, transactionIDList: selectedTransactionsKeys, + ...(isExpenseType ? {excludedTransactionIDList: excludedTransactionIDs} : {}), isBasicExport: exportParameters.isBasicExport, exportColumnLabels: exportParameters.exportColumnLabels, exportName, @@ -775,6 +779,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedTransactionReportIDs, selectedTransactions, selectedTransactionsKeys, + isExpenseType, + excludedTransactionIDs, translate, clearSelectedTransactions, hash, @@ -1454,7 +1460,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions]); const headerButtonsOptions = useMemo(() => { - if (selectedTransactionsKeys.length === 0 || !hash) { + if ((selectedTransactionsKeys.length === 0 && !(isExpenseType && areAllMatchingItemsSelected)) || !hash) { return CONST.EMPTY_ARRAY as unknown as Array>; } @@ -2166,6 +2172,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { expensifyIcons, translate, areAllMatchingItemsSelected, + isExpenseType, isOffline, selectedReports, lastPaymentMethods, diff --git a/src/hooks/useSearchShouldCalculateTotals.ts b/src/hooks/useSearchShouldCalculateTotals.ts index bfbac7546488..bac80b3ac54d 100644 --- a/src/hooks/useSearchShouldCalculateTotals.ts +++ b/src/hooks/useSearchShouldCalculateTotals.ts @@ -7,16 +7,22 @@ import {useMemo} from 'react'; import useOnyx from './useOnyx'; +function getSearchRequestOffsetForMissingAllMatchingCount(offset: number, serverOffset: number | undefined, isAllMatchingItemsCountMissing: boolean): number { + if (!isAllMatchingItemsCountMissing) { + return offset; + } + return Math.min(offset, serverOffset ?? offset); +} + function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, searchHash: number | undefined, enabled: boolean, areAllMatchingItemsSelected = false) { const [savedSearches] = useOnyx(ONYXKEYS.SAVED_SEARCHES); const shouldCalculateTotals = useMemo(() => { - // When the user selects all matching items we always want the server-computed count/total, - // even for an ad-hoc query that isn't a suggested or saved search. This must bypass the - // `enabled` (offset === 0) gate so totals are still requested when more results were loaded - // before select-all was triggered. + // All-matching selections need the server-computed count/total, including for ad-hoc queries. + // The caller enables this only while the initial offset is active or the count is still missing, + // so later pagination requests can avoid recalculating totals. if (areAllMatchingItemsSelected) { - return true; + return enabled; } if (!enabled) { @@ -52,3 +58,4 @@ function useSearchShouldCalculateTotals(searchKey: SearchKey | undefined, search } export default useSearchShouldCalculateTotals; +export {getSearchRequestOffsetForMissingAllMatchingCount}; diff --git a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts index b1ab748a25d2..deefef62ea6f 100644 --- a/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts +++ b/src/libs/API/parameters/ExportSearchItemsToCSVParams.ts @@ -4,6 +4,7 @@ type ExportSearchItemsToCSVParams = { jsonQuery: SearchQueryString; reportIDList: string[]; transactionIDList: string[]; + excludedTransactionIDList?: string[]; isBasicExport: boolean; exportColumnLabels: string; exportName: string; diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 1ba88e362a12..b1125bf7945f 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -883,10 +883,15 @@ function openBulkChangeApproverPage(reportIDList: OpenBulkChangeApproverPagePara write(WRITE_COMMANDS.OPEN_BULK_CHANGE_APPROVER_PAGE, {reportIDList}, {optimisticData, successData}); } -// Tracks in-flight search requests by hash+offset to prevent duplicate API calls -// when both page-level (useSearchPageSetup) and Search-internal (handleSearch) effects -// fire for the same query. Cleared when the request completes. -const inFlightSearchRequests = new Set(); +type InFlightSearchRequest = { + shouldCalculateTotals: boolean; + pendingTotalsRequest?: () => Promise | undefined; +}; + +// Tracks in-flight search requests by hash+offset to prevent duplicate API calls when both page-level +// and Search-internal effects fire for the same query. A totals request is not equivalent to the +// non-totals request already in flight, so preserve one such request to run immediately afterward. +const inFlightSearchRequests = new Map(); let shouldPreventSearchAPI = false; function handlePreventSearchAPI(hash: number | undefined) { @@ -937,16 +942,32 @@ function search({ * optimistic write data. */ skipWaitForWrites?: boolean; -}) { +}): Promise | undefined { if (isLoading || shouldPreventSearchAPI) { return; } const dedupeKey = `${queryJSON.hash}_${offset ?? 0}`; - if (inFlightSearchRequests.has(dedupeKey)) { + const inFlightRequest = inFlightSearchRequests.get(dedupeKey); + if (inFlightRequest) { + if (queryJSON.type === CONST.SEARCH.DATA_TYPES.EXPENSE && shouldCalculateTotals && !inFlightRequest.shouldCalculateTotals) { + inFlightRequest.pendingTotalsRequest = () => + search({ + queryJSON, + searchKey, + offset, + shouldCalculateTotals: true, + prevReportsLength, + isOffline, + isLoading: false, + shouldUpdateLastSearchParams, + skipWaitForWrites, + }); + } return; } - inFlightSearchRequests.add(dedupeKey); + const inFlightRequestState: InFlightSearchRequest = {shouldCalculateTotals}; + inFlightSearchRequests.set(dedupeKey, inFlightRequestState); const {optimisticData, successData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; @@ -1023,6 +1044,7 @@ function search({ }) .finally(() => { inFlightSearchRequests.delete(dedupeKey); + return inFlightRequestState.pendingTotalsRequest?.(); }); if (skipWaitForWrites) { @@ -1362,7 +1384,7 @@ function rejectMoneyRequestsOnSearch( type Params = Record; function exportSearchItemsToCSV( - {jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams, + {jsonQuery, reportIDList, transactionIDList, excludedTransactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams, onDownloadFailed: () => void, translate: LocalizedTranslate, ) { @@ -1395,6 +1417,7 @@ function exportSearchItemsToCSV( jsonQuery, reportIDList: Array.from(reportIDSet), transactionIDList, + ...(excludedTransactionIDList?.length ? {excludedTransactionIDList} : {}), isBasicExport, exportColumnLabels, }) as Params; @@ -1422,7 +1445,15 @@ function exportSearchItemsToCSV( ); } -function queueExportSearchItemsToCSV({jsonQuery, reportIDList, transactionIDList, isBasicExport, exportColumnLabels, exportName}: ExportSearchItemsToCSVParams): string { +function queueExportSearchItemsToCSV({ + jsonQuery, + reportIDList, + transactionIDList, + excludedTransactionIDList, + isBasicExport, + exportColumnLabels, + exportName, +}: ExportSearchItemsToCSVParams): string { const exportID = rand64(); const onyxKey = `${ONYXKEYS.COLLECTION.EXPORT_DOWNLOAD}${exportID}` as const; @@ -1451,6 +1482,7 @@ function queueExportSearchItemsToCSV({jsonQuery, reportIDList, transactionIDList jsonQuery, reportIDList, transactionIDList, + ...(excludedTransactionIDList?.length ? {excludedTransactionIDList} : {}), isBasicExport, exportColumnLabels, exportName, diff --git a/tests/ui/CategoryListItemHeaderTest.tsx b/tests/ui/CategoryListItemHeaderTest.tsx index abfcf88cb49c..07efface03b2 100644 --- a/tests/ui/CategoryListItemHeaderTest.tsx +++ b/tests/ui/CategoryListItemHeaderTest.tsx @@ -36,6 +36,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/ui/MerchantListItemHeaderTest.tsx b/tests/ui/MerchantListItemHeaderTest.tsx index 00364be612db..9910c1b606be 100644 --- a/tests/ui/MerchantListItemHeaderTest.tsx +++ b/tests/ui/MerchantListItemHeaderTest.tsx @@ -36,6 +36,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/ui/MonthListItemHeaderTest.tsx b/tests/ui/MonthListItemHeaderTest.tsx index a429838ed05f..6344ea3e05e9 100644 --- a/tests/ui/MonthListItemHeaderTest.tsx +++ b/tests/ui/MonthListItemHeaderTest.tsx @@ -36,6 +36,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/ui/ReportListItemHeaderTest.tsx b/tests/ui/ReportListItemHeaderTest.tsx index 893891ba849c..ae731e16c579 100644 --- a/tests/ui/ReportListItemHeaderTest.tsx +++ b/tests/ui/ReportListItemHeaderTest.tsx @@ -37,6 +37,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, currentSearchKey: undefined, currentSearchQueryJSON: undefined, diff --git a/tests/ui/WeekListItemHeaderTest.tsx b/tests/ui/WeekListItemHeaderTest.tsx index c49d3c1b9f5e..cd5805902f95 100644 --- a/tests/ui/WeekListItemHeaderTest.tsx +++ b/tests/ui/WeekListItemHeaderTest.tsx @@ -35,6 +35,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/ui/YearListItemHeaderTest.tsx b/tests/ui/YearListItemHeaderTest.tsx index c320bda4eb56..7efd478ac26b 100644 --- a/tests/ui/YearListItemHeaderTest.tsx +++ b/tests/ui/YearListItemHeaderTest.tsx @@ -36,6 +36,7 @@ const mockSearchStateContext = { selectedReports: [], selectedTransactionIDs: [], selectedTransactions: {}, + excludedTransactions: {}, shouldTurnOffSelectionMode: false, shouldResetSearchQuery: false, lastSearchType: undefined, diff --git a/tests/unit/Search/SearchBulkActionsButtonTest.tsx b/tests/unit/Search/SearchBulkActionsButtonTest.tsx new file mode 100644 index 000000000000..accfb9524d8c --- /dev/null +++ b/tests/unit/Search/SearchBulkActionsButtonTest.tsx @@ -0,0 +1,183 @@ +import {render} from '@testing-library/react-native'; + +import SearchBulkActionsButton from '@components/Search/SearchBulkActionsButton'; +import type {SelectedTransactions} from '@components/Search/types'; + +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +type MockButtonProps = { + customText: string; + isLoading: boolean; +}; + +const mockButtonWithDropdownMenu = jest.fn(() => null); +let mockExcludedTransactions: SelectedTransactions = {}; +let mockSearchCount: number | undefined; +let mockSearchIsLoading = false; + +jest.mock('@components/ButtonWithDropdownMenu', () => ({ + __esModule: true, + default: (props: MockButtonProps) => mockButtonWithDropdownMenu(props), +})); +jest.mock('@components/DecisionModal', () => () => null); +jest.mock('@components/HoldOrRejectEducationalModal', () => () => null); +jest.mock('@components/HoldSubmitterEducationalModal', () => () => null); +jest.mock('@components/ReportPDFDownloadModal', () => () => null); +jest.mock('@components/KYCWall', () => ({ + __esModule: true, + default: ({children}: {children: (triggerKYCFlow: jest.Mock, buttonRef: React.RefObject) => React.ReactNode}) => children(jest.fn(), {current: null}), +})); +jest.mock('@components/LockedAccountModalProvider', () => ({ + useLockedAccountState: () => ({isAccountLocked: false}), + useLockedAccountActions: () => ({showLockedAccountModal: jest.fn()}), +})); +jest.mock('@components/DelegateNoAccessModalProvider', () => ({ + useDelegateNoAccessState: () => ({isDelegateAccessRestricted: false}), + useDelegateNoAccessActions: () => ({showDelegateNoAccessModal: jest.fn()}), +})); +jest.mock('@hooks/useThemeStyles', () => ({__esModule: true, default: () => ({flexRow: {}, alignItemsCenter: {}, gap3: {}})})); +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string, params?: {count?: number}) => (params?.count === undefined ? key : `${key}:${params.count}`)}), +})); +jest.mock('@hooks/useNetwork', () => ({__esModule: true, default: () => ({isOffline: false})})); +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({shouldUseNarrowLayout: false, isSmallScreenWidth: false}), +})); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => ({__esModule: true, default: () => ({accountID: 1})})); +jest.mock('@hooks/useOnyx', () => ({__esModule: true, default: () => [undefined]})); +jest.mock('@hooks/usePolicy', () => ({__esModule: true, default: () => undefined})); +jest.mock('@hooks/useSortedActiveAdminPolicies', () => ({__esModule: true, default: () => []})); +jest.mock('@hooks/useSearchBulkActions', () => ({ + __esModule: true, + default: () => ({ + headerButtonsOptions: [], + selectedPolicyIDs: [], + selectedTransactionReportIDs: [], + selectedReportIDs: [], + businessBankAccountOptions: [], + emptyReportsCount: 0, + isDuplicateOptionVisible: false, + isDuplicateReportOptionVisible: false, + allTransactions: {}, + allReports: {}, + searchData: {}, + }), +})); +jest.mock('@components/Search/SearchContext', () => ({ + useSearchSelectionContext: () => ({ + selectedTransactions: {tx1: {isSelected: true}}, + excludedTransactions: mockExcludedTransactions, + selectedReports: [], + areAllMatchingItemsSelected: true, + }), + useSearchResultsContext: () => ({ + currentSearchResults: {search: {count: mockSearchCount, isLoading: mockSearchIsLoading}}, + }), +})); +jest.mock('@libs/ReportUtils', () => { + const reportUtils: unknown = jest.requireActual('@libs/ReportUtils'); + if (!reportUtils || typeof reportUtils !== 'object') { + throw new Error('Expected ReportUtils to export an object'); + } + return {...reportUtils, isExpenseReport: () => false}; +}); +jest.mock('@libs/shouldPopoverUseScrollView', () => ({__esModule: true, default: () => false})); + +const queryJSON = buildSearchQueryJSON('type:expense'); +const reportQueryJSON = buildSearchQueryJSON('type:expense-report'); +if (!queryJSON || !reportQueryJSON) { + throw new Error('Expected the search queries to be valid'); +} + +function makeTransaction(): SelectedTransactions[string] { + return { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: 'report1', + policyID: 'policy1', + amount: 100, + currency: 'USD', + }; +} + +function getButtonProps(): {customText: string; isLoading: boolean} { + const props = mockButtonWithDropdownMenu.mock.calls.at(-1)?.at(0); + if (!props) { + throw new Error('ButtonWithDropdownMenu was not rendered'); + } + return {customText: props.customText, isLoading: props.isLoading}; +} + +describe('SearchBulkActionsButton all-matching label', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExcludedTransactions = {}; + mockSearchCount = undefined; + mockSearchIsLoading = false; + }); + + it('keeps the production loading state while totals are requested', () => { + mockSearchIsLoading = true; + + render(); + + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); + }); + + it('shows the production numeric label when the server count arrives', () => { + mockSearchCount = 172; + + render(); + + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:172', isLoading: false}); + }); + + it('shows the exact count after an item is excluded', () => { + mockSearchCount = 172; + mockExcludedTransactions = {tx2: makeTransaction()}; + + render(); + + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:171', isLoading: false}); + }); + + it('keeps loading when an exclusion exists before the count arrives', () => { + mockSearchIsLoading = true; + mockExcludedTransactions = {tx2: makeTransaction()}; + + render(); + + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); + }); + + it('retains the expense-report loading behavior while the server count is missing', () => { + mockSearchIsLoading = true; + + render(); + + expect(getButtonProps()).toEqual({customText: 'search.exportAll.allMatchingItemsSelected', isLoading: true}); + }); + + it('uses the unmodified server count for expense reports', () => { + mockSearchCount = 320; + mockExcludedTransactions = {tx2: makeTransaction()}; + + render(); + + expect(getButtonProps()).toEqual({customText: 'workspace.common.selected:320', isLoading: false}); + }); +}); diff --git a/tests/unit/Search/SearchSelectionFooterTest.tsx b/tests/unit/Search/SearchSelectionFooterTest.tsx new file mode 100644 index 000000000000..5e4687bb23d1 --- /dev/null +++ b/tests/unit/Search/SearchSelectionFooterTest.tsx @@ -0,0 +1,105 @@ +import {render} from '@testing-library/react-native'; + +import type SearchPageFooter from '@components/Search/SearchPageFooter'; +import SearchSelectionFooter from '@components/Search/SearchSelectionFooter'; +import type {SelectedTransactions} from '@components/Search/types'; + +import CONST from '@src/CONST'; +import type {SearchResults} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import React from 'react'; + +type MockSearchPageFooterProps = React.ComponentProps; + +const mockSearchPageFooter = jest.fn(() => null); +let mockExcludedTransactions: SelectedTransactions = {}; +let mockSearchType: SearchResults['search']['type'] = CONST.SEARCH.DATA_TYPES.EXPENSE; + +jest.mock('@hooks/useSearchShouldCalculateTotals', () => ({ + __esModule: true, + default: () => true, +})); + +jest.mock('@components/Search/SearchPageFooter', () => ({ + __esModule: true, + default: (props: MockSearchPageFooterProps) => mockSearchPageFooter(props), +})); + +jest.mock('@components/Search/SearchContext', () => ({ + useSearchSelectionContext: () => ({ + selectedTransactions: {}, + excludedTransactions: mockExcludedTransactions, + areAllMatchingItemsSelected: true, + }), + useSearchResultsContext: () => ({currentSearchResults: {data: {}}}), + useSearchQueryContext: () => ({ + currentSearchKey: 'expenses', + currentSearchQueryJSON: {type: mockSearchType, hash: 1}, + }), +})); + +function makeTransaction(reportID: string, amount = 100): SelectedTransactions[string] { + return { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID, + policyID: 'policy_1', + amount, + currency: 'USD', + }; +} + +function makeSearchResults(count: number, total: number): OnyxEntry { + return { + search: { + hash: 1, + type: mockSearchType, + offset: 0, + hasMoreResults: true, + hasResults: true, + isLoading: false, + count, + total, + currency: 'USD', + }, + data: {}, + }; +} + +describe('SearchSelectionFooter all-matching exclusions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSearchType = CONST.SEARCH.DATA_TYPES.EXPENSE; + mockExcludedTransactions = {}; + }); + + it('subtracts excluded expenses from the server count and total', () => { + mockExcludedTransactions = {tx1: makeTransaction('report1')}; + + render(); + + expect(mockSearchPageFooter).toHaveBeenLastCalledWith(expect.objectContaining({count: 171, total: 35900, currency: 'USD'})); + }); + + it('keeps the expense-report server count and total unchanged', () => { + mockSearchType = CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + mockExcludedTransactions = { + tx1: makeTransaction('report1'), + tx2: makeTransaction('report1'), + }; + + render(); + + expect(mockSearchPageFooter).toHaveBeenLastCalledWith(expect.objectContaining({count: 10, total: 36000, currency: 'USD'})); + }); +}); diff --git a/tests/unit/Search/SearchSelectionProviderTest.tsx b/tests/unit/Search/SearchSelectionProviderTest.tsx new file mode 100644 index 000000000000..12ae57b124fd --- /dev/null +++ b/tests/unit/Search/SearchSelectionProviderTest.tsx @@ -0,0 +1,238 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; +import {SearchQueryContext} from '@components/Search/SearchContextDefinitions'; +import {SearchSelectionProvider} from '@components/Search/SearchSelectionProvider'; +import type {SearchQueryContextValue, SelectedTransactions} from '@components/Search/types'; + +import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; + +import CONST from '@src/CONST'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; + +import React from 'react'; + +const expenseQueryJSON = buildSearchQueryJSON('type:expense'); +const expenseReportQueryJSON = buildSearchQueryJSON('type:expense-report'); +if (!expenseQueryJSON || !expenseReportQueryJSON) { + throw new Error('Expected the search queries to be valid'); +} + +let mockCurrentSearchQueryJSON = expenseQueryJSON; + +const queryContextValue: SearchQueryContextValue = { + currentSearchHash: 1, + currentSimilarSearchHash: 1, + currentSearchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + currentSearchQueryJSON: expenseQueryJSON, + suggestedSearches: getEmptyObject(), + shouldResetSearchQuery: false, +}; + +function buildSelected(...keys: string[]): SelectedTransactions { + return Object.fromEntries( + keys.map((key) => [ + key, + { + isSelected: true, + canReject: false, + canHold: false, + canSplit: false, + hasBeenSplit: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: 'report_1', + policyID: 'policy_1', + amount: 100, + currency: 'USD', + }, + ]), + ); +} + +function wrapper({children}: {children: React.ReactNode}) { + return ( + + {children} + + ); +} + +function renderSelection() { + return renderHook( + () => ({ + state: useSearchSelectionContext(), + actions: useSearchSelectionActions(), + }), + {wrapper}, + ); +} + +function seedAllMatchingSelection(result: ReturnType['result']) { + act(() => { + result.current.actions.selectAllMatchingItems(true); + result.current.actions.setSelectedTransactions(buildSelected('tx_1', 'tx_2')); + }); +} + +function removeTransaction(selectedTransactions: SelectedTransactions, transactionID: string): SelectedTransactions { + const nextSelection = {...selectedTransactions}; + delete nextSelection[transactionID]; + return nextSelection; +} + +describe('SearchSelectionProvider all-matching exclusions', () => { + beforeEach(() => { + mockCurrentSearchQueryJSON = expenseQueryJSON; + }); + + it('keeps all-matching active and records a row exclusion', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(true); + expect(result.current.state.hasSelectedTransactions).toBe(true); + expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2']); + expect(Object.keys(result.current.state.excludedTransactions)).toEqual(['tx_1']); + }); + + it('keeps a semantic selection when every loaded row is excluded', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection(() => ({}), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + + expect(result.current.state.selectedTransactions).toEqual({}); + expect(Object.keys(result.current.state.excludedTransactions)).toEqual(['tx_1', 'tx_2']); + expect(result.current.state.areAllMatchingItemsSelected).toBe(true); + expect(result.current.state.hasSelectedTransactions).toBe(true); + }); + + it('removes an exclusion when the row is rechecked', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + const tx1 = result.current.state.selectedTransactions.tx_1; + if (!tx1) { + throw new Error('Expected tx_1 to be selected'); + } + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + act(() => { + result.current.actions.applySelection( + (selectedTransactions) => { + const nextSelection = {...selectedTransactions}; + nextSelection.tx_1 = tx1; + return nextSelection; + }, + { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }, + ); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(true); + expect(Object.keys(result.current.state.excludedTransactions)).toEqual([]); + expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2', 'tx_1']); + }); + + it('exits all-matching and clears exclusions when the header deselects all', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + act(() => { + result.current.actions.applySelection(() => ({}), {totalSelectableItemsCount: 2}); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(result.current.state.selectedTransactions).toEqual({}); + expect(result.current.state.excludedTransactions).toEqual({}); + }); + + it('clears exclusions when selection is cleared or a new all-matching session starts', () => { + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + act(() => result.current.actions.selectAllMatchingItems(true)); + expect(result.current.state.excludedTransactions).toEqual({}); + + act(() => result.current.actions.clearSelectedTransactions()); + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(result.current.state.excludedTransactions).toEqual({}); + }); + + it('retains ordinary page-selection behavior', () => { + const {result} = renderSelection(); + act(() => result.current.actions.setSelectedTransactions(buildSelected('tx_1', 'tx_2'))); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: true, + }); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2']); + expect(result.current.state.excludedTransactions).toEqual({}); + }); + + it('keeps the original expense-report behavior when a report is deselected', () => { + mockCurrentSearchQueryJSON = expenseReportQueryJSON; + const {result} = renderSelection(); + seedAllMatchingSelection(result); + + act(() => { + result.current.actions.applySelection((selectedTransactions) => removeTransaction(selectedTransactions, 'tx_1'), { + totalSelectableItemsCount: 2, + shouldPreserveAllMatchingSelection: false, + }); + }); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(false); + expect(Object.keys(result.current.state.selectedTransactions)).toEqual(['tx_2']); + expect(result.current.state.excludedTransactions).toEqual({}); + }); + + it('does not treat an empty expense-report all-matching state as a loaded selection', () => { + mockCurrentSearchQueryJSON = expenseReportQueryJSON; + const {result} = renderSelection(); + + act(() => result.current.actions.selectAllMatchingItems(true)); + + expect(result.current.state.areAllMatchingItemsSelected).toBe(true); + expect(result.current.state.hasSelectedTransactions).toBe(false); + }); +}); diff --git a/tests/unit/Search/searchTotalsLoadingDataTest.ts b/tests/unit/Search/searchTotalsLoadingDataTest.ts index 3bb2877c8852..511c7e739dfb 100644 --- a/tests/unit/Search/searchTotalsLoadingDataTest.ts +++ b/tests/unit/Search/searchTotalsLoadingDataTest.ts @@ -13,8 +13,8 @@ jest.mock('@libs/API', () => ({ read: jest.fn(), })); -function getQueryJSON() { - const queryJSON = buildSearchQueryJSON(''); +function getQueryJSON(query = '') { + const queryJSON = buildSearchQueryJSON(query); if (!queryJSON) { throw new Error('Query JSON should be defined for test setup'); } @@ -39,12 +39,18 @@ type SearchRequestData = { }>; }; +type SearchResponse = { + onyxData: Array<{value: {search: {offset: number; hasMoreResults: boolean}; data: Record}}>; + jsonCode: number; +}; + function getMakeRequestWithSideEffectsMock() { return makeRequestWithSideEffects as unknown as { mock: { - calls: Array<[unknown, unknown, SearchRequestData?]>; + calls: Array<[unknown, {jsonQuery?: string}, SearchRequestData?]>; }; - mockResolvedValue: (value: {onyxData: Array<{value: {search: {offset: number; hasMoreResults: boolean}; data: Record}}>; jsonCode: number}) => void; + mockResolvedValue: (value: SearchResponse) => void; + mockImplementationOnce: (implementation: () => Promise) => void; }; } @@ -134,4 +140,127 @@ describe('search loading totals handling', () => { expect(loadingSearchData?.total).toBeUndefined(); expect(loadingSearchData?.currency).toBeUndefined(); }); + + it('queues a totals request when the same non-totals search is already in flight', async () => { + const queryJSON = getQueryJSON('type:expense'); + const response: SearchResponse = { + onyxData: [{value: {search: {offset: 50, hasMoreResults: true}, data: {}}}], + jsonCode: CONST.JSON_CODE.SUCCESS, + }; + let resolveFirstRequest: (value: SearchResponse) => void = () => {}; + const firstRequestPromise = new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + const makeRequestWithSideEffectsMock = getMakeRequestWithSideEffectsMock(); + makeRequestWithSideEffectsMock.mockImplementationOnce(() => firstRequestPromise); + + const firstSearch = search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: false, + isLoading: false, + }); + search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + + await Promise.resolve(); + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + + resolveFirstRequest(response); + await firstSearch; + await Promise.resolve(); + + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(2); + const [, queuedRequestParameters] = makeRequestWithSideEffectsMock.mock.calls.at(-1) ?? []; + const queuedQuery: unknown = JSON.parse(queuedRequestParameters?.jsonQuery ?? '{}'); + expect(queuedQuery).toEqual(expect.objectContaining({shouldCalculateTotals: true})); + }); + + it('keeps the original expense-report deduplication when a totals request collides with an in-flight request', async () => { + const queryJSON = getQueryJSON('type:expense-report'); + const response: SearchResponse = { + onyxData: [{value: {search: {offset: 50, hasMoreResults: true}, data: {}}}], + jsonCode: CONST.JSON_CODE.SUCCESS, + }; + let resolveFirstRequest: (value: SearchResponse) => void = () => {}; + const firstRequestPromise = new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + const makeRequestWithSideEffectsMock = getMakeRequestWithSideEffectsMock(); + makeRequestWithSideEffectsMock.mockImplementationOnce(() => firstRequestPromise); + + const firstSearch = search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.REPORTS, + offset: 50, + shouldCalculateTotals: false, + isLoading: false, + }); + search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.REPORTS, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + + await Promise.resolve(); + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + + resolveFirstRequest(response); + await firstSearch; + await Promise.resolve(); + + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + }); + + it('does not queue another request when the in-flight search already calculates totals', async () => { + const queryJSON = getQueryJSON(); + const response: SearchResponse = { + onyxData: [{value: {search: {offset: 50, hasMoreResults: true}, data: {}}}], + jsonCode: CONST.JSON_CODE.SUCCESS, + }; + let resolveFirstRequest: (value: SearchResponse) => void = () => {}; + const firstRequestPromise = new Promise((resolve) => { + resolveFirstRequest = resolve; + }); + const makeRequestWithSideEffectsMock = getMakeRequestWithSideEffectsMock(); + makeRequestWithSideEffectsMock.mockImplementationOnce(() => firstRequestPromise); + + const firstSearch = search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + search({ + queryJSON, + searchKey: CONST.SEARCH.SEARCH_KEYS.EXPENSES, + offset: 50, + shouldCalculateTotals: true, + isLoading: false, + }); + + await Promise.resolve(); + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + + resolveFirstRequest(response); + await firstSearch; + + expect(makeRequestWithSideEffectsMock.mock.calls).toHaveLength(1); + }); }); diff --git a/tests/unit/Search/useRowSelectionTest.tsx b/tests/unit/Search/useRowSelectionTest.tsx index 27b9d068f996..320e14c6118c 100644 --- a/tests/unit/Search/useRowSelectionTest.tsx +++ b/tests/unit/Search/useRowSelectionTest.tsx @@ -10,6 +10,7 @@ import React from 'react'; const baseSelectionContext = { currentSelectedTransactionReportID: undefined, + excludedTransactions: {}, selectedTransactionIDs: [], selectedReports: [], shouldTurnOffSelectionMode: false, @@ -63,13 +64,15 @@ function renderWithSelection(hook: () => T, selectionValue: SearchSelectionCo function renderRowSelection({ keyForList, selectedTransactions, + excludedTransactions = {}, areAllMatchingItemsSelected, }: { keyForList: string | undefined; selectedTransactions: SelectedTransactions; + excludedTransactions?: SelectedTransactions; areAllMatchingItemsSelected: boolean; }): {isSelected: boolean} { - return renderWithSelection(() => useRowSelection(keyForList), {...baseSelectionContext, areAllMatchingItemsSelected, selectedTransactions}); + return renderWithSelection(() => useRowSelection(keyForList), {...baseSelectionContext, areAllMatchingItemsSelected, selectedTransactions, excludedTransactions}); } function renderSelectionCounts(selectedTransactions: SelectedTransactions): {selected: number} { @@ -86,6 +89,17 @@ describe('useRowSelection', () => { expect(renderRowSelection({keyForList: 'tx_not_in_map', selectedTransactions: {}, areAllMatchingItemsSelected: true}).isSelected).toBe(true); }); + it('does not mark an excluded row selected when areAllMatchingItemsSelected is true', () => { + expect( + renderRowSelection({ + keyForList: 'tx_1', + selectedTransactions: {}, + excludedTransactions: buildSelected('tx_1'), + areAllMatchingItemsSelected: true, + }).isSelected, + ).toBe(false); + }); + it('returns not-selected when keyForList is undefined', () => { expect(renderRowSelection({keyForList: undefined, selectedTransactions: buildSelected('tx_1'), areAllMatchingItemsSelected: false}).isSelected).toBe(false); }); diff --git a/tests/unit/Search/useSyncSelectedReportsTest.tsx b/tests/unit/Search/useSyncSelectedReportsTest.tsx index 2c742787d0d1..ee0f931e4df6 100644 --- a/tests/unit/Search/useSyncSelectedReportsTest.tsx +++ b/tests/unit/Search/useSyncSelectedReportsTest.tsx @@ -15,6 +15,7 @@ const createSetSelectedReportsMock = () => jest.fn(); const baseSelectionContext = { currentSelectedTransactionReportID: undefined, + excludedTransactions: {}, selectedTransactionIDs: [], selectedReports: [], shouldTurnOffSelectionMode: false, diff --git a/tests/unit/SearchActionsTest.ts b/tests/unit/SearchActionsTest.ts index a51976dc9831..e063118c1904 100644 --- a/tests/unit/SearchActionsTest.ts +++ b/tests/unit/SearchActionsTest.ts @@ -1,18 +1,26 @@ -import {queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import {exportSearchItemsToCSV, queueExportSearchItemsToCSV, queueExportSearchWithTemplate} from '@libs/actions/Search'; import {write} from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; +import fileDownload from '@libs/fileDownload'; +import {translate} from '@libs/Localize'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; +const translateForTest: LocalizedTranslate = (path, ...parameters) => translate(CONST.LOCALES.EN, path, ...parameters); + jest.mock('@libs/API'); +jest.mock('@libs/fileDownload'); jest.mock('@libs/Network/enhanceParameters', () => ({ __esModule: true, default: (_: string, params: Record) => params, })); const mockWrite = jest.mocked(write); +const mockFileDownload = jest.mocked(fileDownload); function getWriteOptions(): {optimisticData: AnyOnyxUpdate[]; failureData: AnyOnyxUpdate[]} { const options = mockWrite.mock.calls.at(-1)?.at(2); @@ -61,6 +69,59 @@ describe('queueExportSearchItemsToCSV', () => { expect(failureUpdate).toBeDefined(); expect(failureUpdate?.value).toEqual({state: CONST.EXPORT_DOWNLOAD.STATE.FAILED, exportType: CONST.EXPORT_DOWNLOAD.TYPE.CSV}); }); + + it('includes excluded transaction IDs in the queued CSV payload', () => { + queueExportSearchItemsToCSV({ + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + excludedTransactionIDList: ['tx2'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }); + + expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.QUEUE_EXPORT_SEARCH_ITEMS_TO_CSV, expect.objectContaining({excludedTransactionIDList: ['tx2']}), expect.any(Object)); + }); + + it('does not add an exclusion field when there are no exclusions', () => { + queueExportSearchItemsToCSV({ + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }); + + expect(mockWrite.mock.calls.at(-1)?.at(1)).not.toHaveProperty('excludedTransactionIDList'); + }); +}); + +describe('exportSearchItemsToCSV', () => { + beforeEach(() => jest.clearAllMocks()); + + it('includes excluded transaction IDs in the direct CSV form payload', () => { + const appendSpy = jest.spyOn(FormData.prototype, 'append'); + + exportSearchItemsToCSV( + { + jsonQuery: '{}', + reportIDList: [], + transactionIDList: ['tx1'], + excludedTransactionIDList: ['tx2'], + isBasicExport: true, + exportColumnLabels: '{}', + exportName: 'Basic export', + }, + jest.fn(), + translateForTest, + ); + + expect(appendSpy).toHaveBeenCalledWith('excludedTransactionIDList', 'tx2'); + expect(mockFileDownload).toHaveBeenCalled(); + appendSpy.mockRestore(); + }); }); describe('queueExportSearchWithTemplate', () => { diff --git a/tests/unit/hooks/useSearchBulkActionsTest.ts b/tests/unit/hooks/useSearchBulkActionsTest.ts index 877513b6911a..b371681ef1d2 100644 --- a/tests/unit/hooks/useSearchBulkActionsTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsTest.ts @@ -153,12 +153,14 @@ jest.mock('@hooks/usePaymentContext', () => ({ const mockClearSelectedTransactions = jest.fn(); const mockSelectAllMatchingItems = jest.fn(); let mockSelectedTransactions: SelectedTransactions = {}; +let mockExcludedTransactions: SelectedTransactions = {}; let mockSelectedReports: SelectedReports[] = []; let mockAreAllMatchingItemsSelected = false; jest.mock('@components/Search/SearchContext', () => ({ useSearchSelectionContext: () => ({ selectedTransactions: mockSelectedTransactions, + excludedTransactions: mockExcludedTransactions, selectedReports: mockSelectedReports, areAllMatchingItemsSelected: mockAreAllMatchingItemsSelected, }), @@ -198,6 +200,13 @@ const baseQueryJSON: SearchQueryJSON = { filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense'}, }; +const expenseReportQueryJSON: SearchQueryJSON = { + ...baseQueryJSON, + inputQuery: 'type:expense-report status:all', + type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + filters: {operator: CONST.SEARCH.SYNTAX_OPERATORS.AND, left: 'type', right: 'expense-report'}, +}; + function makeSelectedTransaction(overrides: Partial = {}): SelectedTransactions[string] { return { isSelected: true, @@ -229,6 +238,7 @@ describe('useSearchBulkActions - CSV export flow', () => { mockAreAllMatchingItemsSelected = false; await Onyx.clear(); mockSelectedTransactions = {}; + mockExcludedTransactions = {}; mockSelectedReports = []; await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'}); @@ -241,6 +251,7 @@ describe('useSearchBulkActions - CSV export flow', () => { it('handleBasicExport with select-all tracks the export', async () => { mockAreAllMatchingItemsSelected = true; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockExcludedTransactions = {tx2: makeSelectedTransaction()}; const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); @@ -258,9 +269,55 @@ describe('useSearchBulkActions - CSV export flow', () => { }); expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalled(); + expect(mockQueueExportSearchItemsToCSV).toHaveBeenCalledWith(expect.objectContaining({excludedTransactionIDList: ['tx2']})); expect(result.current.exportDownloadStatusModal).not.toBeNull(); }); + it('keeps export available when every loaded transaction is excluded from an all-matching selection', async () => { + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = {}; + mockExcludedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: baseQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.some((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT)).toBe(true); + }); + }); + + it('keeps the original expense-report export guard when no loaded transaction is selected', async () => { + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = {}; + mockExcludedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + expect(result.current.headerButtonsOptions).toEqual([]); + }); + + it('does not send exclusions for an expense-report export', async () => { + mockAreAllMatchingItemsSelected = true; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + mockExcludedTransactions = {tx2: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON})); + + await waitFor(() => { + expect(result.current.headerButtonsOptions.length).toBeGreaterThan(0); + }); + + const exportOption = result.current.headerButtonsOptions.find((option) => option.value === CONST.SEARCH.BULK_ACTION_TYPES.EXPORT); + const onSelected = exportOption?.subMenuItems?.find((item) => item.text === 'export.basicExport')?.onSelected ?? exportOption?.onSelected; + + await act(async () => { + onSelected?.(); + }); + + const exportPayload = mockQueueExportSearchItemsToCSV.mock.calls.at(-1)?.at(0); + expect(exportPayload).toBeDefined(); + expect(exportPayload).not.toHaveProperty('excludedTransactionIDList'); + }); + it('handleBasicExport with manual selection does not track any export', async () => { mockAreAllMatchingItemsSelected = false; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; diff --git a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts index 4ec2528a44a3..108eace944e1 100644 --- a/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts +++ b/tests/unit/hooks/useSearchShouldCalculateTotals.test.ts @@ -1,6 +1,6 @@ import {renderHook} from '@testing-library/react-native'; -import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -83,9 +83,23 @@ describe('useSearchShouldCalculateTotals', () => { expect(result.current).toBe(true); }); - it('returns true when all matching items are selected even when the hook is disabled (select-all bypasses the offset gate)', () => { + it('returns false for an all-matching selection when the caller has already obtained totals for pagination', () => { const {result} = renderHook(() => useSearchShouldCalculateTotals(CONST.SEARCH.SEARCH_KEYS.EXPENSES, 123, false, true)); - expect(result.current).toBe(true); + expect(result.current).toBe(false); + }); +}); + +describe('getSearchRequestOffsetForMissingAllMatchingCount', () => { + it('uses the server-confirmed offset when the local offset advanced without loading another page', () => { + expect(getSearchRequestOffsetForMissingAllMatchingCount(50, 0, true)).toBe(0); + }); + + it('keeps the offset when the page was genuinely loaded', () => { + expect(getSearchRequestOffsetForMissingAllMatchingCount(50, 50, true)).toBe(50); + }); + + it('keeps the pagination offset after the matching count is available', () => { + expect(getSearchRequestOffsetForMissingAllMatchingCount(100, 50, false)).toBe(100); }); }); diff --git a/tests/utils/MockSearchContextProvider.tsx b/tests/utils/MockSearchContextProvider.tsx index e95248fc0c24..4bb5453f259d 100644 --- a/tests/utils/MockSearchContextProvider.tsx +++ b/tests/utils/MockSearchContextProvider.tsx @@ -48,6 +48,7 @@ function splitState(value: SearchStateContextValue): { }, selection: { selectedTransactions: value.selectedTransactions, + excludedTransactions: value.excludedTransactions, selectedTransactionIDs: value.selectedTransactionIDs, selectedReports: value.selectedReports, currentSelectedTransactionReportID: value.currentSelectedTransactionReportID,