diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 251f50f2e9b2d..de5f3bdd3a7f7 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -1401,8 +1401,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/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 35c0c48a8837f..fcc0f8a20940f 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, diff --git a/src/components/Search/SearchList/index.tsx b/src/components/Search/SearchList/index.tsx index dddc4a080a44b..024e087aff41b 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'; @@ -196,16 +196,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) => { @@ -216,10 +251,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; @@ -312,10 +353,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; @@ -324,7 +362,7 @@ function SearchList({ setLongPressedItemTransactions(itemTransactions); setIsModalVisible(true); }, - [groupBy, route.key, shouldPreventLongPressRow, isSmallScreenWidth, isMobileSelectionModeEnabled, onCheckboxPress], + [route.key, shouldPreventLongPressRow, isSmallScreenWidth, isMobileSelectionModeEnabled, onCheckboxPress], ); const turnOnSelectionMode = useCallback(() => { @@ -453,7 +491,7 @@ function SearchList({ const tableHeaderVisible = canSelectMultiple || !!SearchTableHeader; const selectAllButtonVisible = canSelectMultiple && !SearchTableHeader; - const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === flattenedItemsWithoutPendingDelete.length; + const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === totalItems; const content = ( @@ -463,7 +501,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 4c6c8414bb778..9c8fed240d1e1 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 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'; @@ -53,6 +60,7 @@ import { isTransactionGroupListItemType, isTransactionListItemType, isTransactionMemberGroupListItemType, + isTransactionReportGroupListItemType, isTransactionWithdrawalIDGroupListItemType, shouldShowEmptyState, shouldShowYear as shouldShowYearUtil, @@ -97,9 +105,13 @@ function mapTransactionItemToSelectedEntry( originalItemTransaction: OnyxEntry, currentUserLogin: string, 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; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const amount = item.modifiedAmount || item.amount; + return [ item.keyForList, { @@ -124,8 +136,8 @@ function mapTransactionItemToSelectedEntry( groupCurrency: item.groupCurrency, groupExchangeRate: item.groupExchangeRate, reportID: item.reportID, - policyID: item.report?.policyID, - amount: item.modifiedAmount ?? item.amount, + policyID: item.policyID, + amount: allowNegativeAmount ? amount : Math.abs(amount), groupAmount: item.groupAmount, currency: item.currency, isFromOneTransactionReport: isOneTransactionReport(item.report), @@ -135,6 +147,27 @@ function mapTransactionItemToSelectedEntry( ]; } +function mapEmptyReportToSelectedEntry(item: TransactionReportGroupListItemType): [string, SelectedTransactionInfo] { + return [ + item.keyForList ?? '', + { + 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, @@ -149,42 +182,11 @@ 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, outstandingReportsByPolicyID, false); return { ...selectedTransactions, - [item.keyForList]: { - isSelected: true, - canReject: canRejectRequest, - canHold: canHoldRequest, - isHeld: isOnHold(item), - canUnhold: canUnholdRequest, - canSplit: isSplitAction(item.report, [itemTransaction], originalItemTransaction, currentUserLogin, 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, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - amount: Math.abs(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, - }, + [key]: selectedInfo, }; } @@ -491,9 +493,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) => 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; @@ -634,25 +655,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[]) => { @@ -671,14 +704,43 @@ 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 ?? '', outstandingReportsByPolicyID), - filteredData, - ); + const updatedTransactions = prepareTransactionsList(item, itemTransaction, originalItemTransaction, selectedTransactions, email ?? '', 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}; @@ -687,26 +749,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 ?? '', 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 ?? '', outstandingReportsByPolicyID); + }), + ), + }; + setSelectedTransactions(updatedTransactions, filteredData); + updateSelectAllMatchingItemsState(updatedTransactions); }, - [setSelectedTransactions, selectedTransactions, filteredData, transactions, outstandingReportsByPolicyID, searchResults?.data, email], + [selectedTransactions, setSelectedTransactions, filteredData, updateSelectAllMatchingItemsState, transactions, email, outstandingReportsByPolicyID, searchResults?.data], ); const onSelectRow = useCallback( @@ -910,51 +972,54 @@ 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 ?? '', outstandingReportsByPolicyID); - }), - ), - ), - filteredData, - ); - - return; - } - - setSelectedTransactions( - Object.fromEntries( - (filteredData as TransactionListItemType[]) + 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 ?? '', outstandingReportsByPolicyID); + }); + }); + updatedTransactions = Object.fromEntries(allSelections); + } else { + // When items are not grouped, data is TransactionListItemType[] not TransactionGroupListItemType[] + updatedTransactions = Object.fromEntries( + (filteredData as TransactionListItemType[]) + .filter((item) => !isTransactionPendingDelete(item)) .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 ?? '', outstandingReportsByPolicyID); }), - ), - filteredData, - ); + ); + } + setSelectedTransactions(updatedTransactions, filteredData); + updateSelectAllMatchingItemsState(updatedTransactions); }, [ validGroupBy, isExpenseReportType, - filteredData, selectedTransactions, setSelectedTransactions, + filteredData, + updateSelectAllMatchingItemsState, clearSelectedTransactions, transactions, + email, outstandingReportsByPolicyID, searchResults?.data, - email, ]); const onLayout = useCallback(() => { diff --git a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx index 0242a3aad7074..0a0859f509314 100644 --- a/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx +++ b/src/components/SelectionListWithSections/Search/ExpenseReportListItem.tsx @@ -59,9 +59,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 {isDelegateAccessRestricted, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext); diff --git a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx index bf5d453944951..c369b094c74bf 100644 --- a/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx +++ b/src/components/SelectionListWithSections/Search/TransactionGroupListItem.tsx @@ -145,10 +145,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; @@ -208,11 +212,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) => { @@ -288,7 +289,7 @@ function TransactionGroupListItem({ report={groupItem as TransactionReportGroupListItemType} onSelectRow={(listItem) => onSelectRow(listItem, transactionPreviewData)} onCheckboxPress={onCheckboxPress} - isDisabled={isDisabledOrEmpty} + isDisabled={isDisabled} isFocused={isFocused} canSelectMultiple={canSelectMultiple} isSelectAllChecked={isSelectAllChecked} @@ -323,6 +324,9 @@ function TransactionGroupListItem({ onExpandIconPress, isFocused, searchType, + groupBy, + isDisabled, + onDEWModalOpen, onSelectRow, transactionPreviewData, ], diff --git a/src/languages/de.ts b/src/languages/de.ts index 596d3229b0844..27bc9d97b453a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -552,6 +552,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', @@ -1194,8 +1198,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: 'Sind Sie sicher, dass Sie diesen Bericht löschen möchten?', + 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 4eba58e91e096..946ab9b84316a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -544,6 +544,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', @@ -1185,8 +1189,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 80e29ddee6c98..68506d5ced428 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -299,6 +299,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', @@ -916,8 +920,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 6d549456652ec..a331be4738f17 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -553,6 +553,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', @@ -1193,8 +1197,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 ed74ff8d04b08..579005161cef4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -553,6 +553,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', @@ -1189,8 +1193,14 @@ const translations: TranslationDeepObject = { one: 'Sei sicuro di voler eliminare questa spesa?', other: 'Sei sicuro di voler eliminare queste spese?', }), - deleteReport: 'Elimina resoconto', - 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 b05c8be8d62ac..a633689ceb4ce 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -553,6 +553,10 @@ const translations: TranslationDeepObject = { value: '値', downloadFailedTitle: 'ダウンロードに失敗しました', downloadFailedDescription: 'ダウンロードを完了できませんでした。後でもう一度お試しください。', + downloadFailedEmptyReportDescription: () => ({ + one: '空のレポートはエクスポートできません。', + other: () => '空のレポートはエクスポートできません。', + }), filterLogs: 'ログをフィルター', network: 'ネットワーク', reportID: 'レポート ID', @@ -1190,8 +1194,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 60de38a79867c..5ef71c2f20b51 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -553,6 +553,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', @@ -1189,8 +1193,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: 'Gereed', settledElsewhere: 'Elders betaald', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 536cec1da2f71..ab204c691bc95 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -553,6 +553,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', @@ -1188,9 +1192,15 @@ 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?', - settledExpensify: 'Opłacone', + 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', individual: 'Indywidualny', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f4d070481d452..7c627b644bca5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -553,6 +553,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', @@ -1188,8 +1192,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: 'Você 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 e7c85607477df..1b4f353c16b31 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -553,6 +553,10 @@ const translations: TranslationDeepObject = { value: '值', downloadFailedTitle: '下载失败', downloadFailedDescription: '您的下载未能完成。请稍后再试。', + downloadFailedEmptyReportDescription: () => ({ + one: '您无法导出空报告。', + other: () => '您无法导出空报告。', + }), filterLogs: '筛选日志', network: '网络', reportID: '报告 ID', @@ -1171,8 +1175,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 01415c68c99af..f4968b00c2d81 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -2563,6 +2563,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) && diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 29b64221bbb50..3b443982a0fd5 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'; @@ -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} 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'; @@ -53,7 +64,7 @@ import SafeString from '@src/utils/SafeString'; import {setPersonalBankAccountContinueKYCOnSuccess} from './BankAccounts'; 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 = { @@ -722,6 +733,37 @@ 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, + 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, reportTransactions, transactionsViolations, bankAccountList); + } + } +} + function deleteMoneyRequestOnSearch(hash: number, transactionIDList: string[]) { const {optimisticData: loadingOptimisticData, finallyData} = getOnyxLoadingData(hash); @@ -1289,6 +1331,7 @@ export { clearAdvancedFilters, setSearchContext, deleteSavedSearch, + bulkDeleteReports, payMoneyRequestOnSearch, approveMoneyRequestOnSearch, handleActionButtonPress, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 475882508eb97..4207312b69340 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, @@ -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'; @@ -139,6 +139,7 @@ function SearchPage({route}: SearchPageProps) { const [rejectModalAction, setRejectModalAction] = useState | 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}); @@ -298,6 +299,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; + } + if (areAllMatchingItemsSelected) { const result = await showConfirmModal({ title: translate('search.exportSearchResults.title'), @@ -331,6 +348,7 @@ function SearchPage({route}: SearchPageProps) { transactionIDList: selectedTransactionsKeys, }, () => { + setEmptyReportsCount(0); setIsDownloadErrorModalVisible(true); }, translate, @@ -338,17 +356,17 @@ function SearchPage({route}: SearchPageProps) { clearSelectedTransactions(undefined, true); }, [ isOffline, - areAllMatchingItemsSelected, - showConfirmModal, - translate, - selectedTransactionsKeys, status, - hash, selectedReports, + areAllMatchingItemsSelected, queryJSON, - selectAllMatchingItems, + selectedTransactionsKeys, + translate, clearSelectedTransactions, - setIsDownloadErrorModalVisible, + currentSearchResults?.data, + showConfirmModal, + hash, + selectAllMatchingItems, ]); const handleApproveWithDEWCheck = useCallback(async () => { @@ -414,6 +432,38 @@ function SearchPage({route}: SearchPageProps) { clearSelectedTransactions, ]); + 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?.reportID) { + continue; + } + if (selectedItem.action === CONST.SEARCH.ACTION_TYPES.VIEW && key === selectedItem.reportID) { + hasNonOneTransactionReport = true; + reportIDs.add(selectedItem.reportID); + } else { + expenses += 1; + reportIDs.add(selectedItem.reportID); + if (!selectedItem.isFromOneTransactionReport) { + hasNonOneTransactionReport = true; + } + } + } + + 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 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); @@ -428,8 +478,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, @@ -441,11 +491,28 @@ 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 ?? '', reportTransactions, transactionsViolations, bankAccountList); clearSelectedTransactions(); }); }); - }, [isOffline, showConfirmModal, translate, selectedTransactionsKeys, hash, clearSelectedTransactions]); + }, [ + isOffline, + hash, + showConfirmModal, + deleteModalTitle, + deleteModalPrompt, + translate, + allTransactions, + allTransactionViolations, + selectedTransactions, + currentUserPersonalDetails.email, + bankAccountList, + clearSelectedTransactions, + ]); const onBulkPaySelected = useCallback( (paymentMethod?: PaymentMethodType, additionalData?: Record) => { @@ -909,20 +976,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({ @@ -1131,11 +1219,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 +1342,6 @@ function SearchPage({route}: SearchPageProps) { isVisible={isOfflineModalVisible} onClose={handleOfflineModalClose} /> - {!!rejectModalAction && ( )} + ); } diff --git a/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts new file mode 100644 index 0000000000000..2e5c70209073b --- /dev/null +++ b/tests/unit/Search/deleteSelectedItemsOnSearchTest.ts @@ -0,0 +1,273 @@ +/* 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, + 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', + 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, 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', + 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', + 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', + 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, 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', + 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', + 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, '', {}, {}, {}); + + // 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, + 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', + 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, 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', + 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', + 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, '', {}, {}, {}); + + // Should not call deleteAppReport for transactions + expect(deleteAppReport).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/TransactionGroupListItemTest.tsx b/tests/unit/TransactionGroupListItemTest.tsx index 0a3dc1ebb35eb..00f97240254b1 100644 --- a/tests/unit/TransactionGroupListItemTest.tsx +++ b/tests/unit/TransactionGroupListItemTest.tsx @@ -24,6 +24,49 @@ jest.mock('@libs/SearchUIUtils', () => ({ getTableMinWidth: jest.fn(() => 0), })); +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, @@ -92,6 +135,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', @@ -332,3 +425,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(); + }); +});