From d4054c06fc28782acbf3cbe6d46ac255fe9621cc Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sat, 31 Jan 2026 00:21:18 +0700 Subject: [PATCH 1/4] chore: revert change from 80561 --- src/components/MoneyReportHeader.tsx | 6 +- src/components/Search/SearchContext.tsx | 10 +- src/components/Search/SearchList/index.tsx | 72 +++- src/components/Search/index.tsx | 285 ++++++++++------ src/components/Search/types.ts | 2 +- .../Search/ExpenseReportListItem.tsx | 5 +- .../Search/TransactionGroupListItem.tsx | 23 +- src/languages/de.ts | 14 +- src/languages/en.ts | 14 +- src/languages/es.ts | 14 +- src/languages/fr.ts | 14 +- src/languages/it.ts | 14 +- src/languages/ja.ts | 14 +- src/languages/nl.ts | 14 +- src/languages/pl.ts | 14 +- src/languages/pt-BR.ts | 14 +- src/languages/zh-hans.ts | 14 +- src/libs/SearchUIUtils.ts | 65 +++- src/libs/actions/Report/index.ts | 12 + src/libs/actions/Search.ts | 50 ++- src/pages/Search/SearchPage.tsx | 122 +++++-- .../PopoverReportActionContextMenu.tsx | 2 +- .../Search/deleteSelectedItemsOnSearchTest.ts | 284 +++++++++++++++ tests/unit/TransactionGroupListItemTest.tsx | 322 ++++++++++++++++++ 24 files changed, 1203 insertions(+), 197 deletions(-) create mode 100644 tests/unit/Search/deleteSelectedItemsOnSearchTest.ts diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index f1ea38ed1fcda..8f3f16fdd9e79 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1465,8 +1465,8 @@ function MoneyReportHeader({ } const result = await showConfirmModal({ - title: translate('iou.deleteReport'), - prompt: translate('iou.deleteReportConfirmation'), + title: translate('iou.deleteReport', {count: 1}), + prompt: translate('iou.deleteReportConfirmation', {count: 1}), confirmText: translate('common.delete'), cancelText: translate('common.cancel'), danger: true, @@ -1480,7 +1480,7 @@ function MoneyReportHeader({ Navigation.goBack(backToRoute); // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { - deleteAppReport(moneyRequestReport?.reportID, email ?? '', accountID, reportTransactions, allTransactionViolations, bankAccountList); + deleteAppReport(moneyRequestReport?.reportID, email ?? '', accountID, reportTransactions, allTransactionViolations, bankAccountList, currentSearchHash); }); }); }, diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index e307b57750cfc..04f7ab2fac243 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -153,7 +153,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, diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 8e1b0771d52e4..1ad4bd5ae60a7 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -48,7 +48,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions'; import {turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import DateUtils from '@libs/DateUtils'; 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'; @@ -239,16 +239,51 @@ function SearchList({ } return data; }, [data, groupBy, type]); - const flattenedItemsWithoutPendingDelete = useMemo(() => flattenedItems.filter((t) => t?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [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(() => { - return flattenedItemsWithoutPendingDelete.reduce((acc, item) => { - if (item.keyForList && selectedTransactions[item.keyForList]?.isSelected) { - return acc + 1; - } - return acc; + const selectedTransactionsCount = flattenedItems.reduce((acc, item) => { + const isTransactionSelected = !!(item?.keyForList && selectedTransactions[item.keyForList]?.isSelected); + return acc + (isTransactionSelected ? 1 : 0); }, 0); - }, [flattenedItemsWithoutPendingDelete, selectedTransactions]); + + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + const selectedEmptyReports = emptyReports.reduce((acc, item) => { + const isEmptyReportSelected = !!(item.keyForList && selectedTransactions[item.keyForList]?.isSelected); + return acc + (isEmptyReportSelected ? 1 : 0); + }, 0); + + return selectedEmptyReports + selectedTransactionsCount; + } + + return selectedTransactionsCount; + }, [flattenedItems, type, data, emptyReports, selectedTransactions]); + + const totalItems = useMemo(() => { + if (type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && isTransactionGroupListItemArray(data)) { + 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; + } + + 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 itemsWithSelection = useMemo(() => { return data.map((item) => { @@ -259,10 +294,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; @@ -354,10 +395,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; @@ -366,7 +404,7 @@ function SearchList({ setLongPressedItemTransactions(itemTransactions); setIsModalVisible(true); }, - [groupBy, route.key, shouldPreventLongPressRow, isSmallScreenWidth, isMobileSelectionModeEnabled, onCheckboxPress], + [route.key, shouldPreventLongPressRow, isSmallScreenWidth, isMobileSelectionModeEnabled, onCheckboxPress], ); const turnOnSelectionMode = useCallback(() => { @@ -493,7 +531,7 @@ function SearchList({ const tableHeaderVisible = canSelectMultiple || !!SearchTableHeader; const selectAllButtonVisible = canSelectMultiple && !SearchTableHeader; - const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === flattenedItemsWithoutPendingDelete.length && hasLoadedAllTransactions; + const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems && hasLoadedAllTransactions; const content = ( @@ -503,11 +541,11 @@ function SearchList({ 0 && (selectedItemsLength !== flattenedItemsWithoutPendingDelete.length || !hasLoadedAllTransactions)} + isIndeterminate={selectedItemsLength > 0 && (selectedItemsLength !== totalItems || !hasLoadedAllTransactions)} onPress={() => { onAllCheckboxPress(); }} - disabled={flattenedItems.length === 0} + disabled={totalItems === 0} /> )} diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 59e59a9363449..761a67358a006 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 {ModalActions} from '@components/Modal/Global/ModalContext'; import SearchTableHeader 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 {useWideRHPActions} from '@components/WideRHPContextProvider'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; @@ -61,6 +68,7 @@ import { isTransactionMerchantGroupListItemType, isTransactionMonthGroupListItemType, isTransactionQuarterGroupListItemType, + isTransactionReportGroupListItemType, isTransactionTagGroupListItemType, isTransactionWeekGroupListItemType, isTransactionWithdrawalIDGroupListItemType, @@ -109,9 +117,12 @@ function mapTransactionItemToSelectedEntry( currentUserLogin: string, currentUserAccountID: number, outstandingReportsByPolicyID?: OutstandingReportsByPolicyIDDerivedValue, + allowNegativeAmount = true, ): [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 = hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : item.amount; + return [ item.keyForList, { @@ -137,8 +148,8 @@ function mapTransactionItemToSelectedEntry( groupCurrency: item.groupCurrency, groupExchangeRate: item.groupExchangeRate, reportID: item.reportID, - policyID: item.report?.policyID, - amount: hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : item.amount, + policyID: item.policyID, + amount: allowNegativeAmount ? amount : Math.abs(amount), groupAmount: item.groupAmount, currency: item.currency, isFromOneTransactionReport: isOneTransactionReport(item.report), @@ -149,6 +160,28 @@ function mapTransactionItemToSelectedEntry( ]; } +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType): [string, SelectedTransactionInfo] { + return [ + item.keyForList ?? '', + { + isFromOneTransactionReport: false, + isSelected: true, + canHold: false, + canSplit: false, + canReject: false, + hasBeenSplit: 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, + currency: '', + }, + ]; +} + function prepareTransactionsList( item: TransactionListItemType, itemTransaction: OnyxEntry, @@ -164,43 +197,19 @@ function prepareTransactionsList( return transactions; } - 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 [key, selectedInfo] = mapTransactionItemToSelectedEntry( + item, + itemTransaction, + originalItemTransaction, + currentUserLogin, + currentUserAccountID, + outstandingReportsByPolicyID, + false, + ); return { ...selectedTransactions, - [item.keyForList]: { - transaction: item, - isSelected: true, - canReject: canRejectRequest, - canHold: canHoldRequest, - isHeld: isOnHold(item), - canUnhold: canUnholdRequest, - canSplit: isSplitAction(item.report, [itemTransaction], originalItemTransaction, currentUserLogin, currentUserAccountID, item.policy), - hasBeenSplit: getOriginalTransactionWithSplitInfo(itemTransaction, originalItemTransaction).isExpenseSplit, - 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(hasValidModifiedAmount(item) ? Number(item.modifiedAmount) : item.amount), - groupAmount: item.groupAmount, - groupCurrency: item.groupCurrency, - groupExchangeRate: item.groupExchangeRate, - currency: item.currency, - isFromOneTransactionReport: isOneTransactionReport(item.report), - ownerAccountID: item.reportAction?.actorAccountID, - reportAction: item.reportAction, - report: item.report, - }, + [key]: selectedInfo, }; } @@ -560,9 +569,28 @@ function Search({ continue; } + if (transactionGroup.transactions.length === 0 && isTransactionReportGroupListItemType(transactionGroup)) { + const reportKey = transactionGroup.keyForList; + if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + continue; + } + if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) { + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup); + newTransactionList[reportKey] = { + ...emptyReportSelection, + isSelected: areAllMatchingItemsSelected || selectedTransactions[reportKey]?.isSelected, + }; + } + continue; + } + // 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: TransactionListItemType) => 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; @@ -721,25 +749,37 @@ function Search({ isRefreshingSelection.current = false; }, [selectedTransactions]); - useEffect(() => { - if (!isFocused) { - return; - } - - if (!filteredData.length || isRefreshingSelection.current) { - return; - } - const areItemsGrouped = !!validGroupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; - const flattenedItems = areItemsGrouped ? (filteredData as TransactionGroupListItemType[]).flatMap((item) => item.transactions) : filteredData; - 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, filteredData, searchResults?.search?.hasMoreResults, selectedTransactions, selectAllMatchingItems, shouldShowSelectAllMatchingItems, validGroupBy, type]); + const updateSelectAllMatchingItemsState = useCallback( + (updatedSelectedTransactions: SelectedTransactions) => { + if (!filteredData.length || isRefreshingSelection.current) { + return; + } + const areItemsGrouped = !!validGroupBy || type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const totalSelectableItemsCount = areItemsGrouped + ? (filteredData 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 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; + + // 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); + } + }, + [filteredData, validGroupBy, type, searchResults?.search?.hasMoreResults, shouldShowSelectAllMatchingItems, selectAllMatchingItems], + ); const toggleTransaction = useCallback( (item: SearchListItem, itemTransactions?: TransactionListItemType[]) => { @@ -758,14 +798,52 @@ function Search({ } const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item.transactionID}`] as OnyxEntry; const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - setSelectedTransactions( - prepareTransactionsList(item, itemTransaction, originalItemTransaction, selectedTransactions, email ?? '', accountID, outstandingReportsByPolicyID), - filteredData, + const updatedTransactions = prepareTransactionsList( + item, + itemTransaction, + originalItemTransaction, + selectedTransactions, + email ?? '', + accountID, + outstandingReportsByPolicyID, ); + setSelectedTransactions(updatedTransactions, filteredData); + 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, filteredData); + updateSelectAllMatchingItemsState(reducedSelectedTransactions); + return; + } + + const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(item); + const updatedTransactions = { + ...selectedTransactions, + [reportKey]: emptyReportSelection, + }; + setSelectedTransactions(updatedTransactions, filteredData); + updateSelectAllMatchingItemsState(updatedTransactions); + return; + } + if (currentTransactions.some((transaction) => selectedTransactions[transaction.keyForList]?.isSelected)) { const reducedSelectedTransactions: SelectedTransactions = {...selectedTransactions}; @@ -774,26 +852,26 @@ function Search({ } setSelectedTransactions(reducedSelectedTransactions, filteredData); + updateSelectAllMatchingItemsState(reducedSelectedTransactions); return; } - setSelectedTransactions( - { - ...selectedTransactions, - ...Object.fromEntries( - currentTransactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => { - const itemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; - const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); - }), - ), - }, - filteredData, - ); + const updatedTransactions = { + ...selectedTransactions, + ...Object.fromEntries( + currentTransactions + .filter((t) => !isTransactionPendingDelete(t)) + .map((transactionItem) => { + const itemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; + const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + }), + ), + }; + setSelectedTransactions(updatedTransactions, filteredData); + updateSelectAllMatchingItemsState(updatedTransactions); }, - [setSelectedTransactions, selectedTransactions, filteredData, transactions, outstandingReportsByPolicyID, searchResults?.data, email, accountID], + [selectedTransactions, setSelectedTransactions, filteredData, updateSelectAllMatchingItemsState, transactions, email, accountID, outstandingReportsByPolicyID, searchResults?.data], ); const onSelectRow = useCallback( @@ -1157,30 +1235,31 @@ function Search({ if (totalSelected > 0) { clearSelectedTransactions(); + updateSelectAllMatchingItemsState({}); return; } + let updatedTransactions: SelectedTransactions; if (areItemsGrouped) { - setSelectedTransactions( - Object.fromEntries( - (filteredData as TransactionGroupListItemType[]).flatMap((item) => - item.transactions - .filter((t) => !isTransactionPendingDelete(t)) - .map((transactionItem) => { - const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; - const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; - return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); - }), - ), - ), - filteredData, - ); - - return; - } - - setSelectedTransactions( - Object.fromEntries( + const allSelections: Array<[string, SelectedTransactionInfo]> = (filteredData 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) => { + const itemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transactionID}`] as OnyxEntry; + const originalItemTransaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; + return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); + }); + }); + updatedTransactions = Object.fromEntries(allSelections); + } else { + // When items are not grouped, data is TransactionListItemType[] not TransactionGroupListItemType[] + updatedTransactions = Object.fromEntries( (filteredData as TransactionListItemType[]) .filter((t) => !isTransactionPendingDelete(t)) .map((transactionItem) => { @@ -1188,21 +1267,23 @@ function Search({ const originalItemTransaction = searchResults?.data?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${itemTransaction?.comment?.originalTransactionID}`]; return mapTransactionItemToSelectedEntry(transactionItem, itemTransaction, originalItemTransaction, email ?? '', accountID, outstandingReportsByPolicyID); }), - ), - filteredData, - ); + ); + } + setSelectedTransactions(updatedTransactions, filteredData); + updateSelectAllMatchingItemsState(updatedTransactions); }, [ validGroupBy, isExpenseReportType, - filteredData, selectedTransactions, setSelectedTransactions, + filteredData, + updateSelectAllMatchingItemsState, clearSelectedTransactions, transactions, - outstandingReportsByPolicyID, - searchResults?.data, email, accountID, + outstandingReportsByPolicyID, + searchResults?.data, ]); const onLayout = useCallback(() => { diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 7faab6847ef1e..53cbf01d76291 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -10,7 +10,7 @@ import type IconAsset from '@src/types/utils/IconAsset'; /** Model of the selected transaction */ type SelectedTransactionInfo = { /** The transaction itself */ - transaction: Transaction; + transaction?: Transaction; /** Whether the transaction is selected */ isSelected: boolean; diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index c4d7a7e4ad08a..14bf14256fbe0 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -79,9 +79,8 @@ function ExpenseReportListItem({ }, [searchData, reportItem.reportID]); 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]); // Prefer live Onyx policy data over snapshot to ensure fresh policy settings // like isAttendeeTrackingEnabled is not missing diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index 0f3fbff029489..b0fce9375c1a9 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -162,10 +162,13 @@ 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; @@ -234,11 +237,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 onExpandedRowLongPress = useCallback( (transaction: TransactionListItemType) => { @@ -403,7 +403,7 @@ function TransactionGroupListItem({ report={groupItem as TransactionReportGroupListItemType} onSelectRow={(listItem) => onSelectRow(listItem, transactionPreviewData)} onCheckboxPress={onCheckboxPress} - isDisabled={isDisabledOrEmpty} + isDisabled={isDisabled} isFocused={isFocused} canSelectMultiple={canSelectMultiple} isSelectAllChecked={isSelectAllChecked} @@ -431,13 +431,14 @@ function TransactionGroupListItem({ canSelectMultiple, isSelectAllChecked, isIndeterminate, - onDEWModalOpen, - isDEWBetaEnabled, - groupBy, - isExpanded, onExpandIconPress, + isExpanded, isFocused, searchType, + groupBy, + isDisabled, + onDEWModalOpen, + isDEWBetaEnabled, onSelectRow, transactionPreviewData, ], diff --git a/src/languages/de.ts b/src/languages/de.ts index 8da341287e34e..34b4c09f32927 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -359,6 +359,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `Ich akzeptiere die Expensify-Nutzungsbedingungen und die Datenschutzerklärung`, acceptTermsAndConditions: `Ich akzeptiere die Allgemeinen Geschäftsbedingungen`, acceptTermsOfService: `Ich akzeptiere die Expensify-Nutzungsbedingungen`, + downloadFailedEmptyReportDescription: () => ({ + one: 'Sie können keinen leeren Bericht exportieren.', + other: () => 'Sie können keine leeren Berichte exportieren.', + }), remove: 'Entfernen', admin: 'Admin', owner: 'Eigentümer', @@ -1228,8 +1232,14 @@ const translations: TranslationDeepObject = { one: 'Sind Sie sicher, dass Sie diese Ausgabe löschen möchten?', other: 'Sind Sie sicher, dass Sie diese Ausgaben löschen möchten?', }), - 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: 'Fertig', settledElsewhere: 'Anderswo bezahlt', diff --git a/src/languages/en.ts b/src/languages/en.ts index 62a48239b88bb..bb783f46ac0ca 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -354,6 +354,10 @@ const translations = { acceptTermsAndPrivacy: `I accept the Expensify Terms of Service and Privacy Policy`, acceptTermsAndConditions: `I accept the terms and conditions`, acceptTermsOfService: `I accept the Expensify Terms of Service`, + downloadFailedEmptyReportDescription: () => ({ + one: "You can't export an empty report.", + other: () => `You can't export empty reports.`, + }), remove: 'Remove', admin: 'Admin', owner: 'Owner', @@ -1257,8 +1261,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 550115b5b9021..e6a6e3741d21f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -130,6 +130,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `Acepto los Términos de Servicio y la Política de Privacidad de Expensify`, acceptTermsAndConditions: `Acepto los Términos y Condiciones`, acceptTermsOfService: `Acepto los Términos de Servicio`, + downloadFailedEmptyReportDescription: () => ({ + one: 'No puedes exportar un informe vacío.', + other: () => `No puedes exportar informes vacíos.`, + }), remove: 'Eliminar', admin: 'Administrador', owner: 'Dueño', @@ -1003,8 +1007,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 726c88e995a49..e5d3e0c3e7c12 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -359,6 +359,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `J’accepte les Conditions d’utilisation d’Expensify et la Politique de confidentialité`, acceptTermsAndConditions: `J’accepte les conditions générales`, acceptTermsOfService: `J’accepte les Conditions d’utilisation d’Expensify`, + downloadFailedEmptyReportDescription: () => ({ + one: 'Vous ne pouvez pas exporter un rapport vide.', + other: () => 'Vous ne pouvez pas exporter des rapports vides.', + }), remove: 'Supprimer', admin: 'Admin', owner: 'Propriétaire', @@ -1230,8 +1234,14 @@ const translations: TranslationDeepObject = { one: 'Êtes-vous sûr de vouloir supprimer cette dépense ?', other: 'Voulez-vous vraiment supprimer ces dépenses ?', }), - deleteReport: 'Supprimer le rapport', - deleteReportConfirmation: 'Voulez-vous vraiment 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: 'Terminé', settledElsewhere: 'Payé ailleurs', diff --git a/src/languages/it.ts b/src/languages/it.ts index 33c0428bfdf4e..4508498960fac 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -359,6 +359,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `Accetto i Termini di servizio di Expensify e l'Informativa sulla privacy`, acceptTermsAndConditions: `Accetto i termini e condizioni`, acceptTermsOfService: `Accetto i Termini di servizio di Expensify`, + downloadFailedEmptyReportDescription: () => ({ + one: 'Non puoi esportare un rapporto vuoto.', + other: () => 'Non puoi esportare rapporti vuoti.', + }), remove: 'Rimuovi', admin: 'Amministratore', owner: 'Proprietario', @@ -1225,8 +1229,14 @@ const translations: TranslationDeepObject = { one: 'Sei sicuro di voler eliminare questa spesa?', other: 'Sei sicuro di voler eliminare queste spese?', }), - deleteReport: 'Elimina report', - 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 761da2729ec5d..5e589b9cd8416 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -359,6 +359,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `私は、Expensify 利用規約およびプライバシーポリシーに同意します`, acceptTermsAndConditions: `利用規約に同意します`, acceptTermsOfService: `Expensify 利用規約に同意します`, + downloadFailedEmptyReportDescription: () => ({ + one: '空のレポートはエクスポートできません。', + other: () => '空のレポートはエクスポートできません。', + }), remove: '削除', admin: '管理者', owner: 'オーナー', @@ -1224,8 +1228,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 f4f2720e38722..19302ce0db36e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -359,6 +359,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `Ik ga akkoord met de Expensify Servicevoorwaarden en het Privacybeleid`, acceptTermsAndConditions: `Ik ga akkoord met de algemene voorwaarden`, acceptTermsOfService: `Ik ga akkoord met de Expensify-servicevoorwaarden`, + downloadFailedEmptyReportDescription: () => ({ + one: 'Je kunt geen leeg rapport exporteren.', + other: () => 'Je kunt geen lege rapporten exporteren.', + }), remove: 'Verwijderen', admin: 'Beheerder', owner: 'Eigenaar', @@ -1225,8 +1229,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 je zeker dat je 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: 'Gereed', settledElsewhere: 'Elders betaald', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7c790e3888ced..bdd509e4f8975 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -359,6 +359,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `Akceptuję Warunki korzystania z usługi Expensify oraz Politykę prywatności`, acceptTermsAndConditions: `Akceptuję warunki i postanowienia`, acceptTermsOfService: `Akceptuję Warunki korzystania z usługi Expensify`, + downloadFailedEmptyReportDescription: () => ({ + one: 'Nie możesz eksportować pustego raportu.', + other: () => 'Nie możesz eksportować pustych raportów.', + }), remove: 'Usuń', admin: 'Administrator', owner: 'Właściciel', @@ -1226,8 +1230,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: 'Opłacone', done: 'Gotowe', settledElsewhere: 'Opłacone gdzie indziej', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 84ec54156619e..67656c9563ffe 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -359,6 +359,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `Eu aceito os Termos de Serviço da Expensify e a Política de Privacidade`, acceptTermsAndConditions: `Eu aceito os termos e condições`, acceptTermsOfService: `Eu aceito os Termos de Serviço da Expensify`, + downloadFailedEmptyReportDescription: () => ({ + one: 'Você não pode exportar um relatório vazio.', + other: () => 'Você não pode exportar relatórios vazios.', + }), remove: 'Remover', admin: 'Administrador', owner: 'Proprietário', @@ -1224,8 +1228,14 @@ const translations: TranslationDeepObject = { one: 'Você 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 7630d11c09fb3..3a239d793e40c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -359,6 +359,10 @@ const translations: TranslationDeepObject = { acceptTermsAndPrivacy: `我接受Expensify 服务条款隐私政策`, acceptTermsAndConditions: `我接受条款和条件`, acceptTermsOfService: `我接受Expensify 服务条款`, + downloadFailedEmptyReportDescription: () => ({ + one: '您无法导出空报告。', + other: () => '您无法导出空报告。', + }), remove: '移除', admin: '管理员', owner: '所有者', @@ -1206,8 +1210,14 @@ const translations: TranslationDeepObject = { one: '你确定要删除此报销吗?', other: '您确定要删除这些报销吗?', }), - deleteReport: '删除报表', - deleteReportConfirmation: '您确定要删除此报告吗?', + deleteReport: () => ({ + one: '删除报告', + other: '删除报告', + }), + deleteReportConfirmation: () => ({ + one: '您确定要删除此报告吗?', + other: '您确定要删除这些报告吗?', + }), settledExpensify: '已支付', done: '完成', settledElsewhere: '在其他地方已支付', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 9aeb5c7f90ab6..e2528b6f54190 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -22,6 +22,7 @@ import type { SearchStatus, SearchView, SearchWithdrawalType, + SelectedReports, SelectedTransactionInfo, SingularSearchStatus, SortOrder, @@ -3215,6 +3216,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) && @@ -4449,25 +4457,52 @@ function getTableMinWidth(columns: SearchColumnType[]) { return minWidth; } -function shouldShowDeleteOption(selectedTransactions: Record, currentSearchResults: SearchResults['data'] | undefined, isOffline: boolean) { +function shouldShowDeleteOption( + selectedTransactions: Record, + currentSearchResults: SearchResults['data'] | undefined, + isOffline: boolean, + selectedReports: SelectedReports[] = [], + searchDataType?: SearchDataTypes, +) { const selectedTransactionsKeys = Object.keys(selectedTransactions); return ( !isOffline && - selectedTransactionsKeys.every((id) => { - const transaction = currentSearchResults?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`] ?? selectedTransactions[id]?.transaction; - if (!transaction) { - return false; - } - const parentReportID = transaction.reportID; - const parentReport = currentSearchResults?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`] ?? selectedTransactions[id].report; - const reportActions = currentSearchResults?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`]; - const parentReportAction = - Object.values(reportActions ?? {}).find((action) => (isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined) === id) ?? - selectedTransactions[id].reportAction; - - return canDeleteMoneyRequestReport(parentReport, [transaction], parentReportAction ? [parentReportAction] : []); - }) + (selectedReports.length && searchDataType !== CONST.SEARCH.DATA_TYPES.EXPENSE + ? selectedReports.every((selectedReport) => { + const fullReport = currentSearchResults?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + if (!fullReport) { + return false; + } + const reportActionsData = currentSearchResults?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selectedReport.reportID}`]; + const reportActionsArray = Object.values(reportActionsData ?? {}); + const reportTransactions: OnyxTypes.Transaction[] = []; + const searchData = currentSearchResults ?? {}; + for (const key of Object.keys(searchData)) { + if (!key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION)) { + continue; + } + const item = searchData[key as keyof typeof searchData] as OnyxTypes.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?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`] ?? selectedTransactions[id]?.transaction; + if (!transaction) { + return false; + } + const parentReportID = transaction.reportID; + const parentReport = currentSearchResults?.[`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`] ?? selectedTransactions[id].report; + const reportActions = currentSearchResults?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentReportID}`]; + const parentReportAction = + Object.values(reportActions ?? {}).find((action) => (isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined) === id) ?? + selectedTransactions[id].reportAction; + + return canDeleteMoneyRequestReport(parentReport, [transaction], parentReportAction ? [parentReportAction] : []); + })) ); } diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index b9ef0aba9b311..2a08b7b2d0dab 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5091,11 +5091,23 @@ function deleteAppReport( reportTransactions: Record, allTransactionViolations: OnyxCollection, bankAccountList: OnyxEntry, + hash?: number, ) { if (!reportID) { Log.warn('[Report] deleteReport called with no reportID'); return; } + + // Update search results to mark report as deleted when called from search + if (hash) { + Onyx.merge(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + data: { + [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]: {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + }, + }); + } + const optimisticData: Array< OnyxUpdate< | typeof ONYXKEYS.COLLECTION.REPORT diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index fe9db6b5762e4..47a15deb4e6e4 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'; @@ -46,7 +46,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} 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 {OnyxData} from '@src/types/onyx/Request'; @@ -56,7 +67,7 @@ import {setPersonalBankAccountContinueKYCOnSuccess} from './BankAccounts'; import type {RejectMoneyRequestData} from './IOU'; import {prepareRejectMoneyRequestData, rejectMoneyRequest} from './IOU'; import {isCurrencySupportedForGlobalReimbursement} from './Policy/Policy'; -import {setOptimisticTransactionThread} from './Report'; +import {deleteAppReport, setOptimisticTransactionThread} from './Report'; import {saveLastSearchParams} from './ReportNavigation'; type OnyxSearchResponse = { @@ -775,6 +786,38 @@ 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, + currentUserAccountIDParam: number, + reportTransactions: Record, + transactionsViolations: Record, + bankAccountList: OnyxEntry, +) { + const transactionIDList: string[] = []; + const reportIDList: string[] = []; + + 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) { + for (const reportID of reportIDList) { + deleteAppReport(reportID, currentUserEmailParam, currentUserAccountIDParam, reportTransactions, transactionsViolations, bankAccountList); + } + } +} + function deleteMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { const {optimisticData: loadingOptimisticData, finallyData} = getOnyxLoadingData(hash); @@ -1348,6 +1391,7 @@ export { search, deleteMoneyRequestOnSearch, holdMoneyRequestOnSearch, + bulkDeleteReports, unholdMoneyRequestOnSearch, rejectMoneyRequestsOnSearch, exportSearchItemsToCSV, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index a6089001a18a1..607a67fbb499b 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -40,7 +40,7 @@ import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransacti import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter, searchInServer} from '@libs/actions/Report'; import { approveMoneyRequestOnSearch, - deleteMoneyRequestOnSearch, + bulkDeleteReports, exportSearchItemsToCSV, getExportTemplates, getLastPolicyBankAccountID, @@ -90,7 +90,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'; @@ -129,6 +129,7 @@ function SearchPage({route}: SearchPageProps) { 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 {accountID} = useCurrentUserPersonalDetails(); const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); @@ -142,6 +143,8 @@ function SearchPage({route}: SearchPageProps) { typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT > | null>(null); + const [emptyReportsCount, setEmptyReportsCount] = useState(0); + const [dismissedRejectUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_REJECT_USE_EXPLANATION, {canBeMissing: true}); const [dismissedHoldUseExplanation] = useOnyx(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, {canBeMissing: true}); @@ -248,6 +251,22 @@ function SearchPage({route}: SearchPageProps) { const beginExportWithTemplate = useCallback( async (templateName: string, templateType: string, policyID: string | undefined) => { + 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; + } + // If the user has selected a large number of items, we'll use the queryJSON to search for the reportIDs and transactionIDs necessary for the export if (areAllMatchingItemsSelected) { queueExportSearchWithTemplate({ @@ -281,7 +300,17 @@ function SearchPage({route}: SearchPageProps) { } clearSelectedTransactions(undefined, true); }, - [queryJSON, selectedTransactionsKeys, areAllMatchingItemsSelected, selectedTransactionReportIDs, showConfirmModal, translate, clearSelectedTransactions], + [ + selectedReports, + areAllMatchingItemsSelected, + showConfirmModal, + translate, + clearSelectedTransactions, + currentSearchResults?.data, + queryJSON, + selectedTransactionReportIDs, + selectedTransactionsKeys, + ], ); const policyIDsWithVBBA = useMemo(() => { @@ -333,6 +362,7 @@ function SearchPage({route}: SearchPageProps) { transactionIDList: selectedTransactionsKeys, }, () => { + setEmptyReportsCount(0); setIsDownloadErrorModalVisible(true); }, translate, @@ -416,6 +446,31 @@ function SearchPage({route}: SearchPageProps) { clearSelectedTransactions, ]); + const {expenseCount, uniqueReportCount} = useMemo(() => { + let expenses = 0; + const reportIDs = new Set(); + + 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) { + reportIDs.add(selectedItem.reportID); + } else { + expenses += 1; + reportIDs.add(selectedItem.reportID); + } + } + + return {expenseCount: expenses, uniqueReportCount: reportIDs.size}; + }, [selectedTransactions]); + + const isDeletingOnlyExpenses = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE && expenseCount > 0; + 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 handleDeleteSelectedTransactions = useCallback(async () => { if (isOffline) { setIsOfflineModalVisible(true); @@ -430,8 +485,8 @@ function SearchPage({route}: SearchPageProps) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(async () => { const result = await showConfirmModal({ - 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: true, @@ -443,11 +498,29 @@ 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); + 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 ?? '', accountID, reportTransactions, transactionsViolations, bankAccountList); clearSelectedTransactions(); }); }); - }, [isOffline, showConfirmModal, translate, selectedTransactionsKeys, hash, clearSelectedTransactions]); + }, [ + isOffline, + hash, + showConfirmModal, + deleteModalTitle, + deleteModalPrompt, + translate, + allTransactions, + allTransactionViolations, + accountID, + selectedTransactions, + currentUserPersonalDetails.email, + bankAccountList, + clearSelectedTransactions, + ]); const onBulkPaySelected = useCallback( (paymentMethod?: PaymentMethodType, additionalData?: Record) => { @@ -923,7 +996,7 @@ function SearchPage({route}: SearchPageProps) { }); } - if (shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, isOffline)) { + if (shouldShowDeleteOption(selectedTransactions, currentSearchResults?.data, isOffline, selectedReports, queryJSON?.type)) { options.push({ icon: expensifyIcons.Trashcan, text: translate('search.bulkActions.delete'), @@ -1131,11 +1204,20 @@ function SearchPage({route}: SearchPageProps) { const shouldUseClientTotal = !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected); const selectedTransactionItems = Object.values(selectedTransactions); const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency; - const count = shouldUseClientTotal ? selectedTransactionsKeys.length : metadata?.count; + const numberOfExpense = shouldUseClientTotal + ? selectedTransactionsKeys.reduce((count, key) => { + const item = selectedTransactions[key]; + // 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; - 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]); const onSortPressedCallback = useCallback(() => { setIsSorting(true); @@ -1245,15 +1327,6 @@ function SearchPage({route}: SearchPageProps) { isVisible={isOfflineModalVisible} onClose={handleOfflineModalClose} /> - {!!rejectModalAction && ( )} + ); } diff --git a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx index a07efba6485ef..4c6e7b2088ee9 100644 --- a/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/inbox/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -369,7 +369,7 @@ function PopoverReportActionContextMenu({ref}: PopoverReportActionContextMenuPro deleteTransactions([originalMessage.IOUTransactionID], duplicateTransactions, duplicateTransactionViolations, currentSearchHash); } } else if (isReportPreviewAction(reportAction)) { - deleteAppReport(reportAction.childReportID, email ?? '', currentUserAccountID, reportTransactions, allTransactionViolations, bankAccountList); + deleteAppReport(reportAction.childReportID, email ?? '', currentUserAccountID, reportTransactions, allTransactionViolations, bankAccountList, currentSearchHash); } else if (reportAction) { // eslint-disable-next-line @typescript-eslint/no-deprecated InteractionManager.runAfterInteractions(() => { diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts new file mode 100644 index 0000000000000..b9654c0a29572 --- /dev/null +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -0,0 +1,284 @@ +/* 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', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy123', + amount: 0, + currency: 'USD', + }, + report_456: { + reportID: 'report_456', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 0, + currency: 'USD', + }, + }; + + const currentUserEmail = ''; + const transactions = {}; + const transactionsViolations = {}; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, 1, 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, {}); + }); + + it('should handle mixed selection of empty reports and transactions', () => { + const hash = 12345; + const selectedTransactions: Record = { + report_123: { + reportID: 'report_123', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy123', + amount: 0, + currency: 'USD', + }, + transaction_789: { + reportID: 'report_456', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 1000, + currency: 'USD', + }, + transaction_101: { + reportID: 'report_456', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 500, + currency: 'USD', + }, + }; + + const currentUserEmail = ''; + const transactions = {}; + const transactionsViolations = {}; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, 1, transactions, transactionsViolations, {}); + + // Should call deleteAppReport for empty report + expect(deleteAppReport).toHaveBeenCalledTimes(1); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations, {}); + }); + + it('should not delete reports when no empty reports are selected', () => { + const hash = 12345; + const selectedTransactions: Record = { + transaction_789: { + reportID: 'report_456', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 1000, + currency: 'USD', + }, + transaction_101: { + reportID: 'report_456', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 500, + currency: 'USD', + }, + }; + + bulkDeleteReports(hash, selectedTransactions, '', 1, {}, {}, {}); + + // Should not call deleteAppReport + expect(deleteAppReport).not.toHaveBeenCalled(); + }); + + it('should handle empty selection gracefully', () => { + const hash = 12345; + const selectedTransactions: Record = {}; + + bulkDeleteReports(hash, selectedTransactions, '', 1, {}, {}, {}); + + // 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', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy123', + amount: 0, + currency: 'USD', + }, + different_key: { + reportID: 'report_456', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: false, + canChangeReport: false, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 0, + currency: 'USD', + }, + }; + + const currentUserEmail = ''; + const transactions = {}; + const transactionsViolations = {}; + bulkDeleteReports(hash, selectedTransactions, currentUserEmail, 1, 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).not.toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations); + }); + }); + + describe('Transaction Deletion', () => { + it('should handle transaction deletion when transactions are selected', () => { + const hash = 12345; + const selectedTransactions: Record = { + transaction_789: { + reportID: 'report_456', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 1000, + currency: 'USD', + }, + transaction_101: { + reportID: 'report_456', + isFromOneTransactionReport: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, + isSelected: true, + canSplit: false, + canReject: false, + hasBeenSplit: false, + canHold: true, + canChangeReport: true, + isHeld: false, + canUnhold: false, + policyID: 'policy456', + amount: 500, + currency: 'USD', + }, + }; + + bulkDeleteReports(hash, selectedTransactions, '', 1, {}, {}, {}); + + // Should not call deleteAppReport for transactions + expect(deleteAppReport).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 71b9fae209316..105f41ff618a4 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -25,6 +25,49 @@ jest.mock('@libs/SearchUIUtils', () => ({ getSuggestedSearches: jest.fn(() => ({})), })); +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', + shouldShowYear: false, + shouldShowYearSubmitted: false, + shouldShowYearApproved: false, + shouldShowYearExported: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, +}; + const mockTransaction: TransactionListItemType = { accountID: 1, amount: 0, @@ -93,6 +136,56 @@ 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', + shouldShowYear: false, + shouldShowYearSubmitted: false, + shouldShowYearApproved: false, + shouldShowYearExported: false, + action: CONST.SEARCH.ACTION_TYPES.VIEW, +}; + const mockReport: TransactionReportGroupListItemType = { accountID: 1, chatReportID: '4735435600700077', @@ -333,3 +426,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 cabeca2cd8ac4b0200d62fc3601a6374dc563e88 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sat, 31 Jan 2026 00:36:29 +0700 Subject: [PATCH 2/4] fix: types --- src/components/Search/SearchList/index.tsx | 2 +- src/pages/Search/SearchTransactionsChangeReport.tsx | 2 +- tests/unit/Search/deleteSelectedItemsOnSearchTest.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index 1ad4bd5ae60a7..6eacf50506be4 100644 --- a/src/components/Search/SearchList/index.tsx +++ b/src/components/Search/SearchList/index.tsx @@ -392,7 +392,7 @@ function SearchList({ } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - if (shouldPreventLongPressRow || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox) { + if (shouldPreventLongPressRow || !isSmallScreenWidth || item?.isDisabled || item?.isDisabledCheckbox || item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { return; } diff --git a/src/pages/Search/SearchTransactionsChangeReport.tsx b/src/pages/Search/SearchTransactionsChangeReport.tsx index 5b83b3dfa51b6..0ff7985cce550 100644 --- a/src/pages/Search/SearchTransactionsChangeReport.tsx +++ b/src/pages/Search/SearchTransactionsChangeReport.tsx @@ -34,7 +34,7 @@ function SearchTransactionsChangeReport() { Object.values(selectedTransactions).reduce( (transactionsCollection, transactionItem) => { // eslint-disable-next-line no-param-reassign - transactionsCollection[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transaction.transactionID}`] = transactionItem.transaction; + transactionsCollection[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionItem.transaction?.transactionID}`] = transactionItem.transaction; return transactionsCollection; }, {} as NonNullable>, diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index b9654c0a29572..5617d18d618b4 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -68,7 +68,7 @@ describe('bulkDeleteReports', () => { // Should call deleteAppReport for each empty report expect(deleteAppReport).toHaveBeenCalledTimes(2); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations, {}); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, 1, transactions, transactionsViolations, {}); expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations, {}); }); @@ -132,7 +132,7 @@ describe('bulkDeleteReports', () => { // Should call deleteAppReport for empty report expect(deleteAppReport).toHaveBeenCalledTimes(1); - expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, transactions, transactionsViolations, {}); + expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, 1, transactions, transactionsViolations, {}); }); it('should not delete reports when no empty reports are selected', () => { @@ -232,7 +232,7 @@ describe('bulkDeleteReports', () => { // 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, 1, transactions, transactionsViolations, {}); expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations); }); }); From 025c7d2a5f19e8f5fc868c3c6a251f419a090f89 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sat, 31 Jan 2026 01:01:38 +0700 Subject: [PATCH 3/4] fix: test --- tests/unit/Search/deleteSelectedItemsOnSearchTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts index 5617d18d618b4..65ed002f0cf3b 100644 --- a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -69,7 +69,7 @@ describe('bulkDeleteReports', () => { // Should call deleteAppReport for each empty report expect(deleteAppReport).toHaveBeenCalledTimes(2); expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, 1, transactions, transactionsViolations, {}); - expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations, {}); + expect(deleteAppReport).toHaveBeenCalledWith('report_456', currentUserEmail, 1, transactions, transactionsViolations, {}); }); it('should handle mixed selection of empty reports and transactions', () => { @@ -233,7 +233,7 @@ describe('bulkDeleteReports', () => { // Should only call deleteAppReport for the first report where key === reportID expect(deleteAppReport).toHaveBeenCalledTimes(1); expect(deleteAppReport).toHaveBeenCalledWith('report_123', currentUserEmail, 1, transactions, transactionsViolations, {}); - expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail, transactions, transactionsViolations); + expect(deleteAppReport).not.toHaveBeenCalledWith('report_456', currentUserEmail, 1, transactions, transactionsViolations); }); }); From 43399476f98c32cff5d479a9d104efeb32d6f001 Mon Sep 17 00:00:00 2001 From: lorretheboy Date: Sat, 31 Jan 2026 01:33:40 +0700 Subject: [PATCH 4/4] fix: move expenses issue --- src/hooks/useSelectedTransactionsActions.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 34a8b69d18268..1c779facfee30 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -97,17 +97,16 @@ function useSelectedTransactionsActions({ const knownOwnerIDs = new Set(); let hasUnknownOwner = false; - for (const selectedTransactionInfo of Object.values(selectedTransactionsMeta ?? {})) { - const ownerAccountID = selectedTransactionInfo?.ownerAccountID; - if (typeof ownerAccountID === 'number') { - knownOwnerIDs.add(ownerAccountID); - } else { - hasUnknownOwner = true; - } - } - for (const selectedTransaction of selectedTransactionsList) { + const transactionID = selectedTransaction?.transactionID; const reportID = selectedTransaction?.reportID; + + const metadataOwnerID = selectedTransactionsMeta?.[transactionID]?.ownerAccountID; + if (typeof metadataOwnerID === 'number') { + knownOwnerIDs.add(metadataOwnerID); + continue; + } + if (!reportID || reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { hasUnknownOwner = true; continue;