Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
20452de
Reapply "Add a More menu option to switch single-expense reports betw…
MelvinBot Oct 12, 2026
25ae4f8
Delete single-expense expense reports as reports in the table view
MelvinBot Oct 12, 2026
c74fbcc
Keep selection mode on for single-expense reports in the table view
MelvinBot Oct 12, 2026
56e11d1
Disable report actions while a single expense is selected in the tabl…
MelvinBot Oct 12, 2026
18bcdf9
Open the submit-to popover from bulk Submit in report selection mode
MelvinBot Oct 12, 2026
b9fe756
Keep Pay someone reports in the expense view
MelvinBot Oct 12, 2026
03ec6fb
Close the RHP after deleting the only expense of a table view report
MelvinBot Oct 12, 2026
55c19aa
Go to the kept report when a merge empties the report underneath
MelvinBot Oct 12, 2026
bf0b5fd
Run oxfmt on MoneyReportHeaderActionsSingleExpenseTableViewTest
MelvinBot Oct 12, 2026
e0d898d
Keep Pay someone reports out of the super-wide hint in Search
MelvinBot Oct 13, 2026
94d9df8
Merge branch 'main' into claude-reapplySingleExpenseReportViewToggle
MelvinBot Oct 13, 2026
9662ac9
Only skip popping back to Search report when the super wide RHP shows…
MelvinBot Oct 15, 2026
bf9321e
Merge remote-tracking branch 'origin/main' into claude-reapplySingleE…
MelvinBot Oct 15, 2026
523492a
Merge remote-tracking branch 'origin/main' into claude-reapplySingleE…
MelvinBot Oct 16, 2026
ab8e60d
Ignore pending-delete expenses when checking if deleting an expense d…
MelvinBot Oct 16, 2026
5815c86
Don't let the expense thread mark a single-expense report as read in …
MelvinBot Oct 16, 2026
469f42f
Merge branch 'main' into claude-reapplySingleExpenseReportViewToggle
MelvinBot Oct 17, 2026
b2d70c9
Merge branch 'main' into claude-reapplySingleExpenseReportViewToggle
MelvinBot Oct 19, 2026
ba40d93
Notify for expense thread comments while a single-expense report is o…
MelvinBot Oct 19, 2026
5901d5f
Ignore pending-delete expenses when deciding whether to return to the…
MelvinBot Oct 19, 2026
e341e5a
Explain why shouldShowPushNotification reads the single-expense repor…
MelvinBot Oct 19, 2026
8dd45e7
Merge remote-tracking branch 'origin/main' into claude-reapplySingleE…
MelvinBot Oct 19, 2026
6ff5d95
Format doesReportKeepExpensesAfterMerge test array for oxfmt
MelvinBot Oct 19, 2026
c547377
Navigate back when deleting the only expense of a report in the Inbox…
MelvinBot Oct 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1724,6 +1724,7 @@ const CONST = {
DUPLICATE_EXPENSE: 'duplicateExpense',
DUPLICATE_REPORT: 'duplicateReport',
MOVE_EXPENSE: 'moveExpense',
TOGGLE_SINGLE_EXPENSE_VIEW: 'toggleSingleExpenseView',
},
PRIMARY_ACTIONS: {
SUBMIT: 'submit',
Expand Down Expand Up @@ -2285,6 +2286,10 @@ const CONST = {
DETAILED: 'detailed',
MATRIX: 'matrix',
},
SINGLE_EXPENSE_REPORT_VIEW: {
EXPENSE: 'expense',
TABLE: 'table',
},
} as const,
UNREPORTED_EXPENSES_PAGE_SIZE: 50,
COMPOSER: {
Expand Down Expand Up @@ -9920,6 +9925,7 @@ const CONST = {
PAY: 'MoreMenu-Pay',
DUPLICATE_REPORT: 'MoreMenu-DuplicateReport',
MOVE_EXPENSE: 'MoreMenu-MoveExpense',
TOGGLE_SINGLE_EXPENSE_VIEW: 'MoreMenu-ToggleSingleExpenseView',
},
REPORT_PREVIEW: {
CARD: 'ReportPreview-Card',
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,9 @@ const ONYXKEYS = {
/** Dragged table column widths, keyed by `columnResizingID` then column key. Per device, since ideal widths depend on window size. */
TABLE_COLUMN_WIDTHS: 'tableColumnWidths',

/** Stores whether the user views single-expense reports in the expense view or the table view */
NVP_SINGLE_EXPENSE_REPORT_VIEW: 'nvp_expensify_singleExpenseReportView',

/** Partial transaction data used for MFA authorize transaction preview */
TRANSACTIONS_PENDING_3DS_REVIEW: 'transactionsPending3DSReview',

Expand Down Expand Up @@ -2016,6 +2019,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_REPORT_LAYOUT_OPTION]: string;
[ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS]: string[];
[ONYXKEYS.TABLE_COLUMN_WIDTHS]: OnyxTypes.TableColumnWidths;
[ONYXKEYS.NVP_SINGLE_EXPENSE_REPORT_VIEW]: ValueOf<typeof CONST.REPORT_LAYOUT.SINGLE_EXPENSE_REPORT_VIEW>;
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
[ONYXKEYS.RAM_ONLY_EXPENSE_ADDED_GROWL_TRANSACTION_IDS]: Record<string, SearchDataTypes>;
Expand Down
4 changes: 2 additions & 2 deletions src/components/BulkActionBar/types.ts
Original file line number Diff line number Diff line change
@@ -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<TValueType> = {
Expand Down Expand Up @@ -41,7 +41,7 @@ type BulkActionBarProps<TValueType> = {
* 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<ComponentRef<typeof View> | null>;
barRef?: Ref<ComponentRef<typeof View>>;

/**
* Extra styles for the absolutely positioned layer the bar floats in. Pass a `bottom` here to lift the bar above
Expand Down
8 changes: 6 additions & 2 deletions src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -134,6 +135,7 @@ function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButt
const shouldShowBackButton = shouldDisplayBackButton || shouldUseNarrowLayout;

const isMobileSelectionModeEnabled = useMobileSelectionMode();
const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID);
const isFocused = useIsFocused();

useEffect(() => {
Expand All @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@ import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals';
import useShareActions from '@hooks/useShareActions';
import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
import useVerifyAccountAndResume from '@hooks/useVerifyAccountAndResume';

import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy';
import {setSingleExpenseReportView} from '@libs/actions/ReportLayout';
import {search} from '@libs/actions/Search';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import getPlatform from '@libs/getPlatform';
Expand Down Expand Up @@ -118,6 +120,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo
});
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const {singleExpenseReportView, shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID);
const [isSelfTourViewed = false] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
selector: hasSeenTourSelector,
});
Expand Down Expand Up @@ -316,7 +319,7 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo
? sortPoliciesByName(activeAdminPolicies, localeCompare)
: [];

const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Cash', 'ArrowRight', 'Building']);
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Cash', 'ArrowRight', 'Building', 'Table', 'Receipt']);

// Build PAY action sub-items. Workspace-policy entries carry the policy as data and have no onSelected;
// MoneyReportHeaderKYCDropdown picks them up via onSubItemSelected where triggerKYCFlow is in scope.
Expand Down Expand Up @@ -421,6 +424,18 @@ function MoneyReportHeaderSecondaryActionsInner({reportID, primaryAction, isRepo

// Merge all action implementations
const secondaryActionsImplementation: Record<string, (typeof lifecycleActionEntries)[string]> = {
[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'),
Expand Down
6 changes: 5 additions & 1 deletion src/components/MoneyReportHeaderActions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,7 +57,10 @@ function MoneyReportHeaderActions({reportID, primaryAction, isReportInSearch, ba
const {selectedTransactionIDs} = useSearchSelectionContext();
const {clearSelectedTransactions} = useSearchSelectionActions();
const hasSelectedTransactions = !!selectedTransactionIDs.length;
const isTransactionThread = !!transactionThreadReportID;

// In the table view a single-expense report lists its expense as a selectable row, so it acts like a multi-expense report here.
const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(moneyRequestReport?.reportID);
const isTransactionThread = !!transactionThreadReportID && !shouldUseTableViewForSingleExpense;

useEffect(() => {
if (!transactionThreadReportID) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {usePersonalDetailsByIDs} from '@hooks/usePersonalDetails';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useReportsParentHierarchy from '@hooks/useReportsParentHierarchy';
import useResponsiveLayoutOnWideRHP from '@hooks/useResponsiveLayoutOnWideRHP';
import useSingleExpenseReportView from '@hooks/useSingleExpenseReportView';
import useThemeStyles from '@hooks/useThemeStyles';
import useUnreadMarker from '@hooks/useUnreadMarker';

Expand Down Expand Up @@ -131,6 +132,7 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon

const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions, false, reportTransactionIDs);
const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`);
const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID);

const isReportArchived = useReportIsArchived(reportID);
const canPerformWriteAction = canUserPerformWriteAction(report, isReportArchived);
Expand Down Expand Up @@ -193,7 +195,8 @@ function MoneyRequestReportActionsListContent({reportIDFromRoute, onLayout}: Mon
const {markNewestActionAsRead, completeSkippedMarkAsRead} = useMarkAsRead({
reportID: reportID ?? reportIDFromRoute ?? '',
report,
transactionThreadReport,
// The table view doesn't show the expense thread's actions, so a new one there shouldn't mark the report as read
transactionThreadReport: shouldUseTableViewForSingleExpense ? undefined : transactionThreadReport,
sortedVisibleReportActions: visibleReportActionsNewestFirst,
sortedReportActions: reportActions,
isScrolledToEnd: !hasScrolledOverThreshold,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,6 +142,7 @@ function MoneyRequestReportView({report, reportIDFromRoute, reportLoadingState,
const reportErrors = visibleTransactions.length === 1 && visibleTransactions.at(0)?.errors ? undefined : allReportErrors;
const reportTransactionIDs = visibleTransactions.map((transaction) => transaction.transactionID);
const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs);
const {shouldUseTableViewForSingleExpense} = useSingleExpenseReportView(reportID);

const isReportLoadPending = useIsReportLoadPending(reportID);
const dismissReportCreationError = () => {
Expand All @@ -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 ? (
<MoneyRequestHeader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 (
<KYCWall
ref={kycWallRef}
Expand All @@ -74,7 +79,7 @@ function SelectionBulkActionBar({
selectedCount={selectedTransactionIDs.length}
options={selectedTransactionsOptions}
onClearSelection={onClearSelection}
barRef={buttonRef}
barRef={mergeRefs(buttonRef, submitToAnchorRef)}
style={styles.zIndex10}
onSubItemSelected={(item, _index, event) => {
if (isSecondaryActionAWorkspacePolicyOption(item)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext';
import useShouldShowReportBulkActionBar from '@components/MoneyRequestReportView/useShouldShowReportBulkActionBar';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import ProcessMoneyReportHoldMenu from '@components/ProcessMoneyReportHoldMenu';
import {ReportSubmitToPopoverAnchor} from '@components/ReportSubmitToPopoverAnchor';
import {ReportSubmitToPopoverMeasurableAnchor, ReportSubmitToPopoverRoot} from '@components/ReportSubmitToPopoverAnchor';
import BulkDuplicateHandler from '@components/Search/BulkDuplicateHandler';
import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext';

Expand Down Expand Up @@ -282,7 +282,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
<View
style={[isInLandscapeMode ? [styles.flexRowReverse, styles.justifyContentBetween, styles.alignItemsCenter, styles.gap6, styles.pb3, styles.ph5] : styles.flexColumn]}
>
<ReportSubmitToPopoverAnchor reportID={reportID}>
<ReportSubmitToPopoverMeasurableAnchor>
<SelectionDropdown
hasPayInSelectionMode={hasPayInSelectionMode}
chatReport={chatReport}
Expand All @@ -296,7 +296,7 @@ function SelectionToolbar({reportID, transactions, reportActions}: SelectionTool
kycWallRef={kycWallRef}
shouldPopoverUseScrollView={popoverUseScrollView}
/>
</ReportSubmitToPopoverAnchor>
</ReportSubmitToPopoverMeasurableAnchor>

<SelectAllCheckbox
isSelectAllChecked={isSelectAllChecked}
Expand Down Expand Up @@ -363,12 +363,16 @@ function SelectionToolbarGate({reportID, transactions, reportActions}: Selection
return null;
}

// The toolbar's own hooks open the submit-to popover for bulk Submit, so its provider has to sit above the toolbar.
// The bulk action bar and the selection dropdown mark themselves as its anchor.
return (
<SelectionToolbar
reportID={reportID}
transactions={transactions}
reportActions={reportActions}
/>
<ReportSubmitToPopoverRoot reportID={reportID}>
<SelectionToolbar
reportID={reportID}
transactions={transactions}
reportActions={reportActions}
/>
</ReportSubmitToPopoverRoot>
);
}

Expand Down
6 changes: 6 additions & 0 deletions src/components/ReportSubmitToPopoverAnchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -322,5 +327,6 @@ export {
SEARCH_REPORT_SUBMIT_TO_POPOVER_ANCHOR_ALIGNMENT,
useOpenReportSubmitToPopover,
useOpenSearchReportSubmitToPopover,
useReportSubmitToPopoverAnchorRef,
useSearchSubmitPopoverGuard,
};
Loading
Loading