diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a295f031849a..e12d5291ab12 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1724,6 +1724,7 @@ const CONST = { DUPLICATE_EXPENSE: 'duplicateExpense', DUPLICATE_REPORT: 'duplicateReport', MOVE_EXPENSE: 'moveExpense', + TOGGLE_SINGLE_EXPENSE_VIEW: 'toggleSingleExpenseView', }, PRIMARY_ACTIONS: { SUBMIT: 'submit', @@ -2285,6 +2286,10 @@ const CONST = { DETAILED: 'detailed', MATRIX: 'matrix', }, + SINGLE_EXPENSE_REPORT_VIEW: { + EXPENSE: 'expense', + TABLE: 'table', + }, } as const, UNREPORTED_EXPENSES_PAGE_SIZE: 50, COMPOSER: { @@ -9920,6 +9925,7 @@ const CONST = { PAY: 'MoreMenu-Pay', DUPLICATE_REPORT: 'MoreMenu-DuplicateReport', MOVE_EXPENSE: 'MoreMenu-MoveExpense', + TOGGLE_SINGLE_EXPENSE_VIEW: 'MoreMenu-ToggleSingleExpenseView', }, REPORT_PREVIEW: { CARD: 'ReportPreview-Card', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 3e1c1496626d..54250c4c250b 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -930,6 +930,9 @@ const ONYXKEYS = { /** Dragged table column widths, keyed by `columnResizingID` then column key. Per device, since ideal widths depend on window size. */ TABLE_COLUMN_WIDTHS: 'tableColumnWidths', + /** Stores whether the user views single-expense reports in the expense view or the table view */ + NVP_SINGLE_EXPENSE_REPORT_VIEW: 'nvp_expensify_singleExpenseReportView', + /** Partial transaction data used for MFA authorize transaction preview */ TRANSACTIONS_PENDING_3DS_REVIEW: 'transactionsPending3DSReview', @@ -2016,6 +2019,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_REPORT_LAYOUT_OPTION]: string; [ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[]; [ONYXKEYS.TABLE_COLUMN_WIDTHS]: OnyxTypes.TableColumnWidths; + [ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW]: ValueOf; [ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined; [ONYXKEYS.PERSONAL_POLICY_ID]: string; [ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record; diff --git a/src/components/BulkActionBar/types.ts b/src/components/BulkActionBar/types.ts index 586ec4c3d5a7..db3ad6f4f6f4 100644 --- a/src/components/BulkActionBar/types.ts +++ b/src/components/BulkActionBar/types.ts @@ -1,7 +1,7 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type {ComponentRef, RefObject} from 'react'; +import type {ComponentRef, Ref} from 'react'; import type {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; type BulkActionBarProps = { @@ -41,7 +41,7 @@ type BulkActionBarProps = { * Anchor for popovers a caller opens against the bar, such as the KYC wall Search puts behind its pay action. * Attached to the bar itself, since which button opened the flow is not something the bar exposes. */ - barRef?: RefObject | null>; + barRef?: Ref>; /** * Extra styles for the absolutely positioned layer the bar floats in. Pass a `bottom` here to lift the bar above diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 84c1bbdc682d..56ffa6a2112d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -7,6 +7,7 @@ import useOnyx from '@hooks/useOnyx'; import useReportPrimaryAction from '@hooks/useReportPrimaryAction'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -134,6 +135,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const shouldShowBackButton = shouldDisplayBackButton || shouldUseNarrowLayout; const isMobileSelectionModeEnabled = useMobileSelectionMode(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID); const isFocused = useIsFocused(); useEffect(() => { @@ -143,11 +145,13 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt }, []); if (isMobileSelectionModeEnabled && shouldUseNarrowLayout && isFocused) { - // If mobile selection mode is enabled but only one or no transactions remain, turn it off. The selection mode + // If mobile selection mode is enabled but too few transactions remain to show the table, turn it off. A single + // expense still shows the table when the user chose the table view for single-expense reports. The selection mode // is shared with every screen, so this report only gets to turn it off while it is the focused one. Another // screen on top of it, such as the add existing expense modal, owns the mode for as long as it is open. const visibleTransactions = transactions.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); - if (visibleTransactions.length <= 1) { + const minTransactionsForSelection = shouldUseTableViewForSingleExpense ? 1 : 2; + if (visibleTransactions.length < minTransactionsForSelection) { turnOffMobileSelectionMode(); } diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index b81209f4e2e6..3bd0310a81f4 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -36,12 +36,14 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useShareActions from '@hooks/useShareActions'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import useVerifyAccountAndResume from '@hooks/useVerifyAccountAndResume'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; +import {setSingleExpenseReportView} from '@libs/actions/ReportLayout'; import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getPlatform from '@libs/getPlatform'; @@ -118,6 +120,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo }); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const {singleExpenseReportView, shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID); const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasSeenTourSelector, }); @@ -316,7 +319,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo ? sortPoliciesByName(activeAdminPolicies, localeCompare) : []; - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Cash', 'ArrowRight', 'Building']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Cash', 'ArrowRight', 'Building', 'Table', 'Receipt']); // Build PAY action sub-items. Workspace-policy entries carry the policy as data and have no onSelected; // MoneyReportHeaderKYCDropdown picks them up via onSubItemSelected where triggerKYCFlow is in scope. @@ -421,6 +424,18 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo // Merge all action implementations const secondaryActionsImplementation: Record = { + [CONST.REPORT.SECONDARY_ACTIONS.TOGGLE_SINGLE_EXPENSE_VIEW]: { + value: CONST.REPORT.SECONDARY_ACTIONS.TOGGLE_SINGLE_EXPENSE_VIEW, + text: shouldUseTableViewForSingleExpense ? translate('reportLayout.switchToExpenseView') : translate('reportLayout.switchToTableView'), + icon: shouldUseTableViewForSingleExpense ? expensifyIcons.Receipt : expensifyIcons.Table, + sentryLabel: CONST.SENTRY_LABEL.MORE_MENU.TOGGLE_SINGLE_EXPENSE_VIEW, + onSelected: () => { + setSingleExpenseReportView( + shouldUseTableViewForSingleExpense ? CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE : CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE, + singleExpenseReportView, + ); + }, + }, [CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS]: { value: CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS, text: translate('iou.viewDetails'), diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index a564fad4a815..a2ceb7f1c384 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -8,6 +8,7 @@ import useLayoutSpacing from '@hooks/useLayoutSpacing'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -56,7 +57,10 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, ba const {selectedTransactionIDs} = useSearchSelectionContext(); const {clearSelectedTransactions} = useSearchSelectionActions(); const hasSelectedTransactions = !!selectedTransactionIDs.length; - const isTransactionThread = !!transactionThreadReportID; + + // In the table view a single-expense report lists its expense as a selectable row, so it acts like a multi-expense report here. + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID); + const isTransactionThread = !!transactionThreadReportID && !shouldUseTableViewForSingleExpense; useEffect(() => { if (!transactionThreadReportID) { diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index a2b475766e8e..c69d475e197a 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -10,6 +10,7 @@ import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy'; import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import useThemeStyles from '@hooks/useThemeStyles'; import useUnreadMarker from '@hooks/useUnreadMarker'; @@ -131,6 +132,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions, false, reportTransactionIDs); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); const isReportArchived = useReportIsArchived(reportID); const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived); @@ -193,7 +195,8 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon const {markNewestActionAsRead, completeSkippedMarkAsRead} = useMarkAsRead({ reportID: reportID ?? reportIDFromRoute ?? '', report, - transactionThreadReport, + // The table view doesn't show the expense thread's actions, so a new one there shouldn't mark the report as read + transactionThreadReport: shouldUseTableViewForSingleExpense ? undefined : transactionThreadReport, sortedVisibleReportActions: visibleReportActionsNewestFirst, sortedReportActions: reportActions, isScrolledToEnd: !hasScrolledOverThreshold, diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index ebef8dc41714..66e4841726e0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -14,6 +14,7 @@ import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import useThemeStyles from '@hooks/useThemeStyles'; import {removeFailedReport} from '@libs/actions/Report'; @@ -141,6 +142,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, const reportErrors = visibleTransactions.length === 1 && visibleTransactions.at(0)?.errors ? undefined : allReportErrors; const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); const isReportLoadPending = useIsReportLoadPending(reportID); const dismissReportCreationError = () => { @@ -158,10 +160,11 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState, const shouldShowOpenReportLoadingSkeleton = !!(isReportLoadPending && reportActions.length === 0 && !isOffline) || shouldWaitForTransactions; const isEmptyTransactionReport = visibleTransactions?.length === 0 && transactionThreadReportID === undefined; - const shouldDisplayMoneyRequestActionsList = !!isEmptyTransactionReport || shouldDisplayReportTableView(report, visibleTransactions ?? []); + const shouldDisplayMoneyRequestActionsList = !!isEmptyTransactionReport || shouldDisplayReportTableView(report, visibleTransactions ?? [], shouldUseTableViewForSingleExpense); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); - const shouldShowWideRHPReceipt = visibleTransactions.length === 1 && !isSmallScreenWidth && !!transactionThreadReport; + // The side-by-side receipt belongs to the single-expense view, so it's hidden when the user picks the table view + const shouldShowWideRHPReceipt = visibleTransactions.length === 1 && !isSmallScreenWidth && !!transactionThreadReport && !shouldUseTableViewForSingleExpense; const reportHeaderView = isTransactionThreadView ? ( { if (isSecondaryActionAWorkspacePolicyOption(item)) { diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index 78bcf1fdfca0..357615ad6ff6 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -4,7 +4,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import useShouldShowReportBulkActionBar from '@components/MoneyRequestReportView/useShouldShowReportBulkActionBar'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu'; -import {ReportSubmitToPopoverAnchor} from '@components/ReportSubmitToPopoverAnchor'; +import {ReportSubmitToPopoverMeasurableAnchor, ReportSubmitToPopoverRoot} from '@components/ReportSubmitToPopoverAnchor'; import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler'; import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext'; @@ -282,7 +282,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool - + - + + + + ); } diff --git a/src/components/ReportSubmitToPopoverAnchor.tsx b/src/components/ReportSubmitToPopoverAnchor.tsx index 9c4785f74222..33246850711d 100644 --- a/src/components/ReportSubmitToPopoverAnchor.tsx +++ b/src/components/ReportSubmitToPopoverAnchor.tsx @@ -59,6 +59,11 @@ function useOpenReportSubmitToPopover(): OpenReportSubmitToPopover { return useContext(ReportSubmitToPopoverContext); } +/** The ref the submit-to popover measures, for anchors that can't be wrapped in {@link ReportSubmitToPopoverMeasurableAnchor}. */ +function useReportSubmitToPopoverAnchorRef() { + return useContext(ReportSubmitToPopoverAnchorRefContext); +} + /** Opens the shared Search submit-to popover for a report (e.g. bulk actions outside a list row). */ function useOpenSearchReportSubmitToPopover() { const host = useContext(ReportSubmitToPopoverHostContext); @@ -322,5 +327,6 @@ export { SEARCH_REPORT_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT, useOpenReportSubmitToPopover, useOpenSearchReportSubmitToPopover, + useReportSubmitToPopoverAnchorRef, useSearchSubmitPopoverGuard, }; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 262d9cdacfca..4c1c1c4cf42d 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -19,6 +19,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSaveSortedReportIDs from '@hooks/useSaveSortedReportIDs'; import useSearchAutoRefetch from '@hooks/useSearchAutoRefetch'; import useSearchShouldCalculateTotals, {getSearchRequestOffsetForMissingAllMatchingCount} from '@hooks/useSearchShouldCalculateTotals'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import useStableArrayReference from '@hooks/useStableArrayReference'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -34,13 +35,14 @@ import { shouldRefreshActiveTransactionIDs, } from '@libs/actions/TransactionThreadNavigation'; import Log from '@libs/Log'; +import {shouldUseMultiExpenseReportLayout} from '@libs/MoneyRequestReportUtils'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import openInternalRouteInNewTab, {isModifiedMousePress} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {flushPendingSearchWrite, hasPendingSearchWrite} from '@libs/pendingSearchWrite'; -import {isCreatedTaskReportAction} from '@libs/ReportActionsUtils'; +import {isCreatedTaskReportAction, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; import {isOneTransactionReport} from '@libs/ReportUtils'; import {searchKeyToSavedSearchID} from '@libs/SearchKeyUtils'; import {buildCannedSearchQuery, buildSearchQueryString} from '@libs/SearchQueryUtils'; @@ -183,6 +185,7 @@ function Search({ const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { @@ -756,7 +759,10 @@ function Search({ } } - if (item.transactions.length > 1) { + // A single-expense report in the table view opens at the same width as a multi-expense report. + // A "Pay someone" report always uses the expense view, so it keeps the normal wide width. + const isSentMoneyReport = isSentMoneyReportAction(firstTransaction?.reportAction); + if (shouldUseMultiExpenseReportLayout(item.transactions.length, shouldUseTableViewForSingleExpense && !isSentMoneyReport)) { markReportRHPWidth(reportID, 'super-wide'); } else { unmarkReportRHPWidth(reportID, 'super-wide'); @@ -844,6 +850,7 @@ function Search({ getCurrencyDecimals, conciergeChat, delegateAccountID, + shouldUseTableViewForSingleExpense, ], ); diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 0fb1ddd2d909..986657046e4c 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -24,6 +24,7 @@ import { generateReportID, getPolicyExpenseChat, isDM, + isExpenseReport, isSelfDM, navigateOnDeleteExpense, } from '@libs/ReportUtils'; @@ -78,6 +79,7 @@ import {useAllPersonalDetails} from './usePersonalDetails'; import usePersonalPolicy from './usePersonalPolicy'; import useReportIsArchived from './useReportIsArchived'; import useRestrictedActionPolicyID from './useRestrictedActionPolicyID'; +import useSingleExpenseReportView from './useSingleExpenseReportView'; import useSplitEffectivePolicy from './useSplitEffectivePolicy'; import useTheme from './useTheme'; import useThrottledButtonState from './useThrottledButtonState'; @@ -210,7 +212,12 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic const hasMultipleSplits = !!transaction?.comment?.originalTransactionID && getChildTransactions(allTransactions, transaction.comment.originalTransactionID).length > 1; const hasSplitIndicator = isExpenseSplit && hasMultipleSplits; const isDeletingOwnExpense = requestParentReportAction?.actorAccountID === accountID; - const shouldShowEditSplitOnDeleteAction = isDeletingOwnExpense && !!transaction?.transactionID && shouldOpenSplitExpenseEditFlowOnDelete([transaction.transactionID]); + + // In the table view a single-expense expense report is deleted as a report, so its expense moves to the self DM. + // IOU and invoice reports can't be deleted as a report, so they keep deleting their only expense. + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); + const shouldDeleteSingleExpense = isDeletingOwnExpense && !(shouldUseTableViewForSingleExpense && isExpenseReport(moneyRequestReport)); + const shouldShowEditSplitOnDeleteAction = shouldDeleteSingleExpense && !!transaction?.transactionID && shouldOpenSplitExpenseEditFlowOnDelete([transaction.transactionID]); // Duplicate report throttle const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); @@ -550,7 +557,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic onSelected: async () => { const transactionCount = Object.keys(transactions).length; - if (transactionCount === 1 && isDeletingOwnExpense) { + if (transactionCount === 1 && shouldDeleteSingleExpense) { if (shouldShowEditSplitOnDeleteAction && transaction?.transactionID) { deleteTransactions([transaction.transactionID], duplicateTransactions, duplicateTransactionViolations, currentSearchHash, false); return; diff --git a/src/hooks/useSingleExpenseReportView.ts b/src/hooks/useSingleExpenseReportView.ts new file mode 100644 index 000000000000..e1d7af3f9af2 --- /dev/null +++ b/src/hooks/useSingleExpenseReportView.ts @@ -0,0 +1,41 @@ +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {ValueOf} from 'type-fest'; + +import {isSentMoneyReportSelector} from '@selectors/ReportAction'; + +import useOnyx from './useOnyx'; + +type UseSingleExpenseReportViewResult = { + /** The view the user picked for single-expense reports, defaulting to the expense view */ + singleExpenseReportView: ValueOf; + + /** Whether single-expense reports should render in the table view */ + shouldUseTableViewForSingleExpense: boolean; + + /** Whether the saved preference is still loading from storage */ + isLoading: boolean; +}; + +/** + * Returns the user's saved view for single-expense reports (expense view or table view). + * + * Pass the report's ID to apply the view to that report. A "Pay someone" report always uses the expense view: its + * expense is the report itself, so it has no expense thread for a table row to open. + */ +function useSingleExpenseReportView(reportID?: string): UseSingleExpenseReportViewResult { + const [singleExpenseReportView = CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE, singleExpenseReportViewMetadata] = useOnyx(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW); + const [isSentMoneyReport = false] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(reportID)}`, {selector: isSentMoneyReportSelector}); + + return { + singleExpenseReportView, + shouldUseTableViewForSingleExpense: singleExpenseReportView === CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE && !isSentMoneyReport, + isLoading: isLoadingOnyxValue(singleExpenseReportViewMetadata), + }; +} + +export default useSingleExpenseReportView; diff --git a/src/hooks/useTransactionThreadReportID.ts b/src/hooks/useTransactionThreadReportID.ts index 1ba28f4523eb..a4943a8d0c80 100644 --- a/src/hooks/useTransactionThreadReportID.ts +++ b/src/hooks/useTransactionThreadReportID.ts @@ -1,5 +1,5 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getAllNonDeletedTransactions, getEffectiveTransactionThreadReportID} from '@libs/MoneyRequestReportUtils'; import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -8,6 +8,7 @@ import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; import usePaginatedReportActions from './usePaginatedReportActions'; import useReportTransactionsCollection from './useReportTransactionsCollection'; +import useSingleExpenseReportView from './useSingleExpenseReportView'; /** * Derives the single-transaction thread report IDs and filtered actions for a money request report. @@ -25,10 +26,11 @@ function useTransactionThreadReportID(reportID: string | undefined) { const nonDeletedTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); const visibleTransactions = nonDeletedTransactions?.filter((t) => isOffline || t.pendingAction !== 'delete'); const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const isSentMoneyReport = reportActions.some((action) => isSentMoneyReportAction(action)); - const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + const effectiveTransactionThreadReportID = getEffectiveTransactionThreadReportID(transactionThreadReportID, isSentMoneyReport, shouldUseTableViewForSingleExpense); return { transactionThreadReportID, diff --git a/src/languages/de.ts b/src/languages/de.ts index 95710fccbaf2..99eb6045fa03 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10480,6 +10480,8 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc category: 'Kategorie', tag: 'Tag', }, + switchToTableView: 'Zur Tabellenansicht wechseln', + switchToExpenseView: 'Zur Ausgabenansicht wechseln', }, report: { newReport: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 322bae595055..5baeb0d10966 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10680,6 +10680,8 @@ ${reportName}`, category: 'Κατηγορία', tag: 'Ετικέτα', }, + switchToTableView: 'Εναλλαγή σε προβολή πίνακα', + switchToExpenseView: 'Εναλλαγή σε προβολή δαπάνης', }, report: { newReport: { diff --git a/src/languages/en.ts b/src/languages/en.ts index e11f453b5db7..312d712a3cf0 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10719,6 +10719,8 @@ const translations = { category: 'Category', tag: 'Tag', }, + switchToTableView: 'Switch to table view', + switchToExpenseView: 'Switch to expense view', }, report: { newReport: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 954331664658..01d01ddce3b9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10290,6 +10290,8 @@ ${reportName}`, category: 'Categoría', tag: 'Etiqueta', }, + switchToTableView: 'Cambiar a vista de tabla', + switchToExpenseView: 'Cambiar a vista de gasto', }, report: { newReport: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a35a8506e5f9..c5f48a6f44fa 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10510,6 +10510,8 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e category: 'Catégorie', tag: 'Tag', }, + switchToTableView: 'Passer à la vue tableau', + switchToExpenseView: 'Passer à la vue dépense', }, report: { newReport: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 88ce439f43b8..7babf837a307 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10431,6 +10431,8 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, category: 'Categoria', tag: 'Etichetta', }, + switchToTableView: 'Passa alla vista tabella', + switchToExpenseView: 'Passa alla vista spesa', }, report: { newReport: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index afe40800db2b..0e05fbd3f598 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10278,6 +10278,8 @@ ${reportName}`, category: 'カテゴリ', tag: 'タグ', }, + switchToTableView: 'テーブル表示に切り替え', + switchToExpenseView: '経費表示に切り替え', }, report: { newReport: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5cfa1a7d0489..5ab20211b510 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10398,6 +10398,8 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, category: 'Categorie', tag: 'Label', }, + switchToTableView: 'Overschakelen naar tabelweergave', + switchToExpenseView: 'Overschakelen naar uitgavenweergave', }, report: { newReport: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 791df2822ef4..ac0dcb9dbb59 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10414,6 +10414,8 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, category: 'Kategoria', tag: 'Tag', }, + switchToTableView: 'Przełącz na widok tabeli', + switchToExpenseView: 'Przełącz na widok wydatku', }, report: { newReport: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 14b8e80954f6..9a23d1a6d3fc 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10395,6 +10395,8 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, category: 'Categoria', tag: 'Etiqueta', }, + switchToTableView: 'Mudar para visualização em tabela', + switchToExpenseView: 'Mudar para visualização de despesa', }, report: { newReport: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 989b0710d02e..ac40dd5dc27f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -10003,6 +10003,8 @@ ${reportName}`, category: '类别', tag: '标签', }, + switchToTableView: '切换到表格视图', + switchToExpenseView: '切换到报销视图', }, report: { newReport: { diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index 19414a189282..f9de03dab913 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -317,6 +317,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.NVP_SEARCH_SIDEBAR, ONYXKEYS.NVP_SEEN_NEW_USER_MODAL, ONYXKEYS.NVP_SIDE_PANEL, + ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, ONYXKEYS.NVP_SUBMIT_MIGRATION_MODAL_SHOWN, ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.NVP_TRY_NEW_DOT, diff --git a/src/libs/MoneyRequestReportUtils.ts b/src/libs/MoneyRequestReportUtils.ts index b3d1ee6f846a..3724c72526c9 100644 --- a/src/libs/MoneyRequestReportUtils.ts +++ b/src/libs/MoneyRequestReportUtils.ts @@ -166,10 +166,30 @@ function isSingleTransactionReport(report: OnyxEntry, transactions: Tran * Returns whether a "table" ReportView/MoneyRequestReportView should be used for the report. * * If report is a special "transaction thread" we want to use other Report views. - * Likewise, if report has only 1 connected transaction, then we also use other views. + * Likewise, if report has only 1 connected transaction, then we also use other views, + * unless the user chose the table view for single-expense reports. */ -function shouldDisplayReportTableView(report: OnyxEntry, transactions: Transaction[]) { - return !isReportTransactionThread(report) && !isSingleTransactionReport(report, transactions); +function shouldDisplayReportTableView(report: OnyxEntry, transactions: Transaction[], shouldUseTableViewForSingleExpense = false) { + if (isReportTransactionThread(report)) { + return false; + } + return shouldUseTableViewForSingleExpense || !isSingleTransactionReport(report, transactions); +} + +/** + * Returns whether the report shows its expenses in a table and so gets the multi-expense layout (super-wide RHP). + * A single-expense report counts too when the user chose the table view for single-expense reports. + */ +function shouldUseMultiExpenseReportLayout(transactionCount: number, shouldUseTableViewForSingleExpense: boolean) { + return transactionCount > 1 || (shouldUseTableViewForSingleExpense && transactionCount === 1); +} + +/** + * Returns the transaction thread that report comments go to, or undefined when they go to the report itself. + * In the table view the thread's actions aren't shown in the report, so comments go to the report itself. + */ +function getEffectiveTransactionThreadReportID(transactionThreadReportID: string | undefined, isSentMoneyReport: boolean, shouldUseTableViewForSingleExpense: boolean) { + return isSentMoneyReport || shouldUseTableViewForSingleExpense ? undefined : transactionThreadReportID; } function shouldWaitForTransactions( @@ -253,6 +273,8 @@ export { getAllNonDeletedTransactions, isSingleTransactionReport, shouldDisplayReportTableView, + shouldUseMultiExpenseReportLayout, + getEffectiveTransactionThreadReportID, shouldWaitForTransactions, isBillableEnabledOnPolicy, getTransactionRejectErrorKey, diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index 271f69728542..600fa9153349 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -14,6 +14,7 @@ import usePersonalDetailByLogin from '@hooks/usePersonalDetailByLogin'; import useReconcileHighContrastIntent from '@hooks/useReconcileHighContrastIntent'; import useReportAttributes from '@hooks/useReportAttributes'; import useRootNavigationState from '@hooks/useRootNavigationState'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import {init, isClientTheLeader} from '@libs/ActiveClientManager'; import {isQAServerActive} from '@libs/ApiUtils'; @@ -122,7 +123,10 @@ function AuthScreensInitHandler() { const topmostReportID = useRootNavigationState(Navigation.getFocusedReportId); // Not `Navigation.isNavigationReady()`: that resolves on the container's first render, before this navigator exists. const tabNavigatorStateKey = useRootNavigationState(getTabNavigatorStateKey); - const topmostOneTransactionThreadReportID = useOneTransactionThreadReportID(topmostReportID); + const oneTransactionThreadReportID = useOneTransactionThreadReportID(topmostReportID); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(topmostReportID); + // The table view doesn't show the expense thread's actions, so a comment there should still notify while the report is open + const topmostOneTransactionThreadReportID = shouldUseTableViewForSingleExpense ? undefined : oneTransactionThreadReportID; // We use a ref so the Pusher callback (registered once on mount) always reads the latest value without re-subscribing. const topmostOneTransactionThreadReportIDRef = useRef(topmostOneTransactionThreadReportID); diff --git a/src/libs/Notification/PushNotification/shouldShowPushNotification.ts b/src/libs/Notification/PushNotification/shouldShowPushNotification.ts index 2c3631b9b7cc..ce23ffb29146 100644 --- a/src/libs/Notification/PushNotification/shouldShowPushNotification.ts +++ b/src/libs/Notification/PushNotification/shouldShowPushNotification.ts @@ -10,7 +10,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Report as ReportType, ReportActions} from '@src/types/onyx'; import type {PushPayload} from '@ua/react-native-airship'; -import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -38,6 +39,14 @@ Onyx.connectWithoutView({ callback: (value) => (allReports = value), }); +// connectWithoutView() is the only option here: Airship calls shouldShowPushNotification() from outside the React tree and owns its callback signature, +// so we can neither use useOnyx() nor pass this value in as a parameter. This is not a pattern to copy - prefer useOnyx() or passing data in as a parameter. +let singleExpenseReportView: OnyxEntry>; +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, + callback: (value) => (singleExpenseReportView = value), +}); + /** * Returns whether the given Airship notification should be shown depending on the current state of the app */ @@ -58,7 +67,12 @@ export default function shouldShowPushNotification(pushPayload: PushPayload): bo const topmostReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${topmostReportID}`]; const topmostChatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${topmostReport?.chatReportID}`]; const topmostReportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${topmostReportID}`]; - const topmostOneTransactionThreadReportID = ReportActionUtils.getOneTransactionThreadReportID(topmostReport, topmostChatReport, topmostReportActions, getIsOffline()); + // The table view doesn't show the expense thread's actions, so a comment there should still notify while the report is open. A "Pay someone" report always uses the expense view. + const isSentMoneyReport = Object.values(topmostReportActions ?? {}).some(ReportActionUtils.isSentMoneyReportAction); + const shouldUseTableViewForSingleExpense = singleExpenseReportView === CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE && !isSentMoneyReport; + const topmostOneTransactionThreadReportID = shouldUseTableViewForSingleExpense + ? undefined + : ReportActionUtils.getOneTransactionThreadReportID(topmostReport, topmostChatReport, topmostReportActions, getIsOffline()); shouldShow = Report.shouldShowReportActionNotification(String(data.reportID), topmostOneTransactionThreadReportID, currentUserAccountID, reportAction, true); } diff --git a/src/libs/PopoverMenuSections.ts b/src/libs/PopoverMenuSections.ts index 41d4ec38e1f4..cd78b91fe32e 100644 --- a/src/libs/PopoverMenuSections.ts +++ b/src/libs/PopoverMenuSections.ts @@ -24,7 +24,7 @@ const REPORT_MORE_MENU_SECTIONS = [ [REPORT_SECONDARY_ACTIONS.SPLIT, REPORT_SECONDARY_ACTIONS.MERGE, REPORT_SECONDARY_ACTIONS.DUPLICATE_EXPENSE, REPORT_SECONDARY_ACTIONS.MOVE_EXPENSE], [REPORT_SECONDARY_ACTIONS.DUPLICATE_REPORT, REPORT_SECONDARY_ACTIONS.CHANGE_WORKSPACE, REPORT_SECONDARY_ACTIONS.CHANGE_APPROVER], [REPORT_SECONDARY_ACTIONS.EXPORT, REPORT_SECONDARY_ACTIONS.DOWNLOAD_PDF, REPORT_SECONDARY_ACTIONS.DOWNLOAD_RECEIPTS, REPORT_SECONDARY_ACTIONS.SHARE, REPORT_SECONDARY_ACTIONS.PRINT], - [REPORT_SECONDARY_ACTIONS.VIEW_DETAILS, REPORT_SECONDARY_ACTIONS.DELETE], + [REPORT_SECONDARY_ACTIONS.TOGGLE_SINGLE_EXPENSE_VIEW, REPORT_SECONDARY_ACTIONS.VIEW_DETAILS, REPORT_SECONDARY_ACTIONS.DELETE], ]; const TRANSACTION_MORE_MENU_SECTIONS = [ diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 11baada8cb50..59990ec60f64 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -18,6 +18,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {areTransactionsEligibleForMerge} from './MergeTransactionUtils'; +import {isSingleTransactionReport} from './MoneyRequestReportUtils'; import { arePaymentsEnabled as arePaymentsEnabledUtils, canMemberWrite, @@ -48,6 +49,7 @@ import { hasPendingDEWApprove, hasPendingDEWSubmit, isPayAction, + isSentMoneyReportAction, } from './ReportActionsUtils'; import {getReportPrimaryAction, isPrimaryPayAction} from './ReportPrimaryActionUtils'; import { @@ -1207,6 +1209,12 @@ function getSecondaryReportActions({ options.push(CONST.REPORT.SECONDARY_ACTIONS.CHANGE_APPROVER); } + // Reports with more than one expense always use the table view, so the switch only applies to single-expense reports. + // "Pay someone" reports always use the expense view, because their expense is the report itself. + if (isSingleTransactionReport(report, reportTransactions) && !reportActions?.some(isSentMoneyReportAction)) { + options.push(CONST.REPORT.SECONDARY_ACTIONS.TOGGLE_SINGLE_EXPENSE_VIEW); + } + options.push(CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS); if (isDeleteAction(report, reportTransactions, currentUserAccountID, rules, policy, cardList, reportActions ?? [], true)) { diff --git a/src/libs/TransactionUtils/duplicates.ts b/src/libs/TransactionUtils/duplicates.ts index b3ec2c66ea5c..92a11d42aad6 100644 --- a/src/libs/TransactionUtils/duplicates.ts +++ b/src/libs/TransactionUtils/duplicates.ts @@ -221,6 +221,17 @@ function canMergeDuplicates(keptReport: OnyxEntry, transactionIDList: st return isReportMergeableForDuplicates(keptReport) && transactionIDList.length > 0; } +/** + * Whether a report still has an expense after a duplicate merge discards the given transactions. Expenses that are + * already pending deletion don't count. A report whose transactions haven't loaded is treated as keeping expenses. + */ +function doesReportKeepExpensesAfterMerge(reportTransactions: Transaction[], discardedTransactionIDs: Set): boolean { + if (reportTransactions.length === 0) { + return true; + } + return reportTransactions.some((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !discardedTransactionIDs.has(transaction.transactionID)); +} + /** * This function compares fields of duplicate transactions and determines which fields should be kept and which should be changed. * @@ -470,6 +481,7 @@ export { buildNewTransactionAfterReviewingDuplicates, canMergeDuplicates, compareDuplicateTransactionFields, + doesReportKeepExpensesAfterMerge, removeSettledAndApprovedTransactions, removeTransactionFromDuplicateTransactionViolation, }; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 9165c2264c68..dcc792714ad8 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -53,6 +53,7 @@ import { buildNewTransactionAfterReviewingDuplicates, canMergeDuplicates, compareDuplicateTransactionFields, + doesReportKeepExpensesAfterMerge, removeSettledAndApprovedTransactions, removeTransactionFromDuplicateTransactionViolation, } from './duplicates'; @@ -1938,6 +1939,7 @@ export { buildNewTransactionAfterReviewingDuplicates, buildMergeDuplicatesParams, canMergeDuplicates, + doesReportKeepExpensesAfterMerge, getReimbursable, hasNonReimbursableTransactions, isPayAtEndExpense, diff --git a/src/libs/actions/ReportLayout.ts b/src/libs/actions/ReportLayout.ts index dedc156894a4..30740eb725c8 100644 --- a/src/libs/actions/ReportLayout.ts +++ b/src/libs/actions/ReportLayout.ts @@ -10,9 +10,14 @@ import type {ReportLayoutGroupBy, ReportLayoutOption, ReportLayoutSelection} fro import type {AnyOnyxUpdate} from '@src/types/onyx/Request'; import type {OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; +type SingleExpenseReportView = ValueOf; + +const SINGLE_EXPENSE_REPORT_VIEW_NVP_NAME = 'expensify_singleExpenseReportView'; + /** * Apply a report layout selection from the group-by selector. * "None" maps to the matrix layout (a flat, ungrouped list) and clears the group-by field. @@ -137,4 +142,32 @@ function setReportDetailsColumns(columns: SearchCustomColumnIds[], previousValue API.write(WRITE_COMMANDS.SET_REPORT_DETAILS_COLUMNS, parameters, {optimisticData, failureData}); } -export {setReportLayout, getReportLayoutGroupBy, getReportLayoutSelection, isMatrixLayout, setReportDetailsColumns}; +/** + * Set whether the user views single-expense reports in the expense view or the table view. + * The preference is saved on the account so it applies to every single-expense report on every device. + */ +function setSingleExpenseReportView(view: SingleExpenseReportView, previousValue?: SingleExpenseReportView | null) { + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, + value: view, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, + value: previousValue ?? null, + }, + ]; + + const parameters: SetNameValuePairsParams = { + [`nameValuePairs[${SINGLE_EXPENSE_REPORT_VIEW_NVP_NAME}]`]: view, + }; + + API.write(WRITE_COMMANDS.SET_NAME_VALUE_PAIRS, parameters, {optimisticData, failureData}); +} + +export {setReportLayout, getReportLayoutGroupBy, getReportLayoutSelection, isMatrixLayout, setReportDetailsColumns, setSingleExpenseReportView}; diff --git a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts index d07aee9cd73b..a866b933ac93 100644 --- a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts +++ b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts @@ -5,6 +5,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useLocalize from '@hooks/useLocalize'; +import getCentralPaneReportID from '@libs/Navigation/helpers/getCentralPaneReportID'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isTrackExpenseAction} from '@libs/ReportActionsUtils'; @@ -47,6 +48,7 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep moneyRequestReport, iouTransactionID, iouReport, + iouReportTransactions, chatIOUReport, isChatIOUReportArchived, isSingleTransactionView, @@ -54,6 +56,9 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep }: Partial = requestData ?? {}; let urlToNavigateBack: Route | undefined; + // Deleting a report's only expense deletes the report too. A single-expense report in the table view opens its + // expense on top of the report, so going back to the report would land on the "Not here" page. + let shouldCloseRHPAfterReportDeletion = false; // Only proceed with navigation logic if transaction was actually deleted if (!isEmptyObject(requestParentReportAction)) { const rootState = navigationRef.getRootState(); @@ -62,17 +67,24 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep const previousRoute = rhpRoutes.at(-2); const previousRouteReportID = previousRoute?.params && 'reportID' in previousRoute.params ? previousRoute.params.reportID : undefined; const superWideRHPIndex = rhpRoutes.findIndex((rhpRoute) => SUPER_WIDE_RIGHT_MODALS.has(rhpRoute.name)); + const superWideRHPParams = rhpRoutes.at(superWideRHPIndex)?.params; + const superWideRHPReportID = superWideRHPIndex > -1 && superWideRHPParams && 'reportID' in superWideRHPParams ? superWideRHPParams.reportID : undefined; + const remainingTransactions = iouReportTransactions?.filter((transaction) => transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const isParentReportDeleted = !!iouReport?.reportID && remainingTransactions?.length === 1; // If the deleted expense is displayed directly below, close the entire RHP const isSuperWideRHPDisplayed = superWideRHPIndex > -1; + shouldCloseRHPAfterReportDeletion = isParentReportDeleted && superWideRHPReportID === iouReport?.reportID; + // On narrow layouts the report sits in the Inbox central pane instead of the super wide RHP + const isDeletedReportUnderRHP = shouldCloseRHPAfterReportDeletion || (isParentReportDeleted && getCentralPaneReportID() === iouReport?.reportID); const isSuperWideRHPDisplayedDirectlyBelow = isSuperWideRHPDisplayed && superWideRHPIndex === rhpRoutes.length - 2; if (isSuperWideRHPDisplayedDirectlyBelow && previousRouteReportID === reportID) { Navigation.dismissModal(); return; } - // If the deleted expense is opened from the super wide rhp, go back there. - if (previousRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT && previousRouteReportID === reportID) { + // If the deleted expense is opened from the super wide rhp, go back there, unless going back would show the deleted report. + if (!isDeletedReportUnderRHP && previousRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT && previousRouteReportID === reportID) { if (isSuperWideRHPDisplayed) { const distanceToPop = rhpRoutes.length - 1 - superWideRHPIndex; navigationRef.dispatch({...StackActions.pop(distanceToPop), target: rhp?.state?.key}); @@ -111,6 +123,10 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep if (!urlToNavigateBack) { Navigation.dismissModal(); + } else if (shouldCloseRHPAfterReportDeletion) { + // Going back to the super wide RHP would show the deleted report, so close the RHP to reveal the screen below + setDeleteTransactionNavigateBackUrl(urlToNavigateBack); + Navigation.dismissModal(); } else { setDeleteTransactionNavigateBackUrl(urlToNavigateBack); navigateBackOnDeleteTransaction(urlToNavigateBack); diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index f5cdbe80163c..84e34f35d446 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -18,6 +18,7 @@ import usePrevious from '@hooks/usePrevious'; import {useDerivedReportNameByReportID} from '@hooks/useReportAttributes'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import useSubmitToDestinationVisible from '@hooks/useSubmitToDestinationVisible'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; @@ -25,7 +26,7 @@ import useTransactionThreadReportID from '@hooks/useTransactionThreadReportID'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Log from '@libs/Log'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getAllNonDeletedTransactions, shouldUseMultiExpenseReportLayout} from '@libs/MoneyRequestReportUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {RightModalNavigatorParamList} from '@libs/Navigation/types'; @@ -126,6 +127,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { const {isEditingDisabled, isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportIDFromRoute, isReportArchived); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(report?.reportID); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const [conciergeChat] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`); const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); @@ -184,8 +186,9 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { return {snapshotTransaction: transaction, snapshotViolations: violations}; }, [snapshot?.data, allReportTransactions, reportIDFromRoute]); - // If there is more than one transaction, display the report in Super Wide RHP, otherwise it will be shown in Wide RHP - const shouldShowSuperWideRHP = visibleTransactions.length > 1; + // If there is more than one transaction, display the report in Super Wide RHP, otherwise it will be shown in Wide RHP. + // A single-expense report in the table view also uses the Super Wide RHP. + const shouldShowSuperWideRHP = shouldUseMultiExpenseReportLayout(visibleTransactions.length, shouldUseTableViewForSingleExpense); useRHPWidth(shouldShowSuperWideRHP ? 'super-wide' : 'wide'); diff --git a/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx index cc6e82235d9f..fdc5ff3569ac 100644 --- a/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx @@ -16,6 +16,7 @@ import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useThemeStyles from '@hooks/useThemeStyles'; import useTransactionsByID from '@hooks/useTransactionsByID'; import useTransactionThreadReportIDs from '@hooks/useTransactionThreadReportIDs'; @@ -73,6 +74,8 @@ function DynamicConfirmationPage() { ); const {superWideRHPRouteKeys} = useWideRHPState(); const isSuperWideRHPDisplayed = superWideRHPRouteKeys.length > 0; + const superWideRHPReportID = isSuperWideRHPDisplayed ? Navigation.getTopmostSuperWideRHPReportID() : undefined; + const superWideRHPReportTransactions = useReportTransactionsCollection(superWideRHPReportID); const [duplicates] = useTransactionsByID(reviewDuplicates?.duplicates); const transactionsMergeParams = useMemo( @@ -122,7 +125,11 @@ function DynamicConfirmationPage() { allReportActionsList: allReportActions, allReportsList: allReports, }); - if (isSuperWideRHPDisplayed) { + // The merge removes the discarded expenses. A single-expense report in the table view also uses the super wide RHP, + // and once its only expense is discarded it is gone, so going back to it would show "Not here". + const discardedTransactionIDs = new Set(mergeParams.transactionList.map((transaction) => transaction.transactionID)); + const doesSuperWideRHPReportKeepExpenses = TransactionUtils.doesReportKeepExpensesAfterMerge(Object.values(superWideRHPReportTransactions), discardedTransactionIDs); + if (isSuperWideRHPDisplayed && doesSuperWideRHPReportKeepExpenses) { Navigation.dismissToSuperWideRHP(); return; } @@ -135,7 +142,18 @@ function DynamicConfirmationPage() { Navigation.dismissModal({ afterTransition: () => Navigation.navigate(keptReportRoute, {forceReplace: true}), }); - }, [childReportID, transactionsMergeParams, taxData, currentUserAccountID, currentUserLogin, isSuperWideRHPDisplayed, allTransactionViolations, allReportActions, allReports]); + }, [ + childReportID, + transactionsMergeParams, + taxData, + currentUserAccountID, + currentUserLogin, + isSuperWideRHPDisplayed, + superWideRHPReportTransactions, + allTransactionViolations, + allReportActions, + allReports, + ]); const handleResolveDuplicates = useCallback(() => { resolveDuplicates({...transactionsMergeParams, ...taxData, transactionThreadReportIDMap, allTransactionViolations, allReportActionsList: allReportActions, delegateAccountID}); diff --git a/src/pages/inbox/OneTransactionThreadRedirectHandler.tsx b/src/pages/inbox/OneTransactionThreadRedirectHandler.tsx index 5dd4288127e8..b1474000b33c 100644 --- a/src/pages/inbox/OneTransactionThreadRedirectHandler.tsx +++ b/src/pages/inbox/OneTransactionThreadRedirectHandler.tsx @@ -1,5 +1,6 @@ import useIsOneTransactionThread from '@hooks/useIsOneTransactionThread'; import useOnyx from '@hooks/useOnyx'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; @@ -85,10 +86,15 @@ function OneTransactionThreadRedirectHandler() { // A message deep link points at an action inside the thread, so dropping the thread route would drop its anchor. const hasLinkedReportAction = !!route.params?.reportActionID; + // In the table view the expense is a row in the report, so its thread is a real destination. Wait for the saved + // preference to load so a deep link doesn't redirect before we know which view the user picked. + const {shouldUseTableViewForSingleExpense, isLoading: isSingleExpenseReportViewLoading} = useSingleExpenseReportView(); + const referrer = route.name === SCREENS.REPORT ? route.params?.referrer : undefined; const backTo = route.params?.backTo; - const shouldRedirectToParentReport = !!parentReportID && isOneTransactionThread && !isSteppingThroughExpenses && !hasLinkedReportAction; + const shouldRedirectToParentReport = + !!parentReportID && isOneTransactionThread && !isSteppingThroughExpenses && !hasLinkedReportAction && !isSingleExpenseReportViewLoading && !shouldUseTableViewForSingleExpense; useEffect(() => { if (!isFocused || !shouldRedirectToParentReport || !parentReportID) { diff --git a/src/pages/inbox/ReportActions.tsx b/src/pages/inbox/ReportActions.tsx index e864155f7bc3..035e9eee7d83 100644 --- a/src/pages/inbox/ReportActions.tsx +++ b/src/pages/inbox/ReportActions.tsx @@ -6,6 +6,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitForTransactions as shouldWaitForTransactionsUtil} from '@libs/MoneyRequestReportUtils'; @@ -54,10 +55,11 @@ function ReportActions() { const allReportTransactions = useReportTransactionsCollection(reportIDFromRoute); const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(report?.reportID); const isMoneyRequestOrInvoiceReport = isMoneyRequestReport(report) || isInvoiceReport(report); const shouldWaitForTransactions = shouldWaitForTransactionsUtil(report, reportTransactions, reportLoadingState, isReportLoadPending, isOffline); - const shouldDisplayMoneyRequestActionsList = isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, reportTransactions); + const shouldDisplayMoneyRequestActionsList = isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, reportTransactions, shouldUseTableViewForSingleExpense); // The app-load skeleton is hoisted out of the body so the body's data hooks/effects never run // during app boot. It only applies on the chat path (after the skeleton and money-request diff --git a/src/pages/inbox/WideRHPReceiptPanel.tsx b/src/pages/inbox/WideRHPReceiptPanel.tsx index 1e6f3d200a34..fa5b5df087c1 100644 --- a/src/pages/inbox/WideRHPReceiptPanel.tsx +++ b/src/pages/inbox/WideRHPReceiptPanel.tsx @@ -9,10 +9,11 @@ import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useParentReportAction from '@hooks/useParentReportAction'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; import useThemeStyles from '@hooks/useThemeStyles'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getAllNonDeletedTransactions, shouldDisplayReportTableView} from '@libs/MoneyRequestReportUtils'; +import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldUseMultiExpenseReportLayout} from '@libs/MoneyRequestReportUtils'; import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isTransactionThread} from '@libs/ReportActionsUtils'; import {isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; @@ -61,10 +62,14 @@ function WideRHPReceiptPanelGate() { const reportTransactionIDs = visibleTransactions?.map((transaction) => transaction.transactionID); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(report?.reportID); const isMoneyRequestOrInvoiceReport = isMoneyRequestReport(report) || isInvoiceReport(report); - const hasMultipleTransactions = (visibleTransactions?.length ?? 0) > 1; - const isConfirmedMultiTransactionReport = isMoneyRequestOrInvoiceReport && hasMultipleTransactions && shouldDisplayReportTableView(report, visibleTransactions ?? []); + const visibleTransactionsCount = visibleTransactions?.length ?? 0; + // A single-expense report in the table view gets the same layout as a multi-expense report. Empty reports keep the wide layout. + const hasTransactionsInTable = shouldUseMultiExpenseReportLayout(visibleTransactionsCount, shouldUseTableViewForSingleExpense); + const isConfirmedMultiTransactionReport = + isMoneyRequestOrInvoiceReport && hasTransactionsInTable && shouldDisplayReportTableView(report, visibleTransactions ?? [], shouldUseTableViewForSingleExpense); const shouldShowWideRHP = !isConfirmedMultiTransactionReport && diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerReportData.ts b/src/pages/inbox/report/ReportActionCompose/useComposerReportData.ts index 4bff522a7cdc..9c6a51e1bf50 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerReportData.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerReportData.ts @@ -2,8 +2,9 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; -import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; +import {getAllNonDeletedTransactions, getEffectiveTransactionThreadReportID} from '@libs/MoneyRequestReportUtils'; import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, isSentMoneyReportAction} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; @@ -22,6 +23,7 @@ function useComposerReportData(reportID: string): ComposerReportData { const {isOffline} = useNetwork(); const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const allReportTransactions = useReportTransactionsCollection(reportID); @@ -30,7 +32,7 @@ function useComposerReportData(reportID: string): ComposerReportData { const reportTransactionIDs = visibleTransactions?.map((t) => t.transactionID); const isSentMoneyReport = filteredReportActions.some((action) => isSentMoneyReportAction(action)); const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, filteredReportActions, isOffline, reportTransactionIDs); - const effectiveTransactionThreadReportID = isSentMoneyReport ? undefined : transactionThreadReportID; + const effectiveTransactionThreadReportID = getEffectiveTransactionThreadReportID(transactionThreadReportID, isSentMoneyReport, shouldUseTableViewForSingleExpense); return {report, filteredReportActions, effectiveTransactionThreadReportID}; } diff --git a/src/selectors/ReportAction.ts b/src/selectors/ReportAction.ts index c27d0c5824a4..10aa9dbee76c 100644 --- a/src/selectors/ReportAction.ts +++ b/src/selectors/ReportAction.ts @@ -6,6 +6,7 @@ import { getOriginalMessage, getSortedReportActions, isActionOfType, + isSentMoneyReportAction, } from '@libs/ReportActionsUtils'; import {hasReportBeenForwardedSinceLastSubmit} from '@libs/ReportUtils'; @@ -152,6 +153,9 @@ function getReceiptScanFailedIOUActionDataSelector( const transactionThreadReportIDSelector = (transactionID: string | undefined) => (reportActions: OnyxEntry) => transactionID ? getIOUActionForTransactionID(Object.values(reportActions ?? {}), transactionID)?.childReportID : undefined; +/** Returns whether the report is a "Pay someone" (sent money) report, which has a pay action with IOUDetails instead of an expense-creating one. */ +const isSentMoneyReportSelector = (reportActions: OnyxEntry) => Object.values(reportActions ?? {}).some(isSentMoneyReportAction); + /** Returns the ID of the author's newest comment in the report that can show the feedback prompt. */ const getLatestFeedbackActionIDByAuthorSelector = (authorAccountID: number | undefined) => (reportActions: OnyxEntry) => authorAccountID ? getLatestConciergeFeedbackActionIDFromReportActions(reportActions, authorAccountID) : undefined; @@ -187,6 +191,7 @@ function getParentReportActionWithoutThreadRepliesSelector(parentReportActions: } export { + isSentMoneyReportSelector, getLatestFeedbackActionIDByAuthorSelector, getParentReportActionSelector, getLastClosedReportAction, diff --git a/tests/actions/ReportLayoutTest.ts b/tests/actions/ReportLayoutTest.ts index beb19dcd957b..0614efcd5b55 100644 --- a/tests/actions/ReportLayoutTest.ts +++ b/tests/actions/ReportLayoutTest.ts @@ -1,8 +1,9 @@ -import {getReportLayoutGroupBy, getReportLayoutSelection, isMatrixLayout, setReportLayout} from '@libs/actions/ReportLayout'; +import {getReportLayoutGroupBy, getReportLayoutSelection, isMatrixLayout, setReportLayout, setSingleExpenseReportView} from '@libs/actions/ReportLayout'; import * as API from '@libs/API'; import {WRITE_COMMANDS} from '@libs/API/types'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; jest.mock('@libs/API'); @@ -10,6 +11,7 @@ const mockWrite = jest.mocked(API.write); const LAYOUT_OPTION_NVP_NAME = 'expensify_layoutOption'; const GROUP_BY_OPTION_NVP_NAME = 'expensify_groupByOption'; +const SINGLE_EXPENSE_REPORT_VIEW_NVP_NAME = 'expensify_singleExpenseReportView'; const NAME_VALUE_PAIRS_KEY_PREFIX = 'nameValuePairs['; function isRecord(value: unknown): value is Record { @@ -148,3 +150,40 @@ describe('getReportLayoutSelection', () => { expect(getReportLayoutSelection(undefined, undefined)).toBe(CONST.REPORT_LAYOUT.GROUP_BY.CATEGORY); }); }); + +describe('setSingleExpenseReportView', () => { + beforeEach(() => { + mockWrite.mockClear(); + }); + + it('saves the table view on the account NVP', () => { + // Given a user who hasn't picked a view yet + // When they switch to the table view + setSingleExpenseReportView(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + + // Then the choice is saved on their account so it applies on every device + expect(mockWrite).toHaveBeenCalledTimes(1); + expect(mockWrite.mock.calls.at(0)?.[0]).toBe(WRITE_COMMANDS.SET_NAME_VALUE_PAIRS); + expect(getWrittenNameValuePairs()).toEqual({[SINGLE_EXPENSE_REPORT_VIEW_NVP_NAME]: CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE}); + }); + + it('applies the new view optimistically and restores the previous view on failure', () => { + // Given a user who is on the table view + // When they switch back to the expense view + setSingleExpenseReportView(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE, CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + + // Then the expense view shows right away, and the table view comes back if the request fails + const onyxData = mockWrite.mock.calls.at(0)?.[2]; + expect(onyxData?.optimisticData).toEqual([{onyxMethod: 'set', key: ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, value: CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE}]); + expect(onyxData?.failureData).toEqual([{onyxMethod: 'set', key: ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, value: CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE}]); + }); + + it('clears the optimistic view on failure when no view was saved before', () => { + // Given a user who hasn't picked a view yet + // When they switch to the table view + setSingleExpenseReportView(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + + // Then a failed request falls back to the default view + expect(mockWrite.mock.calls.at(0)?.[2]?.failureData).toEqual([{onyxMethod: 'set', key: ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, value: null}]); + }); +}); diff --git a/tests/navigation/OneTransactionThreadRedirectHandlerTest.tsx b/tests/navigation/OneTransactionThreadRedirectHandlerTest.tsx index ab13e3d23aee..6ba8877286d3 100644 --- a/tests/navigation/OneTransactionThreadRedirectHandlerTest.tsx +++ b/tests/navigation/OneTransactionThreadRedirectHandlerTest.tsx @@ -20,6 +20,7 @@ const SIBLING_TRANSACTION_ID = '22222'; // Inlined because a `jest.mock` factory may only close over locals initialized with a literal. const TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS_KEY = 'transactionThreadNavigationTransactionIDs'; +const SINGLE_EXPENSE_REPORT_VIEW_KEY = 'nvp_expensify_singleExpenseReportView'; const mockNavigate = jest.fn(); @@ -53,6 +54,8 @@ jest.mock('@react-navigation/native', () => { let mockParentReportID: string | undefined = EXPENSE_REPORT_ID; let mockParentTransactionCount: number | undefined = 1; let mockSiblingTransactionIDs: string[] | undefined; +let mockSingleExpenseReportView: string | undefined; +let mockSingleExpenseReportViewStatus = 'loaded'; jest.mock('@hooks/useOnyx', () => ({ __esModule: true, @@ -60,6 +63,9 @@ jest.mock('@hooks/useOnyx', () => ({ if (key === TRANSACTION_THREAD_NAVIGATION_TRANSACTION_IDS_KEY) { return [options?.selector ? options.selector(mockSiblingTransactionIDs) : mockSiblingTransactionIDs, {status: 'loaded'}]; } + if (key === SINGLE_EXPENSE_REPORT_VIEW_KEY) { + return [mockSingleExpenseReportView, {status: mockSingleExpenseReportViewStatus}]; + } const value = key.endsWith(EXPENSE_REPORT_ID) ? {reportID: EXPENSE_REPORT_ID, type: 'expense', transactionCount: mockParentTransactionCount} : {reportID: THREAD_REPORT_ID, parentReportID: mockParentReportID, parentReportActionID: '1'}; @@ -102,6 +108,8 @@ describe('OneTransactionThreadRedirectHandler', () => { mockOneTransactionThreadReportID = THREAD_REPORT_ID; mockParentReportAction = createIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE); mockSiblingTransactionIDs = undefined; + mockSingleExpenseReportView = undefined; + mockSingleExpenseReportViewStatus = 'loaded'; }); it('replaces the route with the parent report when the thread is the only expense of the report', async () => { @@ -289,4 +297,46 @@ describe('OneTransactionThreadRedirectHandler', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); + + it('mocks the single-expense report view under the key the handler subscribes to', () => { + expect(SINGLE_EXPENSE_REPORT_VIEW_KEY).toBe(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW); + }); + + it('keeps the thread route when the user views single-expense reports in the table view', async () => { + // Given the user picked the table view, where the expense is a row the user opens on purpose + mockSingleExpenseReportView = CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE; + + // When the expense's thread opens + render(); + + await waitForBatchedUpdatesWithAct(); + + // Then it stays on the thread instead of bouncing back to the report + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('waits for the single-expense report view to load before redirecting', async () => { + // Given the saved view hasn't loaded yet, so we can't tell whether the user picked the table view + mockSingleExpenseReportViewStatus = 'loading'; + + // When the expense's thread opens, for example from a deep link + render(); + + await waitForBatchedUpdatesWithAct(); + + // Then it doesn't redirect too early + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('redirects when the user views single-expense reports in the expense view', async () => { + // Given the user explicitly picked the expense view + mockSingleExpenseReportView = CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE; + + // When the expense's thread opens + render(); + + // Then it's replaced with the report, which shows the expense inline + await waitFor(() => expect(mockNavigate).toHaveBeenCalledTimes(1)); + expect(mockNavigate).toHaveBeenCalledWith(`r/${EXPENSE_REPORT_ID}`, {forceReplace: true}); + }); }); diff --git a/tests/ui/AuthScreensInitHandlerTest.tsx b/tests/ui/AuthScreensInitHandlerTest.tsx index bf024aebff8c..2025d0dc4795 100644 --- a/tests/ui/AuthScreensInitHandlerTest.tsx +++ b/tests/ui/AuthScreensInitHandlerTest.tsx @@ -2,6 +2,8 @@ import {render} from '@testing-library/react-native'; import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import useOneTransactionThreadReportID from '@hooks/useOneTransactionThreadReportID'; + import {init as activeClientManagerInit, isClientTheLeader, isReady} from '@libs/ActiveClientManager'; import {isQAServerActive} from '@libs/ApiUtils'; import AuthScreensInitHandler from '@libs/Navigation/AppNavigator/AuthScreensInitHandler'; @@ -32,6 +34,7 @@ import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatch const TEST_ACCOUNT_ID = 1; const QA_APP_KEY = 'qa-app-key'; +const TRANSACTION_THREAD_REPORT_ID = '20'; jest.mock('@libs/ApiUtils', () => ({ ...jest.requireActual>('@libs/ApiUtils'), @@ -126,6 +129,11 @@ jest.mock('@selectors/Onboarding', () => ({ jest.mock('@src/components/ConfirmedRoute.tsx'); +jest.mock('@hooks/useOneTransactionThreadReportID', () => ({ + __esModule: true, + default: jest.fn(() => undefined), +})); + const mockedPusherInit = jest.mocked(Pusher.init); const mockedGetCurrentUrl = jest.mocked(getCurrentUrl); const mockedIsActiveRoute = jest.mocked(Navigation.isActiveRoute); @@ -135,6 +143,7 @@ const mockedIsClientTheLeader = jest.mocked(isClientTheLeader); const mockedIsReady = jest.mocked(isReady); const mockedSubscribeToUserEvents = jest.mocked(subscribeToUserEvents); const mockedIsQAServerActive = jest.mocked(isQAServerActive); +const mockedUseOneTransactionThreadReportID = jest.mocked(useOneTransactionThreadReportID); function renderAuthScreensInitHandler() { return render( @@ -161,6 +170,7 @@ describe('AuthScreensInitHandler', () => { mockedIsReady.mockReturnValue(Promise.resolve()); mockedIsActiveRoute.mockReturnValue(false); mockedIsQAServerActive.mockReturnValue(false); + mockedUseOneTransactionThreadReportID.mockReturnValue(undefined); wrapOnyxWithWaitForBatchedUpdates(Onyx); await Onyx.clear(); await waitForBatchedUpdates(); @@ -235,6 +245,25 @@ describe('AuthScreensInitHandler', () => { expect(getter()).toBeUndefined(); }); + it.each([ + ['expense', CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE, TRANSACTION_THREAD_REPORT_ID], + ['table', CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE, undefined], + ])('passes the right expense thread to Pusher notifications when single-expense reports use the %s view', async (_view, singleExpenseReportView, expectedThreadReportID) => { + // Given an open single-expense report with an expense thread. Only the expense view shows that thread's comments. + mockedUseOneTransactionThreadReportID.mockReturnValue(TRANSACTION_THREAD_REPORT_ID); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: TEST_ACCOUNT_ID, email: 'test@test.com'}); + await Onyx.set(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, singleExpenseReportView); + await waitForBatchedUpdates(); + + // When the handler subscribes to user events + renderAuthScreensInitHandler(); + await waitForBatchedUpdatesWithAct(); + + // Then the thread getter returns the thread only when its comments are shown, so in the table view they still notify + const getTopmostOneTransactionThreadReportID = mockedSubscribeToUserEvents.mock.calls.at(0)?.[2]; + expect(getTopmostOneTransactionThreadReportID?.()).toBe(expectedThreadReportID); + }); + it.each([ ['production', false, CONFIG.PUSHER.APP_KEY], ['QA', true, QA_APP_KEY], diff --git a/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx b/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx new file mode 100644 index 000000000000..d9209c32a9d1 --- /dev/null +++ b/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx @@ -0,0 +1,95 @@ +import {render} from '@testing-library/react-native'; + +import MoneyReportHeaderActions from '@components/MoneyReportHeaderActions'; +import MoneyReportHeaderSecondaryActions from '@components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions'; +import MoneyReportHeaderPrimaryAction from '@components/MoneyReportHeaderPrimaryAction'; + +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +const REPORT_ID = '1001'; + +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); +jest.mock('@hooks/useExportAgainModal', () => jest.fn(() => ({triggerExportOrConfirm: jest.fn()}))); +jest.mock('@hooks/useLayoutSpacing', () => jest.fn(() => ({pageGutter: {}}))); +jest.mock('@hooks/useResponsiveLayout', () => jest.fn(() => ({shouldUseNarrowLayout: false, isMediumScreenWidth: false, isInLandscapeMode: false}))); +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => jest.fn(() => ({isWideRHPDisplayedOnWideLayout: false, isSuperWideRHPDisplayedOnWideLayout: false, shouldUseNarrowLayout: false}))); +jest.mock('@hooks/useSingleExpenseReportView', () => jest.fn()); + +// useThemeStyles throws without a ; return a proxy that yields an empty style object for any key. +jest.mock('@hooks/useThemeStyles', () => { + const styleProxy = new Proxy({}, {get: () => ({})}); + return jest.fn(() => styleProxy); +}); + +// A single-expense report always has a transaction thread, whichever view it is shown in. +jest.mock('@components/MoneyReportTransactionThreadContext', () => ({ + __esModule: true, + useMoneyReportTransactionThread: jest.fn(() => ({transactionThreadReportID: '1002'})), +})); + +// The user has selected the report's only expense. +jest.mock('@components/Search/SearchContext', () => ({ + __esModule: true, + useSearchSelectionContext: jest.fn(() => ({selectedTransactionIDs: ['tx-1']})), + useSearchSelectionActions: jest.fn(() => ({clearSelectedTransactions: jest.fn()})), +})); + +jest.mock('@components/MoneyReportHeaderPrimaryAction', () => jest.fn(() => null)); +jest.mock('@components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions', () => jest.fn(() => null)); +jest.mock('@components/MoneyReportHeaderActions/MoneyReportHeaderSelectionDropdown', () => jest.fn(() => null)); + +const mockedUseSingleExpenseReportView = jest.mocked(useSingleExpenseReportView); +const mockedPrimaryAction = jest.mocked(MoneyReportHeaderPrimaryAction); +const mockedSecondaryActions = jest.mocked(MoneyReportHeaderSecondaryActions); + +function mockSingleExpenseReportView(shouldUseTableViewForSingleExpense: boolean) { + mockedUseSingleExpenseReportView.mockReturnValue({ + singleExpenseReportView: shouldUseTableViewForSingleExpense ? CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE : CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE, + shouldUseTableViewForSingleExpense, + isLoading: false, + }); +} + +function renderActions() { + return render( + , + ); +} + +describe('MoneyReportHeaderActions on a single-expense report', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('disables Submit and More while the expense is selected in the table view', () => { + // Given a single-expense report shown in the table view, where its expense is a selectable row + mockSingleExpenseReportView(true); + + // When the header renders while that expense is selected + renderActions(); + + // Then the report-level actions are disabled so they can't be mixed up with the bulk actions on the selection + expect(mockedPrimaryAction.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({isDisabled: true})); + expect(mockedSecondaryActions.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({isDisabled: true})); + }); + + it('keeps Submit and More enabled in the expense view', () => { + // Given a single-expense report shown in the expense view, which has no selectable rows + mockSingleExpenseReportView(false); + + // When the header renders with a leftover selection + renderActions(); + + // Then the report-level actions stay enabled, as there is no bulk selection the user can act on + expect(mockedPrimaryAction.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({isDisabled: false})); + expect(mockedSecondaryActions.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({isDisabled: false})); + }); +}); diff --git a/tests/ui/components/MoneyReportHeaderSelectionModeTest.tsx b/tests/ui/components/MoneyReportHeaderSelectionModeTest.tsx index 323e51a4fca4..020dca0327d0 100644 --- a/tests/ui/components/MoneyReportHeaderSelectionModeTest.tsx +++ b/tests/ui/components/MoneyReportHeaderSelectionModeTest.tsx @@ -350,5 +350,42 @@ describe('MoneyReportHeader selection mode header', () => { expect(mockTurnOffMobileSelectionMode).toHaveBeenCalled(); expect(lastHeaderTitle()).toBeUndefined(); }); + + describe('when the user chose the table view for single-expense reports', () => { + beforeEach(() => { + mockUseOnyx.mockImplementation((key) => { + if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { + return createOnyxResult(report); + } + if (key === ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW) { + return createOnyxResult(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + } + return createOnyxResult(undefined); + }); + }); + + it('keeps selection mode on in a narrow window when a single transaction is left', () => { + // Given a single-expense report that shows its expense in the table, where a row can be selected + mockTransactions([null]); + + // When the header renders with selection mode on + renderHeader(); + + // Then selection mode stays on, so the user can select the only row + expect(mockTurnOffMobileSelectionMode).not.toHaveBeenCalled(); + expect(lastHeaderTitle()).toBe('common.selectMultiple'); + }); + + it('turns selection mode off in a narrow window when no transactions are left', () => { + // Given the report's only expense was deleted, so there is no row left to select + mockTransactions([CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE]); + + // When the header renders with selection mode on + renderHeader(); + + // Then selection mode is turned off + expect(mockTurnOffMobileSelectionMode).toHaveBeenCalled(); + }); + }); }); }); diff --git a/tests/ui/components/SelectionToolbarSubmitToPopoverTest.tsx b/tests/ui/components/SelectionToolbarSubmitToPopoverTest.tsx new file mode 100644 index 000000000000..ca63091664d0 --- /dev/null +++ b/tests/ui/components/SelectionToolbarSubmitToPopoverTest.tsx @@ -0,0 +1,99 @@ +import {render} from '@testing-library/react-native'; + +import SelectionToolbar from '@components/MoneyRequestReportView/SelectionToolbar'; +import {useOpenReportSubmitToPopover} from '@components/ReportSubmitToPopoverAnchor'; + +import useReportSubmitToPopover from '@hooks/useReportSubmitToPopover'; +import useSelectionModeReportActions from '@hooks/useSelectionModeReportActions'; + +import type * as ReactNavigationNative from '@react-navigation/native'; + +import React from 'react'; + +const REPORT_ID = '1001'; + +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined, {status: 'loaded'}])); +jest.mock('@hooks/useLocalize', () => jest.fn(() => ({translate: (key: string) => key}))); +jest.mock('@hooks/useNetworkWithOfflineStatus', () => jest.fn(() => ({isOffline: false}))); +jest.mock('@hooks/useResponsiveLayoutOnWideRHP', () => jest.fn(() => ({shouldUseNarrowLayout: false, isInLandscapeMode: false}))); +jest.mock('@hooks/useMobileSelectionMode', () => jest.fn(() => false)); +jest.mock('@hooks/useConfirmModal', () => jest.fn(() => ({showConfirmModal: jest.fn()}))); +jest.mock('@hooks/useFilterSelectedTransactions', () => jest.fn()); +jest.mock('@hooks/useSelectedTransactionsActions', () => + jest.fn(() => ({options: [], isDuplicateOptionVisible: false, setDuplicateHandler: jest.fn(), allTransactions: undefined, allReports: undefined})), +); +jest.mock('@hooks/useSelectionModeReportActions', () => jest.fn()); +jest.mock('@hooks/useReportSubmitToPopover', () => jest.fn()); +jest.mock('@components/MoneyRequestReportView/useShouldShowReportBulkActionBar', () => jest.fn(() => false)); +jest.mock('@components/DelegateNoAccessModalProvider', () => ({ + __esModule: true, + useDelegateNoAccessState: jest.fn(() => ({isDelegateAccessRestricted: false})), + useDelegateNoAccessActions: jest.fn(() => ({showDelegateNoAccessModal: jest.fn()})), +})); +jest.mock('@components/DecisionModal', () => jest.fn(() => null)); + +// useThemeStyles throws without a ; return a proxy that yields an empty style object for any key. +jest.mock('@hooks/useThemeStyles', () => { + const styleProxy = new Proxy({}, {get: () => ({})}); + return jest.fn(() => styleProxy); +}); + +// The user has selected the report's only expense, so the toolbar is shown. +jest.mock('@components/Search/SearchContext', () => ({ + __esModule: true, + useSearchSelectionContext: jest.fn(() => ({selectedTransactionIDs: ['tx-1'], currentSelectedTransactionReportID: '1001'})), + useSearchSelectionActions: jest.fn(() => ({clearSelectedTransactions: jest.fn(), setSelectedTransactions: jest.fn(), setCurrentSelectedTransactionReportID: jest.fn()})), +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + __esModule: true, + useRoute: jest.fn(() => ({key: 'route-1', name: 'Report', params: {}})), + useFocusEffect: jest.fn(), +})); + +const mockedUseSelectionModeReportActions = jest.mocked(useSelectionModeReportActions); +const mockedUseReportSubmitToPopover = jest.mocked(useReportSubmitToPopover); + +describe('SelectionToolbar bulk Submit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('opens the submit-to popover from the selection bulk actions', () => { + // Given the submit-to popover's opener, which a Submit workspace's bulk Submit calls instead of submitting directly + const openReportSubmitToPopover = jest.fn(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- only the opener matters here, and the popover element is never rendered + mockedUseReportSubmitToPopover.mockReturnValue({ + anchorRef: {current: null}, + openReportSubmitToPopover, + closeReportSubmitToPopover: jest.fn(), + reportSubmitToPopover: null, + isReportSubmitToPopoverVisible: false, + isReportSubmitToDismissGuardActive: false, + consumeIgnoreNextSearchSubmitPress: () => false, + } as unknown as ReturnType); + + // And the toolbar's bulk report actions, which get that opener from context + let openFromBulkSubmit: ReturnType | undefined; + mockedUseSelectionModeReportActions.mockImplementation(() => { + openFromBulkSubmit = useOpenReportSubmitToPopover(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the toolbar only reads these fields on the path this test renders + return {selectionModeReportLevelActions: [], allExpensesSelected: true} as unknown as ReturnType; + }); + + render( + , + ); + + // When bulk Submit asks to open the submit-to popover + openFromBulkSubmit?.(); + + // Then the popover opens, rather than the click falling through to the no-op default with no provider above the toolbar + expect(openReportSubmitToPopover).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/MoneyRequestReportUtilsTest.ts b/tests/unit/MoneyRequestReportUtilsTest.ts index 3da3991823ea..ada2aae0fdf7 100644 --- a/tests/unit/MoneyRequestReportUtilsTest.ts +++ b/tests/unit/MoneyRequestReportUtilsTest.ts @@ -1,10 +1,13 @@ import type {TransactionListItemType} from '@components/Search/SearchList/ListItem/types'; import { + getEffectiveTransactionThreadReportID, getReportIDForTransaction, isBillableEnabledOnPolicy, isEveryReportTransactionSelected, isSelectableReportTransaction, + shouldDisplayReportTableView, + shouldUseMultiExpenseReportLayout, shouldWaitForTransactions, } from '@libs/MoneyRequestReportUtils'; @@ -275,4 +278,67 @@ describe('MoneyRequestReportUtils', () => { expect(shouldWaitForTransactions(reportBaseMock, [], reportLoadingState, false, false)).toBe(true); }); }); + + describe('shouldDisplayReportTableView', () => { + const singleTransaction = createMock({transactionID: '555', reportID: reportBaseMock.reportID}); + const secondTransaction = createMock({transactionID: '556', reportID: reportBaseMock.reportID}); + + test('uses the single-expense view for a single-expense report by default', () => { + // Given a report with one expense and no saved preference + // Then it renders in the single-expense view + expect(shouldDisplayReportTableView(reportBaseMock, [singleTransaction])).toBe(false); + }); + + test('uses the table view for a single-expense report when the user picked the table view', () => { + // Given a report with one expense and a user who picked the table view + // Then it renders in the table view + expect(shouldDisplayReportTableView(reportBaseMock, [singleTransaction], true)).toBe(true); + }); + + test('always uses the table view for a report with more than one expense', () => { + // Given a report with two expenses, the preference doesn't apply + expect(shouldDisplayReportTableView(reportBaseMock, [singleTransaction, secondTransaction])).toBe(true); + expect(shouldDisplayReportTableView(reportBaseMock, [singleTransaction, secondTransaction], true)).toBe(true); + }); + }); + + describe('shouldUseMultiExpenseReportLayout', () => { + test('uses the multi-expense layout for a report with more than one expense', () => { + // Given a report with two expenses, the preference doesn't apply + expect(shouldUseMultiExpenseReportLayout(2, false)).toBe(true); + expect(shouldUseMultiExpenseReportLayout(2, true)).toBe(true); + }); + + test('uses the multi-expense layout for a single-expense report only when the user picked the table view', () => { + // Given a report with one expense + // Then only the table view preference gives it the multi-expense layout + expect(shouldUseMultiExpenseReportLayout(1, false)).toBe(false); + expect(shouldUseMultiExpenseReportLayout(1, true)).toBe(true); + }); + + test('keeps the default layout for an empty report', () => { + // Given a report with no expenses, the table view preference doesn't apply + expect(shouldUseMultiExpenseReportLayout(0, true)).toBe(false); + }); + }); + + describe('getEffectiveTransactionThreadReportID', () => { + test('sends comments to the transaction thread in the single-expense view', () => { + // Given a single-expense report in the single-expense view + // Then comments go to the transaction thread + expect(getEffectiveTransactionThreadReportID('123', false, false)).toBe('123'); + }); + + test('sends comments to the report itself in the table view', () => { + // Given a single-expense report in the table view, where the thread's actions aren't shown + // Then comments go to the report itself + expect(getEffectiveTransactionThreadReportID('123', false, true)).toBeUndefined(); + }); + + test('sends comments to the report itself for a sent money report', () => { + // Given a sent money report + // Then comments go to the report itself + expect(getEffectiveTransactionThreadReportID('123', true, false)).toBeUndefined(); + }); + }); }); diff --git a/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx b/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx index 557ded97b566..a2d15bdefe73 100644 --- a/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx +++ b/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx @@ -26,7 +26,7 @@ jest.mock( jest.mock('@hooks/useMarkOpenReportEndOnSkeleton', () => () => undefined); jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); -jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/useOnyx', () => () => [undefined, {status: 'loaded'}]); jest.mock('@hooks/usePaginatedReportActions', () => () => ({reportActions: {}})); jest.mock('@hooks/useReportTransactionsCollection', () => () => ({})); diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index 8d92e4bdc532..5cb0739ca7ce 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -176,6 +176,118 @@ describe('getSecondaryAction', () => { ).toEqual(result); }); + it('includes TOGGLE_SINGLE_EXPENSE_VIEW right before VIEW_DETAILS on a report with one expense', () => { + // Given a report that holds exactly one expense + const report = createMock({reportID: REPORT_ID}); + const policy = createMock({}); + const transaction = createMock({transactionID: '1', reportID: REPORT_ID}); + + // When the More menu actions are computed + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [transaction], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + rules: undefined, + cardList: undefined, + }); + + // Then the option to switch views is offered, just before "View details" + const toggleIndex = result.indexOf(CONST.REPORT.SECONDARY_ACTIONS.TOGGLE_SINGLE_EXPENSE_VIEW); + expect(toggleIndex).toBeGreaterThan(-1); + expect(result.at(toggleIndex + 1)).toBe(CONST.REPORT.SECONDARY_ACTIONS.VIEW_DETAILS); + }); + + it('does not include TOGGLE_SINGLE_EXPENSE_VIEW on a report with more than one expense', () => { + // Given a report with two expenses, which always uses the table view + const report = createMock({reportID: REPORT_ID}); + const policy = createMock({}); + const firstTransaction = createMock({transactionID: '1', reportID: REPORT_ID}); + const secondTransaction = createMock({transactionID: '2', reportID: REPORT_ID}); + + // When the More menu actions are computed + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [firstTransaction, secondTransaction], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + rules: undefined, + cardList: undefined, + }); + + // Then there's no option to switch views + expect(result).not.toContain(CONST.REPORT.SECONDARY_ACTIONS.TOGGLE_SINGLE_EXPENSE_VIEW); + }); + + it('does not include TOGGLE_SINGLE_EXPENSE_VIEW on an empty report', () => { + // Given a report with no expenses + const report = createMock({reportID: REPORT_ID}); + const policy = createMock({}); + + // When the More menu actions are computed + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + rules: undefined, + cardList: undefined, + }); + + // Then there's no option to switch views + expect(result).not.toContain(CONST.REPORT.SECONDARY_ACTIONS.TOGGLE_SINGLE_EXPENSE_VIEW); + }); + + it('does not include TOGGLE_SINGLE_EXPENSE_VIEW on a "Pay someone" report', () => { + // Given a "Pay someone" report, whose one expense comes from a pay action with IOUDetails + const report = createMock({reportID: REPORT_ID}); + const policy = createMock({}); + const transaction = createMock({transactionID: '1', reportID: REPORT_ID}); + const sentMoneyAction = createMock({ + reportActionID: '1', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUTransactionID: '1', IOUDetails: {amount: 100, comment: '', currency: CONST.CURRENCY.USD}}, + }); + + // When the More menu actions are computed + const result = getSecondaryReportActions({ + currentUserLogin: EMPLOYEE_EMAIL, + currentUserAccountID: EMPLOYEE_ACCOUNT_ID, + submitterLogin: '', + report, + chatReport, + reportTransactions: [transaction], + originalTransaction: createMock({}), + violations: {}, + bankAccountList: {}, + policy, + reportActions: [sentMoneyAction], + rules: undefined, + cardList: undefined, + }); + + // Then there's no option to switch views, because the report has no expense thread for a table row to open + expect(result).not.toContain(CONST.REPORT.SECONDARY_ACTIONS.TOGGLE_SINGLE_EXPENSE_VIEW); + }); + it('includes DOWNLOAD_RECEIPTS when at least one transaction has a receipt', () => { const report = createMock({}); const policy = createMock({}); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 63cbaa1d2546..514cf89029b2 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5460,6 +5460,47 @@ describe('TransactionUtils', () => { }); }); + describe('doesReportKeepExpensesAfterMerge', () => { + const DISCARDED_TRANSACTION_ID = '1'; + const OTHER_TRANSACTION_ID = '2'; + + it('returns true when another expense stays on the report', () => { + // Given a report with the discarded expense and another active expense + const reportTransactions = [ + {...createRandomTransaction(1), transactionID: DISCARDED_TRANSACTION_ID}, + {...createRandomTransaction(2), transactionID: OTHER_TRANSACTION_ID}, + ]; + + // When the merge discards one of them, Then the report still has the other one + expect(TransactionUtils.doesReportKeepExpensesAfterMerge(reportTransactions, new Set([DISCARDED_TRANSACTION_ID]))).toBe(true); + }); + + it('returns false when the merge discards the only expense on the report', () => { + // Given a report whose only expense is the one being discarded + const reportTransactions = [{...createRandomTransaction(1), transactionID: DISCARDED_TRANSACTION_ID}]; + + // When the merge discards it, Then the report has no expenses left, so going back to it would show "Not here" + expect(TransactionUtils.doesReportKeepExpensesAfterMerge(reportTransactions, new Set([DISCARDED_TRANSACTION_ID]))).toBe(false); + }); + + it('returns false when the only other expense is already pending deletion', () => { + // Given a report with the discarded expense and another expense that is already being deleted + const reportTransactions = [ + {...createRandomTransaction(1), transactionID: DISCARDED_TRANSACTION_ID}, + {...createRandomTransaction(2), transactionID: OTHER_TRANSACTION_ID, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, + ]; + + // When the merge discards the first one, Then the pending delete doesn't count, so the report has no expenses left + expect(TransactionUtils.doesReportKeepExpensesAfterMerge(reportTransactions, new Set([DISCARDED_TRANSACTION_ID]))).toBe(false); + }); + + it('returns true when the report transactions are not loaded', () => { + // Given no transactions loaded for the report, so we can't tell whether it would be emptied + // When the merge runs, Then the report is treated as keeping expenses, so the app goes back to it as before + expect(TransactionUtils.doesReportKeepExpensesAfterMerge([], new Set([DISCARDED_TRANSACTION_ID]))).toBe(true); + }); + }); + describe('buildNewTransactionAfterReviewingDuplicates', () => { it('preserves the kept transaction tax amount when the selected tax code matches the existing tax code', () => { const duplicatedTransaction = generateTransaction({ diff --git a/tests/unit/hooks/useExpenseActionsSingleExpenseTableViewDeleteTest.tsx b/tests/unit/hooks/useExpenseActionsSingleExpenseTableViewDeleteTest.tsx new file mode 100644 index 000000000000..989659defe28 --- /dev/null +++ b/tests/unit/hooks/useExpenseActionsSingleExpenseTableViewDeleteTest.tsx @@ -0,0 +1,190 @@ +import {act, renderHook, waitFor} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import {useMoneyReportTransactionThread} from '@components/MoneyReportTransactionThreadContext'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import useExpenseActions from '@hooks/useExpenseActions'; + +import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import showConfirmModalAfterMoreMenuDismiss from '@libs/showConfirmModalAfterMoreMenuDismiss'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction} from '@src/types/onyx'; + +import type {ValueOf} from 'type-fest'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomReportAction from '../../utils/collections/reportActions'; +import {createRandomReport} from '../../utils/collections/reports'; +import createRandomTransaction from '../../utils/collections/transaction'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +// The confirm modal tells which delete path ran, so cancel it to stop before any navigation or deletion happens. +jest.mock('@libs/showConfirmModalAfterMoreMenuDismiss', () => ({__esModule: true, default: jest.fn(() => Promise.resolve({action: 'CLOSE'}))})); + +// useConfirmModal reaches into the global ModalContext which isn't mounted here; stub it (the confirm result is +// provided by the showConfirmModalAfterMoreMenuDismiss mock above). +jest.mock('@hooks/useConfirmModal', () => ({__esModule: true, default: () => ({showConfirmModal: jest.fn(), closeModal: jest.fn()})})); + +const mockDeleteTransactions = jest.fn(() => ({action: 'deleted', deletedTransactionThreadReportIDs: []})); +const mockShouldOpenSplitExpenseEditFlowOnDelete = jest.fn(() => false); + +jest.mock('@hooks/useDeleteTransactions', () => ({ + __esModule: true, + default: () => ({deleteTransactions: mockDeleteTransactions, shouldOpenSplitExpenseEditFlowOnDelete: mockShouldOpenSplitExpenseEditFlowOnDelete}), +})); + +// The transaction-thread data normally comes from a context provider fed by Onyx; supply it directly. +jest.mock('@components/MoneyReportTransactionThreadContext', () => ({__esModule: true, useMoneyReportTransactionThread: jest.fn()})); + +const mockShowConfirmModalAfterMoreMenuDismiss = jest.mocked(showConfirmModalAfterMoreMenuDismiss); + +const expenseReportID = '41'; +const transactionID = '42'; +const iouActionID = '43'; + +const wrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + +); + +/** Set up an open report owned by the current user (accountID 0) that holds exactly one of their expenses. */ +async function setUpSingleExpenseReport( + singleExpenseReportView: ValueOf, + reportType: typeof CONST.REPORT.TYPE.EXPENSE | typeof CONST.REPORT.TYPE.IOU = CONST.REPORT.TYPE.EXPENSE, +) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const iouAction = { + ...createRandomReportAction(Number(iouActionID)), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: 0, + originalMessage: { + IOUReportID: expenseReportID, + IOUTransactionID: transactionID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: CONST.CURRENCY.USD, + }, + pendingAction: null, + } as ReportAction; + const report: Report = { + ...createRandomReport(Number(expenseReportID), undefined), + type: reportType, + ownerAccountID: 0, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + }; + const transaction = { + ...createRandomTransaction(Number(transactionID)), + transactionID, + reportID: expenseReportID, + status: CONST.TRANSACTION.STATUS.POSTED, + comment: {originalTransactionID: '44'}, + }; + + jest.mocked(useMoneyReportTransactionThread).mockReturnValue({ + iouTransactionID: transactionID, + requestParentReportAction: iouAction, + transactionThreadReportID: undefined, + transactionThreadReport: undefined, + reportActions: [iouAction], + }); + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, report); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, {[iouActionID]: iouAction}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + await Onyx.set(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, singleExpenseReportView); + }); + await waitForBatchedUpdatesWithAct(); +} + +describe('useExpenseActions - deleting a single-expense report from the More menu', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + initOnyxDerivedValues(); + return waitForBatchedUpdatesWithAct(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + mockShouldOpenSplitExpenseEditFlowOnDelete.mockReturnValue(false); + await act(async () => { + await Onyx.clear(); + }); + }); + + it('deletes the report, not the expense, in the table view', async () => { + // Given the user chose the table view for single-expense reports and owns the report's only expense + await setUpSingleExpenseReport(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + const {result} = renderHook(() => useExpenseActions({reportID: expenseReportID, isReportInSearch: false, backTo: undefined}), {wrapper}); + await waitFor(() => { + // Wait for the translated label so the confirm modal below is built with loaded translations + expect(result.current.actions[CONST.REPORT.SECONDARY_ACTIONS.DELETE]?.text).toBe('Delete'); + }); + + // When the user selects More > Delete + await act(async () => { + await result.current.actions[CONST.REPORT.SECONDARY_ACTIONS.DELETE]?.onSelected?.(); + }); + + // Then the report-level delete runs, which moves the expense to the self DM instead of deleting it + expect(mockShowConfirmModalAfterMoreMenuDismiss).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({title: 'Delete report'})); + expect(mockDeleteTransactions).not.toHaveBeenCalled(); + }); + + it('deletes the expense in the expense view', async () => { + // Given the user kept the default expense view and owns the report's only expense + await setUpSingleExpenseReport(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE); + const {result} = renderHook(() => useExpenseActions({reportID: expenseReportID, isReportInSearch: false, backTo: undefined}), {wrapper}); + await waitFor(() => { + // Wait for the translated label so the confirm modal below is built with loaded translations + expect(result.current.actions[CONST.REPORT.SECONDARY_ACTIONS.DELETE]?.text).toBe('Delete'); + }); + + // When the user selects More > Delete + await act(async () => { + await result.current.actions[CONST.REPORT.SECONDARY_ACTIONS.DELETE]?.onSelected?.(); + }); + + // Then the expense-level delete runs, as the expense view shows a single expense + expect(mockShowConfirmModalAfterMoreMenuDismiss).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({title: 'Delete expense'})); + }); + + it('deletes the expense of an IOU report in the table view', async () => { + // Given the user chose the table view and owns the only expense of an IOU report, which can't be deleted as a report + await setUpSingleExpenseReport(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE, CONST.REPORT.TYPE.IOU); + const {result} = renderHook(() => useExpenseActions({reportID: expenseReportID, isReportInSearch: false, backTo: undefined}), {wrapper}); + await waitFor(() => { + // Wait for the translated label so the confirm modal below is built with loaded translations + expect(result.current.actions[CONST.REPORT.SECONDARY_ACTIONS.DELETE]?.text).toBe('Delete'); + }); + + // When the user selects More > Delete + await act(async () => { + await result.current.actions[CONST.REPORT.SECONDARY_ACTIONS.DELETE]?.onSelected?.(); + }); + + // Then the expense-level delete still runs + expect(mockShowConfirmModalAfterMoreMenuDismiss).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({title: 'Delete expense'})); + }); + + it('labels the action as Delete instead of Edit splits in the table view', async () => { + // Given the report's only expense is part of a split that would open the edit splits flow on delete + mockShouldOpenSplitExpenseEditFlowOnDelete.mockReturnValue(true); + await setUpSingleExpenseReport(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + + // When the actions are built in the table view + const {result} = renderHook(() => useExpenseActions({reportID: expenseReportID, isReportInSearch: false, backTo: undefined}), {wrapper}); + + // Then the menu says Delete, matching the report-level delete it runs + await waitFor(() => { + expect(result.current.actions[CONST.REPORT.SECONDARY_ACTIONS.DELETE]?.text).toBe('Delete'); + }); + }); +}); diff --git a/tests/unit/hooks/useSingleExpenseReportView.test.ts b/tests/unit/hooks/useSingleExpenseReportView.test.ts new file mode 100644 index 000000000000..5f566db35088 --- /dev/null +++ b/tests/unit/hooks/useSingleExpenseReportView.test.ts @@ -0,0 +1,65 @@ +import {renderHook, waitFor} from '@testing-library/react-native'; + +import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import createMock from '../../utils/createMock'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const REPORT_ID = '1'; +const REPORT_ACTION_ID = '10'; + +function buildIOUAction(type: typeof CONST.IOU.REPORT_ACTION_TYPE.CREATE | typeof CONST.IOU.REPORT_ACTION_TYPE.PAY): ReportAction { + return createMock({ + reportActionID: REPORT_ACTION_ID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type, + IOUTransactionID: '100', + ...(type === CONST.IOU.REPORT_ACTION_TYPE.PAY && {IOUDetails: {amount: 100, comment: '', currency: CONST.CURRENCY.USD}}), + }, + }); +} + +describe('useSingleExpenseReportView', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.set(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + await waitForBatchedUpdates(); + }); + + it('uses the table view for a single-expense report when the user picked it', async () => { + // Given the user picked the table view and the report's expense comes from a regular expense action + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[REPORT_ACTION_ID]: buildIOUAction(CONST.IOU.REPORT_ACTION_TYPE.CREATE)}); + await waitForBatchedUpdates(); + + // When the view is read for that report + const {result} = renderHook(() => useSingleExpenseReportView(REPORT_ID)); + + // Then the report uses the table view + await waitFor(() => expect(result.current.shouldUseTableViewForSingleExpense).toBe(true)); + }); + + it('uses the expense view for a "Pay someone" report even when the user picked the table view', async () => { + // Given the user picked the table view and the report is a "Pay someone" report + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[REPORT_ACTION_ID]: buildIOUAction(CONST.IOU.REPORT_ACTION_TYPE.PAY)}); + await waitForBatchedUpdates(); + + // When the view is read for that report + const {result} = renderHook(() => useSingleExpenseReportView(REPORT_ID)); + + // Then the report keeps the expense view, because it has no expense thread for a table row to open, + // while the saved preference itself is unchanged + await waitFor(() => expect(result.current.singleExpenseReportView).toBe(CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE)); + expect(result.current.shouldUseTableViewForSingleExpense).toBe(false); + }); +}); diff --git a/tests/unit/hooks/useTransactionThreadReportID.test.ts b/tests/unit/hooks/useTransactionThreadReportID.test.ts index 9c82821e1429..1cbd4b1976c8 100644 --- a/tests/unit/hooks/useTransactionThreadReportID.test.ts +++ b/tests/unit/hooks/useTransactionThreadReportID.test.ts @@ -99,8 +99,11 @@ function asReportOnyxResult(report: Report | undefined): UseOnyxResult { return [report, loadedReportMetadata]; } -function wireReportOnyx(moneyReport: Report | undefined, chatReport: Report | undefined, hookReportID: string | undefined): void { +function wireReportOnyx(moneyReport: Report | undefined, chatReport: Report | undefined, hookReportID: string | undefined, singleExpenseReportView?: string): void { mockUseOnyx.mockImplementation((key: OnyxKey) => { + if (key === ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW) { + return [singleExpenseReportView, loadedReportMetadata]; + } const moneyReportKey = collectionReportKey(hookReportID); if (key === moneyReportKey) { return asReportOnyxResult(moneyReport); @@ -208,6 +211,38 @@ describe('useTransactionThreadReportID', () => { expect(result.current.reportActions?.map((a) => a.reportActionID)).toEqual(['iou-created-1']); }); + it('returns no effective thread when the user views single-expense reports in the table view', () => { + // Given a single-expense report and a user who picked the table view, where the thread's actions aren't shown + const moneyReport = makeExpenseReportWithChat(); + + wireReportOnyx(moneyReport, makeDMChatReport(), MONEY_REPORT_ID, CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + const iouCreate = makeIOUCreatedAction({childReportID: THREAD_REPORT_ID}); + mockUsePaginatedReportActions.mockReturnValue({ + reportActions: [iouCreate], + linkedAction: undefined, + oldestUnreadReportAction: undefined, + sortedAllReportActions: [iouCreate], + hasOlderActions: false, + hasNewerActions: false, + report: moneyReport, + }); + mockUseReportTransactionsCollection.mockReturnValue( + transactionsRecordForReport([ + createMock({ + transactionID: 'txn-1', + reportID: MONEY_REPORT_ID, + }), + ]), + ); + + // When the hook resolves the thread + const {result} = renderHook(() => useTransactionThreadReportID(MONEY_REPORT_ID)); + + // Then the thread is still found, but comments target the report so they show up in the table view + expect(result.current.transactionThreadReportID).toBe(THREAD_REPORT_ID); + expect(result.current.effectiveTransactionThreadReportID).toBeUndefined(); + }); + it('uses CONST.FAKE_REPORT_ID when no childReportID is set on the lone IOU request action', () => { const moneyReport = makeExpenseReportWithChat(); diff --git a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts index a1ef65b18149..257adb3145fe 100644 --- a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts +++ b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts @@ -2,6 +2,7 @@ import {act, renderHook} from '@testing-library/react-native'; import {ModalActions} from '@components/Modal/Global/ModalContext'; +import getCentralPaneReportID from '@libs/Navigation/helpers/getCentralPaneReportID'; import Navigation from '@libs/Navigation/Navigation'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {navigateBackOnDeleteTransaction} from '@libs/ReportUtils'; @@ -17,7 +18,7 @@ import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {ReportAction} from '@src/types/onyx'; +import type {Report, ReportAction, Transaction} from '@src/types/onyx'; import {StackActions} from '@react-navigation/native'; @@ -50,6 +51,10 @@ jest.mock('@libs/Navigation/Navigation', () => ({ dispatch: (action: unknown) => mockDispatch(action) as unknown, }, })); +jest.mock('@libs/Navigation/helpers/getCentralPaneReportID', () => ({ + __esModule: true, + default: jest.fn(), +})); jest.mock('@libs/Navigation/TransitionTracker', () => ({ __esModule: true, default: {runAfterTransitions: jest.fn()}, @@ -108,6 +113,7 @@ describe('useReportDetailsDeleteModal', () => { jest.clearAllMocks(); mockShowConfirmModal.mockResolvedValue({action: ModalActions.CONFIRM}); jest.mocked(Navigation.getTopmostSearchReportRouteParams).mockReturnValue(undefined); + jest.mocked(getCentralPaneReportID).mockReturnValue(undefined); jest.mocked(Navigation.setNavigationActionToMicrotaskQueue).mockImplementation((callback: () => void) => callback()); jest.mocked(TransitionTracker.runAfterTransitions).mockImplementation(({callback}) => { callback?.(); @@ -177,6 +183,102 @@ describe('useReportDetailsDeleteModal', () => { expect(Navigation.dismissModal).not.toHaveBeenCalled(); }); + it('should close the RHP instead of popping back when deleting the only expense of the report in the super wide RHP', async () => { + // Given a single-expense report in the table view, whose expense was opened on top of the report + setRHPRoutes([ + {name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {reportID: '10'}}, + {name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {reportID: REPORT_ID}}, + {name: SCREENS.RIGHT_MODAL.REPORT_DETAILS}, + ]); + jest.mocked(getNavigationUrlOnMoneyRequestDelete).mockReturnValue(BACK_URL); + const requestData = buildRequestData({iouReport: createMock({reportID: '10'}), iouReportTransactions: [createMock({transactionID: TRANSACTION_ID})]}); + + // When the delete is confirmed + await confirmDelete(requestData, jest.fn()); + + // Then the RHP closes, because deleting the only expense also deletes the report and going back would show "Not here" + expect(mockDispatch).not.toHaveBeenCalled(); + expect(navigateBackOnDeleteTransaction).not.toHaveBeenCalled(); + expect(setDeleteTransactionNavigateBackUrl).toHaveBeenCalledWith(BACK_URL); + expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); + }); + + it('should still pop back to the super wide RHP when the report keeps other expenses', async () => { + // Given a report with two expenses in the super wide RHP, and one of them opened on top of it + setRHPRoutes([ + {name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {reportID: '10'}}, + {name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {reportID: REPORT_ID}}, + {name: SCREENS.RIGHT_MODAL.REPORT_DETAILS}, + ]); + const requestData = buildRequestData({ + iouReport: createMock({reportID: '10'}), + iouReportTransactions: [createMock({transactionID: TRANSACTION_ID}), createMock({transactionID: '1001'})], + }); + + // When the delete is confirmed + await confirmDelete(requestData, jest.fn()); + + // Then the RHP pops back to the report, which still exists + expect(mockDispatch).toHaveBeenCalledWith({...StackActions.pop(2), target: RHP_KEY}); + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + }); + + it('should close the RHP when the only other expense on the report is already pending deletion', async () => { + // Given a report in the super wide RHP whose second expense was deleted offline, so only the opened expense is still on the report + setRHPRoutes([ + {name: SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, params: {reportID: '10'}}, + {name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {reportID: REPORT_ID}}, + {name: SCREENS.RIGHT_MODAL.REPORT_DETAILS}, + ]); + jest.mocked(getNavigationUrlOnMoneyRequestDelete).mockReturnValue(BACK_URL); + const requestData = buildRequestData({ + iouReport: createMock({reportID: '10'}), + iouReportTransactions: [ + createMock({transactionID: TRANSACTION_ID}), + createMock({transactionID: '1001', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}), + ], + }); + + // When the delete is confirmed + await confirmDelete(requestData, jest.fn()); + + // Then the RHP closes, because the pending-delete expense doesn't count and deleting this one also deletes the report + expect(mockDispatch).not.toHaveBeenCalled(); + expect(navigateBackOnDeleteTransaction).not.toHaveBeenCalled(); + expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); + }); + + it('should close the RHP without navigating away when deleting the only expense from a Search report outside the super wide RHP', async () => { + // Given the default expense view, where the expense was opened from Search without a super wide RHP below it + setRHPRoutes([{name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {reportID: REPORT_ID}}, {name: SCREENS.RIGHT_MODAL.REPORT_DETAILS}]); + jest.mocked(getNavigationUrlOnMoneyRequestDelete).mockReturnValue(BACK_URL); + const requestData = buildRequestData({iouReport: createMock({reportID: '10'}), iouReportTransactions: [createMock({transactionID: TRANSACTION_ID})]}); + + // When the delete is confirmed + await confirmDelete(requestData, jest.fn()); + + // Then the RHP closes back to Search, because closing it doesn't reveal the deleted report, so the user isn't sent to the chat + expect(Navigation.dismissModal).toHaveBeenCalledTimes(1); + expect(navigateBackOnDeleteTransaction).not.toHaveBeenCalled(); + expect(setDeleteTransactionNavigateBackUrl).not.toHaveBeenCalled(); + }); + + it('should navigate back instead of closing the RHP when deleting the only expense of the report in the Inbox central pane', async () => { + // Given a narrow layout, where the single-expense report in the table view sits in the Inbox central pane and its expense was opened in the RHP + setRHPRoutes([{name: SCREENS.RIGHT_MODAL.SEARCH_REPORT, params: {reportID: REPORT_ID}}, {name: SCREENS.RIGHT_MODAL.REPORT_DETAILS}]); + jest.mocked(getCentralPaneReportID).mockReturnValue('10'); + jest.mocked(getNavigationUrlOnMoneyRequestDelete).mockReturnValue(BACK_URL); + const requestData = buildRequestData({iouReport: createMock({reportID: '10'}), iouReportTransactions: [createMock({transactionID: TRANSACTION_ID})]}); + + // When the delete is confirmed + await confirmDelete(requestData, jest.fn()); + + // Then the app navigates back to the chat, because closing the RHP would reveal the deleted report and show "Not here" + expect(Navigation.dismissModal).not.toHaveBeenCalled(); + expect(setDeleteTransactionNavigateBackUrl).toHaveBeenCalledWith(BACK_URL); + expect(navigateBackOnDeleteTransaction).toHaveBeenCalledWith(BACK_URL); + }); + it('should navigate to the track expense URL when the deleted expense is a tracked expense', async () => { // Given a tracked expense whose delete leads back to a known report jest.mocked(getNavigationUrlAfterTrackExpenseDelete).mockReturnValue(BACK_URL); diff --git a/tests/unit/shouldShowPushNotificationTest.ts b/tests/unit/shouldShowPushNotificationTest.ts new file mode 100644 index 000000000000..bad9c34a3488 --- /dev/null +++ b/tests/unit/shouldShowPushNotificationTest.ts @@ -0,0 +1,87 @@ +import shouldShowPushNotification from '@libs/Notification/PushNotification/shouldShowPushNotification'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; + +import {shouldShowReportActionNotification} from '@userActions/Report'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; + +import type {PushPayload} from '@ua/react-native-airship'; + +import Onyx from 'react-native-onyx'; + +import createMock from '../utils/createMock'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const REPORT_ID = '10'; +const TRANSACTION_THREAD_REPORT_ID = '20'; + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: {getTopmostReportId: jest.fn(() => '10')}, +})); +jest.mock('@userActions/Report', () => ({ + shouldShowReportActionNotification: jest.fn(() => true), +})); + +function buildPushPayload(): PushPayload { + return createMock({ + extras: {payload: {type: 'reportComment', reportID: Number(TRANSACTION_THREAD_REPORT_ID), reportActionID: '1', title: '', subtitle: ''}}, + }); +} + +describe('shouldShowPushNotification', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + jest.spyOn(ReportActionsUtils, 'getOneTransactionThreadReportID').mockReturnValue(TRANSACTION_THREAD_REPORT_ID); + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('passes the expense thread of the open report when single-expense reports use the expense view', async () => { + // Given single-expense reports use the expense view, which shows the expense thread's comments + await Onyx.set(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE); + await waitForBatchedUpdates(); + + // When a push notification arrives for a comment on that expense thread + shouldShowPushNotification(buildPushPayload()); + + // Then the thread counts as part of the open report, so its comment doesn't notify while the report is open + expect(shouldShowReportActionNotification).toHaveBeenCalledWith(TRANSACTION_THREAD_REPORT_ID, TRANSACTION_THREAD_REPORT_ID, expect.any(Number), null, true); + }); + + it('leaves the expense thread out when single-expense reports use the table view', async () => { + // Given single-expense reports use the table view, which doesn't show the expense thread's comments + await Onyx.set(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + await waitForBatchedUpdates(); + + // When a push notification arrives for a comment on that expense thread + shouldShowPushNotification(buildPushPayload()); + + // Then the thread isn't treated as part of the open report, so its comment can still notify + expect(shouldShowReportActionNotification).toHaveBeenCalledWith(TRANSACTION_THREAD_REPORT_ID, undefined, expect.any(Number), null, true); + }); + + it('keeps the expense thread for a "Pay someone" report in the table view', async () => { + // Given the table view is on, but the open report is a "Pay someone" report, which always uses the expense view + const sentMoneyAction = createMock({ + reportActionID: '100', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: {type: CONST.IOU.REPORT_ACTION_TYPE.PAY, IOUDetails: {amount: 100, currency: CONST.CURRENCY.USD, comment: ''}, amount: 100, currency: CONST.CURRENCY.USD}, + }); + await Onyx.set(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW, CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[sentMoneyAction.reportActionID]: sentMoneyAction}); + await waitForBatchedUpdates(); + + // When a push notification arrives for a comment on that expense thread + shouldShowPushNotification(buildPushPayload()); + + // Then the thread still counts as part of the open report, as it does in the expense view + expect(shouldShowReportActionNotification).toHaveBeenCalledWith(TRANSACTION_THREAD_REPORT_ID, TRANSACTION_THREAD_REPORT_ID, expect.any(Number), null, true); + }); +});