From b09211b3fdae428c21c3fced91d3dbe9af8547a8 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Mon, 17 Nov 2025 01:33:51 +0800 Subject: [PATCH 01/30] chore: draft sol --- src/components/Search/SearchList/index.tsx | 48 ++- src/components/Search/index.tsx | 238 ++++++++----- .../Search/TransactionGroupListItem.tsx | 15 +- src/libs/actions/Search.ts | 29 +- src/pages/Search/SearchPage.tsx | 4 +- .../Search/deleteSelectedItemsOnSearchTest.ts | 264 +++++++++++++++ tests/unit/TransactionGroupListItemTest.tsx | 314 ++++++++++++++++++ 7 files changed, 805 insertions(+), 107 deletions(-) create mode 100644 tests/unit/Search/deleteSelectedItemsOnSearchTest.ts diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 92b345d4ff044..75dae7c8e937f 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -183,15 +183,36 @@ function SearchList({ } return data; }, [data, groupBy, type]); - const flattenedItemsWithoutPendingDelete = useMemo(() => flattenedItems.filter((t) => t?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [flattenedItems]); - - const selectedItemsLength = useMemo( - () => - flattenedItems.reduce((acc, item) => { - return item?.isSelected ? acc + 1 : acc; - }, 0), - [flattenedItems], - ); + const emptyReports = useMemo(() => { + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + return data.filter((item) => item.transactions.length === 0); + } + return []; + }, [data, type]); + + const selectedItemsLength = useMemo(() => { + const selectedTransactions = flattenedItems.reduce((acc, item) => { + return acc + (item?.isSelected ? 1 : 0); + }, 0); + + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + const selectedEmptyReports = emptyReports.reduce((acc, item) => { + return acc + (item.isSelected ? 1 : 0); + }, 0); + + return selectedEmptyReports + selectedTransactions; + } + + return selectedTransactions; + }, [flattenedItems, type, data, emptyReports]); + + const totalItems = useMemo(() => { + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + return emptyReports.length + flattenedItems.length; + } + + return flattenedItems.length; + }, [data, type, flattenedItems, emptyReports]); const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -237,10 +258,7 @@ function SearchList({ if (shouldPreventLongPressRow || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox) { return; } - // disable long press for empty expense reports - if ('transactions' in item && item.transactions.length === 0 && !groupBy) { - return; - } + if (isMobileSelectionModeEnabled) { onCheckboxPress(item, itemTransactions); return; @@ -373,7 +391,7 @@ function SearchList({ const tableHeaderVisible = (canSelectMultiple || !!SearchTableHeader) && (!groupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT); const selectAllButtonVisible = canSelectMultiple && !SearchTableHeader; - const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === flattenedItemsWithoutPendingDelete.length; + const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems; return ( @@ -383,7 +401,7 @@ function SearchList({ 0 && selectedItemsLength !== flattenedItemsWithoutPendingDelete.length} + isIndeterminate={selectedItemsLength > 0 && selectedItemsLength !== totalItems} onPress={() => { onAllCheckboxPress(); }} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 459628a614377..d5b3ee105c9fc 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -9,7 +9,14 @@ import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import ConfirmModal from '@components/ConfirmModal'; import SearchTableHeader, {getExpenseHeaders} from '@components/SelectionListWithSections/SearchTableHeader'; -import type {ReportActionListItemType, SearchListItem, SelectionListHandle, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; +import type { + ReportActionListItemType, + SearchListItem, + SelectionListHandle, + TransactionGroupListItemType, + TransactionListItemType, + TransactionReportGroupListItemType, +} from '@components/SelectionListWithSections/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {WideRHPContext} from '@components/WideRHPContextProvider'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; @@ -51,6 +58,7 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionMemberGroupListItemType, + isTransactionReportGroupListItemType, isTransactionWithdrawalIDGroupListItemType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, @@ -92,7 +100,11 @@ type SearchProps = { const expenseHeaders = getExpenseHeaders(); -function mapTransactionItemToSelectedEntry(item: TransactionListItemType, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue): [string, SelectedTransactionInfo] { +function mapTransactionItemToSelectedEntry( + item: TransactionListItemType, + outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, + allowNegativeAmount = true, +): [string, SelectedTransactionInfo] { return [ item.keyForList, { @@ -115,7 +127,7 @@ function mapTransactionItemToSelectedEntry(item: TransactionListItemType, outsta convertedCurrency: item.convertedCurrency, reportID: item.reportID, policyID: item.report?.policyID, - amount: item.modifiedAmount ?? item.amount, + amount: allowNegativeAmount ? (item.modifiedAmount ?? item.amount) : Math.abs(item.modifiedAmount ?? item.amount), convertedAmount: item.convertedAmount, currency: item.currency, isFromOneTransactionReport: item.isFromOneTransactionReport, @@ -134,6 +146,27 @@ function mapToTransactionItemWithAdditionalInfo( return {...item, shouldAnimateInHighlight, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple, hash}; } +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType): [string, SelectedTransactionInfo] { + return [ + item.keyForList ?? '', + { + isSelected: true, + canDelete: true, + canHold: false, + isHeld: false, + canUnhold: false, + canChangeReport: false, + action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: item.reportID, + policyID: item.policyID ?? CONST.POLICY.ID_FAKE, + amount: 0, + convertedAmount: 0, + convertedCurrency: '', + currency: '', + }, + ]; +} + function mapToItemWithAdditionalInfo(item: SearchListItem, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean, shouldAnimateInHighlight: boolean, hash?: number) { if (isTaskListItemType(item)) { return { @@ -160,47 +193,25 @@ function mapToItemWithAdditionalInfo(item: SearchListItem, selectedTransactions: mapToTransactionItemWithAdditionalInfo(transaction, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight, hash), ), isSelected: - item?.transactions?.length > 0 && - item.transactions?.filter((t) => !isTransactionPendingDelete(t)).every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected && canSelectMultiple), + item?.transactions?.length > 0 + ? item.transactions?.filter((t) => !isTransactionPendingDelete(t)).every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected && canSelectMultiple) + : !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple), hash, }; } -function prepareTransactionsList(item: TransactionListItemType, selectedTransactions: SelectedTransactions, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue) { +function toggleTransactionInList(item: TransactionListItemType, selectedTransactions: SelectedTransactions, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue) { if (selectedTransactions[item.keyForList]?.isSelected) { const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; return transactions; } + const [key, selectedInfo] = mapTransactionItemToSelectedEntry(item, outstandingReportsByPolicyID, false); + return { ...selectedTransactions, - [item.keyForList]: { - isSelected: true, - canDelete: item.canDelete, - canHold: item.canHold, - isHeld: isOnHold(item), - canUnhold: item.canUnhold, - canChangeReport: canEditFieldOfMoneyRequest( - item.reportAction, - CONST.EDIT_REQUEST_FIELD.REPORT, - undefined, - undefined, - outstandingReportsByPolicyID, - item, - item.report, - item.policy, - ), - action: item.action, - reportID: item.reportID, - policyID: item.policyID, - amount: Math.abs(item.modifiedAmount || item.amount), - convertedAmount: item.convertedAmount, - convertedCurrency: item.convertedCurrency, - currency: item.currency, - isFromOneTransactionReport: item.isFromOneTransactionReport, - ownerAccountID: item.reportAction?.actorAccountID, - }, + [key]: selectedInfo, }; } @@ -461,6 +472,22 @@ function Search({ if (!Object.hasOwn(transactionGroup, 'transactions') || !('transactions' in transactionGroup)) { return; } + + if (transactionGroup.transactions.length === 0 && isTransactionReportGroupListItemType(transactionGroup)) { + const reportKey = transactionGroup.keyForList; + if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); + newTransactionList[reportKey] = { + ...emptyReportSelection, + isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, + }; + } + return; + } + transactionGroup.transactions.forEach((transactionItem) => { if (!(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) { return; @@ -564,25 +591,36 @@ function Search({ isRefreshingSelection.current = false; }, [selectedTransactions]); - useEffect(() => { - if (!isFocused) { - return; - } - - if (!data.length || isRefreshingSelection.current) { - return; - } - const areItemsGrouped = !!validGroupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; - const flattenedItems = areItemsGrouped ? (data as TransactionGroupListItemType[]).flatMap((item) => item.transactions) : data; - const areAllItemsSelected = flattenedItems.length === Object.keys(selectedTransactions).length; - - // If the user has selected all the expenses in their view but there are more expenses matched by the search - // give them the option to select all matching expenses - shouldShowSelectAllMatchingItems(!!(areAllItemsSelected && searchResults?.search?.hasMoreResults)); - if (!areAllItemsSelected) { - selectAllMatchingItems(false); - } - }, [isFocused, data, searchResults?.search?.hasMoreResults, selectedTransactions, selectAllMatchingItems, shouldShowSelectAllMatchingItems, validGroupBy, type]); + const updateSelectAllMatchingItemsState = useCallback( + (updatedSelectedTransactions: SelectedTransactions) => { + if (!data.length || isRefreshingSelection.current) { + return; + } + const areItemsGrouped = !!validGroupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const totalSelectableItemsCount = areItemsGrouped + ? (data as TransactionGroupListItemType[]).reduce((count, item) => { + // For empty reports, count the report itself as a selectable item + if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item)) { + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return count; + } + return count + 1; + } + // For regular reports, count all transactions + return count + item.transactions.length; + }, 0) + : data.length; + const areAllItemsSelected = totalSelectableItemsCount === Object.keys(updatedSelectedTransactions).length; + + // If the user has selected all the expenses in their view but there are more expenses matched by the search + // give them the option to select all matching expenses + shouldShowSelectAllMatchingItems(!!(areAllItemsSelected && searchResults?.search?.hasMoreResults)); + if (!areAllItemsSelected) { + selectAllMatchingItems(false); + } + }, + [data, validGroupBy, type, searchResults?.search?.hasMoreResults, shouldShowSelectAllMatchingItems, selectAllMatchingItems], + ); const toggleTransaction = useCallback( (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => { @@ -599,11 +637,44 @@ function Search({ if (isTransactionPendingDelete(item)) { return; } - setSelectedTransactions(prepareTransactionsList(item, selectedTransactions, outstandingReportsByPolicyID), data); + const updatedTransactions = toggleTransactionInList(item, selectedTransactions, outstandingReportsByPolicyID); + setSelectedTransactions(updatedTransactions, data); + updateSelectAllMatchingItemsState(updatedTransactions); + return; } const currentTransactions = itemTransactions ?? item.transactions; + // Handle empty reports - treat the report itself as selectable + if (currentTransactions.length === 0 && isTransactionReportGroupListItemType(item)) { + const reportKey = item.keyForList; + if (!reportKey) { + return; + } + + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + + if (selectedTransactions[reportKey]?.isSelected) { + // Deselect the empty report + const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; + delete reducedSelectedTransactions[reportKey]; + setSelectedTransactions(reducedSelectedTransactions, data); + updateSelectAllMatchingItemsState(reducedSelectedTransactions); + return; + } + + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); + const updatedTransactions = { + ...selectedTransactions, + [reportKey]: emptyReportSelection, + }; + setSelectedTransactions(updatedTransactions, data); + updateSelectAllMatchingItemsState(updatedTransactions); + return; + } + if (currentTransactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)) { const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; @@ -612,20 +683,20 @@ function Search({ }); setSelectedTransactions(reducedSelectedTransactions, data); + updateSelectAllMatchingItemsState(reducedSelectedTransactions); return; } - setSelectedTransactions( - { - ...selectedTransactions, - ...Object.fromEntries( - currentTransactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), - ), - }, - data, - ); + const updatedTransactions = { + ...selectedTransactions, + ...Object.fromEntries( + currentTransactions + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), + ), + }; + setSelectedTransactions(updatedTransactions, data); + updateSelectAllMatchingItemsState(updatedTransactions); }, [data, selectedTransactions, outstandingReportsByPolicyID, setSelectedTransactions], ); @@ -822,32 +893,37 @@ function Search({ if (totalSelected > 0) { clearSelectedTransactions(); + updateSelectAllMatchingItemsState({}); return; } + let updatedTransactions: SelectedTransactions; if (areItemsGrouped) { - setSelectedTransactions( - Object.fromEntries( - (data as TransactionGroupListItemType[]).flatMap((item) => - item.transactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), - ), + const allSelections: Array<[string, SelectedTransactionInfo]> = (data as TransactionGroupListItemType[]).flatMap((item) => { + if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item) && item.keyForList) { + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return []; + } + return [mapEmptyReportToSelectedEntry(item)]; + } + + return item.transactions + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)); + }); + updatedTransactions = Object.fromEntries(allSelections); + } else { + updatedTransactions = Object.fromEntries( + (data as TransactionGroupListItemType[]).flatMap((item) => + item.transactions + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), ), - data, ); - - return; } - setSelectedTransactions( - Object.fromEntries( - (data as TransactionListItemType[]) - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), - ), - data, - ); + setSelectedTransactions(updatedTransactions, data); + updateSelectAllMatchingItemsState(updatedTransactions); }, [clearSelectedTransactions, data, validGroupBy, selectedTransactions, setSelectedTransactions, outstandingReportsByPolicyID, isExpenseReportType]); const onLayout = useCallback(() => handleSelectionListScroll(sortedSelectedData, searchListRef.current), [handleSelectionListScroll, sortedSelectedData]); diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index 318f8764bf190..b618f105f1668 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -128,10 +128,14 @@ function TransactionGroupListItem({ return transactions.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); }, [transactions]); - const isSelectAllChecked = selectedItemsLength === transactions.length && transactions.length > 0; + const isEmpty = transactions.length === 0; + + const isEmptyReportSelected = isEmpty && item?.keyForList && selectedTransactions[item.keyForList]?.isSelected; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isSelectAllChecked = isEmptyReportSelected || (selectedItemsLength === transactionsWithoutPendingDelete.length && transactionsWithoutPendingDelete.length > 0); + const isIndeterminate = selectedItemsLength > 0 && selectedItemsLength !== transactionsWithoutPendingDelete.length; - const isEmpty = transactions.length === 0; // Currently only the transaction report groups have transactions where the empty view makes sense const shouldDisplayEmptyView = isEmpty && isExpenseReportType; const isDisabledOrEmpty = isEmpty || isDisabled; @@ -190,11 +194,8 @@ function TransactionGroupListItem({ }, [isExpenseReportType, transactions.length, onSelectRow, transactionPreviewData, item, handleToggle]); const onLongPress = useCallback(() => { - if (isEmpty) { - return; - } onLongPressRow?.(item, isExpenseReportType ? undefined : transactions); - }, [isEmpty, isExpenseReportType, item, onLongPressRow, transactions]); + }, [isExpenseReportType, item, onLongPressRow, transactions]); const onCheckboxPress = useCallback( (val: TItem) => { @@ -260,7 +261,7 @@ function TransactionGroupListItem({ report={groupItem as TransactionReportGroupListItemType} onSelectRow={(listItem) => onSelectRow(listItem, transactionPreviewData)} onCheckboxPress={onCheckboxPress} - isDisabled={isDisabledOrEmpty} + isDisabled={isDisabled} isFocused={isFocused} canSelectMultiple={canSelectMultiple} isSelectAllChecked={isSelectAllChecked} diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index d1e831c6a9ebc..407397d17ec1a 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -6,7 +6,7 @@ import type {FormOnyxValues} from '@components/Form/types'; import type {ContinueActionParams, PaymentMethod, PaymentMethodType} from '@components/KYCWall/types'; import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type {BankAccountMenuItem, PaymentData, SearchQueryJSON, SelectedReports, SelectedTransactions} from '@components/Search/types'; +import type {BankAccountMenuItem, PaymentData, SearchQueryJSON, SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; import type {TransactionListItemType, TransactionReportGroupListItemType} from '@components/SelectionListWithSections/types'; import * as API from '@libs/API'; import {waitForWrites} from '@libs/API'; @@ -49,7 +49,7 @@ import type SearchResults from '@src/types/onyx/SearchResults'; import type Nullable from '@src/types/utils/Nullable'; import SafeString from '@src/utils/SafeString'; import {setPersonalBankAccountContinueKYCOnSuccess} from './BankAccounts'; -import {setOptimisticTransactionThread} from './Report'; +import {deleteAppReport, setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; type OnyxSearchResponse = { @@ -606,6 +606,30 @@ function exportToIntegrationOnSearch(hash: number, reportID: string, connectionN API.write(WRITE_COMMANDS.REPORT_EXPORT, params, {optimisticData, failureData, successData}); } +function bulkDeleteReports(hash: number, selectedTransactions: Record, currentSearchResults?: SearchResults) { + const transactionIDList: string[] = []; + const reportIDList: string[] = []; + + Object.keys(selectedTransactions).forEach((key) => { + const selectedItem = selectedTransactions[key]; + if (selectedItem.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === selectedItem.reportID) { + reportIDList.push(selectedItem.reportID); + } else { + transactionIDList.push(key); + } + }); + + if (transactionIDList.length > 0) { + deleteMoneyRequestOnSearch(hash, transactionIDList, currentSearchResults); + } + + if (reportIDList.length > 0) { + reportIDList.forEach((reportID) => { + deleteAppReport(reportID); + }); + } +} + function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], transactionIDList?: string[], currentSearchKey?: SearchKey) { // eslint-disable-next-line @typescript-eslint/no-deprecated const createOnyxData = (update: Partial | Partial | null, shouldRemoveReportFromView = false): OnyxUpdate[] => { @@ -1081,6 +1105,7 @@ export { holdMoneyRequestOnSearch, unholdMoneyRequestOnSearch, exportSearchItemsToCSV, + bulkDeleteReports, queueExportSearchItemsToCSV, queueExportSearchWithTemplate, updateAdvancedFilters, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 964212e7a1621..13101ada9a076 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -37,7 +37,7 @@ import {confirmReadyToOpenApp} from '@libs/actions/App'; import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, - deleteMoneyRequestOnSearch, + bulkDeleteReports, exportSearchItemsToCSV, getExportTemplates, getLastPolicyBankAccountID, @@ -638,7 +638,7 @@ function SearchPage({route}: SearchPageProps) { // We need to wait for modal to fully disappear before clearing them to avoid translation flicker between singular vs plural // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys, currentSearchResults); + bulkDeleteReports(hash, selectedTransactions, currentSearchResults); clearSelectedTransactions(); }); }; diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts new file mode 100644 index 0000000000000..5e1bda64759bb --- /dev/null +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -0,0 +1,264 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import Onyx from 'react-native-onyx'; +import type {SelectedTransactionInfo} from '@components/Search/types'; +import {bulkDeleteReports} from '@libs/actions/Search'; +import {deleteAppReport} from '@userActions/Report'; +import CONST from '@src/CONST'; + +jest.mock('@userActions/Report', () => ({ + deleteAppReport: jest.fn(), +})); + +jest.mock('@libs/API', () => ({ + write: jest.fn(), +})); + +describe('bulkDeleteReports', () => { + beforeEach(() => { + jest.clearAllMocks(); + return Onyx.clear(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Empty Report Deletion', () => { + it('should delete empty reports when selected', () => { + const hash = 12345; + const selectedTransactions: Record = { + report_123: { + reportID: 'report_123', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy123', + amount: 0, + convertedAmount: 0, + convertedCurrency: 'USD', + currency: 'USD', + }, + report_456: { + reportID: 'report_456', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 0, + convertedAmount: 0, + convertedCurrency: 'USD', + currency: 'USD', + }, + }; + + bulkDeleteReports(hash, selectedTransactions); + + // Should call deleteAppReport for each empty report + expect(deleteAppReport).toHaveBeenCalledTimes(2); + expect(deleteAppReport).toHaveBeenCalledWith('report_123'); + expect(deleteAppReport).toHaveBeenCalledWith('report_456'); + }); + + it('should handle mixed selection of empty reports and transactions', () => { + const hash = 12345; + const selectedTransactions: Record = { + report_123: { + reportID: 'report_123', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy123', + amount: 0, + convertedAmount: 0, + convertedCurrency: 'USD', + currency: 'USD', + }, + transaction_789: { + reportID: 'report_456', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 1000, + convertedAmount: 1000, + convertedCurrency: 'USD', + currency: 'USD', + }, + transaction_101: { + reportID: 'report_456', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 500, + convertedAmount: 500, + convertedCurrency: 'USD', + currency: 'USD', + }, + }; + + bulkDeleteReports(hash, selectedTransactions); + + // Should call deleteAppReport for empty report + expect(deleteAppReport).toHaveBeenCalledTimes(1); + expect(deleteAppReport).toHaveBeenCalledWith('report_123'); + }); + + it('should not delete reports when no empty reports are selected', () => { + const hash = 12345; + const selectedTransactions: Record = { + transaction_789: { + reportID: 'report_456', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 1000, + convertedAmount: 1000, + convertedCurrency: 'USD', + currency: 'USD', + }, + transaction_101: { + reportID: 'report_456', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 500, + convertedAmount: 500, + convertedCurrency: 'USD', + currency: 'USD', + }, + }; + + bulkDeleteReports(hash, selectedTransactions); + + // Should not call deleteAppReport + expect(deleteAppReport).not.toHaveBeenCalled(); + }); + + it('should handle empty selection gracefully', () => { + const hash = 12345; + const selectedTransactions: Record = {}; + + bulkDeleteReports(hash, selectedTransactions); + + // Should not call any deletion functions + expect(deleteAppReport).not.toHaveBeenCalled(); + }); + + it('should only delete reports where key matches reportID for VIEW action', () => { + const hash = 12345; + const selectedTransactions: Record = { + report_123: { + reportID: 'report_123', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy123', + amount: 0, + convertedAmount: 0, + convertedCurrency: 'USD', + currency: 'USD', + }, + different_key: { + reportID: 'report_456', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 0, + convertedAmount: 0, + convertedCurrency: 'USD', + currency: 'USD', + }, + }; + + bulkDeleteReports(hash, selectedTransactions); + + // Should only call deleteAppReport for the first report where key === reportID + expect(deleteAppReport).toHaveBeenCalledTimes(1); + expect(deleteAppReport).toHaveBeenCalledWith('report_123'); + expect(deleteAppReport).not.toHaveBeenCalledWith('report_456'); + }); + }); + + describe('Transaction Deletion', () => { + it('should handle transaction deletion when transactions are selected', () => { + const hash = 12345; + const selectedTransactions: Record = { + transaction_789: { + reportID: 'report_456', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 1000, + convertedAmount: 1000, + convertedCurrency: 'USD', + currency: 'USD', + }, + transaction_101: { + reportID: 'report_456', + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canDelete: true, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 500, + convertedAmount: 500, + convertedCurrency: 'USD', + currency: 'USD', + }, + }; + + bulkDeleteReports(hash, selectedTransactions); + + // Should not call deleteAppReport for transactions + expect(deleteAppReport).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 79abf4cd5cbe5..8575a644ae200 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -23,6 +23,45 @@ jest.mock('@libs/SearchUIUtils', () => ({ isCorrectSearchUserName: jest.fn(() => true), })); +const mockEmptyReport: TransactionReportGroupListItemType = { + accountID: 1, + chatReportID: '4735435600700077', + chatType: undefined, + created: '2025-09-19 20:00:47', + currency: 'USD', + isOneTransactionReport: false, + isOwnPolicyExpenseChat: false, + isWaitingOnBankAccount: false, + managerID: 1, + nonReimbursableTotal: 0, + oldPolicyName: '', + ownerAccountID: 1, + parentReportActionID: '2454187434077044186', + parentReportID: '4735435600700077', + policyID: '06F34677820A4D07', + reportID: '515146912679679', + reportName: 'Expense Report #515146912679679', + stateNum: 1, + statusNum: 1, + total: 0, + type: 'expense', + unheldTotal: 0, + from: { + accountID: 1, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_15.png', + displayName: 'Main Applause QA', + }, + to: { + accountID: 1, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_15.png', + displayName: 'Main Applause QA', + }, + transactions: [], + groupedBy: 'expense-report', + keyForList: '515146912679679', + action: CONST.SEARCH.ACTION_TYPES.VIEW, +}; + const mockTransaction: TransactionListItemType = { accountID: 1, amount: 0, @@ -87,6 +126,52 @@ const mockTransaction: TransactionListItemType = { }, }; +const mockNonEmptyReport: TransactionReportGroupListItemType = { + accountID: 2, + chatReportID: '4735435600700078', + chatType: undefined, + created: '2025-09-20 10:00:00', + currency: 'USD', + isOneTransactionReport: false, + isOwnPolicyExpenseChat: false, + isWaitingOnBankAccount: false, + managerID: 2, + nonReimbursableTotal: 0, + oldPolicyName: '', + ownerAccountID: 2, + parentReportActionID: '2454187434077044187', + parentReportID: '4735435600700078', + policyID: '06F34677820A4D07', + reportID: '515146912679680', + reportName: 'Expense Report #515146912679680', + stateNum: 1, + statusNum: 1, + total: -1284, + type: 'expense', + unheldTotal: -1284, + from: { + accountID: 2, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_16.png', + displayName: 'Test User', + }, + to: { + accountID: 2, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_16.png', + displayName: 'Test User', + }, + transactions: [ + { + ...mockTransaction, + transactionID: '2', + reportID: '515146912679680', + keyForList: '2', + }, + ], + groupedBy: 'expense-report', + keyForList: '515146912679680', + action: CONST.SEARCH.ACTION_TYPES.VIEW, +}; + const mockReport: TransactionReportGroupListItemType = { accountID: 1, chatReportID: '4735435600700077', @@ -320,3 +405,232 @@ describe('TransactionGroupListItem', () => { expect(screen.getByTestId('ReportSearchHeader')).toBeTruthy(); }); }); + +describe('Empty Report Selection', () => { + const mockOnSelectRow = jest.fn(); + const mockOnCheckboxPress = jest.fn(); + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS], + }); + jest.spyOn(NativeNavigation, 'useRoute').mockReturnValue({key: '', name: ''}); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockOnSelectRow.mockClear(); + mockOnCheckboxPress.mockClear(); + return act(async () => { + await Onyx.clear(); + await waitForBatchedUpdatesWithAct(); + }); + }); + + const defaultProps: TransactionGroupListItemProps = { + item: mockEmptyReport, + showTooltip: false, + onSelectRow: mockOnSelectRow, + onCheckboxPress: mockOnCheckboxPress, + searchType: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, + canSelectMultiple: true, + }; + + function TestWrapper({children}: {children: React.ReactNode}) { + return ( + + + {children} + + + ); + } + + const renderTransactionGroupListItem = () => { + return render( + , + {wrapper: TestWrapper}, + ); + }; + + it('should render an empty report with checkbox', async () => { + renderTransactionGroupListItem(); + await waitForBatchedUpdatesWithAct(); + + // Then the empty report should be rendered with a checkbox + expect(screen.getByRole(CONST.ROLE.CHECKBOX)).toBeTruthy(); + expect(screen.getByRole(CONST.ROLE.CHECKBOX)).not.toBeChecked(); + expect(screen.getByTestId('ReportSearchHeader')).toBeTruthy(); + expect(screen.getByTestId('TotalCell')).toBeTruthy(); + expect(screen.getByTestId('ActionCell')).toBeTruthy(); + }); + + it('should call onCheckboxPress when checkbox is clicked on an empty report', async () => { + renderTransactionGroupListItem(); + await waitForBatchedUpdatesWithAct(); + + // When clicking on the empty report checkbox + const checkbox = screen.getByRole(CONST.ROLE.CHECKBOX); + expect(checkbox).not.toBeChecked(); + + fireEvent.press(checkbox); + await waitForBatchedUpdatesWithAct(); + + // Then onCheckboxPress should be called with the empty report and undefined (for groupBy reports) + expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined); + }); + + it('should call onCheckboxPress multiple times when checkbox is clicked multiple times', async () => { + renderTransactionGroupListItem(); + await waitForBatchedUpdatesWithAct(); + + const checkbox = screen.getByRole(CONST.ROLE.CHECKBOX); + + // First click + fireEvent.press(checkbox); + await waitForBatchedUpdatesWithAct(); + expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); + + // Second click + fireEvent.press(checkbox); + await waitForBatchedUpdatesWithAct(); + + // Then onCheckboxPress should be called twice + expect(mockOnCheckboxPress).toHaveBeenCalledTimes(2); + }); + + it('should not show expandable content for an empty report', async () => { + renderTransactionGroupListItem(); + await waitForBatchedUpdatesWithAct(); + + // Empty reports should not have expandable transaction content + // The AnimatedCollapsible content should not be visible + const collapsibleContent = screen.queryByTestId(CONST.ANIMATED_COLLAPSIBLE_CONTENT_TEST_ID); + + // The collapsible content should not be rendered for empty reports + expect(collapsibleContent).toBeNull(); + }); + + it('should handle selecting both empty and non-empty reports', async () => { + // First render and select an empty report + const {unmount: unmountEmpty} = renderTransactionGroupListItem(); + await waitForBatchedUpdatesWithAct(); + + const emptyCheckbox = screen.getByRole(CONST.ROLE.CHECKBOX); + expect(emptyCheckbox).not.toBeChecked(); + + fireEvent.press(emptyCheckbox); + await waitForBatchedUpdatesWithAct(); + + expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockEmptyReport, undefined); + + unmountEmpty(); + mockOnCheckboxPress.mockClear(); + + // Render and select a non-empty report + const nonEmptyProps: TransactionGroupListItemProps = { + ...defaultProps, + item: mockNonEmptyReport, + }; + + const {unmount: unmountNonEmpty} = render( + , + {wrapper: TestWrapper}, + ); + await waitForBatchedUpdatesWithAct(); + + const nonEmptyCheckbox = screen.getByRole(CONST.ROLE.CHECKBOX); + expect(nonEmptyCheckbox).not.toBeChecked(); + + fireEvent.press(nonEmptyCheckbox); + await waitForBatchedUpdatesWithAct(); + + expect(mockOnCheckboxPress).toHaveBeenCalledTimes(1); + expect(mockOnCheckboxPress).toHaveBeenCalledWith(mockNonEmptyReport, undefined); + + unmountNonEmpty(); + }); + + it('should track the number of checkbox presses for multiple selections', async () => { + renderTransactionGroupListItem(); + await waitForBatchedUpdatesWithAct(); + + const checkbox = screen.getByRole(CONST.ROLE.CHECKBOX); + + for (let i = 1; i <= 3; i++) { + fireEvent.press(checkbox); + await waitForBatchedUpdatesWithAct(); + expect(mockOnCheckboxPress).toHaveBeenCalledTimes(i); + } + + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(1, mockEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(2, mockEmptyReport, undefined); + expect(mockOnCheckboxPress).toHaveBeenNthCalledWith(3, mockEmptyReport, undefined); + }); + + it('should show expandable content for non-empty reports', async () => { + const nonEmptyProps: TransactionGroupListItemProps = { + ...defaultProps, + item: mockNonEmptyReport, + }; + + render( + , + {wrapper: TestWrapper}, + ); + await waitForBatchedUpdatesWithAct(); + + // Non-empty reports should have an expand button + const expandButton = screen.getByLabelText('Expand'); + expect(expandButton).toBeTruthy(); + + // Initially, the collapsible content should not be visible + let collapsibleContent = screen.queryByTestId(CONST.ANIMATED_COLLAPSIBLE_CONTENT_TEST_ID); + expect(collapsibleContent).toBeNull(); + + // Click the expand button + fireEvent.press(expandButton); + await waitForBatchedUpdatesWithAct(); + + // After expanding, the collapsible content should be visible + collapsibleContent = screen.queryByTestId(CONST.ANIMATED_COLLAPSIBLE_CONTENT_TEST_ID); + expect(collapsibleContent).toBeTruthy(); + + // The button label should change to 'Collapse' + const collapseButton = screen.getByLabelText('Collapse'); + expect(collapseButton).toBeTruthy(); + + // Click the collapse button + fireEvent.press(collapseButton); + await waitForBatchedUpdatesWithAct(); + + // The button label should change back to 'Expand' + const expandButtonAgain = screen.getByLabelText('Expand'); + expect(expandButtonAgain).toBeTruthy(); + }); + + it('should not show expand button for empty reports', async () => { + renderTransactionGroupListItem(); + await waitForBatchedUpdatesWithAct(); + + // Empty reports should have an expand button but it should be disabled + const expandButton = screen.queryByLabelText('Expand'); + expect(expandButton).toBeTruthy(); + + // The collapsible content should not be rendered for empty reports + const collapsibleContent = screen.queryByTestId(CONST.ANIMATED_COLLAPSIBLE_CONTENT_TEST_ID); + expect(collapsibleContent).toBeNull(); + }); +}); From ec6d33ee7769ee266b3bfa43702b0609647c843d Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 20 Nov 2025 01:41:28 +0800 Subject: [PATCH 02/30] fix: 74518 --- src/languages/de.ts | 10 ++++++++-- src/languages/en.ts | 10 ++++++++-- src/languages/es.ts | 10 ++++++++-- src/languages/fr.ts | 10 ++++++++-- src/languages/it.ts | 10 ++++++++-- src/languages/ja.ts | 10 ++++++++-- src/languages/nl.ts | 10 ++++++++-- src/languages/pl.ts | 10 ++++++++-- src/languages/pt-BR.ts | 10 ++++++++-- src/languages/zh-hans.ts | 10 ++++++++-- src/pages/Search/SearchPage.tsx | 28 ++++++++++++++++++++++++---- 11 files changed, 104 insertions(+), 24 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 026c503e9d4c3..0020125e89c86 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1261,8 +1261,14 @@ const translations: TranslationDeepObject = { one: 'Möchten Sie diesen Ausgabenposten wirklich löschen?', other: 'Möchten Sie diese Ausgaben wirklich löschen?', }), - deleteReport: 'Bericht löschen', - deleteReportConfirmation: 'Möchten Sie diesen Bericht wirklich löschen?', + deleteReport: () => ({ + one: 'Bericht löschen', + other: 'Berichte löschen', + }), + deleteReportConfirmation: () => ({ + one: 'Möchten Sie diesen Bericht wirklich löschen?', + other: 'Möchten Sie diese Berichte wirklich löschen?', + }), settledExpensify: 'Bezahlt', done: 'Fertiggestellt', settledElsewhere: 'Anderswo bezahlt', diff --git a/src/languages/en.ts b/src/languages/en.ts index c96626c4e9e69..196699d21e5c2 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1247,8 +1247,14 @@ const translations = { one: 'Are you sure that you want to delete this expense?', other: 'Are you sure that you want to delete these expenses?', }), - deleteReport: 'Delete report', - deleteReportConfirmation: 'Are you sure that you want to delete this report?', + deleteReport: () => ({ + one: 'Delete report', + other: 'Delete reports', + }), + deleteReportConfirmation: () => ({ + one: 'Are you sure that you want to delete this report?', + other: 'Are you sure that you want to delete these reports?', + }), settledExpensify: 'Paid', done: 'Done', settledElsewhere: 'Paid elsewhere', diff --git a/src/languages/es.ts b/src/languages/es.ts index f8bddb8e5e075..2958bb9bef04b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -909,8 +909,14 @@ const translations: TranslationDeepObject = { one: '¿Estás seguro de que quieres eliminar esta solicitud?', other: '¿Estás seguro de que quieres eliminar estas solicitudes?', }), - deleteReport: 'Eliminar informe', - deleteReportConfirmation: '¿Estás seguro de que quieres eliminar este informe?', + deleteReport: () => ({ + one: 'Eliminar informe', + other: 'Eliminar informes', + }), + deleteReportConfirmation: () => ({ + one: '¿Estás seguro de que quieres eliminar este informe?', + other: '¿Estás seguro de que quieres eliminar estos informes?', + }), settledExpensify: 'Pagado', done: 'Listo', settledElsewhere: 'Pagado de otra forma', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 65a90800969eb..705cc6be625b7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1263,8 +1263,14 @@ const translations: TranslationDeepObject = { one: 'Êtes-vous sûr de vouloir supprimer cette dépense ?', other: 'Êtes-vous sûr de vouloir supprimer ces dépenses ?', }), - deleteReport: 'Supprimer le rapport', - deleteReportConfirmation: 'Êtes-vous sûr de vouloir supprimer ce rapport ?', + deleteReport: () => ({ + one: 'Supprimer le rapport', + other: 'Supprimer les rapports', + }), + deleteReportConfirmation: () => ({ + one: 'Êtes-vous sûr de vouloir supprimer ce rapport ?', + other: 'Êtes-vous sûr de vouloir supprimer ces rapports ?', + }), settledExpensify: 'Payé', done: 'Fait', settledElsewhere: 'Payé ailleurs', diff --git a/src/languages/it.ts b/src/languages/it.ts index 7c1b49832e9b1..4f2fe096336ba 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1258,8 +1258,14 @@ const translations: TranslationDeepObject = { one: 'Sei sicuro di voler eliminare questa spesa?', other: 'Sei sicuro di voler eliminare queste spese?', }), - deleteReport: 'Elimina rapporto', - deleteReportConfirmation: 'Sei sicuro di voler eliminare questo report?', + deleteReport: () => ({ + one: 'Elimina rapporto', + other: 'Elimina rapporti', + }), + deleteReportConfirmation: () => ({ + one: 'Sei sicuro di voler eliminare questo report?', + other: 'Sei sicuro di voler eliminare questi report?', + }), settledExpensify: 'Pagato', done: 'Fatto', settledElsewhere: 'Pagato altrove', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 50cdbcc137ef5..b60140dcf69ad 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1260,8 +1260,14 @@ const translations: TranslationDeepObject = { one: 'この経費を削除してもよろしいですか?', other: 'これらの経費を削除してもよろしいですか?', }), - deleteReport: 'レポートを削除', - deleteReportConfirmation: 'このレポートを削除してもよろしいですか?', + deleteReport: () => ({ + one: 'レポートを削除', + other: 'レポートを削除', + }), + deleteReportConfirmation: () => ({ + one: 'このレポートを削除してもよろしいですか?', + other: 'これらのレポートを削除してもよろしいですか?', + }), settledExpensify: '支払済み', done: '完了', settledElsewhere: '他で支払い済み', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ee4ec19ffd778..b60a76d9ccb56 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1258,8 +1258,14 @@ const translations: TranslationDeepObject = { one: 'Weet je zeker dat je deze uitgave wilt verwijderen?', other: 'Weet je zeker dat je deze uitgaven wilt verwijderen?', }), - deleteReport: 'Rapport verwijderen', - deleteReportConfirmation: 'Weet u zeker dat u dit rapport wilt verwijderen?', + deleteReport: () => ({ + one: 'Rapport verwijderen', + other: 'Rapporten verwijderen', + }), + deleteReportConfirmation: () => ({ + one: 'Weet u zeker dat u dit rapport wilt verwijderen?', + other: 'Weet u zeker dat u deze rapporten wilt verwijderen?', + }), settledExpensify: 'Betaald', done: 'Klaar', settledElsewhere: 'Elders betaald', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 799a765c4a3b4..0d061d8b21f4e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1257,8 +1257,14 @@ const translations: TranslationDeepObject = { one: 'Czy na pewno chcesz usunąć ten wydatek?', other: 'Czy na pewno chcesz usunąć te wydatki?', }), - deleteReport: 'Usuń raport', - deleteReportConfirmation: 'Czy na pewno chcesz usunąć ten raport?', + deleteReport: () => ({ + one: 'Usuń raport', + other: 'Usuń raporty', + }), + deleteReportConfirmation: () => ({ + one: 'Czy na pewno chcesz usunąć ten raport?', + other: 'Czy na pewno chcesz usunąć te raporty?', + }), settledExpensify: 'Zapłacono', done: 'Gotowe', settledElsewhere: 'Opłacone gdzie indziej', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e098465ba32e9..6fe0f49a451dc 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1257,8 +1257,14 @@ const translations: TranslationDeepObject = { one: 'Tem certeza de que deseja excluir esta despesa?', other: 'Tem certeza de que deseja excluir estas despesas?', }), - deleteReport: 'Excluir relatório', - deleteReportConfirmation: 'Tem certeza de que deseja excluir este relatório?', + deleteReport: () => ({ + one: 'Excluir relatório', + other: 'Excluir relatórios', + }), + deleteReportConfirmation: () => ({ + one: 'Tem certeza de que deseja excluir este relatório?', + other: 'Tem certeza de que deseja excluir estes relatórios?', + }), settledExpensify: 'Pago', done: 'Concluído', settledElsewhere: 'Pago em outro lugar', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index a88da237f1690..e37cb89a2481b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1241,8 +1241,14 @@ const translations: TranslationDeepObject = { one: '您确定要删除此费用吗?', other: '您确定要删除这些费用吗?', }), - deleteReport: '删除报告', - deleteReportConfirmation: '您确定要删除此报告吗?', + deleteReport: () => ({ + one: '删除报告', + other: '删除报告', + }), + deleteReportConfirmation: () => ({ + one: '您确定要删除此报告吗?', + other: '您确定要删除这些报告吗?', + }), settledExpensify: '已支付', done: '完成', settledElsewhere: '在其他地方支付', diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index a5c1e93013480..d5bebf0157ae2 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -643,6 +643,26 @@ function SearchPage({route}: SearchPageProps) { }); }; + const {reportCount, expenseCount} = useMemo(() => { + let reports = 0; + let expenses = 0; + + Object.keys(selectedTransactions).forEach((key) => { + const selectedItem = selectedTransactions[key]; + if (selectedItem.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === selectedItem.reportID) { + reports += 1; + } else { + expenses += 1; + } + }); + + return {reportCount: reports, expenseCount: expenses}; + }, [selectedTransactions]); + + const isDeletingOnlyReports = reportCount > 0 && expenseCount === 0; + const deleteModalTitle = isDeletingOnlyReports ? translate('iou.deleteReport', {count: reportCount}) : translate('iou.deleteExpense', {count: expenseCount}); + const deleteModalPrompt = isDeletingOnlyReports ? translate('iou.deleteReportConfirmation', {count: reportCount}) : translate('iou.deleteConfirmation', {count: expenseCount}); + const saveFileAndInitMoneyRequest = (files: FileObject[]) => { const initialTransaction = initMoneyRequest({ isFromGlobalCreate: true, @@ -839,8 +859,8 @@ function SearchPage({route}: SearchPageProps) { onCancel={() => { setIsDeleteExpensesConfirmModalVisible(false); }} - title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})} - prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})} + title={deleteModalTitle} + prompt={deleteModalPrompt} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} danger @@ -978,8 +998,8 @@ function SearchPage({route}: SearchPageProps) { onCancel={() => { setIsDeleteExpensesConfirmModalVisible(false); }} - title={translate('iou.deleteExpense', {count: selectedTransactionsKeys.length})} - prompt={translate('iou.deleteConfirmation', {count: selectedTransactionsKeys.length})} + title={deleteModalTitle} + prompt={deleteModalPrompt} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} danger From 4705a3e6206899f9b2475f3dc3903019e582df6d Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 20 Nov 2025 01:55:54 +0800 Subject: [PATCH 03/30] fix: 74519 --- src/components/Search/index.tsx | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index d18afbc5d9ee5..78f52f712ce90 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -148,12 +148,14 @@ function mapToTransactionItemWithAdditionalInfo( return {...item, shouldAnimateInHighlight, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple, hash}; } -function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType): [string, SelectedTransactionInfo] { +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType, currentUserAccountID: number | undefined): [string, SelectedTransactionInfo] { + const canDelete = item.ownerAccountID === currentUserAccountID; + return [ item.keyForList ?? '', { isSelected: true, - canDelete: true, + canDelete, canHold: false, isHeld: false, canUnhold: false, @@ -488,7 +490,7 @@ function Search({ return; } if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup, accountID); newTransactionList[reportKey] = { ...emptyReportSelection, isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, @@ -690,7 +692,7 @@ function Search({ return; } - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item, accountID); const updatedTransactions = { ...selectedTransactions, [reportKey]: emptyReportSelection, @@ -723,7 +725,7 @@ function Search({ setSelectedTransactions(updatedTransactions, data); updateSelectAllMatchingItemsState(updatedTransactions); }, - [selectedTransactions, setSelectedTransactions, data, updateSelectAllMatchingItemsState, outstandingReportsByPolicyID], + [selectedTransactions, setSelectedTransactions, data, updateSelectAllMatchingItemsState, outstandingReportsByPolicyID, accountID], ); const onSelectRow = useCallback( @@ -929,7 +931,7 @@ function Search({ if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return []; } - return [mapEmptyReportToSelectedEntry(item)]; + return [mapEmptyReportToSelectedEntry(item, accountID)]; } return item.transactions @@ -949,7 +951,17 @@ function Search({ setSelectedTransactions(updatedTransactions, data); updateSelectAllMatchingItemsState(updatedTransactions); - }, [validGroupBy, isExpenseReportType, selectedTransactions, setSelectedTransactions, data, updateSelectAllMatchingItemsState, clearSelectedTransactions, outstandingReportsByPolicyID]); + }, [ + validGroupBy, + isExpenseReportType, + selectedTransactions, + setSelectedTransactions, + data, + updateSelectAllMatchingItemsState, + clearSelectedTransactions, + outstandingReportsByPolicyID, + accountID, + ]); const onLayout = useCallback(() => handleSelectionListScroll(sortedSelectedData, searchListRef.current), [handleSelectionListScroll, sortedSelectedData]); From 45f9f54f282297b6afa63c8baebb950757708527 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 20 Nov 2025 02:04:39 +0800 Subject: [PATCH 04/30] fix: 74520 --- src/components/Search/index.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 78f52f712ce90..75adee78b3b41 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -940,12 +940,11 @@ function Search({ }); updatedTransactions = Object.fromEntries(allSelections); } else { + // When items are not grouped, data is TransactionListItemType[] not TransactionGroupListItemType[] updatedTransactions = Object.fromEntries( - (data as TransactionGroupListItemType[]).flatMap((item) => - item.transactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), - ), + (data as TransactionListItemType[]) + .filter((item) => !isTransactionPendingDelete(item)) + .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), ); } From 579f47ef3ebb7e859151603f767e9650af0e6a99 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 20 Nov 2025 02:30:30 +0800 Subject: [PATCH 05/30] fix: 74521 --- src/pages/Search/SearchPage.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index d5bebf0157ae2..78f4212acd38c 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -874,15 +874,6 @@ function SearchPage({route}: SearchPageProps) { isVisible={isOfflineModalVisible} onClose={() => setIsOfflineModalVisible(false)} /> - setIsDownloadErrorModalVisible(false)} - secondOptionText={translate('common.buttonConfirm')} - isVisible={isDownloadErrorModalVisible} - onClose={() => setIsDownloadErrorModalVisible(false)} - /> { @@ -909,6 +900,15 @@ function SearchPage({route}: SearchPageProps) { /> )} + setIsDownloadErrorModalVisible(false)} + secondOptionText={translate('common.buttonConfirm')} + isVisible={isDownloadErrorModalVisible} + onClose={() => setIsDownloadErrorModalVisible(false)} + /> ); } From cf54e2f88372ab52c126f410da2a59ccc9119699 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 20 Nov 2025 02:40:16 +0800 Subject: [PATCH 06/30] fix: 74534 --- src/libs/SearchUIUtils.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 30ab92ca71b35..1b3e0d7a7e4cb 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1922,6 +1922,13 @@ function isSearchResultsEmpty(searchResults: SearchResults, groupBy?: SearchGrou if (groupBy) { return !Object.keys(searchResults?.data).some((key) => isGroupEntry(key)); } + + if (searchResults?.search?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT) { + return !Object.keys(searchResults?.data).some( + (key) => isReportEntry(key) && (searchResults?.data[key as keyof typeof searchResults.data] as OnyxTypes.Report)?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + ); + } + return !Object.keys(searchResults?.data).some( (key) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION) && From 9d4cecad907b96bb2ec0e1d34fee909b75b6e550 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 20 Nov 2025 02:48:43 +0800 Subject: [PATCH 07/30] fix: 74537 --- src/components/Search/SearchContext.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 5efee66d492e7..08c9d98036623 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -92,7 +92,15 @@ function SearchContextProvider({children}: ChildrenProps) { if (data.length && data.every(isTransactionReportGroupListItemType)) { selectedReports = data - .filter((item) => isMoneyRequestReport(item) && item.transactions.length > 0 && item.transactions.every(({keyForList}) => selectedTransactions[keyForList]?.isSelected)) + .filter((item) => { + if (!isMoneyRequestReport(item)) { + return false; + } + if (item.transactions.length === 0) { + return !!item.keyForList && selectedTransactions[item.keyForList]?.isSelected; + } + return item.transactions.every(({keyForList}) => selectedTransactions[keyForList]?.isSelected); + }) .map(({reportID, action = CONST.SEARCH.ACTION_TYPES.VIEW, total = CONST.DEFAULT_NUMBER_ID, policyID, allActions = [action], currency, chatReportID}) => ({ reportID, action, From e772673bd5c31591ad0c9004938e2c9fe9c97e57 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 20 Nov 2025 03:08:15 +0800 Subject: [PATCH 08/30] fix: 74539 --- src/pages/Search/SearchPage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 78f4212acd38c..f432478fdc335 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -816,11 +816,16 @@ function SearchPage({route}: SearchPageProps) { const shouldUseClientTotal = !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.convertedCurrency; - const count = shouldUseClientTotal ? selectedTransactionsKeys.length : metadata?.count; + const numberOfExpense = shouldUseClientTotal + ? selectedTransactionsKeys.filter((key) => { + const item = selectedTransactions[key]; + return !(item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID); + }).length + : metadata?.count; const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.convertedAmount ?? 0), 0) : metadata?.total; - return {count, total, currency}; - }, [areAllMatchingItemsSelected, metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys.length]); + return {count: numberOfExpense, total, currency}; + }, [areAllMatchingItemsSelected, metadata?.count, metadata?.currency, metadata?.total, selectedTransactions, selectedTransactionsKeys]); if (shouldUseNarrowLayout) { return ( From 4218daf06bcee98028fd5ce41e2a1565f4faf5bc Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 23 Nov 2025 22:51:10 +0800 Subject: [PATCH 09/30] fix: select issue --- src/components/Search/index.tsx | 252 ++++++++++++------ .../Search/ExpenseReportListItem.tsx | 5 +- 2 files changed, 169 insertions(+), 88 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 1caf807c78769..0583e97a0ae27 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -9,7 +9,14 @@ import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import ConfirmModal from '@components/ConfirmModal'; import SearchTableHeader, {getExpenseHeaders} from '@components/SelectionListWithSections/SearchTableHeader'; -import type {ReportActionListItemType, SearchListItem, SelectionListHandle, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionListWithSections/types'; +import type { + ReportActionListItemType, + SearchListItem, + SelectionListHandle, + TransactionGroupListItemType, + TransactionListItemType, + TransactionReportGroupListItemType, +} from '@components/SelectionListWithSections/types'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import {WideRHPContext} from '@components/WideRHPContextProvider'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; @@ -51,6 +58,7 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionMemberGroupListItemType, + isTransactionReportGroupListItemType, isTransactionWithdrawalIDGroupListItemType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, @@ -93,7 +101,11 @@ type SearchProps = { const expenseHeaders = getExpenseHeaders(); -function mapTransactionItemToSelectedEntry(item: TransactionListItemType, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue): [string, SelectedTransactionInfo] { +function mapTransactionItemToSelectedEntry( + item: TransactionListItemType, + outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, + allowNegativeAmount = true, +): [string, SelectedTransactionInfo] { const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy); return [ @@ -118,7 +130,7 @@ function mapTransactionItemToSelectedEntry(item: TransactionListItemType, outsta convertedCurrency: item.convertedCurrency, reportID: item.reportID, policyID: item.report?.policyID, - amount: item.modifiedAmount ?? item.amount, + amount: allowNegativeAmount ? (item.modifiedAmount ?? item.amount) : Math.abs(item.modifiedAmount ?? item.amount), convertedAmount: item.convertedAmount, currency: item.currency, isFromOneTransactionReport: item.isFromOneTransactionReport, @@ -127,6 +139,29 @@ function mapTransactionItemToSelectedEntry(item: TransactionListItemType, outsta ]; } +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType, currentUserAccountID: number | undefined): [string, SelectedTransactionInfo] { + const canDelete = item.ownerAccountID === currentUserAccountID; + + return [ + item.keyForList ?? '', + { + isSelected: true, + canDelete, + canHold: false, + isHeld: false, + canUnhold: false, + canChangeReport: false, + action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, + reportID: item.reportID, + policyID: item.policyID ?? CONST.POLICY.ID_FAKE, + amount: 0, + convertedAmount: 0, + convertedCurrency: '', + currency: '', + }, + ]; +} + function mapToTransactionItemWithAdditionalInfo( item: TransactionListItemType, selectedTransactions: SelectedTransactions, @@ -163,49 +198,25 @@ function mapToItemWithAdditionalInfo(item: SearchListItem, selectedTransactions: mapToTransactionItemWithAdditionalInfo(transaction, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight, hash), ), isSelected: - item?.transactions?.length > 0 && - item.transactions?.filter((t) => !isTransactionPendingDelete(t)).every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected && canSelectMultiple), + item?.transactions?.length > 0 + ? item.transactions?.filter((t) => !isTransactionPendingDelete(t)).every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected && canSelectMultiple) + : !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple), hash, }; } -function prepareTransactionsList(item: TransactionListItemType, selectedTransactions: SelectedTransactions, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue) { +function toggleTransactionInList(item: TransactionListItemType, selectedTransactions: SelectedTransactions, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue) { if (selectedTransactions[item.keyForList]?.isSelected) { const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; return transactions; } - const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy); + const [key, selectedInfo] = mapTransactionItemToSelectedEntry(item, outstandingReportsByPolicyID, false); return { ...selectedTransactions, - [item.keyForList]: { - isSelected: true, - canDelete: item.canDelete, - canHold: canHoldRequest, - isHeld: isOnHold(item), - canUnhold: canUnholdRequest, - canChangeReport: canEditFieldOfMoneyRequest( - item.reportAction, - CONST.EDIT_REQUEST_FIELD.REPORT, - undefined, - undefined, - outstandingReportsByPolicyID, - item, - item.report, - item.policy, - ), - action: item.action, - reportID: item.reportID, - policyID: item.policyID, - amount: Math.abs(item.modifiedAmount || item.amount), - convertedAmount: item.convertedAmount, - convertedCurrency: item.convertedCurrency, - currency: item.currency, - isFromOneTransactionReport: item.isFromOneTransactionReport, - ownerAccountID: item.reportAction?.actorAccountID, - }, + [key]: selectedInfo, }; } @@ -469,6 +480,21 @@ function Search({ continue; } + if (transactionGroup.transactions.length === 0 && isTransactionReportGroupListItemType(transactionGroup)) { + const reportKey = transactionGroup.keyForList; + if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup, accountID); + newTransactionList[reportKey] = { + ...emptyReportSelection, + isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, + }; + } + return; + } + // For expense reports: when ANY transaction is selected, we want ALL transactions in the report selected. // This ensures report-level selection persists when new transactions are added. const hasAnySelected = isExpenseReportType && transactionGroup.transactions.some((transaction) => transaction.transactionID in selectedTransactions); @@ -596,25 +622,36 @@ function Search({ isRefreshingSelection.current = false; }, [selectedTransactions]); - useEffect(() => { - if (!isFocused) { - return; - } - - if (!data.length || isRefreshingSelection.current) { - return; - } - const areItemsGrouped = !!validGroupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; - const flattenedItems = areItemsGrouped ? (data as TransactionGroupListItemType[]).flatMap((item) => item.transactions) : data; - const areAllItemsSelected = flattenedItems.length === Object.keys(selectedTransactions).length; - - // If the user has selected all the expenses in their view but there are more expenses matched by the search - // give them the option to select all matching expenses - shouldShowSelectAllMatchingItems(!!(areAllItemsSelected && searchResults?.search?.hasMoreResults)); - if (!areAllItemsSelected) { - selectAllMatchingItems(false); - } - }, [isFocused, data, searchResults?.search?.hasMoreResults, selectedTransactions, selectAllMatchingItems, shouldShowSelectAllMatchingItems, validGroupBy, type]); + const updateSelectAllMatchingItemsState = useCallback( + (updatedSelectedTransactions: SelectedTransactions) => { + if (!data.length || isRefreshingSelection.current) { + return; + } + const areItemsGrouped = !!validGroupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const totalSelectableItemsCount = areItemsGrouped + ? (data as TransactionGroupListItemType[]).reduce((count, item) => { + // For empty reports, count the report itself as a selectable item + if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item)) { + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return count; + } + return count + 1; + } + // For regular reports, count all transactions + return count + item.transactions.length; + }, 0) + : data.length; + const areAllItemsSelected = totalSelectableItemsCount === Object.keys(updatedSelectedTransactions).length; + + // If the user has selected all the expenses in their view but there are more expenses matched by the search + // give them the option to select all matching expenses + shouldShowSelectAllMatchingItems(!!(areAllItemsSelected && searchResults?.search?.hasMoreResults)); + if (!areAllItemsSelected) { + selectAllMatchingItems(false); + } + }, + [data, validGroupBy, type, searchResults?.search?.hasMoreResults, shouldShowSelectAllMatchingItems, selectAllMatchingItems], + ); const toggleTransaction = useCallback( (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => { @@ -631,11 +668,43 @@ function Search({ if (isTransactionPendingDelete(item)) { return; } - setSelectedTransactions(prepareTransactionsList(item, selectedTransactions, outstandingReportsByPolicyID), data); + const updatedTransactions = toggleTransactionInList(item, selectedTransactions, outstandingReportsByPolicyID); + setSelectedTransactions(updatedTransactions, data); + updateSelectAllMatchingItemsState(updatedTransactions); return; } const currentTransactions = itemTransactions ?? item.transactions; + // Handle empty reports - treat the report itself as selectable + if (currentTransactions.length === 0 && isTransactionReportGroupListItemType(item)) { + const reportKey = item.keyForList; + if (!reportKey) { + return; + } + + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return; + } + + if (selectedTransactions[reportKey]?.isSelected) { + // Deselect the empty report + const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; + delete reducedSelectedTransactions[reportKey]; + setSelectedTransactions(reducedSelectedTransactions, data); + updateSelectAllMatchingItemsState(reducedSelectedTransactions); + return; + } + + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item, accountID); + const updatedTransactions = { + ...selectedTransactions, + [reportKey]: emptyReportSelection, + }; + setSelectedTransactions(updatedTransactions, data); + updateSelectAllMatchingItemsState(updatedTransactions); + return; + } + if (currentTransactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)) { const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; @@ -644,22 +713,22 @@ function Search({ } setSelectedTransactions(reducedSelectedTransactions, data); + updateSelectAllMatchingItemsState(reducedSelectedTransactions); return; } - setSelectedTransactions( - { - ...selectedTransactions, - ...Object.fromEntries( - currentTransactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), - ), - }, - data, - ); + const updatedTransactions = { + ...selectedTransactions, + ...Object.fromEntries( + currentTransactions + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), + ), + }; + setSelectedTransactions(updatedTransactions, data); + updateSelectAllMatchingItemsState(updatedTransactions); }, - [data, selectedTransactions, outstandingReportsByPolicyID, setSelectedTransactions], + [selectedTransactions, setSelectedTransactions, data, updateSelectAllMatchingItemsState, outstandingReportsByPolicyID, accountID], ); const onSelectRow = useCallback( @@ -853,33 +922,46 @@ function Search({ if (totalSelected > 0) { clearSelectedTransactions(); + updateSelectAllMatchingItemsState({}); return; } + let updatedTransactions: SelectedTransactions; if (areItemsGrouped) { - setSelectedTransactions( - Object.fromEntries( - (data as TransactionGroupListItemType[]).flatMap((item) => - item.transactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), - ), - ), - data, + const allSelections: Array<[string, SelectedTransactionInfo]> = (data as TransactionGroupListItemType[]).flatMap((item) => { + if (item.transactions.length === 0 && isTransactionReportGroupListItemType(item) && item.keyForList) { + if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + return []; + } + return [mapEmptyReportToSelectedEntry(item, accountID)]; + } + return item.transactions + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)); + }); + updatedTransactions = Object.fromEntries(allSelections); + } else { + // When items are not grouped, data is TransactionListItemType[] not TransactionGroupListItemType[] + updatedTransactions = Object.fromEntries( + (data as TransactionListItemType[]) + .filter((item) => !isTransactionPendingDelete(item)) + .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), ); - - return; } - setSelectedTransactions( - Object.fromEntries( - (data as TransactionListItemType[]) - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => mapTransactionItemToSelectedEntry(transactionItem, outstandingReportsByPolicyID)), - ), - data, - ); - }, [clearSelectedTransactions, data, validGroupBy, selectedTransactions, setSelectedTransactions, outstandingReportsByPolicyID, isExpenseReportType]); + setSelectedTransactions(updatedTransactions, data); + updateSelectAllMatchingItemsState(updatedTransactions); + }, [ + validGroupBy, + isExpenseReportType, + selectedTransactions, + setSelectedTransactions, + data, + updateSelectAllMatchingItemsState, + clearSelectedTransactions, + outstandingReportsByPolicyID, + accountID, + ]); const onLayout = useCallback(() => { endSpan(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS_TAB); diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index 8e5886d9ccdc1..194c237f05858 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -50,9 +50,8 @@ function ExpenseReportListItem({ }, [snapshotData, reportItem.policyID]); const isDisabledCheckbox = useMemo(() => { - const isEmpty = reportItem.transactions.length === 0; - return isEmpty ?? reportItem.isDisabled ?? reportItem.isDisabledCheckbox; - }, [reportItem.isDisabled, reportItem.isDisabledCheckbox, reportItem.transactions.length]); + return reportItem.isDisabled ?? reportItem.isDisabledCheckbox; + }, [reportItem.isDisabled, reportItem.isDisabledCheckbox]); const handleOnButtonPress = useCallback(() => { handleActionButtonPress( From 0d2f7f3e9e673ece946123a83b9ffef66f375907 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 23 Nov 2025 23:11:19 +0800 Subject: [PATCH 10/30] fix: 74538 --- src/components/Search/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 0583e97a0ae27..3cc8ee3522066 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -497,7 +497,11 @@ function Search({ // For expense reports: when ANY transaction is selected, we want ALL transactions in the report selected. // This ensures report-level selection persists when new transactions are added. - const hasAnySelected = isExpenseReportType && transactionGroup.transactions.some((transaction) => transaction.transactionID in selectedTransactions); + // Also check if the report itself was selected (when it was empty) by checking the reportID key + const reportKey = transactionGroup.keyForList; + const wasReportSelected = reportKey && reportKey in selectedTransactions; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const hasAnySelected = isExpenseReportType && (wasReportSelected || transactionGroup.transactions.some((transaction) => transaction.transactionID in selectedTransactions)); for (const transactionItem of transactionGroup.transactions) { const isSelected = transactionItem.transactionID in selectedTransactions; From 40f848143fb970a2cd40029ff7df8cd7b174d831 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 23 Nov 2025 23:18:48 +0800 Subject: [PATCH 11/30] fix: 74538 --- src/components/MoneyReportHeader.tsx | 4 ++-- src/components/Search/index.tsx | 4 ++-- src/libs/actions/Search.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 4428440ecdac8..352a63ed3fb76 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1178,8 +1178,8 @@ function MoneyReportHeader({ } const result = await showConfirmModal({ - title: translate('iou.deleteReport'), - prompt: translate('iou.deleteReportConfirmation'), + title: translate('iou.deleteReport', {count: transactionCount}), + prompt: translate('iou.deleteReportConfirmation', {count: transactionCount}), confirmText: translate('common.delete'), cancelText: translate('common.cancel'), danger: true, diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 3cc8ee3522066..66844857c6087 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -483,7 +483,7 @@ function Search({ if (transactionGroup.transactions.length === 0 && isTransactionReportGroupListItemType(transactionGroup)) { const reportKey = transactionGroup.keyForList; if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - return; + continue; } if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup, accountID); @@ -492,7 +492,7 @@ function Search({ isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, }; } - return; + continue; } // For expense reports: when ANY transaction is selected, we want ALL transactions in the report selected. diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 80dca12effa7f..5666c515d7261 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -8,7 +8,6 @@ import type {LocalizedTranslate} from '@components/LocaleContextProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {BankAccountMenuItem, PaymentData, SearchQueryJSON, SelectedReports, SelectedTransactionInfo, SelectedTransactions} from '@components/Search/types'; import type {TransactionListItemType, TransactionReportGroupListItemType} from '@components/SelectionListWithSections/types'; -import {deleteAppReport} from '@libs/actions/Report'; import * as API from '@libs/API'; import {waitForWrites} from '@libs/API'; import type {ExportSearchItemsToCSVParams, ExportSearchWithTemplateParams, ReportExportParams, SubmitReportParams} from '@libs/API/parameters'; @@ -50,6 +49,7 @@ import type SearchResults from '@src/types/onyx/SearchResults'; import type Nullable from '@src/types/utils/Nullable'; import SafeString from '@src/utils/SafeString'; import {setPersonalBankAccountContinueKYCOnSuccess} from './BankAccounts'; +import {deleteAppReport} from './Report'; import {setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; From 113ff85d50b6be8ae271df2f2baf00f698125c77 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 23 Nov 2025 23:21:26 +0800 Subject: [PATCH 12/30] fix: lint --- src/libs/actions/Search.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 5666c515d7261..f9703ee5089b4 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -49,8 +49,7 @@ import type SearchResults from '@src/types/onyx/SearchResults'; import type Nullable from '@src/types/utils/Nullable'; import SafeString from '@src/utils/SafeString'; import {setPersonalBankAccountContinueKYCOnSuccess} from './BankAccounts'; -import {deleteAppReport} from './Report'; -import {setOptimisticTransactionThread} from './Report'; +import {deleteAppReport, setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; type OnyxSearchResponse = { From 7ff612ee2846ead6c2fbac5507eba14caa50dc8c Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 4 Dec 2025 01:49:15 +0800 Subject: [PATCH 13/30] fix: eslint --- src/libs/actions/Search.ts | 10 +++++----- src/pages/Search/SearchPage.tsx | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 805cf8f7d6426..7f52ae7e87fb9 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -42,7 +42,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {FILTER_KEYS} from '@src/types/form/SearchAdvancedFiltersForm'; import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; -import type {ExportTemplate, LastPaymentMethod, LastPaymentMethodType, Policy, Report, ReportAction, ReportActions, SearchResults, Transaction} from '@src/types/onyx'; +import type {ExportTemplate, LastPaymentMethod, LastPaymentMethodType, Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; import type {PaymentInformation} from '@src/types/onyx/LastPaymentMethod'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; @@ -694,23 +694,23 @@ function bulkDeleteReports(hash: number, selectedTransactions: Record { + for (const key of Object.keys(selectedTransactions)) { const selectedItem = selectedTransactions[key]; if (selectedItem.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === selectedItem.reportID) { reportIDList.push(selectedItem.reportID); } else { transactionIDList.push(key); } - }); + } if (transactionIDList.length > 0) { deleteMoneyRequestOnSearch(hash, transactionIDList); } if (reportIDList.length > 0) { - reportIDList.forEach((reportID) => { + for (const reportID of reportIDList) { deleteAppReport(reportID, currentUserEmailParam); - }); + } } } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4c90abd2b3789..076474d5892d5 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -822,14 +822,14 @@ function SearchPage({route}: SearchPageProps) { let reports = 0; let expenses = 0; - Object.keys(selectedTransactions).forEach((key) => { + for (const key of Object.keys(selectedTransactions)) { const selectedItem = selectedTransactions[key]; if (selectedItem.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === selectedItem.reportID) { reports += 1; } else { expenses += 1; } - }); + }; return {reportCount: reports, expenseCount: expenses}; }, [selectedTransactions]); From 10d9b5105d16a7c906a0d5ba317728fc21126673 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 4 Dec 2025 01:58:09 +0800 Subject: [PATCH 14/30] fix: err message --- src/components/Search/SearchList/index.tsx | 2 +- src/pages/Search/SearchPage.tsx | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 90c998ecce9d4..19d2bc07e8815 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -212,7 +212,7 @@ function SearchList({ } return flattenedItems.length; - }, [data, type, flattenedItems, emptyReports]); + }, [data, type, flattenedItems.length, emptyReports.length]); const {translate} = useLocalize(); const {isOffline} = useNetwork(); diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 076474d5892d5..35790a51f18c4 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -818,25 +818,34 @@ function SearchPage({route}: SearchPageProps) { }); }; - const {reportCount, expenseCount} = useMemo(() => { + const {reportCount, expenseCount, isAllOneTransactionReports} = useMemo(() => { let reports = 0; let expenses = 0; + let hasNonOneTransactionReport = false; for (const key of Object.keys(selectedTransactions)) { const selectedItem = selectedTransactions[key]; if (selectedItem.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === selectedItem.reportID) { reports += 1; + hasNonOneTransactionReport = true; } else { expenses += 1; + if (!selectedItem.isFromOneTransactionReport) { + hasNonOneTransactionReport = true; + } } - }; + } - return {reportCount: reports, expenseCount: expenses}; + return {reportCount: reports, expenseCount: expenses, isAllOneTransactionReports: !hasNonOneTransactionReport && expenses > 0}; }, [selectedTransactions]); - const isDeletingOnlyReports = reportCount > 0 && expenseCount === 0; - const deleteModalTitle = isDeletingOnlyReports ? translate('iou.deleteReport', {count: reportCount}) : translate('iou.deleteExpense', {count: expenseCount}); - const deleteModalPrompt = isDeletingOnlyReports ? translate('iou.deleteReportConfirmation', {count: reportCount}) : translate('iou.deleteConfirmation', {count: expenseCount}); + // Show "delete expense" only when ALL selected items are from one-transaction reports + // Show "delete report" in all other cases (empty reports, multi-expense reports, or mixtures) + const isDeletingOnlyExpenses = isAllOneTransactionReports; + const deleteModalTitle = isDeletingOnlyExpenses ? translate('iou.deleteExpense', {count: expenseCount}) : translate('iou.deleteReport', {count: reportCount || expenseCount}); + const deleteModalPrompt = isDeletingOnlyExpenses + ? translate('iou.deleteConfirmation', {count: expenseCount}) + : translate('iou.deleteReportConfirmation', {count: reportCount || expenseCount}); const saveFileAndInitMoneyRequest = (files: FileObject[]) => { const initialTransaction = initMoneyRequest({ From 89f1fd1f14eb9dcc15ae9b3fbb13cf74a1631381 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 4 Dec 2025 02:23:01 +0800 Subject: [PATCH 15/30] fix: selection issue --- src/components/Search/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 03a1b5f2a7e4d..57b4ff83572c5 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -598,7 +598,8 @@ function Search({ }; } } - if (isEmptyObject(newTransactionList)) { + + if (isEmptyObject(newTransactionList) && isEmptyObject(selectedTransactions)) { return; } From 638743fd4197b6f5d4ac1439681e9a018a43bfa3 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 4 Dec 2025 02:27:31 +0800 Subject: [PATCH 16/30] fix: test --- .../Search/deleteSelectedItemsOnSearchTest.ts | 47 ++++++------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index 5e1bda64759bb..b2cd1b4eb268d 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -38,8 +38,6 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy123', amount: 0, - convertedAmount: 0, - convertedCurrency: 'USD', currency: 'USD', }, report_456: { @@ -53,18 +51,17 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy456', amount: 0, - convertedAmount: 0, - convertedCurrency: 'USD', currency: 'USD', }, }; - bulkDeleteReports(hash, selectedTransactions); + const currentUserEmail = ''; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail); // Should call deleteAppReport for each empty report expect(deleteAppReport).toHaveBeenCalledTimes(2); - expect(deleteAppReport).toHaveBeenCalledWith('report_123'); - expect(deleteAppReport).toHaveBeenCalledWith('report_456'); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail); + expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail); }); it('should handle mixed selection of empty reports and transactions', () => { @@ -81,8 +78,6 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy123', amount: 0, - convertedAmount: 0, - convertedCurrency: 'USD', currency: 'USD', }, transaction_789: { @@ -96,8 +91,6 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy456', amount: 1000, - convertedAmount: 1000, - convertedCurrency: 'USD', currency: 'USD', }, transaction_101: { @@ -111,17 +104,16 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy456', amount: 500, - convertedAmount: 500, - convertedCurrency: 'USD', currency: 'USD', }, }; - bulkDeleteReports(hash, selectedTransactions); + const currentUserEmail = ''; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail); // Should call deleteAppReport for empty report expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123'); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail); }); it('should not delete reports when no empty reports are selected', () => { @@ -138,8 +130,6 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy456', amount: 1000, - convertedAmount: 1000, - convertedCurrency: 'USD', currency: 'USD', }, transaction_101: { @@ -153,13 +143,11 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy456', amount: 500, - convertedAmount: 500, - convertedCurrency: 'USD', currency: 'USD', }, }; - bulkDeleteReports(hash, selectedTransactions); + bulkDeleteReports(hash, selectedTransactions, ''); // Should not call deleteAppReport expect(deleteAppReport).not.toHaveBeenCalled(); @@ -169,7 +157,7 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = {}; - bulkDeleteReports(hash, selectedTransactions); + bulkDeleteReports(hash, selectedTransactions, ''); // Should not call any deletion functions expect(deleteAppReport).not.toHaveBeenCalled(); @@ -189,8 +177,6 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy123', amount: 0, - convertedAmount: 0, - convertedCurrency: 'USD', currency: 'USD', }, different_key: { @@ -204,18 +190,17 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy456', amount: 0, - convertedAmount: 0, - convertedCurrency: 'USD', currency: 'USD', }, }; - bulkDeleteReports(hash, selectedTransactions); + const currentUserEmail = ''; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail); // Should only call deleteAppReport for the first report where key === reportID expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123'); - expect(deleteAppReport).not.toHaveBeenCalledWith('report_456'); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail); + expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail); }); }); @@ -234,8 +219,6 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy456', amount: 1000, - convertedAmount: 1000, - convertedCurrency: 'USD', currency: 'USD', }, transaction_101: { @@ -249,13 +232,11 @@ describe('bulkDeleteReports', () => { canUnhold: false, policyID: 'policy456', amount: 500, - convertedAmount: 500, - convertedCurrency: 'USD', currency: 'USD', }, }; - bulkDeleteReports(hash, selectedTransactions); + bulkDeleteReports(hash, selectedTransactions, ''); // Should not call deleteAppReport for transactions expect(deleteAppReport).not.toHaveBeenCalled(); From d82ed03e51e6779d920fc1b052734c04654ad5f4 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Thu, 4 Dec 2025 02:46:48 +0800 Subject: [PATCH 17/30] fix: type err --- tests/unit/TransactionGroupListItemTest.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 19a430bf3ca27..c2057d67d50ec 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -59,6 +59,7 @@ const mockEmptyReport: TransactionReportGroupListItemType = { transactions: [], groupedBy: 'expense-report', keyForList: '515146912679679', + shouldShowYear: false, action: CONST.SEARCH.ACTION_TYPES.VIEW, }; @@ -166,6 +167,7 @@ const mockNonEmptyReport: TransactionReportGroupListItemType = { ], groupedBy: 'expense-report', keyForList: '515146912679680', + shouldShowYear: false, action: CONST.SEARCH.ACTION_TYPES.VIEW, }; From 6dc296d3fce17935745bf0aa8fb400c72d62ab51 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Tue, 9 Dec 2025 18:29:22 +0800 Subject: [PATCH 18/30] fix: resolve some comments --- src/components/Search/SearchList/index.tsx | 19 ++++++++++++++++--- src/components/Search/index.tsx | 9 +++++---- src/pages/Search/SearchPage.tsx | 10 +++++++--- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 19d2bc07e8815..7c7e04ba41d4d 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -208,11 +208,24 @@ function SearchList({ const totalItems = useMemo(() => { if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { - return emptyReports.length + flattenedItems.length; + const selectableEmptyReports = emptyReports.filter((item) => item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const selectableTransactions = flattenedItems.filter((item) => { + if ('pendingAction' in item) { + return item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + } + return true; + }); + return selectableEmptyReports.length + selectableTransactions.length; } - return flattenedItems.length; - }, [data, type, flattenedItems.length, emptyReports.length]); + const selectableTransactions = flattenedItems.filter((item) => { + if ('pendingAction' in item) { + return item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + } + return true; + }); + return selectableTransactions.length; + }, [data, type, flattenedItems, emptyReports]); const {translate} = useLocalize(); const {isOffline} = useNetwork(); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 6d446510b5d48..1b36d9c63a088 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -201,7 +201,7 @@ function mapToItemWithAdditionalInfo(item: SearchListItem, selectedTransactions: }; } -function toggleTransactionInList(item: TransactionListItemType, selectedTransactions: SelectedTransactions, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue) { +function prepareTransactionsList(item: TransactionListItemType, selectedTransactions: SelectedTransactions, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue) { if (selectedTransactions[item.keyForList]?.isSelected) { const {[item.keyForList]: omittedTransaction, ...transactions} = selectedTransactions; @@ -647,8 +647,9 @@ function Search({ } return count + 1; } - // For regular reports, count all transactions - return count + item.transactions.length; + // For regular reports, count all transactions except pending delete ones + const selectableTransactions = item.transactions.filter((transaction) => !isTransactionPendingDelete(transaction)); + return count + selectableTransactions.length; }, 0) : filteredData.length; const areAllItemsSelected = totalSelectableItemsCount === Object.keys(updatedSelectedTransactions).length; @@ -678,7 +679,7 @@ function Search({ if (isTransactionPendingDelete(item)) { return; } - const updatedTransactions = toggleTransactionInList(item, selectedTransactions, outstandingReportsByPolicyID); + const updatedTransactions = prepareTransactionsList(item, selectedTransactions, outstandingReportsByPolicyID); setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); return; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 301ce88d14e37..3badce69f64f1 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -995,10 +995,14 @@ function SearchPage({route}: SearchPageProps) { const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency; const numberOfExpense = shouldUseClientTotal - ? selectedTransactionsKeys.filter((key) => { + ? selectedTransactionsKeys.reduce((count, key) => { const item = selectedTransactions[key]; - return !(item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID); - }).length + // Skip empty reports (where key is the reportID itself, not a transactionID) + if (item.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === item.reportID) { + return count; + } + return count + 1; + }, 0) : metadata?.count; const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? 0), 0) : metadata?.total; From 004fcb68188d6fa54dd7e6e757e4983aa63218a4 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Wed, 10 Dec 2025 00:00:50 +0800 Subject: [PATCH 19/30] fix: update export basic data err msg for empty reports --- src/languages/de.ts | 4 ++++ src/languages/en.ts | 4 ++++ src/languages/es.ts | 4 ++++ src/languages/fr.ts | 4 ++++ src/languages/it.ts | 4 ++++ src/languages/ja.ts | 4 ++++ src/languages/nl.ts | 4 ++++ src/languages/pl.ts | 4 ++++ src/languages/pt-BR.ts | 4 ++++ src/languages/zh-hans.ts | 4 ++++ src/pages/Search/SearchPage.tsx | 20 +++++++++++++++++++- 11 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 876a33f1d7907..23674be4a8b82 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -610,6 +610,10 @@ const translations: TranslationDeepObject = { value: 'Wert', downloadFailedTitle: 'Download fehlgeschlagen', downloadFailedDescription: 'Ihr Download konnte nicht abgeschlossen werden. Bitte versuchen Sie es später noch einmal.', + downloadFailedEmptyReportDescription: () => ({ + one: 'Sie können keinen leeren Bericht exportieren.', + other: () => 'Sie können keine leeren Berichte exportieren.', + }), filterLogs: 'Protokolle filtern', network: 'Netzwerk', reportID: 'Berichts-ID', diff --git a/src/languages/en.ts b/src/languages/en.ts index 7c660a1a6f008..341a61230c95e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -602,6 +602,10 @@ const translations = { value: 'Value', downloadFailedTitle: 'Download failed', downloadFailedDescription: "Your download couldn't be completed. Please try again later.", + downloadFailedEmptyReportDescription: () => ({ + one: "You can't export an empty report.", + other: () => `You can't export empty reports.`, + }), filterLogs: 'Filter Logs', network: 'Network', reportID: 'Report ID', diff --git a/src/languages/es.ts b/src/languages/es.ts index fd8467c2d18d6..2961f4b988b1f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -288,6 +288,10 @@ const translations: TranslationDeepObject = { value: 'Valor', downloadFailedTitle: 'Error en la descarga', downloadFailedDescription: 'No se pudo completar la descarga. Por favor, inténtalo más tarde.', + downloadFailedEmptyReportDescription: () => ({ + one: 'No puedes exportar un informe vacío.', + other: () => `No puedes exportar informes vacíos.`, + }), filterLogs: 'Registros de filtrado', network: 'La red', reportID: 'ID del informe', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b01ebc302ba84..7e6afbadd19ff 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -611,6 +611,10 @@ const translations: TranslationDeepObject = { value: 'Valeur', downloadFailedTitle: 'Échec du téléchargement', downloadFailedDescription: 'Votre téléchargement n’a pas pu être terminé. Veuillez réessayer plus tard.', + downloadFailedEmptyReportDescription: () => ({ + one: 'Vous ne pouvez pas exporter un rapport vide.', + other: () => 'Vous ne pouvez pas exporter des rapports vides.', + }), filterLogs: 'Filtrer les journaux', network: 'Réseau', reportID: 'ID du rapport', diff --git a/src/languages/it.ts b/src/languages/it.ts index e7d14c784cb6f..a8f5c7af4c357 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -611,6 +611,10 @@ const translations: TranslationDeepObject = { value: 'Valore', downloadFailedTitle: 'Download non riuscito', downloadFailedDescription: 'Il download non può essere completato. Riprova più tardi.', + downloadFailedEmptyReportDescription: () => ({ + one: 'Non puoi esportare un rapporto vuoto.', + other: () => 'Non puoi esportare rapporti vuoti.', + }), filterLogs: 'Filtra registri', network: 'Rete', reportID: 'ID rapporto', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index fc2d2b5841f57..08c60b0f1560b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -611,6 +611,10 @@ const translations: TranslationDeepObject = { value: '値', downloadFailedTitle: 'ダウンロードに失敗しました', downloadFailedDescription: 'ダウンロードを完了できませんでした。後でもう一度お試しください。', + downloadFailedEmptyReportDescription: () => ({ + one: '空のレポートはエクスポートできません。', + other: () => '空のレポートはエクスポートできません。', + }), filterLogs: 'ログをフィルター', network: 'ネットワーク', reportID: 'レポート ID', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0dc0c065e05f2..a4f6ec92b4028 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -611,6 +611,10 @@ const translations: TranslationDeepObject = { value: 'Waarde', downloadFailedTitle: 'Download mislukt', downloadFailedDescription: 'Je download kon niet worden voltooid. Probeer het later opnieuw.', + downloadFailedEmptyReportDescription: () => ({ + one: 'Je kunt geen leeg rapport exporteren.', + other: () => 'Je kunt geen lege rapporten exporteren.', + }), filterLogs: 'Logboeken filteren', network: 'Netwerk', reportID: 'Rapport-ID', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 112a17f8871b7..e94454b9caea2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -611,6 +611,10 @@ const translations: TranslationDeepObject = { value: 'Wartość', downloadFailedTitle: 'Pobieranie nie powiodło się', downloadFailedDescription: 'Nie udało się zakończyć pobierania. Spróbuj ponownie później.', + downloadFailedEmptyReportDescription: () => ({ + one: 'Nie możesz eksportować pustego raportu.', + other: () => 'Nie możesz eksportować pustych raportów.', + }), filterLogs: 'Filtruj logi', network: 'Sieć', reportID: 'ID raportu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 36ae20bce8c4b..100a77e44e699 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -611,6 +611,10 @@ const translations: TranslationDeepObject = { value: 'Valor', downloadFailedTitle: 'Falha no download', downloadFailedDescription: 'Seu download não pôde ser concluído. Tente novamente mais tarde.', + downloadFailedEmptyReportDescription: () => ({ + one: 'Você não pode exportar um relatório vazio.', + other: () => 'Você não pode exportar relatórios vazios.', + }), filterLogs: 'Filtrar Logs', network: 'Rede', reportID: 'ID do Relatório', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f7781b523ec50..f895d461e7a60 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -611,6 +611,10 @@ const translations: TranslationDeepObject = { value: '值', downloadFailedTitle: '下载失败', downloadFailedDescription: '您的下载未能完成。请稍后再试。', + downloadFailedEmptyReportDescription: () => ({ + one: '您无法导出空报告。', + other: () => '您无法导出空报告。', + }), filterLogs: '筛选日志', network: '网络', reportID: '报告 ID', diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 87c375ab6bf37..f36691554ac29 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -119,6 +119,7 @@ function SearchPage({route}: SearchPageProps) { const [searchRequestResponseStatusCode, setSearchRequestResponseStatusCode] = useState(null); const [isDEWModalVisible, setIsDEWModalVisible] = useState(false); const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); + const [emptyReportsCount, setEmptyReportsCount] = useState(0); const [rejectModalAction, setRejectModalAction] = useState | null>(null); @@ -370,6 +371,22 @@ function SearchPage({route}: SearchPageProps) { return; } + const emptyReports = + selectedReports?.filter((selectedReport) => { + if (!selectedReport) { + return false; + } + const fullReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + return (fullReport?.transactionCount ?? 0) === 0; + }) ?? []; + const hasOnlyEmptyReports = selectedReports.length > 0 && emptyReports.length === selectedReports.length; + + if (hasOnlyEmptyReports) { + setEmptyReportsCount(emptyReports.length); + setIsDownloadErrorModalVisible(true); + return; + } + exportSearchItemsToCSV( { query: status, @@ -378,6 +395,7 @@ function SearchPage({route}: SearchPageProps) { transactionIDList: selectedTransactionsKeys, }, () => { + setEmptyReportsCount(0); setIsDownloadErrorModalVisible(true); }, ); @@ -1082,7 +1100,7 @@ function SearchPage({route}: SearchPageProps) { )} Date: Wed, 10 Dec 2025 00:21:30 +0800 Subject: [PATCH 20/30] fix: prural form --- src/pages/Search/SearchPage.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f36691554ac29..cd14dfd8230ea 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -746,10 +746,9 @@ function SearchPage({route}: SearchPageProps) { // Show "delete expense" only when ALL selected items are from one-transaction reports // Show "delete report" in all other cases (empty reports, multi-expense reports, or mixtures) const isDeletingOnlyExpenses = isAllOneTransactionReports; - const deleteModalTitle = isDeletingOnlyExpenses ? translate('iou.deleteExpense', {count: expenseCount}) : translate('iou.deleteReport', {count: reportCount || expenseCount}); - const deleteModalPrompt = isDeletingOnlyExpenses - ? translate('iou.deleteConfirmation', {count: expenseCount}) - : translate('iou.deleteReportConfirmation', {count: reportCount || expenseCount}); + const totalCount = reportCount + expenseCount; + const deleteModalTitle = isDeletingOnlyExpenses ? translate('iou.deleteExpense', {count: expenseCount}) : translate('iou.deleteReport', {count: totalCount}); + const deleteModalPrompt = isDeletingOnlyExpenses ? translate('iou.deleteConfirmation', {count: expenseCount}) : translate('iou.deleteReportConfirmation', {count: totalCount}); const saveFileAndInitMoneyRequest = (files: FileObject[]) => { const initialTransaction = initMoneyRequest({ From 4cecb8e6a033569f95e90b5797f384744de848a5 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 14 Dec 2025 00:29:01 +0800 Subject: [PATCH 21/30] fix: prural delete msg --- src/pages/Search/SearchPage.tsx | 15 ++++--- .../Search/deleteSelectedItemsOnSearchTest.ts | 44 ++++++++++++++----- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index e1479af281f19..2c158c422a9db 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -831,33 +831,34 @@ function SearchPage({route}: SearchPageProps) { }); }; - const {reportCount, expenseCount, isAllOneTransactionReports} = useMemo(() => { - let reports = 0; + const {expenseCount, isAllOneTransactionReports, uniqueReportCount} = useMemo(() => { let expenses = 0; let hasNonOneTransactionReport = false; + const reportIDs = new Set(); for (const key of Object.keys(selectedTransactions)) { const selectedItem = selectedTransactions[key]; if (selectedItem.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === selectedItem.reportID) { - reports += 1; hasNonOneTransactionReport = true; + reportIDs.add(selectedItem.reportID); } else { expenses += 1; + reportIDs.add(selectedItem.reportID); if (!selectedItem.isFromOneTransactionReport) { hasNonOneTransactionReport = true; } } } - return {reportCount: reports, expenseCount: expenses, isAllOneTransactionReports: !hasNonOneTransactionReport && expenses > 0}; + return {expenseCount: expenses, isAllOneTransactionReports: !hasNonOneTransactionReport && expenses > 0, uniqueReportCount: reportIDs.size}; }, [selectedTransactions]); // Show "delete expense" only when ALL selected items are from one-transaction reports // Show "delete report" in all other cases (empty reports, multi-expense reports, or mixtures) const isDeletingOnlyExpenses = isAllOneTransactionReports; - const totalCount = reportCount + expenseCount; - const deleteModalTitle = isDeletingOnlyExpenses ? translate('iou.deleteExpense', {count: expenseCount}) : translate('iou.deleteReport', {count: totalCount}); - const deleteModalPrompt = isDeletingOnlyExpenses ? translate('iou.deleteConfirmation', {count: expenseCount}) : translate('iou.deleteReportConfirmation', {count: totalCount}); + const deleteCount = isDeletingOnlyExpenses ? expenseCount : uniqueReportCount; + const deleteModalTitle = isDeletingOnlyExpenses ? translate('iou.deleteExpense', {count: expenseCount}) : translate('iou.deleteReport', {count: deleteCount}); + const deleteModalPrompt = isDeletingOnlyExpenses ? translate('iou.deleteConfirmation', {count: expenseCount}) : translate('iou.deleteReportConfirmation', {count: deleteCount}); const saveFileAndInitMoneyRequest = (files: FileObject[]) => { const initialTransaction = initMoneyRequest({ diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index b2cd1b4eb268d..a1010127bef62 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -31,7 +31,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_123', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: false, canChangeReport: false, isHeld: false, @@ -44,7 +46,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: false, canChangeReport: false, isHeld: false, @@ -71,7 +75,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_123', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: false, canChangeReport: false, isHeld: false, @@ -84,7 +90,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: true, canChangeReport: true, isHeld: false, @@ -97,7 +105,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: true, canChangeReport: true, isHeld: false, @@ -123,7 +133,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: true, canChangeReport: true, isHeld: false, @@ -136,7 +148,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: true, canChangeReport: true, isHeld: false, @@ -170,7 +184,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_123', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: false, canChangeReport: false, isHeld: false, @@ -183,7 +199,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: false, canChangeReport: false, isHeld: false, @@ -212,7 +230,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: true, canChangeReport: true, isHeld: false, @@ -225,7 +245,9 @@ describe('bulkDeleteReports', () => { reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, - canDelete: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, canHold: true, canChangeReport: true, isHeld: false, From 79b23220b8cb72196e77b5f72be6f5aa165d8f6e Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Fri, 19 Dec 2025 03:57:18 +0700 Subject: [PATCH 22/30] fix: delete --- src/components/Search/index.tsx | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 40c8c2bfe2b77..a9ba45d35f79a 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/react-native'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Animated, {FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; @@ -41,7 +41,7 @@ import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTop import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import Performance from '@libs/Performance'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; -import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils'; +import {canDeleteMoneyRequestReport, canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; import { createAndOpenSearchTransactionThread, @@ -76,7 +76,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import {isActionLoadingSetSelector} from '@src/selectors/ReportMetaData'; -import type {OutstandingReportsByPolicyIDDerivedValue, Transaction} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyIDDerivedValue, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; import type SearchResults from '@src/types/onyx/SearchResults'; import type {SearchTransaction} from '@src/types/onyx/SearchResults'; import type {TransactionViolation} from '@src/types/onyx/TransactionViolation'; @@ -148,11 +148,14 @@ function mapTransactionItemToSelectedEntry( ]; } -function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType): [string, SelectedTransactionInfo] { +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType, reportActions?: OnyxCollection): [string, SelectedTransactionInfo] { + const reportActionsForReport = reportActions?.[item.reportID]; + const itemReportActions = reportActionsForReport ? Object.values(reportActionsForReport).filter((action): action is ReportAction => !!action) : []; + const canDelete = canDeleteMoneyRequestReport(item, [], itemReportActions); return [ item.keyForList ?? '', { - canDelete: true, + canDelete, isSelected: true, canHold: false, canSplit: false, @@ -530,7 +533,7 @@ function Search({ continue; } if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup, reportActions); newTransactionList[reportKey] = { ...emptyReportSelection, isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, @@ -762,7 +765,7 @@ function Search({ return; } - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item, reportActions); const updatedTransactions = { ...selectedTransactions, [reportKey]: emptyReportSelection, @@ -799,7 +802,7 @@ function Search({ setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); }, - [selectedTransactions, setSelectedTransactions, filteredData, updateSelectAllMatchingItemsState, transactions, email, outstandingReportsByPolicyID, searchResults?.data], + [selectedTransactions, setSelectedTransactions, filteredData, updateSelectAllMatchingItemsState, transactions, email, outstandingReportsByPolicyID, searchResults?.data, reportActions], ); const onSelectRow = useCallback( @@ -1013,7 +1016,7 @@ function Search({ if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return []; } - return [mapEmptyReportToSelectedEntry(item)]; + return [mapEmptyReportToSelectedEntry(item, reportActions)]; } return item.transactions .filter((t) => !isTransactionPendingDelete(t)) @@ -1050,6 +1053,7 @@ function Search({ email, outstandingReportsByPolicyID, searchResults?.data, + reportActions, ]); const onLayout = useCallback(() => { From 89c47b234edd052ba98342e17e7104b85bfe6da3 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Fri, 2 Jan 2026 02:18:42 +0700 Subject: [PATCH 23/30] fix: types --- src/components/Search/SearchList/index.tsx | 6 ++-- src/components/Search/index.tsx | 33 ------------------- src/libs/actions/Search.ts | 5 +-- src/pages/Search/SearchPage.tsx | 7 +++- .../Search/deleteSelectedItemsOnSearchTest.ts | 12 +++---- 5 files changed, 18 insertions(+), 45 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 769101281d927..df669e08b4f03 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -200,7 +200,7 @@ function SearchList({ }, [data, type]); const selectedItemsLength = useMemo(() => { - const selectedTransactions = flattenedItems.reduce((acc, item) => { + const selectedTransactionsCount = flattenedItems.reduce((acc, item) => { return acc + (item?.isSelected ? 1 : 0); }, 0); @@ -209,10 +209,10 @@ function SearchList({ return acc + (item.isSelected ? 1 : 0); }, 0); - return selectedEmptyReports + selectedTransactions; + return selectedEmptyReports + selectedTransactionsCount; } - return selectedTransactions; + return selectedTransactionsCount; }, [flattenedItems, type, data, emptyReports]); const totalItems = useMemo(() => { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index cd47ea0011457..5f360a307160c 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -188,39 +188,6 @@ function mapToTransactionItemWithAdditionalInfo( return {...item, shouldAnimateInHighlight, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple, hash}; } -function mapToItemWithAdditionalInfo(item: SearchListItem, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean, shouldAnimateInHighlight: boolean, hash?: number) { - if (isTaskListItemType(item)) { - return { - ...item, - shouldAnimateInHighlight, - hash, - }; - } - - if (isReportActionListItemType(item)) { - return { - ...item, - shouldAnimateInHighlight, - hash, - }; - } - - return isTransactionListItemType(item) - ? mapToTransactionItemWithAdditionalInfo(item, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight, hash) - : { - ...item, - shouldAnimateInHighlight, - transactions: item.transactions?.map((transaction) => - mapToTransactionItemWithAdditionalInfo(transaction, selectedTransactions, canSelectMultiple, shouldAnimateInHighlight, hash), - ), - isSelected: - item?.transactions?.length > 0 - ? item.transactions?.filter((t) => !isTransactionPendingDelete(t)).every((transaction) => selectedTransactions[transaction.keyForList]?.isSelected && canSelectMultiple) - : !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple), - hash, - }; -} - function prepareTransactionsList( item: TransactionListItemType, itemTransaction: OnyxEntry, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index a16e3b8446707..5c11894a3eb7c 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -164,6 +164,7 @@ function getLastPolicyPaymentMethod( return undefined; } + // eslint-disable-next-line @typescript-eslint/no-deprecated const personalPolicy = getPersonalPolicy(); const lastPolicyPaymentMethod = lastPaymentMethods?.[policyID] ?? (isIOUReport && personalPolicy ? lastPaymentMethods?.[personalPolicy.id] : undefined); @@ -700,7 +701,7 @@ function unholdMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { API.write(WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData}); } -function bulkDeleteReports(hash: number, selectedTransactions: Record, currentUserEmailParam: string) { +function bulkDeleteReports(hash: number, selectedTransactions: Record, currentUserEmailParam: string, transactions: Transaction[]) { const transactionIDList: string[] = []; const reportIDList: string[] = []; @@ -719,7 +720,7 @@ function bulkDeleteReports(hash: number, selectedTransactions: Record 0) { for (const reportID of reportIDList) { - deleteAppReport(reportID, currentUserEmailParam); + deleteAppReport(reportID, currentUserEmailParam, transactions); } } } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index dd9852c151ac7..354c705412af2 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -885,7 +885,12 @@ function SearchPage({route}: SearchPageProps) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - bulkDeleteReports(hash, selectedTransactions, currentUserPersonalDetails.email ?? ''); + bulkDeleteReports( + hash, + selectedTransactions, + currentUserPersonalDetails.email ?? '', + allTransactions ? Object.values(allTransactions).filter((transaction): transaction is Transaction => !!transaction) : [], + ); deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys); clearSelectedTransactions(); }); diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index f25d736168081..e05cb552ce9a7 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -62,7 +62,7 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail); + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, []); // Should call deleteAppReport for each empty report expect(deleteAppReport).toHaveBeenCalledTimes(2); @@ -124,7 +124,7 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail); + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, []); // Should call deleteAppReport for empty report expect(deleteAppReport).toHaveBeenCalledTimes(1); @@ -168,7 +168,7 @@ describe('bulkDeleteReports', () => { }, }; - bulkDeleteReports(hash, selectedTransactions, ''); + bulkDeleteReports(hash, selectedTransactions, '', []); // Should not call deleteAppReport expect(deleteAppReport).not.toHaveBeenCalled(); @@ -178,7 +178,7 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = {}; - bulkDeleteReports(hash, selectedTransactions, ''); + bulkDeleteReports(hash, selectedTransactions, '', []); // Should not call any deletion functions expect(deleteAppReport).not.toHaveBeenCalled(); @@ -222,7 +222,7 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail); + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, []); // Should only call deleteAppReport for the first report where key === reportID expect(deleteAppReport).toHaveBeenCalledTimes(1); @@ -269,7 +269,7 @@ describe('bulkDeleteReports', () => { }, }; - bulkDeleteReports(hash, selectedTransactions, ''); + bulkDeleteReports(hash, selectedTransactions, '', []); // Should not call deleteAppReport for transactions expect(deleteAppReport).not.toHaveBeenCalled(); From eb49764110298189a287e5df42e2963b03c017e5 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Fri, 2 Jan 2026 02:26:24 +0700 Subject: [PATCH 24/30] fix: linter --- src/components/Search/index.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 5f360a307160c..497ad3dc8b182 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -178,16 +178,6 @@ function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType, ]; } -function mapToTransactionItemWithAdditionalInfo( - item: TransactionListItemType, - selectedTransactions: SelectedTransactions, - canSelectMultiple: boolean, - shouldAnimateInHighlight: boolean, - hash?: number, -) { - return {...item, shouldAnimateInHighlight, isSelected: selectedTransactions[item.keyForList]?.isSelected && canSelectMultiple, hash}; -} - function prepareTransactionsList( item: TransactionListItemType, itemTransaction: OnyxEntry, From d8eef1e478dd47420e90d392ff9ae3e3382bd5a1 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Mon, 5 Jan 2026 01:33:19 +0700 Subject: [PATCH 25/30] fix: ppline --- src/components/Search/SearchList/index.tsx | 18 +++++++++++++----- .../Search/deleteSelectedItemsOnSearchTest.ts | 19 +++++++++++-------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index df669e08b4f03..8b0a7f7b444c2 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -42,7 +42,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import navigationRef from '@libs/Navigation/navigationRef'; -import {getTableMinWidth} from '@libs/SearchUIUtils'; +import {getTableMinWidth, isTransactionReportGroupListItemType} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; import type {TransactionPreviewData} from '@userActions/Search'; import CONST from '@src/CONST'; @@ -201,19 +201,21 @@ function SearchList({ const selectedItemsLength = useMemo(() => { const selectedTransactionsCount = flattenedItems.reduce((acc, item) => { - return acc + (item?.isSelected ? 1 : 0); + const isTransactionSelected = !!(item?.keyForList && selectedTransactions[item.keyForList]?.isSelected); + return acc + (isTransactionSelected ? 1 : 0); }, 0); if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { const selectedEmptyReports = emptyReports.reduce((acc, item) => { - return acc + (item.isSelected ? 1 : 0); + const isEmptyReportSelected = !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected); + return acc + (isEmptyReportSelected ? 1 : 0); }, 0); return selectedEmptyReports + selectedTransactionsCount; } return selectedTransactionsCount; - }, [flattenedItems, type, data, emptyReports]); + }, [flattenedItems, type, data, emptyReports, selectedTransactions]); const totalItems = useMemo(() => { if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { @@ -245,10 +247,16 @@ function SearchList({ if (!canSelectMultiple) { itemWithSelection = {...item, isSelected: false}; } else { - const hasAnySelected = item.transactions.some((t) => t.keyForList && selectedTransactions[t.keyForList]?.isSelected); + const isEmptyReportSelected = + item.transactions.length === 0 && isTransactionReportGroupListItemType(item) && !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected); + + const hasAnySelected = item.transactions.some((t) => t.keyForList && selectedTransactions[t.keyForList]?.isSelected) || isEmptyReportSelected; if (!hasAnySelected) { itemWithSelection = {...item, isSelected: false}; + } else if (isEmptyReportSelected) { + isSelected = true; + itemWithSelection = {...item, isSelected}; } else { let allNonDeletedSelected = true; let hasNonDeletedTransactions = false; diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index e05cb552ce9a7..ad5027bfbddb3 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -62,12 +62,13 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, []); + const transactions: never[] = []; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions); // Should call deleteAppReport for each empty report expect(deleteAppReport).toHaveBeenCalledTimes(2); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail); - expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions); + expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail, transactions); }); it('should handle mixed selection of empty reports and transactions', () => { @@ -124,11 +125,12 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, []); + const transactions: never[] = []; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions); // Should call deleteAppReport for empty report expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions); }); it('should not delete reports when no empty reports are selected', () => { @@ -222,12 +224,13 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, []); + const transactions: never[] = []; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions); // Should only call deleteAppReport for the first report where key === reportID expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail); - expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions); + expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail, transactions); }); }); From 99a7eb66fcb2fc71cef01276e99a63797198ced0 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Mon, 5 Jan 2026 23:32:46 +0700 Subject: [PATCH 26/30] fix: types --- src/components/Search/index.tsx | 38 +++----------- src/pages/Search/SearchPage.tsx | 49 +++++++++++++------ .../Search/deleteSelectedItemsOnSearchTest.ts | 11 ----- 3 files changed, 43 insertions(+), 55 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 92356bbd19b25..30644f2447c32 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/react-native'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import Animated, {FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; @@ -41,14 +41,7 @@ import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTop import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import Performance from '@libs/Performance'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; -import { - canDeleteMoneyRequestReport, - canEditFieldOfMoneyRequest, - canHoldUnholdReportAction, - canRejectReportAction, - isOneTransactionReport, - selectFilteredReportActions, -} from '@libs/ReportUtils'; +import {canEditFieldOfMoneyRequest, canHoldUnholdReportAction, canRejectReportAction, isOneTransactionReport, selectFilteredReportActions} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils'; import { createAndOpenSearchTransactionThread, @@ -83,7 +76,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import {isActionLoadingSetSelector} from '@src/selectors/ReportMetaData'; -import type {OutstandingReportsByPolicyIDDerivedValue, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; +import type {OutstandingReportsByPolicyIDDerivedValue, Transaction} from '@src/types/onyx'; import type SearchResults from '@src/types/onyx/SearchResults'; import type {SearchTransaction} from '@src/types/onyx/SearchResults'; import type {TransactionViolation} from '@src/types/onyx/TransactionViolation'; @@ -153,14 +146,10 @@ function mapTransactionItemToSelectedEntry( ]; } -function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType, reportActions?: OnyxCollection): [string, SelectedTransactionInfo] { - const reportActionsForReport = reportActions?.[item.reportID]; - const itemReportActions = reportActionsForReport ? Object.values(reportActionsForReport).filter((action): action is ReportAction => !!action) : []; - const canDelete = canDeleteMoneyRequestReport(item, [], itemReportActions); +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType): [string, SelectedTransactionInfo] { return [ item.keyForList ?? '', { - canDelete, isSelected: true, canHold: false, canSplit: false, @@ -498,7 +487,7 @@ function Search({ continue; } if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup, reportActions); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); newTransactionList[reportKey] = { ...emptyReportSelection, isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, @@ -730,7 +719,7 @@ function Search({ return; } - const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item, reportActions); + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); const updatedTransactions = { ...selectedTransactions, [reportKey]: emptyReportSelection, @@ -767,17 +756,7 @@ function Search({ setSelectedTransactions(updatedTransactions, filteredData); updateSelectAllMatchingItemsState(updatedTransactions); }, - [ - selectedTransactions, - setSelectedTransactions, - filteredData, - updateSelectAllMatchingItemsState, - transactions, - email, - outstandingReportsByPolicyID, - searchResults?.data, - reportActions, - ], + [selectedTransactions, setSelectedTransactions, filteredData, updateSelectAllMatchingItemsState, transactions, email, outstandingReportsByPolicyID, searchResults?.data], ); const onSelectRow = useCallback( @@ -992,7 +971,7 @@ function Search({ if (item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return []; } - return [mapEmptyReportToSelectedEntry(item, reportActions)]; + return [mapEmptyReportToSelectedEntry(item)]; } return item.transactions .filter((t) => !isTransactionPendingDelete(t)) @@ -1029,7 +1008,6 @@ function Search({ email, outstandingReportsByPolicyID, searchResults?.data, - reportActions, ]); const onLayout = useCallback(() => { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index f60b40c3a1457..041e161fa019f 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -785,20 +785,41 @@ function SearchPage({route}: SearchPageProps) { const shouldShowDeleteOption = !isOffline && - selectedTransactionsKeys.every((id) => { - const transaction = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; - if (!transaction) { - return false; - } - const parentReportID = transaction.reportID; - const parentReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; - const reportActions = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`]; - const parentReportAction = - Object.values(reportActions ?? {}).find( - (action) => (isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined) === transaction.transactionID, - ) ?? selectedTransactions[id].reportAction; - return canDeleteMoneyRequestReport(parentReport, [transaction], parentReportAction ? [parentReportAction] : []); - }); + (selectedReports.length + ? selectedReports.every((selectedReport) => { + const fullReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + if (!fullReport) { + return false; + } + const reportActionsData = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selectedReport.reportID}`]; + const reportActionsArray = Object.values(reportActionsData ?? {}); + const reportTransactions: Transaction[] = []; + const searchData = currentSearchResults?.data ?? {}; + for (const key of Object.keys(searchData)) { + if (!key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { + continue; + } + const item = searchData[key as keyof typeof searchData] as Transaction | undefined; + if (item && 'transactionID' in item && 'reportID' in item && item.reportID === selectedReport.reportID) { + reportTransactions.push(item); + } + } + return canDeleteMoneyRequestReport(fullReport, reportTransactions, reportActionsArray); + }) + : selectedTransactionsKeys.every((id) => { + const transaction = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`]; + if (!transaction) { + return false; + } + const parentReportID = transaction.reportID; + const parentReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`]; + const reportActions = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`]; + const parentReportAction = + Object.values(reportActions ?? {}).find( + (action) => (isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined) === transaction.transactionID, + ) ?? selectedTransactions[id].reportAction; + return canDeleteMoneyRequestReport(parentReport, [transaction], parentReportAction ? [parentReportAction] : []); + })); if (shouldShowDeleteOption) { options.push({ diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index ad5027bfbddb3..99237a2891c79 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -28,7 +28,6 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = { report_123: { - canDelete: true, reportID: 'report_123', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -44,7 +43,6 @@ describe('bulkDeleteReports', () => { currency: 'USD', }, report_456: { - canDelete: true, reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -75,7 +73,6 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = { report_123: { - canDelete: true, reportID: 'report_123', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -91,7 +88,6 @@ describe('bulkDeleteReports', () => { currency: 'USD', }, transaction_789: { - canDelete: true, reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -107,7 +103,6 @@ describe('bulkDeleteReports', () => { currency: 'USD', }, transaction_101: { - canDelete: true, reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -137,7 +132,6 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = { transaction_789: { - canDelete: true, reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -153,7 +147,6 @@ describe('bulkDeleteReports', () => { currency: 'USD', }, transaction_101: { - canDelete: true, reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -190,7 +183,6 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = { report_123: { - canDelete: true, reportID: 'report_123', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -206,7 +198,6 @@ describe('bulkDeleteReports', () => { currency: 'USD', }, different_key: { - canDelete: true, reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -239,7 +230,6 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = { transaction_789: { - canDelete: true, reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, @@ -255,7 +245,6 @@ describe('bulkDeleteReports', () => { currency: 'USD', }, transaction_101: { - canDelete: true, reportID: 'report_456', action: CONST.SEARCH.ACTION_TYPES.VIEW, isSelected: true, From c1f4366b4f5f1d5d618fff45c0733afad2d9188d Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Tue, 6 Jan 2026 01:30:38 +0700 Subject: [PATCH 27/30] fix: bulkDeleteReports --- src/libs/actions/Search.ts | 12 +++++-- src/pages/Search/SearchPage.tsx | 14 ++++----- .../Search/deleteSelectedItemsOnSearchTest.ts | 31 ++++++++++--------- 3 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 217d2a423ce00..79cc326a0856d 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -45,7 +45,7 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {FILTER_KEYS} from '@src/types/form/SearchAdvancedFiltersForm'; import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; -import type {ExportTemplate, LastPaymentMethod, LastPaymentMethodType, Policy, Report, ReportAction, ReportActions, Transaction} from '@src/types/onyx'; +import type {ExportTemplate, LastPaymentMethod, LastPaymentMethodType, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolations} from '@src/types/onyx'; import type {PaymentInformation} from '@src/types/onyx/LastPaymentMethod'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SearchTransaction} from '@src/types/onyx/SearchResults'; @@ -701,7 +701,13 @@ function unholdMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { API.write(WRITE_COMMANDS.UNHOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList}, {optimisticData, finallyData}); } -function bulkDeleteReports(hash: number, selectedTransactions: Record, currentUserEmailParam: string, transactions: Transaction[]) { +function bulkDeleteReports( + hash: number, + selectedTransactions: Record, + currentUserEmailParam: string, + reportTransactions: Record, + transactionsViolations: Record, +) { const transactionIDList: string[] = []; const reportIDList: string[] = []; @@ -720,7 +726,7 @@ function bulkDeleteReports(hash: number, selectedTransactions: Record 0) { for (const reportID of reportIDList) { - deleteAppReport(reportID, currentUserEmailParam, transactions); + deleteAppReport(reportID, currentUserEmailParam, reportTransactions, transactionsViolations); } } } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 041e161fa019f..b94ce79bd303a 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -91,7 +91,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Policy, Report, SearchResults, Transaction} from '@src/types/onyx'; +import type {Policy, Report, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; import SearchPageNarrow from './SearchPageNarrow'; import SearchPageWide from './SearchPageWide'; @@ -125,6 +125,7 @@ function SearchPage({route}: SearchPageProps) { const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES, {canBeMissing: true}); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS, {canBeMissing: true}); + const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); @@ -924,12 +925,11 @@ function SearchPage({route}: SearchPageProps) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - bulkDeleteReports( - hash, - selectedTransactions, - currentUserPersonalDetails.email ?? '', - allTransactions ? Object.values(allTransactions).filter((transaction): transaction is Transaction => !!transaction) : [], - ); + const reportTransactions = allTransactions ? Object.fromEntries(Object.entries(allTransactions).filter((entry): entry is [string, Transaction] => !!entry[1])) : {}; + const transactionsViolations = allTransactionViolations + ? Object.fromEntries(Object.entries(allTransactionViolations).filter((entry): entry is [string, TransactionViolations] => !!entry[1])) + : {}; + bulkDeleteReports(hash, selectedTransactions, currentUserPersonalDetails.email ?? '', reportTransactions, transactionsViolations); deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys); clearSelectedTransactions(); }); diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index 99237a2891c79..978ef7c050f81 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -60,13 +60,14 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - const transactions: never[] = []; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions); + const transactions = {}; + const transactionsViolations = {}; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations); // Should call deleteAppReport for each empty report expect(deleteAppReport).toHaveBeenCalledTimes(2); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions); - expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail, transactions); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations); + expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations); }); it('should handle mixed selection of empty reports and transactions', () => { @@ -120,12 +121,13 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - const transactions: never[] = []; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions); + const transactions = {}; + const transactionsViolations = {}; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations); // Should call deleteAppReport for empty report expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations); }); it('should not delete reports when no empty reports are selected', () => { @@ -163,7 +165,7 @@ describe('bulkDeleteReports', () => { }, }; - bulkDeleteReports(hash, selectedTransactions, '', []); + bulkDeleteReports(hash, selectedTransactions, '', {}, {}); // Should not call deleteAppReport expect(deleteAppReport).not.toHaveBeenCalled(); @@ -173,7 +175,7 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = {}; - bulkDeleteReports(hash, selectedTransactions, '', []); + bulkDeleteReports(hash, selectedTransactions, '', {}, {}); // Should not call any deletion functions expect(deleteAppReport).not.toHaveBeenCalled(); @@ -215,13 +217,14 @@ describe('bulkDeleteReports', () => { }; const currentUserEmail = ''; - const transactions: never[] = []; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions); + const transactions = {}; + const transactionsViolations = {}; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations); // Should only call deleteAppReport for the first report where key === reportID expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions); - expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail, transactions); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations); + expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations); }); }); @@ -261,7 +264,7 @@ describe('bulkDeleteReports', () => { }, }; - bulkDeleteReports(hash, selectedTransactions, '', []); + bulkDeleteReports(hash, selectedTransactions, '', {}, {}); // Should not call deleteAppReport for transactions expect(deleteAppReport).not.toHaveBeenCalled(); From 578c6932af777479312d2f18e7b0f0fdbdb7d26e Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 11 Jan 2026 10:07:04 +0700 Subject: [PATCH 28/30] fix: comments --- src/components/Search/index.tsx | 2 +- src/pages/Search/SearchPage.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 45e12062064f4..be0fc4a80dbd8 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -109,7 +109,7 @@ function mapTransactionItemToSelectedEntry( ): [string, SelectedTransactionInfo] { const {canHoldRequest, canUnholdRequest} = canHoldUnholdReportAction(item.report, item.reportAction, item.holdReportAction, item, item.policy); const canRejectRequest = item.report ? canRejectReportAction(currentUserLogin, item.report, item.policy) : false; - const amount = item.modifiedAmount || item.amount; + const amount = item.modifiedAmount ?? item.amount; return [ item.keyForList, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 3fb70d8f0ce07..5d7d33198376b 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -959,7 +959,6 @@ function SearchPage({route}: SearchPageProps) { ? Object.fromEntries(Object.entries(allTransactionViolations).filter((entry): entry is [string, TransactionViolations] => !!entry[1])) : {}; bulkDeleteReports(hash, selectedTransactions, currentUserPersonalDetails.email ?? '', reportTransactions, transactionsViolations); - deleteMoneyRequestOnSearch(hash, selectedTransactionsKeys); clearSelectedTransactions(); }); }; @@ -971,6 +970,9 @@ function SearchPage({route}: SearchPageProps) { for (const key of Object.keys(selectedTransactions)) { const selectedItem = selectedTransactions[key]; + if (!selectedItem?.reportID) { + continue; + } if (selectedItem.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === selectedItem.reportID) { hasNonOneTransactionReport = true; reportIDs.add(selectedItem.reportID); From 7325bacdf447013da08c6041eee6e96ce5567481 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sun, 11 Jan 2026 10:13:30 +0700 Subject: [PATCH 29/30] fix: import issue --- src/pages/Search/SearchPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 5d7d33198376b..55cd0648f30f6 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -40,7 +40,6 @@ import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchIn import { approveMoneyRequestOnSearch, bulkDeleteReports, - deleteMoneyRequestOnSearch, exportSearchItemsToCSV, getExportTemplates, getLastPolicyBankAccountID, From 8a34ddb5e6e053c1233682439d4a72374f4ece53 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Wed, 14 Jan 2026 16:23:26 +0700 Subject: [PATCH 30/30] fix: tests --- src/libs/actions/Search.ts | 16 +++++++++++++-- src/pages/Search/SearchPage.tsx | 3 ++- .../Search/deleteSelectedItemsOnSearchTest.ts | 20 +++++++++---------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 5f8e9e1636ec6..3b443982a0fd5 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -45,7 +45,18 @@ import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {FILTER_KEYS} from '@src/types/form/SearchAdvancedFiltersForm'; import type {SearchAdvancedFiltersForm} from '@src/types/form/SearchAdvancedFiltersForm'; -import type {ExportTemplate, LastPaymentMethod, LastPaymentMethodType, Policy, Report, ReportAction, ReportActions, Transaction, TransactionViolations} from '@src/types/onyx'; +import type { + BankAccountList, + ExportTemplate, + LastPaymentMethod, + LastPaymentMethodType, + Policy, + Report, + ReportAction, + ReportActions, + Transaction, + TransactionViolations, +} from '@src/types/onyx'; import type {PaymentInformation} from '@src/types/onyx/LastPaymentMethod'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type Nullable from '@src/types/utils/Nullable'; @@ -728,6 +739,7 @@ function bulkDeleteReports( currentUserEmailParam: string, reportTransactions: Record, transactionsViolations: Record, + bankAccountList: OnyxEntry, ) { const transactionIDList: string[] = []; const reportIDList: string[] = []; @@ -747,7 +759,7 @@ function bulkDeleteReports( if (reportIDList.length > 0) { for (const reportID of reportIDList) { - deleteAppReport(reportID, currentUserEmailParam, reportTransactions, transactionsViolations); + deleteAppReport(reportID, currentUserEmailParam, reportTransactions, transactionsViolations, bankAccountList); } } } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4733b1de06e7e..4207312b69340 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -495,7 +495,7 @@ function SearchPage({route}: SearchPageProps) { const transactionsViolations = allTransactionViolations ? Object.fromEntries(Object.entries(allTransactionViolations).filter((entry): entry is [string, TransactionViolations] => !!entry[1])) : {}; - bulkDeleteReports(hash, selectedTransactions, currentUserPersonalDetails.email ?? '', reportTransactions, transactionsViolations); + bulkDeleteReports(hash, selectedTransactions, currentUserPersonalDetails.email ?? '', reportTransactions, transactionsViolations, bankAccountList); clearSelectedTransactions(); }); }); @@ -510,6 +510,7 @@ function SearchPage({route}: SearchPageProps) { allTransactionViolations, selectedTransactions, currentUserPersonalDetails.email, + bankAccountList, clearSelectedTransactions, ]); diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index 978ef7c050f81..2e5c70209073b 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -62,12 +62,12 @@ describe('bulkDeleteReports', () => { const currentUserEmail = ''; const transactions = {}; const transactionsViolations = {}; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations); + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations, {}); // Should call deleteAppReport for each empty report expect(deleteAppReport).toHaveBeenCalledTimes(2); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations); - expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations, {}); + expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations, {}); }); it('should handle mixed selection of empty reports and transactions', () => { @@ -123,11 +123,11 @@ describe('bulkDeleteReports', () => { const currentUserEmail = ''; const transactions = {}; const transactionsViolations = {}; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations); + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations, {}); // Should call deleteAppReport for empty report expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations, {}); }); it('should not delete reports when no empty reports are selected', () => { @@ -165,7 +165,7 @@ describe('bulkDeleteReports', () => { }, }; - bulkDeleteReports(hash, selectedTransactions, '', {}, {}); + bulkDeleteReports(hash, selectedTransactions, '', {}, {}, {}); // Should not call deleteAppReport expect(deleteAppReport).not.toHaveBeenCalled(); @@ -175,7 +175,7 @@ describe('bulkDeleteReports', () => { const hash = 12345; const selectedTransactions: Record = {}; - bulkDeleteReports(hash, selectedTransactions, '', {}, {}); + bulkDeleteReports(hash, selectedTransactions, '', {}, {}, {}); // Should not call any deletion functions expect(deleteAppReport).not.toHaveBeenCalled(); @@ -219,11 +219,11 @@ describe('bulkDeleteReports', () => { const currentUserEmail = ''; const transactions = {}; const transactionsViolations = {}; - bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations); + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, transactions, transactionsViolations, {}); // Should only call deleteAppReport for the first report where key === reportID expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations, {}); expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations); }); }); @@ -264,7 +264,7 @@ describe('bulkDeleteReports', () => { }, }; - bulkDeleteReports(hash, selectedTransactions, '', {}, {}); + bulkDeleteReports(hash, selectedTransactions, '', {}, {}, {}); // Should not call deleteAppReport for transactions expect(deleteAppReport).not.toHaveBeenCalled();