From 20452de95caabca16f95fec1ddef5f57f7a260c4 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 08:31:29 +0000 Subject: [PATCH 01/18] Reapply "Add a More menu option to switch single-expense reports between expense view and table view" This reverts commit e3cd271e48a60bb1c23b8b8eaa443e21e6aad1aa, which reverted Expensify/App PR 103121. Resolved a conflict in src/ONYXKEYS.ts by keeping both TABLE_COLUMN_WIDTHS and NVP_SINGLE_EXPENSE_REPORT_VIEW. Co-authored-by: daledah --- src/CONST/index.ts | 6 ++ src/ONYXKEYS.ts | 4 + .../MoneyReportHeaderSecondaryActions.tsx | 17 +++- .../MoneyRequestReportView.tsx | 7 +- src/components/Search/index.tsx | 7 +- src/hooks/useSingleExpenseReportView.ts | 33 ++++++++ src/hooks/useTransactionThreadReportID.ts | 6 +- src/languages/de.ts | 2 + src/languages/el.ts | 2 + src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + src/libs/ExportOnyxState/masking.ts | 1 + src/libs/MoneyRequestReportUtils.ts | 28 ++++++- src/libs/PopoverMenuSections.ts | 2 +- src/libs/ReportSecondaryActionUtils.ts | 6 ++ src/libs/actions/ReportLayout.ts | 35 +++++++- .../Search/SearchMoneyRequestReportPage.tsx | 9 ++- .../OneTransactionThreadRedirectHandler.tsx | 8 +- src/pages/inbox/ReportActions.tsx | 4 +- src/pages/inbox/WideRHPReceiptPanel.tsx | 11 ++- .../useComposerReportData.ts | 6 +- tests/actions/ReportLayoutTest.ts | 41 +++++++++- ...neTransactionThreadRedirectHandlerTest.tsx | 50 ++++++++++++ tests/unit/MoneyRequestReportUtilsTest.ts | 66 +++++++++++++++ ...rtActionsWithInboxTabDeferredMountTest.tsx | 2 +- tests/unit/ReportSecondaryActionUtilsTest.ts | 80 +++++++++++++++++++ .../useTransactionThreadReportID.test.ts | 37 ++++++++- 34 files changed, 464 insertions(+), 24 deletions(-) create mode 100644 src/hooks/useSingleExpenseReportView.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c3f8ff3be8a1..7177362bfc2a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1671,6 +1671,7 @@ const CONST = { DUPLICATE_EXPENSE: 'duplicateExpense', DUPLICATE_REPORT: 'duplicateReport', MOVE_EXPENSE: 'moveExpense', + TOGGLE_SINGLE_EXPENSE_VIEW: 'toggleSingleExpenseView', }, PRIMARY_ACTIONS: { SUBMIT: 'submit', @@ -2228,6 +2229,10 @@ const CONST = { DETAILED: 'detailed', MATRIX: 'matrix', }, + SINGLE_EXPENSE_REPORT_VIEW: { + EXPENSE: 'expense', + TABLE: 'table', + }, } as const, UNREPORTED_EXPENSES_PAGE_SIZE: 50, COMPOSER: { @@ -9787,6 +9792,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 b03fd4aec992..322ca9009f10 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -915,6 +915,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', @@ -1980,6 +1983,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/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index 941fbf534f52..dc5fb2d22cc1 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -35,12 +35,14 @@ import usePolicy from '@hooks/usePolicy'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; +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'; @@ -117,6 +119,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo }); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); + const {singleExpenseReportView, shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(); const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasSeenTourSelector, }); @@ -312,7 +315,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. @@ -415,6 +418,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/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index ebef8dc41714..e48d37108b2e 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(); 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 ? ( 1) { + // A single-expense report in the table view opens at the same width as a multi-expense report + if (shouldUseMultiExpenseReportLayout(item.transactions.length, shouldUseTableViewForSingleExpense)) { markReportRHPWidth(reportID, 'super-wide'); } else { unmarkReportRHPWidth(reportID, 'super-wide'); @@ -840,6 +844,7 @@ function Search({ getCurrencyDecimals, conciergeChat, delegateAccountID, + shouldUseTableViewForSingleExpense, ], ); diff --git a/src/hooks/useSingleExpenseReportView.ts b/src/hooks/useSingleExpenseReportView.ts new file mode 100644 index 000000000000..c6ba127ae024 --- /dev/null +++ b/src/hooks/useSingleExpenseReportView.ts @@ -0,0 +1,33 @@ +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import type {ValueOf} from 'type-fest'; + +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). + */ +function useSingleExpenseReportView(): UseSingleExpenseReportViewResult { + const [singleExpenseReportView = CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.EXPENSE, singleExpenseReportViewMetadata] = useOnyx(ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW); + + return { + singleExpenseReportView, + shouldUseTableViewForSingleExpense: singleExpenseReportView === CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE, + isLoading: isLoadingOnyxValue(singleExpenseReportViewMetadata), + }; +} + +export default useSingleExpenseReportView; diff --git a/src/hooks/useTransactionThreadReportID.ts b/src/hooks/useTransactionThreadReportID.ts index 1ba28f4523eb..6bc5bbd47c46 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(); 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 a6c7bd4f43d8..667e92f0f973 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -10275,6 +10275,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 a8064d135494..c852fd737a75 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -10472,6 +10472,8 @@ ${reportName}`, category: 'Κατηγορία', tag: 'Ετικέτα', }, + switchToTableView: 'Εναλλαγή σε προβολή πίνακα', + switchToExpenseView: 'Εναλλαγή σε προβολή δαπάνης', }, report: { newReport: { diff --git a/src/languages/en.ts b/src/languages/en.ts index d0c3e0afb4db..06b3def624ca 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -10503,6 +10503,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 b6baf9a99b81..134ba044ccb5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -10087,6 +10087,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 50468c2f6ff1..fc9c22189eaf 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -10299,6 +10299,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 0aa4601bbbcf..983ddae9cfe6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -10227,6 +10227,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 d4902faeb8ad..1fbf89b4216b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -10076,6 +10076,8 @@ ${reportName}`, category: 'カテゴリ', tag: 'タグ', }, + switchToTableView: 'テーブル表示に切り替え', + switchToExpenseView: '経費表示に切り替え', }, report: { newReport: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fe6965c9421f..d290c0d6a299 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -10194,6 +10194,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 1d49b1469baa..faf96be778c7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -10208,6 +10208,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 222a61d491a6..28fb51000c0c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -10192,6 +10192,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 927e64970d2c..af84759910d7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -9799,6 +9799,8 @@ ${reportName}`, category: '类别', tag: '标签', }, + switchToTableView: '切换到表格视图', + switchToExpenseView: '切换到报销视图', }, report: { newReport: { diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index a6716eae06b5..c5281d94c078 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -315,6 +315,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/PopoverMenuSections.ts b/src/libs/PopoverMenuSections.ts index 1baddf312db4..3946d56d085b 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.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 8bc716e0f830..568c9afacfe4 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, @@ -1205,6 +1206,11 @@ 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 + if (isSingleTransactionReport(report, reportTransactions)) { + 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/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/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index f5cdbe80163c..095a28ab3eac 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(); 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/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..aa2d14d4cf78 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(); 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..a46d46a569f8 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(); 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..7722ba286ac5 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(); 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/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/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 16471032dbf1..dc120c0f27a5 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -171,6 +171,86 @@ 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('includes DOWNLOAD_RECEIPTS when at least one transaction has a receipt', () => { const report = createMock({}); const policy = createMock({}); 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(); From 25ae4f88392a316a3f282fb5be577c85b059a5d6 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 09:28:25 +0000 Subject: [PATCH 02/18] Delete single-expense expense reports as reports in the table view In the table view, More > Delete on a single-expense expense report now runs the report-level delete, which moves the expense to the self DM, instead of deleting the expense. IOU and invoice reports keep deleting their only expense. Co-authored-by: daledah --- src/hooks/useExpenseActions.ts | 11 +- ...ctionsSingleExpenseTableViewDeleteTest.tsx | 190 ++++++++++++++++++ 2 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 tests/unit/hooks/useExpenseActionsSingleExpenseTableViewDeleteTest.tsx diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index 04b24c6e6e91..c22b7f383d88 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -23,6 +23,7 @@ import { generateReportID, getPolicyExpenseChat, isDM, + isExpenseReport, isSelfDM, navigateOnDeleteExpense, } from '@libs/ReportUtils'; @@ -77,6 +78,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'; @@ -195,7 +197,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(); + const shouldDeleteSingleExpense = isDeletingOwnExpense && !(shouldUseTableViewForSingleExpense && isExpenseReport(moneyRequestReport)); + const shouldShowEditSplitOnDeleteAction = shouldDeleteSingleExpense && !!transaction?.transactionID && shouldOpenSplitExpenseEditFlowOnDelete([transaction.transactionID]); // Duplicate report throttle const [isDuplicateReportActive, temporarilyDisableDuplicateReportAction] = useThrottledButtonState(); @@ -534,7 +541,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/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'); + }); + }); +}); From c74fbcc16b40b7501be658a525f7b84773d0093f Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 09:28:29 +0000 Subject: [PATCH 03/18] Keep selection mode on for single-expense reports in the table view The report header turned mobile selection mode off whenever one expense or fewer remained. A single-expense report in the table view shows a selectable row, so selection mode now stays on until no expenses remain. Co-authored-by: daledah --- src/components/MoneyReportHeader.tsx | 8 +++- .../MoneyReportHeaderSelectionModeTest.tsx | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 84c1bbdc682d..a2e509af1637 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(); 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/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(); + }); + }); }); }); From 56e11d1f31a699239a97aa78892bbd28a00dcb29 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 10:02:40 +0000 Subject: [PATCH 04/18] Disable report actions while a single expense is selected in the table view In the table view a single-expense report lists its expense as a selectable row, but the header still treated it as a transaction thread, so Submit and More stayed enabled during bulk selection. The header now treats it like a multi-expense report. Co-authored-by: daledah --- .../MoneyReportHeaderActions/index.tsx | 6 +- ...eaderActionsSingleExpenseTableViewTest.tsx | 97 +++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index a564fad4a815..76a4c49bfda6 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(); + const isTransactionThread = !!transactionThreadReportID && !shouldUseTableViewForSingleExpense; useEffect(() => { if (!transactionThreadReportID) { diff --git a/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx b/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx new file mode 100644 index 000000000000..57cb8c30cf36 --- /dev/null +++ b/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx @@ -0,0 +1,97 @@ +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})); + }); +}); From 18bcdf9ddcb4229bb26bd8483944ac3785225488 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 10:02:40 +0000 Subject: [PATCH 05/18] Open the submit-to popover from bulk Submit in report selection mode On a Submit workspace, bulk Submit opens the submit-to popover, but the selection toolbar had no popover provider above it, so the click did nothing. The provider now wraps the toolbar, and the bulk action bar and the selection dropdown act as its anchor. Co-authored-by: daledah --- src/components/BulkActionBar/types.ts | 4 +- .../SelectionBulkActionBar.tsx | 7 +- .../SelectionToolbar/index.tsx | 20 ++-- .../ReportSubmitToPopoverAnchor.tsx | 6 ++ .../SelectionToolbarSubmitToPopoverTest.tsx | 99 +++++++++++++++++++ 5 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 tests/ui/components/SelectionToolbarSubmitToPopoverTest.tsx 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/MoneyRequestReportView/SelectionToolbar/SelectionBulkActionBar.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/SelectionBulkActionBar.tsx index 94b087580820..31194dcb18ad 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/SelectionBulkActionBar.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/SelectionBulkActionBar.tsx @@ -2,10 +2,12 @@ import BulkActionBar from '@components/BulkActionBar'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import KYCWall from '@components/KYCWall'; import type {KYCWallRef} from '@components/KYCWall/types'; +import {useReportSubmitToPopoverAnchorRef} from '@components/ReportSubmitToPopoverAnchor'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; +import mergeRefs from '@libs/mergeRefs'; import type {KYCFlowEvent, TriggerKYCFlow} from '@libs/PaymentUtils'; import {isSecondaryActionAPaymentOption, isSecondaryActionAWorkspacePolicyOption} from '@libs/PaymentUtils'; @@ -59,6 +61,9 @@ function SelectionBulkActionBar({ const styles = useThemeStyles(); const {isOffline} = useNetwork(); + // Bulk Submit on a Submit workspace opens the submit-to popover, which should open against the bar like the KYC wall does. + const submitToAnchorRef = useReportSubmitToPopoverAnchorRef(); + return ( { if (isSecondaryActionAWorkspacePolicyOption(item)) { diff --git a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx index 326535b4fdac..fbe773007414 100644 --- a/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx +++ b/src/components/MoneyRequestReportView/SelectionToolbar/index.tsx @@ -5,7 +5,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'; @@ -267,7 +267,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/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); + }); +}); From b9fe75621d5cd2c9f764e5f39ec749094f0a6d5d Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 10:31:02 +0000 Subject: [PATCH 06/18] Keep Pay someone reports in the expense view A Pay someone report's expense is the report itself, so it has no expense thread for a table row to open. Pressing the row created an empty thread that the RHP then closed. These reports now ignore the table view preference and don't offer the switch. Co-authored-by: daledah --- src/components/MoneyReportHeader.tsx | 2 +- .../MoneyReportHeaderSecondaryActions.tsx | 2 +- .../MoneyReportHeaderActions/index.tsx | 2 +- .../MoneyRequestReportView.tsx | 2 +- src/hooks/useExpenseActions.ts | 2 +- src/hooks/useSingleExpenseReportView.ts | 12 +++- src/hooks/useTransactionThreadReportID.ts | 2 +- src/libs/ReportSecondaryActionUtils.ts | 6 +- .../Search/SearchMoneyRequestReportPage.tsx | 2 +- src/pages/inbox/ReportActions.tsx | 2 +- src/pages/inbox/WideRHPReceiptPanel.tsx | 2 +- .../useComposerReportData.ts | 2 +- src/selectors/ReportAction.ts | 14 +++- tests/unit/ReportSecondaryActionUtilsTest.ts | 32 +++++++++ .../hooks/useSingleExpenseReportView.test.ts | 65 +++++++++++++++++++ 15 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 tests/unit/hooks/useSingleExpenseReportView.test.ts diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index a2e509af1637..56ffa6a2112d 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -135,7 +135,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt const shouldShowBackButton = shouldDisplayBackButton || shouldUseNarrowLayout; const isMobileSelectionModeEnabled = useMobileSelectionMode(); - const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID); const isFocused = useIsFocused(); useEffect(() => { diff --git a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx index dc5fb2d22cc1..f1ac58281d45 100644 --- a/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx +++ b/src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx @@ -119,7 +119,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo }); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const {singleExpenseReportView, shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(); + const {singleExpenseReportView, shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID); const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { selector: hasSeenTourSelector, }); diff --git a/src/components/MoneyReportHeaderActions/index.tsx b/src/components/MoneyReportHeaderActions/index.tsx index 76a4c49bfda6..a2ceb7f1c384 100644 --- a/src/components/MoneyReportHeaderActions/index.tsx +++ b/src/components/MoneyReportHeaderActions/index.tsx @@ -59,7 +59,7 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, ba const hasSelectedTransactions = !!selectedTransactionIDs.length; // 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(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID); const isTransactionThread = !!transactionThreadReportID && !shouldUseTableViewForSingleExpense; useEffect(() => { diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx index e48d37108b2e..66e4841726e0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportView.tsx @@ -142,7 +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(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); const isReportLoadPending = useIsReportLoadPending(reportID); const dismissReportCreationError = () => { diff --git a/src/hooks/useExpenseActions.ts b/src/hooks/useExpenseActions.ts index c22b7f383d88..cb04ab514589 100644 --- a/src/hooks/useExpenseActions.ts +++ b/src/hooks/useExpenseActions.ts @@ -200,7 +200,7 @@ function useExpenseActions({reportID, isReportInSearch = false, backTo, onDuplic // 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(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); const shouldDeleteSingleExpense = isDeletingOwnExpense && !(shouldUseTableViewForSingleExpense && isExpenseReport(moneyRequestReport)); const shouldShowEditSplitOnDeleteAction = shouldDeleteSingleExpense && !!transaction?.transactionID && shouldOpenSplitExpenseEditFlowOnDelete([transaction.transactionID]); diff --git a/src/hooks/useSingleExpenseReportView.ts b/src/hooks/useSingleExpenseReportView.ts index c6ba127ae024..e1d7af3f9af2 100644 --- a/src/hooks/useSingleExpenseReportView.ts +++ b/src/hooks/useSingleExpenseReportView.ts @@ -1,9 +1,13 @@ +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 = { @@ -19,13 +23,17 @@ type UseSingleExpenseReportViewResult = { /** * 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(): UseSingleExpenseReportViewResult { +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, + shouldUseTableViewForSingleExpense: singleExpenseReportView === CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW.TABLE && !isSentMoneyReport, isLoading: isLoadingOnyxValue(singleExpenseReportViewMetadata), }; } diff --git a/src/hooks/useTransactionThreadReportID.ts b/src/hooks/useTransactionThreadReportID.ts index 6bc5bbd47c46..a4943a8d0c80 100644 --- a/src/hooks/useTransactionThreadReportID.ts +++ b/src/hooks/useTransactionThreadReportID.ts @@ -26,7 +26,7 @@ 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(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); const isSentMoneyReport = reportActions.some((action) => isSentMoneyReportAction(action)); diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 568c9afacfe4..75d9ccfd9053 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -49,6 +49,7 @@ import { hasPendingDEWApprove, hasPendingDEWSubmit, isPayAction, + isSentMoneyReportAction, } from './ReportActionsUtils'; import {getReportPrimaryAction, isPrimaryPayAction} from './ReportPrimaryActionUtils'; import { @@ -1206,8 +1207,9 @@ 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 - if (isSingleTransactionReport(report, reportTransactions)) { + // 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); } diff --git a/src/pages/Search/SearchMoneyRequestReportPage.tsx b/src/pages/Search/SearchMoneyRequestReportPage.tsx index 095a28ab3eac..84e34f35d446 100644 --- a/src/pages/Search/SearchMoneyRequestReportPage.tsx +++ b/src/pages/Search/SearchMoneyRequestReportPage.tsx @@ -127,7 +127,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) { const {isEditingDisabled, isCurrentReportLoadedFromOnyx} = useIsReportReadyToDisplay(report, reportIDFromRoute, isReportArchived); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); - const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(); + 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}); diff --git a/src/pages/inbox/ReportActions.tsx b/src/pages/inbox/ReportActions.tsx index aa2d14d4cf78..035e9eee7d83 100644 --- a/src/pages/inbox/ReportActions.tsx +++ b/src/pages/inbox/ReportActions.tsx @@ -55,7 +55,7 @@ function ReportActions() { const allReportTransactions = useReportTransactionsCollection(reportIDFromRoute); const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); - const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(report?.reportID); const isMoneyRequestOrInvoiceReport = isMoneyRequestReport(report) || isInvoiceReport(report); const shouldWaitForTransactions = shouldWaitForTransactionsUtil(report, reportTransactions, reportLoadingState, isReportLoadPending, isOffline); diff --git a/src/pages/inbox/WideRHPReceiptPanel.tsx b/src/pages/inbox/WideRHPReceiptPanel.tsx index a46d46a569f8..fa5b5df087c1 100644 --- a/src/pages/inbox/WideRHPReceiptPanel.tsx +++ b/src/pages/inbox/WideRHPReceiptPanel.tsx @@ -62,7 +62,7 @@ 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(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(report?.reportID); const isMoneyRequestOrInvoiceReport = isMoneyRequestReport(report) || isInvoiceReport(report); const visibleTransactionsCount = visibleTransactions?.length ?? 0; diff --git a/src/pages/inbox/report/ReportActionCompose/useComposerReportData.ts b/src/pages/inbox/report/ReportActionCompose/useComposerReportData.ts index 7722ba286ac5..9c6a51e1bf50 100644 --- a/src/pages/inbox/report/ReportActionCompose/useComposerReportData.ts +++ b/src/pages/inbox/report/ReportActionCompose/useComposerReportData.ts @@ -23,7 +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(); + const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID); const {reportActions: unfilteredReportActions} = usePaginatedReportActions(report?.reportID); const filteredReportActions = getFilteredReportActionsForReportView(unfilteredReportActions); const allReportTransactions = useReportTransactionsCollection(reportID); diff --git a/src/selectors/ReportAction.ts b/src/selectors/ReportAction.ts index 468dd9bfd28c..96003eda355e 100644 --- a/src/selectors/ReportAction.ts +++ b/src/selectors/ReportAction.ts @@ -1,4 +1,12 @@ -import {filterOutDeprecatedReportActions, getIOUActionForTransactionID, getLinkedTransactionID, getOriginalMessage, getSortedReportActions, isActionOfType} from '@libs/ReportActionsUtils'; +import { + filterOutDeprecatedReportActions, + getIOUActionForTransactionID, + getLinkedTransactionID, + getOriginalMessage, + getSortedReportActions, + isActionOfType, + isSentMoneyReportAction, +} from '@libs/ReportActionsUtils'; import CONST from '@src/CONST'; import type {ReportAction, ReportActions} from '@src/types/onyx'; @@ -142,7 +150,11 @@ 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); + export { + isSentMoneyReportSelector, getParentReportActionSelector, getLastClosedReportAction, getNewestReportActionSelector, diff --git a/tests/unit/ReportSecondaryActionUtilsTest.ts b/tests/unit/ReportSecondaryActionUtilsTest.ts index dc120c0f27a5..00cbc921d468 100644 --- a/tests/unit/ReportSecondaryActionUtilsTest.ts +++ b/tests/unit/ReportSecondaryActionUtilsTest.ts @@ -251,6 +251,38 @@ describe('getSecondaryAction', () => { 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/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); + }); +}); From 03ec6fbb3b439b3ee6ac35116ed73a3f39ea4663 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 10:31:02 +0000 Subject: [PATCH 07/18] Close the RHP after deleting the only expense of a table view report Deleting a report's only expense deletes the report too, so popping back to that report in the super wide RHP showed the Not here page. Co-authored-by: daledah --- .../hooks/useReportDetailsDeleteModal.ts | 14 ++++++- .../useReportDetailsDeleteModalTest.ts | 42 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts index d07aee9cd73b..5702fb70f19e 100644 --- a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts +++ b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts @@ -47,6 +47,7 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep moneyRequestReport, iouTransactionID, iouReport, + iouReportTransactions, chatIOUReport, isChatIOUReportArchived, isSingleTransactionView, @@ -54,6 +55,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,9 +66,13 @@ 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 isParentReportDeleted = !!iouReport?.reportID && iouReportTransactions?.length === 1; // If the deleted expense is displayed directly below, close the entire RHP const isSuperWideRHPDisplayed = superWideRHPIndex > -1; + shouldCloseRHPAfterReportDeletion = isParentReportDeleted && superWideRHPReportID === iouReport?.reportID; const isSuperWideRHPDisplayedDirectlyBelow = isSuperWideRHPDisplayed && superWideRHPIndex === rhpRoutes.length - 2; if (isSuperWideRHPDisplayedDirectlyBelow && previousRouteReportID === reportID) { Navigation.dismissModal(); @@ -72,7 +80,7 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep } // 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 (!isParentReportDeleted && 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 +119,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/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts index a1ef65b18149..c6e391935407 100644 --- a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts +++ b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts @@ -17,7 +17,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'; @@ -177,6 +177,46 @@ 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 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); From 55c19aa866af2782abbedfe89e5d407ffe44dd93 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 10:31:02 +0000 Subject: [PATCH 08/18] Go to the kept report when a merge empties the report underneath Keeping a duplicate from another report discards the only expense of a single-expense report in the table view. Returning to that report in the super wide RHP showed the Not here page. Co-authored-by: daledah --- .../DynamicConfirmationPage.tsx | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx index cb78d528edfc..596a17b2e97a 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( @@ -121,7 +124,13 @@ 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.transactionIDList); + const superWideRHPReportTransactionList = Object.values(superWideRHPReportTransactions); + const doesSuperWideRHPReportKeepExpenses = + superWideRHPReportTransactionList.length === 0 || superWideRHPReportTransactionList.some((transaction) => !discardedTransactionIDs.has(transaction.transactionID)); + if (isSuperWideRHPDisplayed && doesSuperWideRHPReportKeepExpenses) { Navigation.dismissToSuperWideRHP(); return; } @@ -134,7 +143,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}); From bf0b5fda38b0973059aecaba2e70045546995e6e Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 12 Oct 2026 10:53:00 +0000 Subject: [PATCH 09/18] Run oxfmt on MoneyReportHeaderActionsSingleExpenseTableViewTest Co-authored-by: daledah --- .../MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx b/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx index 57cb8c30cf36..d9209c32a9d1 100644 --- a/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx +++ b/tests/ui/components/MoneyReportHeaderActionsSingleExpenseTableViewTest.tsx @@ -16,9 +16,7 @@ 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/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. From e0d898db5ae8985d99861c34349154722e22dd46 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Tue, 13 Oct 2026 09:10:48 +0000 Subject: [PATCH 10/18] Keep Pay someone reports out of the super-wide hint in Search Co-authored-by: daledah --- src/components/Search/index.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 1edcc140ac91..4324b598da78 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -41,7 +41,7 @@ import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRout 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'; @@ -755,8 +755,10 @@ function Search({ } } - // A single-expense report in the table view opens at the same width as a multi-expense report - if (shouldUseMultiExpenseReportLayout(item.transactions.length, shouldUseTableViewForSingleExpense)) { + // 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'); From 9662ac9d65ebe52240b3c739e8f31744aac7a433 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Thu, 15 Oct 2026 16:29:43 +0000 Subject: [PATCH 11/18] Only skip popping back to Search report when the super wide RHP shows the deleted report Co-authored-by: daledah --- .../hooks/useReportDetailsDeleteModal.ts | 4 ++-- .../useReportDetailsDeleteModalTest.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts index 5702fb70f19e..0c025479fe0d 100644 --- a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts +++ b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts @@ -79,8 +79,8 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep return; } - // If the deleted expense is opened from the super wide rhp, go back there. - if (!isParentReportDeleted && 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 (!shouldCloseRHPAfterReportDeletion && 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}); diff --git a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts index c6e391935407..cd71b696d7ed 100644 --- a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts +++ b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts @@ -217,6 +217,21 @@ describe('useReportDetailsDeleteModal', () => { expect(Navigation.dismissModal).not.toHaveBeenCalled(); }); + 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 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); From ab8e60d225467be581fc23611e5834f4179dbb5f Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Fri, 16 Oct 2026 05:29:00 +0000 Subject: [PATCH 12/18] Ignore pending-delete expenses when checking if deleting an expense deletes its report Co-authored-by: daledah --- .../hooks/useReportDetailsDeleteModal.ts | 3 ++- .../useReportDetailsDeleteModalTest.ts | 25 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts index 0c025479fe0d..a5566c30f8a9 100644 --- a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts +++ b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts @@ -68,7 +68,8 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep 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 isParentReportDeleted = !!iouReport?.reportID && iouReportTransactions?.length === 1; + 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; diff --git a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts index cd71b696d7ed..3a60bcfa7e72 100644 --- a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts +++ b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts @@ -217,6 +217,31 @@ describe('useReportDetailsDeleteModal', () => { 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}]); From 5815c86f0551b08b6fe7fda5c84b6cb158948b1f Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Fri, 16 Oct 2026 16:17:17 +0000 Subject: [PATCH 13/18] Don't let the expense thread mark a single-expense report as read in the table view Co-authored-by: daledah --- .../MoneyRequestReportView/MoneyRequestReportActionsList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx index 89754b4227cd..a26fa6d7f6c0 100644 --- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx +++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx @@ -9,6 +9,7 @@ import useParentReportAction from '@hooks/useParentReportAction'; 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'; @@ -127,6 +128,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); @@ -181,7 +183,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, From ba40d938517b14c437199001c8bf072e7cc8ba32 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 19 Oct 2026 11:35:11 +0000 Subject: [PATCH 14/18] Notify for expense thread comments while a single-expense report is open in the table view Co-authored-by: daledah --- .../AppNavigator/AuthScreensInitHandler.tsx | 6 +- .../shouldShowPushNotification.ts | 16 +++- tests/ui/AuthScreensInitHandlerTest.tsx | 29 +++++++ tests/unit/shouldShowPushNotificationTest.ts | 87 +++++++++++++++++++ 4 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 tests/unit/shouldShowPushNotificationTest.ts 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..76dd78978a73 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,12 @@ Onyx.connectWithoutView({ callback: (value) => (allReports = value), }); +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 +65,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/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/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); + }); +}); From 5901d5fe4ca4d44bc8b3ec723af5ee4282a2f800 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 19 Oct 2026 11:44:37 +0000 Subject: [PATCH 15/18] Ignore pending-delete expenses when deciding whether to return to the report after merging duplicates Co-authored-by: daledah --- src/libs/TransactionUtils/duplicates.ts | 12 ++++++ src/libs/TransactionUtils/index.ts | 2 + .../DynamicConfirmationPage.tsx | 4 +- tests/unit/TransactionUtilsTest.ts | 38 +++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) 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/pages/TransactionDuplicate/DynamicConfirmationPage.tsx b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx index 2bd5863b1c35..fdc5ff3569ac 100644 --- a/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx +++ b/src/pages/TransactionDuplicate/DynamicConfirmationPage.tsx @@ -128,9 +128,7 @@ function DynamicConfirmationPage() { // 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 superWideRHPReportTransactionList = Object.values(superWideRHPReportTransactions); - const doesSuperWideRHPReportKeepExpenses = - superWideRHPReportTransactionList.length === 0 || superWideRHPReportTransactionList.some((transaction) => !discardedTransactionIDs.has(transaction.transactionID)); + const doesSuperWideRHPReportKeepExpenses = TransactionUtils.doesReportKeepExpensesAfterMerge(Object.values(superWideRHPReportTransactions), discardedTransactionIDs); if (isSuperWideRHPDisplayed && doesSuperWideRHPReportKeepExpenses) { Navigation.dismissToSuperWideRHP(); return; diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 63cbaa1d2546..7cd99c0dac00 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5460,6 +5460,44 @@ 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({ From e341e5aa675492f31ae9183f8207f02219826ed4 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 19 Oct 2026 18:49:27 +0000 Subject: [PATCH 16/18] Explain why shouldShowPushNotification reads the single-expense report view with connectWithoutView Co-authored-by: daledah --- .../Notification/PushNotification/shouldShowPushNotification.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/Notification/PushNotification/shouldShowPushNotification.ts b/src/libs/Notification/PushNotification/shouldShowPushNotification.ts index 76dd78978a73..ce23ffb29146 100644 --- a/src/libs/Notification/PushNotification/shouldShowPushNotification.ts +++ b/src/libs/Notification/PushNotification/shouldShowPushNotification.ts @@ -39,6 +39,8 @@ 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, From 6ff5d95b56c318a951d66412293e4b2ea1d9f624 Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Mon, 19 Oct 2026 18:56:29 +0000 Subject: [PATCH 17/18] Format doesReportKeepExpensesAfterMerge test array for oxfmt Co-authored-by: daledah --- tests/unit/TransactionUtilsTest.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 7cd99c0dac00..514cf89029b2 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5466,7 +5466,10 @@ describe('TransactionUtils', () => { 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}]; + 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); From c547377968e4dce22e5b9d3b7a7a422600109bee Mon Sep 17 00:00:00 2001 From: "daledah (via MelvinBot)" Date: Wed, 21 Oct 2026 07:59:24 +0000 Subject: [PATCH 18/18] Navigate back when deleting the only expense of a report in the Inbox central pane On narrow layouts the single-expense report sits in the Inbox central pane, not the super wide RHP. Closing the RHP there revealed the deleted report and showed the Not here page. Skip the dismissModal branch in that case so navigation falls through to navigateBackOnDeleteTransaction. Co-authored-by: daledah --- .../hooks/useReportDetailsDeleteModal.ts | 5 ++++- .../useReportDetailsDeleteModalTest.ts | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts b/src/pages/DynamicReportDetailsPage/hooks/useReportDetailsDeleteModal.ts index a5566c30f8a9..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'; @@ -74,6 +75,8 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep // 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(); @@ -81,7 +84,7 @@ function useReportDetailsDeleteModal(reportID: string, caseID: CaseID, parentRep } // If the deleted expense is opened from the super wide rhp, go back there, unless going back would show the deleted report. - if (!shouldCloseRHPAfterReportDeletion && previousRoute?.name === SCREENS.RIGHT_MODAL.SEARCH_REPORT && previousRouteReportID === reportID) { + 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}); diff --git a/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts b/tests/unit/pages/DynamicReportDetailsPage/useReportDetailsDeleteModalTest.ts index 3a60bcfa7e72..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'; @@ -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?.(); @@ -257,6 +263,22 @@ describe('useReportDetailsDeleteModal', () => { 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);