From 9bbfa8adbaf0c0f98fceb10cf8b140f4f8c213fa Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 6 Nov 2026 20:43:31 -0800 Subject: [PATCH 1/3] refactor: isolate money report view selection --- src/pages/inbox/ReportActions.tsx | 94 +++++++++++++++++-------------- 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/src/pages/inbox/ReportActions.tsx b/src/pages/inbox/ReportActions.tsx index 633e720169a6..40d2e740d8a2 100644 --- a/src/pages/inbox/ReportActions.tsx +++ b/src/pages/inbox/ReportActions.tsx @@ -13,6 +13,7 @@ import {isConciergeChatReport, isInvoiceReport, isMoneyRequestReport} from '@lib import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; import {useRoute} from '@react-navigation/native'; import React from 'react'; @@ -32,72 +33,83 @@ const defaultReportLoadingState = { hasLoadingNewerReportActionsError: false, }; -/** - * Route-only orchestrator for the report actions surface. It owns the coarse branching only — - * skeleton vs. money-request table view vs. the chat list — and subscribes to just what those - * branches need. All heavy data derivation lives inside the hook-driven `ReportActionsList` body, - * which is mounted only for the chat path so its hooks/effects never run while a skeleton shows. - */ -function ReportActions() { - const route = useRoute(); - const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); +type ReportActionsViewProps = { + report: Report; +}; +/** Keep chat effects unmounted during app loading, except for the Concierge welcome screen. */ +function ChatReportActions({report}: ReportActionsViewProps) { const {isOffline} = useNetwork(); - - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${reportIDFromRoute}`); const isAppLoadPending = useIsAppLoadPending(); - const isReportLoadPending = useIsReportLoadPending(reportIDFromRoute); - const {reportActions} = usePaginatedReportActions(reportIDFromRoute); + const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); - const allReportTransactions = useReportTransactionsCollection(reportIDFromRoute); - const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); + if (isAppLoadPending && !isOffline && !isConciergeMainDM) { + return ( + + ); + } + + return ( + <> + + + + ); +} - const isMoneyRequestOrInvoiceReport = isMoneyRequestReport(report) || isInvoiceReport(report); +/** Only money-request and invoice reports need transactions to select their actions layout. */ +function MoneyRequestReportActions({report}: ReportActionsViewProps) { + const {isOffline} = useNetwork(); + const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report.reportID}`); + const isReportLoadPending = useIsReportLoadPending(report.reportID); + const {reportActions} = usePaginatedReportActions(report.reportID); + const allReportTransactions = useReportTransactionsCollection(report.reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); const shouldWaitForTransactions = shouldWaitForTransactionsUtil(report, reportTransactions, reportLoadingState, isReportLoadPending, isOffline); - const shouldDisplayMoneyRequestActionsList = isMoneyRequestOrInvoiceReport && shouldDisplayReportTableView(report, reportTransactions); - - // 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 - // branches below) — matching the previous behavior, where this skeleton lived inside the - // chat-only ReportActionsView. - // - // Concierge is excluded so its welcome screen shows while the app loads. - const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); - const shouldShowAppLoadSkeleton = isAppLoadPending && !isOffline && !!report && !shouldWaitForTransactions && !shouldDisplayMoneyRequestActionsList && !isConciergeMainDM; - if (!report || shouldWaitForTransactions) { + if (shouldWaitForTransactions) { return ( ); } - if (shouldDisplayMoneyRequestActionsList) { + if (shouldDisplayReportTableView(report, reportTransactions)) { return ; } - if (shouldShowAppLoadSkeleton) { + return ; +} + +/** Choose the report surface without mounting transaction-based layout selection for ordinary chats. */ +function ReportActions() { + const route = useRoute(); + const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`); + + if (!report) { return ( ); } - return ( - <> - - - - ); + if (isMoneyRequestReport(report) || isInvoiceReport(report)) { + return ; + } + + return ; } type ReportActionsWithInboxTabDeferredMountProps = { From 607e89e7f965e111886726976832b919cea6d3b1 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 6 Nov 2026 21:42:40 -0800 Subject: [PATCH 2/3] test: cover report layout selection and hydration --- tests/ui/ReportActionsTest.tsx | 590 +++++++++++++++--- ...rtActionsWithInboxTabDeferredMountTest.tsx | 41 +- 2 files changed, 546 insertions(+), 85 deletions(-) diff --git a/tests/ui/ReportActionsTest.tsx b/tests/ui/ReportActionsTest.tsx index 6099444f6a52..d69d04cb0842 100644 --- a/tests/ui/ReportActionsTest.tsx +++ b/tests/ui/ReportActionsTest.tsx @@ -1,32 +1,37 @@ -import {render, screen} from '@testing-library/react-native'; +import {act, render, renderHook, screen} from '@testing-library/react-native'; import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; +import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; -import {shouldDisplayReportTableView, shouldWaitForTransactions} from '@libs/MoneyRequestReportUtils'; -import {isConciergeChatReport, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; +import {WRITE_COMMANDS} from '@libs/API/types'; import markOpenReportEnd from '@libs/telemetry/markOpenReportEnd'; import ReportActionsListBody from '@pages/inbox/report/ReportActionsList'; import UserTypingEventListener from '@pages/inbox/report/UserTypingEventListener'; import ReportActions from '@pages/inbox/ReportActions'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ import type * as ReactNavigation from '@react-navigation/native'; +import type {OnyxKey, OnyxValue} from 'react-native-onyx'; +import {useRoute} from '@react-navigation/native'; import React from 'react'; +import {View} from 'react-native'; import Onyx from 'react-native-onyx'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; const REPORT_ID = '123'; +const OTHER_REPORT_ID = '456'; jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -41,40 +46,22 @@ jest.mock('@hooks/useOnyx', () => jest.fn()); jest.mock('@hooks/usePaginatedReportActions', () => jest.fn()); jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn()); -// Only the two helpers the orchestrator branches on are mocked, so each branch can be driven -// deterministically without constructing full transaction fixtures. -jest.mock('@libs/MoneyRequestReportUtils', () => ({ - getAllNonDeletedTransactions: jest.fn(() => []), - shouldDisplayReportTableView: jest.fn(() => false), - shouldWaitForTransactions: jest.fn(() => false), -})); -jest.mock('@libs/ReportUtils', () => ({ - isMoneyRequestReport: jest.fn(() => false), - isInvoiceReport: jest.fn(() => false), - isConciergeChatReport: jest.fn(() => false), -})); - -// The children are mocked so these tests assert only the orchestrator's branching decision, not the -// children's internals (the body has its own dedicated coverage). The skeleton view is kept real so -// we can observe it via its testID. +// Keep the routing, transaction filtering and loading predicates real. Only the expensive leaf +// lists are mocked so assertions describe which surface the user receives. jest.mock('@components/MoneyRequestReportView/MoneyRequestReportActionsList', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/ReportActionsList', () => jest.fn(() => null)); jest.mock('@pages/inbox/report/UserTypingEventListener', () => jest.fn(() => null)); jest.mock('@libs/telemetry/markOpenReportEnd', () => jest.fn()); -const mockUseNetwork = useNetwork as jest.MockedFunction; -const mockUseOnyx = useOnyx as jest.MockedFunction; -const mockUsePaginatedReportActions = usePaginatedReportActions as jest.MockedFunction; -const mockUseReportTransactionsCollection = useReportTransactionsCollection as jest.MockedFunction; -const mockShouldDisplayReportTableView = shouldDisplayReportTableView as jest.MockedFunction; -const mockShouldWaitForTransactions = shouldWaitForTransactions as jest.MockedFunction; -const mockIsMoneyRequestReport = isMoneyRequestReport as jest.MockedFunction; -const mockIsInvoiceReport = isInvoiceReport as jest.MockedFunction; -const mockIsConciergeChatReport = isConciergeChatReport as jest.MockedFunction; -const mockMoneyRequestList = MoneyRequestReportActionsList as jest.MockedFunction; -const mockReportActionsListBody = ReportActionsListBody as jest.MockedFunction; -const mockUserTypingEventListener = UserTypingEventListener as jest.MockedFunction; -const mockMarkOpenReportEnd = markOpenReportEnd as jest.MockedFunction; +const mockUseNetwork = jest.mocked(useNetwork); +const mockUseOnyx = jest.mocked(useOnyx); +const mockUseRoute = jest.mocked(useRoute); +const mockUsePaginatedReportActions = jest.mocked(usePaginatedReportActions); +const mockUseReportTransactionsCollection = jest.mocked(useReportTransactionsCollection); +const mockMoneyRequestList = jest.mocked(MoneyRequestReportActionsList); +const mockReportActionsListBody = jest.mocked(ReportActionsListBody); +const mockUserTypingEventListener = jest.mocked(UserTypingEventListener); +const mockMarkOpenReportEnd = jest.mocked(markOpenReportEnd); const defaultPaginatedReportActionsResult: ReturnType = { reportActions: [], @@ -88,153 +75,590 @@ const defaultPaginatedReportActionsResult: ReturnType; +const moneyReportTypes = [CONST.REPORT.TYPE.EXPENSE, CONST.REPORT.TYPE.IOU, CONST.REPORT.TYPE.INVOICE]; -/** Builds the keyed useOnyx mock the orchestrator reads: the report, its loading state and the app-load request queue. */ -const setupUseOnyx = (options: {report?: OnyxTypes.Report | undefined; isLoadingApp?: boolean; loadingState?: ReportLoadingStateOverrides} = {}) => { - // `'report' in options` distinguishes "not passed" (default to mockReport) from an explicit - // `{report: undefined}` (the report-not-available case) — a destructuring default would swallow the latter. +type ReportLoadingStateOverrides = Partial; + +function setupUseOnyx( + options: { + report?: OnyxTypes.Report | undefined; + isLoadingApp?: boolean; + isReportLoadPending?: boolean; + loadingState?: ReportLoadingStateOverrides; + conciergeReportID?: string; + } = {}, +) { const report = 'report' in options ? options.report : mockReport; - const isLoadingApp = options.isLoadingApp ?? false; - const loadingState = options.loadingState ?? {}; - mockUseOnyx.mockImplementation((key: string) => { - // ReportActions derives app-load state from the request queue via useIsAppLoadPending, which reads these - // queue keys through selectors that resolve to a boolean. Returning that boolean directly mirrors what - // useOnyx yields once the selector runs. The legacy IS_LOADING_APP flag is kept for any direct reader. - if (key === ONYXKEYS.IS_LOADING_APP || key === ONYXKEYS.PERSISTED_REQUESTS || key === ONYXKEYS.PERSISTED_ONGOING_REQUESTS) { - return [isLoadingApp, {status: 'loaded'}]; - } - if (key.includes('reportLoadingState')) { - return [{isLoadingInitialReportActions: false, hasOnceLoadedReportActions: true, ...loadingState}, {status: 'loaded'}]; - } - if (key === `${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`) { - return [report, {status: 'loaded'}]; - } - return [undefined, {status: 'loaded'}]; + const requests: OnyxTypes.AnyRequest[] = []; + if (options.isLoadingApp) { + requests.push({command: WRITE_COMMANDS.OPEN_APP}); + } + if (options.isReportLoadPending) { + requests.push({command: WRITE_COMMANDS.OPEN_REPORT, data: {reportID: report?.reportID ?? REPORT_ID}}); + } + + const values: Partial>> = { + [ONYXKEYS.IS_LOADING_APP]: options.isLoadingApp ?? false, + [ONYXKEYS.PERSISTED_REQUESTS]: requests, + [ONYXKEYS.CONCIERGE_REPORT_ID]: options.conciergeReportID, + [`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report?.reportID ?? REPORT_ID}`]: { + isLoadingInitialReportActions: false, + hasOnceLoadedReportActions: true, + ...options.loadingState, + }, + [`${ONYXKEYS.COLLECTION.REPORT}${report?.reportID ?? REPORT_ID}`]: report, + }; + + mockUseOnyx.mockImplementation((key, onyxOptions) => { + // Each key has its own value type; the generic hook mock also has to support selectors + // used by the real in-flight request hooks instead of returning a shared loading boolean. + const value = values[key]; + const selectedValue = onyxOptions?.selector ? onyxOptions.selector(value) : value; + return [selectedValue ?? undefined, {status: 'loaded'}]; }); -}; +} + +function createTransactions(count: number, reportID = REPORT_ID): OnyxTypes.Transaction[] { + return Array.from({length: count}, (_, index) => ({ + transactionID: `${index + 1}`, + reportID, + amount: 100, + currency: 'USD', + created: '2023-01-01', + merchant: 'Test merchant', + })); +} + +function setupTransactions(transactions: OnyxTypes.Transaction[], reportActions: OnyxTypes.ReportAction[] = []) { + mockUseReportTransactionsCollection.mockReturnValue( + Object.fromEntries(transactions.map((transaction) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction])), + ); + mockUsePaginatedReportActions.mockReturnValue({...defaultPaginatedReportActionsResult, reportActions}); +} + +function restoreReportDataHooks() { + mockUseOnyx.mockImplementation(jest.requireActual<{default: typeof useOnyx}>('@hooks/useOnyx').default); + mockUsePaginatedReportActions.mockImplementation(jest.requireActual<{default: typeof usePaginatedReportActions}>('@hooks/usePaginatedReportActions').default); + mockUseReportTransactionsCollection.mockImplementation(jest.requireActual<{default: typeof useReportTransactionsCollection}>('@hooks/useReportTransactionsCollection').default); + mockReportActionsListBody.mockImplementation(({reportID}) => ); + mockMoneyRequestList.mockImplementation(() => ); +} + +async function seedReportTransactions(reportID: string, transactions: OnyxTypes.Transaction[]) { + // Derived values are not registered in this suite, so seed the real key the transaction hook + // subscribes to without bringing the application's full derived-value initialization into these tests. + await act(async () => { + await Onyx.merge(ONYXKEYS.DERIVED.REPORT_TRANSACTIONS_AND_VIOLATIONS, { + [reportID]: {transactions: Object.fromEntries(transactions.map((transaction) => [transaction.transactionID, transaction])), violations: {}}, + }); + await waitForBatchedUpdates(); + }); +} -describe('ReportActions (orchestrator)', () => { +function createMoneyRequestAction(transaction: OnyxTypes.Transaction): OnyxTypes.ReportAction { + return { + reportActionID: `${transaction.reportID}-${transaction.transactionID}`, + reportID: transaction.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + actorAccountID: mockReport.ownerAccountID, + created: transaction.created, + message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: transaction.transactionID, html: 'Created an expense', text: 'Created an expense'}, + }; +} + +describe('ReportActions', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); }); beforeEach(() => { jest.clearAllMocks(); + mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: REPORT_ID}}); mockUseNetwork.mockReturnValue({isOffline: false}); mockUsePaginatedReportActions.mockReturnValue(defaultPaginatedReportActionsResult); mockUseReportTransactionsCollection.mockReturnValue({}); - mockShouldWaitForTransactions.mockReturnValue(false); - mockShouldDisplayReportTableView.mockReturnValue(false); - mockIsMoneyRequestReport.mockReturnValue(false); - mockIsInvoiceReport.mockReturnValue(false); - mockIsConciergeChatReport.mockReturnValue(false); + mockReportActionsListBody.mockImplementation(() => ); + mockMoneyRequestList.mockImplementation(() => ); setupUseOnyx(); }); afterEach(async () => { + // The request hooks retain observed requests for deferred Onyx flushes. Clear that session + // state through the real hooks so a prior pending request cannot affect the next test. + setupUseOnyx(); + const {unmount} = renderHook(() => { + useIsAppLoadPending(); + useIsReportLoadPending(REPORT_ID); + useIsReportLoadPending(OTHER_REPORT_ID); + }); + unmount(); await waitForBatchedUpdatesWithAct(); await Onyx.clear(); }); it('renders a skeleton when the report is not available yet', () => { + // Given the route exists before its report arrives from Onyx. setupUseOnyx({report: undefined}); + // When the report surface mounts before its data is available. render(); + // Then neither list can mount with a missing report. expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); }); - it('renders a skeleton while waiting for transactions', () => { - mockShouldWaitForTransactions.mockReturnValue(true); + it.each(moneyReportTypes)('chooses the %s layout from the real transaction count', (type) => { + // Given an empty money report whose initial data has already loaded. + setupUseOnyx({report: {...mockReport, type}}); + const {rerender} = render(); + + // When no transactions exist, the empty table is still the correct money surface. + // Then no chat body or typing listener should be mounted. + expect(mockMoneyRequestList).toHaveBeenCalled(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockUserTypingEventListener).not.toHaveBeenCalled(); + + // When a single expense arrives, it uses the chat surface instead of a one-row table. + jest.clearAllMocks(); + setupTransactions(createTransactions(1)); + setupUseOnyx({report: {...mockReport, type}}); + rerender(); + + // Then the shared body and typing listener receive the current report. + expect(mockMoneyRequestList).not.toHaveBeenCalled(); + expect(mockReportActionsListBody.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({reportID: REPORT_ID})); + expect(mockUserTypingEventListener.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({report: expect.objectContaining({type})})); + + // When another expense arrives on the same route, the table must replace the chat surface. + jest.clearAllMocks(); + setupTransactions(createTransactions(2)); + setupUseOnyx({report: {...mockReport, type}}); + rerender(); + // Then the transaction count, not a mocked layout predicate, selects the table. + expect(mockMoneyRequestList).toHaveBeenCalled(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockUserTypingEventListener).not.toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + + it.each(moneyReportTypes)('waits for missing %s transactions while online', (type) => { + // Given the report total proves that an empty local collection is incomplete. + setupUseOnyx({report: {...mockReport, type, total: -100}}); + + // When the report mounts before its transactions are delivered. render(); - expect(screen.getByTestId('ReportActionsSkeletonCover')).toBeTruthy(); + // Then a loading skeleton prevents briefly showing the wrong layout. expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); }); - it('passes report pending state to transaction readiness', () => { + it.each(moneyReportTypes)('waits for the first pending %s report load even with a zero total', (type) => { + // Given OpenReport is queued and the report has never loaded its actions. + setupUseOnyx({report: {...mockReport, type}, isReportLoadPending: true, loadingState: {hasOnceLoadedReportActions: false}}); + + // When the real request selectors scope the load to this report. render(); - expect(mockShouldWaitForTransactions).toHaveBeenLastCalledWith(mockReport, [], expect.objectContaining({hasOnceLoadedReportActions: true}), false, false); + // Then a zero total cannot prematurely reveal an empty table. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockMoneyRequestList).not.toHaveBeenCalled(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); }); - it('renders the money-request table view for a money-request report', () => { - mockIsMoneyRequestReport.mockReturnValue(true); - mockShouldDisplayReportTableView.mockReturnValue(true); + it('does not treat a stored loading flag as an active report request', () => { + // Given a loaded, empty expense report has a stale initial-actions flag but no queued request. + setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}, loadingState: {isLoadingInitialReportActions: true, hasOnceLoadedReportActions: false}}); + // When the route mounts after a restart. render(); + // Then stale metadata cannot keep the report on a skeleton forever. expect(mockMoneyRequestList).toHaveBeenCalled(); - expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); }); - it('renders the chat list body and the typing listener for a chat report', () => { + it('shows a loaded empty report during a later pending report refresh', () => { + // Given the report has loaded before, so an empty collection is meaningful during refresh. + setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}, isReportLoadPending: true}); + + // When a subsequent OpenReport request is still queued. render(); + // Then refreshing does not replace the empty table with a skeleton. + expect(mockMoneyRequestList).toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + + it.each(moneyReportTypes)('shows cached empty %s reports offline instead of waiting for transactions', (type) => { + // Given incomplete data cannot be fetched until the device reconnects. + mockUseNetwork.mockReturnValue({isOffline: true}); + setupUseOnyx({report: {...mockReport, type, total: -100}, isReportLoadPending: true, loadingState: {hasOnceLoadedReportActions: false}}); + + // When the offline report mounts with no cached transactions. + render(); + + // Then the user receives the money surface rather than a permanent loader. + expect(mockMoneyRequestList).toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + + it('does not wait for transactions in an optimistically created report', () => { + // Given creation is queued locally and transactions have not reached the report collection. + setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE, total: -100, pendingFields: {createReport: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}}); + + // When the optimistic report opens before its server response. + render(); + + // Then the optimistic report remains usable without a transaction wait. + expect(mockMoneyRequestList).toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + }); + + it.each([false, true])('keeps pending transaction deletions in the layout count (offline: %s)', (isOffline) => { + // Given a deleting expense must remain visible beside the surviving expense until deletion resolves. + const [first, second] = createTransactions(2); + setupTransactions([{...first, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}, second]); + mockUseNetwork.mockReturnValue({isOffline}); + setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}}); + + // When transaction filtering runs with a pending deletion. + render(); + + // Then the pending row still requires the table rather than the single-expense fallback. + expect(mockMoneyRequestList).toHaveBeenCalled(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + }); + + it.each([false, true])('filters deleted action transactions according to connectivity (offline: %s)', (isOffline) => { + // Given two transactions, one whose action is blanked while its deletion is queued. + const transactions = createTransactions(2); + const deletedAction: OnyxTypes.ReportAction = { + reportActionID: 'deleted-action', + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + created: '2023-01-01', + actorAccountID: 123, + message: {type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, IOUTransactionID: transactions[0].transactionID, html: '', text: ''}, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }; + setupTransactions(transactions, [deletedAction]); + mockUseNetwork.mockReturnValue({isOffline}); + setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}}); + + // When the real filter applies the online or offline deletion rules. + render(); + + // Then offline retains the pending row, while online falls back to the single remaining expense. + expect(mockMoneyRequestList.mock.calls.length > 0).toBe(isOffline); + expect(mockReportActionsListBody.mock.calls.length > 0).toBe(!isOffline); + }); + + it('renders the chat list body and typing listener for an ordinary chat', () => { + // Given an ordinary chat has no money transactions. + setupUseOnyx(); + + // When its route mounts. + render(); + + // Then the shared chat surface receives the report rather than a table. expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); expect(mockMoneyRequestList).not.toHaveBeenCalled(); - expect(mockReportActionsListBody).toHaveBeenCalled(); expect(mockReportActionsListBody.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({reportID: REPORT_ID})); - expect(mockUserTypingEventListener).toHaveBeenCalled(); expect(mockUserTypingEventListener.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({report: mockReport})); }); + it('does not subscribe ordinary chats to money pagination or transaction collections', () => { + // Given stale money data must not make an ordinary chat depend on money hooks. + setupUseOnyx(); + setupTransactions(createTransactions(2)); + + // When the real route chooses its ordinary chat boundary. + render(); + + // Then money-only hook work is absent, not merely ignored after it runs. + expect(mockReportActionsListBody).toHaveBeenCalled(); + expect(mockUsePaginatedReportActions).not.toHaveBeenCalled(); + expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled(); + }); + + it('does not subscribe to money data before the report exists', () => { + // Given the route cannot classify a report that has not arrived yet. + setupUseOnyx({report: undefined}); + + // When it displays the missing-report loader. + render(); + + // Then no money boundary or its subscriptions may mount speculatively. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockUsePaginatedReportActions).not.toHaveBeenCalled(); + expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled(); + }); + + it('does not wait for money data during an ordinary chat report load', () => { + // Given an ordinary chat has an initial OpenReport request and a nonzero stored total. + setupUseOnyx({report: {...mockReport, total: -100}, isReportLoadPending: true, loadingState: {hasOnceLoadedReportActions: false}}); + + // When the chat mounts while only the report request is pending. + render(); + + // Then money readiness cannot block the chat body or run money subscriptions. + expect(mockReportActionsListBody).toHaveBeenCalled(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(mockUsePaginatedReportActions).not.toHaveBeenCalled(); + expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled(); + }); + it('renders the app-load skeleton on the chat path and closes the open-report span with warm:false', () => { + // Given OpenApp is queued while an ordinary chat is selected. setupUseOnyx({isLoadingApp: true}); + // When the report mounts during app initialization. render(); + // Then the heavy chat body waits and the loading render closes its report span as cold. expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); expect(mockReportActionsListBody).not.toHaveBeenCalled(); expect(mockMarkOpenReportEnd).toHaveBeenCalledWith(REPORT_ID, mockReport, {warm: false}); }); - it('mounts the body (not the orchestrator app-load skeleton) for a Concierge report during app load', () => { - mockIsConciergeChatReport.mockReturnValue(true); - setupUseOnyx({isLoadingApp: true}); + it('mounts the Concierge chat body during app load', () => { + // Given this is the main Concierge DM where the welcome screen must remain available. + setupUseOnyx({isLoadingApp: true, conciergeReportID: REPORT_ID}); + // When OpenApp is still queued. render(); + // Then Concierge bypasses the app-load skeleton and its telemetry. expect(mockReportActionsListBody).toHaveBeenCalled(); expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); expect(mockMarkOpenReportEnd).not.toHaveBeenCalled(); }); - it('does not show the app-load skeleton (or fire telemetry) while offline', () => { + it('does not exempt a different chat when a Concierge report ID exists', () => { + // Given the account has a Concierge DM, but this route is a different ordinary chat. + setupUseOnyx({isLoadingApp: true, conciergeReportID: OTHER_REPORT_ID}); + + // When app initialization is pending. + render(); + + // Then only the actual Concierge DM may bypass the app-load skeleton. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + }); + + it('does not show the app-load skeleton or fire its telemetry while offline', () => { + // Given an app-load request cannot resolve on the offline device. mockUseNetwork.mockReturnValue({isOffline: true}); setupUseOnyx({isLoadingApp: true}); + // When the cached chat opens. render(); + // Then offline use remains available without an app-load skeleton. expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); expect(mockReportActionsListBody).toHaveBeenCalled(); expect(mockMarkOpenReportEnd).not.toHaveBeenCalled(); }); - it('does not show the app-load skeleton for a money-request report', () => { - mockIsMoneyRequestReport.mockReturnValue(true); - mockShouldDisplayReportTableView.mockReturnValue(true); - setupUseOnyx({isLoadingApp: true}); + it.each(moneyReportTypes)('does not show the app-load skeleton over a %s table', (type) => { + // Given complete money-report data can already select its table. + setupUseOnyx({report: {...mockReport, type}, isLoadingApp: true}); + setupTransactions(createTransactions(2)); + // When app initialization is still queued. render(); + // Then the app gate applies only to the chat path. expect(mockMoneyRequestList).toHaveBeenCalled(); expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); expect(mockMarkOpenReportEnd).not.toHaveBeenCalled(); }); + + it.each(moneyReportTypes)('keeps the app-load gate for the single-transaction %s chat fallback', (type) => { + // Given a single money transaction selects the shared chat surface. + setupUseOnyx({report: {...mockReport, type}, isLoadingApp: true}); + setupTransactions(createTransactions(1)); + + // When OpenApp has not completed. + render(); + + // Then using a money report does not bypass the chat path's app-load gate. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockMoneyRequestList).not.toHaveBeenCalled(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + }); + + it('changes reports on the same route without retaining the prior money layout', () => { + // Given the mounted route currently displays a report with two expenses. + setupUseOnyx({report: {...mockReport, type: CONST.REPORT.TYPE.EXPENSE}}); + setupTransactions(createTransactions(2)); + const {rerender} = render(); + expect(mockMoneyRequestList).toHaveBeenCalled(); + + // When navigation reuses that route for an ordinary chat. + jest.clearAllMocks(); + mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: OTHER_REPORT_ID}}); + setupUseOnyx({report: {...mockReport, reportID: OTHER_REPORT_ID}}); + rerender(); + + // Then the current report, not the previous transaction count, controls the surface. + expect(mockMoneyRequestList).not.toHaveBeenCalled(); + expect(mockReportActionsListBody.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({reportID: OTHER_REPORT_ID})); + expect(mockUserTypingEventListener.mock.calls.at(-1)?.at(0)).toEqual(expect.objectContaining({report: expect.objectContaining({reportID: OTHER_REPORT_ID})})); + expect(mockUsePaginatedReportActions).not.toHaveBeenCalled(); + expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled(); + }); + + it('switches from chat to a loading money report and then back on the same route', () => { + // Given an ordinary chat is already mounted on a reusable route. + const {rerender} = render(); + expect(mockReportActionsListBody).toHaveBeenCalled(); + + // When the route switches to an expense report whose transactions are missing. + jest.clearAllMocks(); + mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: OTHER_REPORT_ID}}); + setupUseOnyx({report: {...mockReport, reportID: OTHER_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, total: -100}}); + rerender(); + + // Then the new report must wait rather than reuse the old chat body. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockReportActionsListBody).not.toHaveBeenCalled(); + expect(mockUsePaginatedReportActions).toHaveBeenCalledWith(OTHER_REPORT_ID); + expect(mockUseReportTransactionsCollection).toHaveBeenCalledWith(OTHER_REPORT_ID); + + // When navigation reuses the route for the original chat again. + jest.clearAllMocks(); + mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: REPORT_ID}}); + setupUseOnyx(); + rerender(); + + // Then the money loader and its subscriptions do not leak into the chat lifetime. + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(mockReportActionsListBody).toHaveBeenCalled(); + expect(mockUsePaginatedReportActions).not.toHaveBeenCalled(); + expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled(); + }); + + it('hydrates a cold money report through real Onyx subscriptions before choosing its layout', async () => { + // Given neither the report nor its transactions have been cached when the route first opens. + restoreReportDataHooks(); + const {unmount} = render(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(mockUsePaginatedReportActions).not.toHaveBeenCalled(); + expect(mockUseReportTransactionsCollection).not.toHaveBeenCalled(); + + // When the report arrives before its expenses, real subscription updates must retain the loader. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {...mockReport, type: CONST.REPORT.TYPE.EXPENSE, total: -100}); + await waitForBatchedUpdates(); + }); + + // Then incomplete cold data must not briefly select an empty table or chat body. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(screen.queryByTestId('money-report-table')).toBeNull(); + expect(screen.queryByTestId(`chat-report-${REPORT_ID}`)).toBeNull(); + + // When the first expense and its action hydrate through the keys the real hooks observe. + const transactions = createTransactions(1); + const action = createMoneyRequestAction(transactions[0]); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {[action.reportActionID]: action}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [[CONST.PAGINATION_START_ID, action.reportActionID, CONST.PAGINATION_END_ID]]); + await waitForBatchedUpdates(); + }); + await seedReportTransactions(REPORT_ID, transactions); + + // Then the single-expense chat appears without remounting the route or replacing the hooks with mocks. + expect(screen.getByTestId(`chat-report-${REPORT_ID}`)).toBeTruthy(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(screen.queryByTestId('money-report-table')).toBeNull(); + expect(mockUsePaginatedReportActions.mock.results.at(-1)?.value).toEqual(expect.objectContaining({reportActions: [action]})); + unmount(); + }); + + it('does not carry old report subscriptions through an unavailable destination on the same route', async () => { + // Given the current route has a fully cached two-expense report and real money hooks mounted. + restoreReportDataHooks(); + const firstReport = {...mockReport, type: CONST.REPORT.TYPE.EXPENSE, total: -200}; + const firstTransactions = createTransactions(2); + const firstActions = firstTransactions.map(createMoneyRequestAction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, firstReport); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, Object.fromEntries(firstActions.map((action) => [action.reportActionID, action]))); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${REPORT_ID}`, [ + [CONST.PAGINATION_START_ID, ...firstActions.map((action) => action.reportActionID), CONST.PAGINATION_END_ID], + ]); + await seedReportTransactions(REPORT_ID, firstTransactions); + const {rerender, unmount} = render(); + await waitForBatchedUpdatesWithAct(); + expect(screen.getByTestId('money-report-table')).toBeTruthy(); + + // When the same route changes to a report whose Onyx key has never been populated. + mockUseRoute.mockReturnValue({key: 'report-route', name: 'Report', params: {reportID: OTHER_REPORT_ID}}); + rerender(); + await waitForBatchedUpdatesWithAct(); + + // Then the previous report and transaction collection must not select the destination's surface. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(screen.queryByTestId('money-report-table')).toBeNull(); + expect(screen.queryByTestId(`chat-report-${REPORT_ID}`)).toBeNull(); + + // When the destination report hydrates without any of its own transactions yet. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${OTHER_REPORT_ID}`, {...mockReport, reportID: OTHER_REPORT_ID, type: CONST.REPORT.TYPE.EXPENSE, total: -100}); + await waitForBatchedUpdates(); + }); + + // Then the source's two expenses cannot satisfy the destination's transaction readiness. + expect(screen.getByTestId('ReportActionsSkeletonView')).toBeTruthy(); + expect(screen.queryByTestId('money-report-table')).toBeNull(); + expect(mockUseReportTransactionsCollection.mock.results.at(-1)?.value).toEqual({}); + + // When only the destination's single expense and action arrive. + const destinationTransactions = createTransactions(1, OTHER_REPORT_ID).map((transaction) => ({...transaction, transactionID: 'destination-expense'})); + const destinationAction = createMoneyRequestAction(destinationTransactions[0]); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${OTHER_REPORT_ID}`, {[destinationAction.reportActionID]: destinationAction}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_PAGES}${OTHER_REPORT_ID}`, [[CONST.PAGINATION_START_ID, destinationAction.reportActionID, CONST.PAGINATION_END_ID]]); + await waitForBatchedUpdates(); + }); + await seedReportTransactions(OTHER_REPORT_ID, destinationTransactions); + + // Then its own action page and transaction collection select the single-expense chat. + expect(screen.getByTestId(`chat-report-${OTHER_REPORT_ID}`)).toBeTruthy(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(screen.queryByTestId('money-report-table')).toBeNull(); + expect(mockUsePaginatedReportActions.mock.results.at(-1)?.value).toEqual(expect.objectContaining({reportActions: [destinationAction]})); + expect(mockUseReportTransactionsCollection.mock.results.at(-1)?.value).toEqual({[destinationTransactions[0].transactionID]: destinationTransactions[0]}); + + // When late report, action and transaction updates arrive for the source after navigation. + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, {total: 0, type: CONST.REPORT.TYPE.CHAT}); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${REPORT_ID}`, {}); + await waitForBatchedUpdates(); + }); + await seedReportTransactions(REPORT_ID, createTransactions(3)); + + // Then the destination's mounted chat and its own action page remain unchanged. + expect(screen.getByTestId(`chat-report-${OTHER_REPORT_ID}`)).toBeTruthy(); + expect(screen.queryByTestId(`chat-report-${REPORT_ID}`)).toBeNull(); + expect(screen.queryByTestId('ReportActionsSkeletonView')).toBeNull(); + expect(screen.queryByTestId('money-report-table')).toBeNull(); + expect(mockUsePaginatedReportActions.mock.results.at(-1)?.value).toEqual(expect.objectContaining({reportActions: [destinationAction]})); + expect(mockUseReportTransactionsCollection.mock.results.at(-1)?.value).toEqual({[destinationTransactions[0].transactionID]: destinationTransactions[0]}); + unmount(); + }); }); diff --git a/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx b/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx index 557ded97b566..d83abdd18d5e 100644 --- a/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx +++ b/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx @@ -1,5 +1,8 @@ import {render, screen} from '@testing-library/react-native'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; + import {ReportActionsWithInboxTabDeferredMount} from '@pages/inbox/ReportActions'; import type {ComponentType} from 'react'; @@ -27,8 +30,8 @@ jest.mock( jest.mock('@hooks/useMarkOpenReportEndOnSkeleton', () => () => undefined); jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); jest.mock('@hooks/useOnyx', () => () => [undefined]); -jest.mock('@hooks/usePaginatedReportActions', () => () => ({reportActions: {}})); -jest.mock('@hooks/useReportTransactionsCollection', () => () => ({})); +jest.mock('@hooks/usePaginatedReportActions', () => jest.fn(() => ({reportActions: []}))); +jest.mock('@hooks/useReportTransactionsCollection', () => jest.fn(() => ({}))); jest.mock('@react-navigation/native', () => { const navigation = jest.requireActual>('@react-navigation/native'); @@ -49,7 +52,13 @@ jest.mock( ); describe('ReportActionsWithInboxTabDeferredMount', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('renders report actions immediately without an explicit defer request', () => { + // Given Inbox has not requested navigation deferral. + // When the report surface mounts before its report data arrives. render( { />, ); + // Then missing data uses the report loader without introducing a navigation delay. expect(screen.getByTestId('report-actions')).toBeOnTheScreen(); expect(screen.queryByTestId('navigation-deferred-mount')).not.toBeOnTheScreen(); }); it('defers report actions when the Inbox navigation requests it', () => { + // Given Inbox needs its transition to complete before mounting report work. + // When navigation explicitly requests deferral. render( { />, ); + // Then the report boundary, including its money subscriptions, has not mounted yet. expect(screen.getByTestId('navigation-deferred-mount')).toBeOnTheScreen(); expect(screen.queryByTestId('report-actions')).not.toBeOnTheScreen(); + expect(usePaginatedReportActions).not.toHaveBeenCalled(); + expect(useReportTransactionsCollection).not.toHaveBeenCalled(); + }); + + it('mounts report actions when Inbox releases the same deferred route', () => { + // Given a report is waiting for an Inbox navigation transition. + const {rerender} = render( + , + ); + + // When Inbox releases deferral without changing the report route. + rerender( + , + ); + + // Then the report data loader replaces navigation deferral immediately. + expect(screen.getByTestId('report-actions')).toBeOnTheScreen(); + expect(screen.queryByTestId('navigation-deferred-mount')).not.toBeOnTheScreen(); }); }); From bfefc37fb8fc6305eecf8a521763683cc9107c6e Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 6 Nov 2026 21:54:38 -0800 Subject: [PATCH 3/3] refactor: keep report components in separate files --- src/pages/inbox/ChatReportActions.tsx | 48 ++++++++ src/pages/inbox/MoneyRequestReportActions.tsx | 59 +++++++++ src/pages/inbox/ReportActions.tsx | 112 +----------------- ...ReportActionsWithInboxTabDeferredMount.tsx | 41 +++++++ src/pages/inbox/ReportScreen.tsx | 2 +- ...rtActionsWithInboxTabDeferredMountTest.tsx | 2 +- 6 files changed, 153 insertions(+), 111 deletions(-) create mode 100644 src/pages/inbox/ChatReportActions.tsx create mode 100644 src/pages/inbox/MoneyRequestReportActions.tsx create mode 100644 src/pages/inbox/ReportActionsWithInboxTabDeferredMount.tsx diff --git a/src/pages/inbox/ChatReportActions.tsx b/src/pages/inbox/ChatReportActions.tsx new file mode 100644 index 000000000000..2c98a0405ff4 --- /dev/null +++ b/src/pages/inbox/ChatReportActions.tsx @@ -0,0 +1,48 @@ +/** Keep chat effects unmounted during app loading, except for the Concierge welcome screen. */ +import {useIsAppLoadPending} from '@hooks/useInFlightRequests'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; + +import {isConciergeChatReport} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; + +import ReportActionsList from './report/ReportActionsList'; +import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton'; +import UserTypingEventListener from './report/UserTypingEventListener'; + +type ChatReportActionsProps = { + report: Report; +}; + +function ChatReportActions({report}: ChatReportActionsProps) { + const {isOffline} = useNetwork(); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const isAppLoadPending = useIsAppLoadPending(); + const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); + + if (isAppLoadPending && !isOffline && !isConciergeMainDM) { + return ( + + ); + } + + return ( + <> + + + + ); +} + +export default ChatReportActions; diff --git a/src/pages/inbox/MoneyRequestReportActions.tsx b/src/pages/inbox/MoneyRequestReportActions.tsx new file mode 100644 index 000000000000..cafd23019985 --- /dev/null +++ b/src/pages/inbox/MoneyRequestReportActions.tsx @@ -0,0 +1,59 @@ +/** Only money-request and invoice reports need transactions to select their actions layout. */ +import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; + +import {useIsReportLoadPending} from '@hooks/useInFlightRequests'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; + +import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitForTransactions as shouldWaitForTransactionsUtil} from '@libs/MoneyRequestReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; + +import React from 'react'; + +import ChatReportActions from './ChatReportActions'; +import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton'; + +const defaultReportLoadingState = { + hasOnceLoadedReportActions: false, + isLoadingInitialReportActions: true, + isLoadingOlderReportActions: false, + hasLoadingOlderReportActionsError: false, + isLoadingNewerReportActions: false, + hasLoadingNewerReportActionsError: false, +}; + +type MoneyRequestReportActionsProps = { + report: Report; +}; + +function MoneyRequestReportActions({report}: MoneyRequestReportActionsProps) { + const {isOffline} = useNetwork(); + const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report.reportID}`); + const isReportLoadPending = useIsReportLoadPending(report.reportID); + const {reportActions} = usePaginatedReportActions(report.reportID); + const allReportTransactions = useReportTransactionsCollection(report.reportID); + const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); + const shouldWaitForTransactions = shouldWaitForTransactionsUtil(report, reportTransactions, reportLoadingState, isReportLoadPending, isOffline); + + if (shouldWaitForTransactions) { + return ( + + ); + } + + if (shouldDisplayReportTableView(report, reportTransactions)) { + return ; + } + + return ; +} + +export default MoneyRequestReportActions; diff --git a/src/pages/inbox/ReportActions.tsx b/src/pages/inbox/ReportActions.tsx index 40d2e740d8a2..b8c67d5d7271 100644 --- a/src/pages/inbox/ReportActions.tsx +++ b/src/pages/inbox/ReportActions.tsx @@ -1,94 +1,19 @@ -import MoneyRequestReportActionsList from '@components/MoneyRequestReportView/MoneyRequestReportActionsList'; -import NavigationDeferredMount from '@components/NavigationDeferredMount'; - -import {useIsAppLoadPending, useIsReportLoadPending} from '@hooks/useInFlightRequests'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; -import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {getAllNonDeletedTransactions, shouldDisplayReportTableView, shouldWaitForTransactions as shouldWaitForTransactionsUtil} from '@libs/MoneyRequestReportUtils'; -import {isConciergeChatReport, isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; +import {isInvoiceReport, isMoneyRequestReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; import {useRoute} from '@react-navigation/native'; import React from 'react'; import type ReportScreenNavigationProps from './types'; -import ReportActionsList from './report/ReportActionsList'; +import ChatReportActions from './ChatReportActions'; +import MoneyRequestReportActions from './MoneyRequestReportActions'; import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton'; -import UserTypingEventListener from './report/UserTypingEventListener'; - -const defaultReportLoadingState = { - hasOnceLoadedReportActions: false, - isLoadingInitialReportActions: true, - isLoadingOlderReportActions: false, - hasLoadingOlderReportActionsError: false, - isLoadingNewerReportActions: false, - hasLoadingNewerReportActionsError: false, -}; - -type ReportActionsViewProps = { - report: Report; -}; - -/** Keep chat effects unmounted during app loading, except for the Concierge welcome screen. */ -function ChatReportActions({report}: ReportActionsViewProps) { - const {isOffline} = useNetwork(); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const isAppLoadPending = useIsAppLoadPending(); - const isConciergeMainDM = isConciergeChatReport(report, conciergeReportID); - - if (isAppLoadPending && !isOffline && !isConciergeMainDM) { - return ( - - ); - } - - return ( - <> - - - - ); -} - -/** Only money-request and invoice reports need transactions to select their actions layout. */ -function MoneyRequestReportActions({report}: ReportActionsViewProps) { - const {isOffline} = useNetwork(); - const [reportLoadingState = defaultReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report.reportID}`); - const isReportLoadPending = useIsReportLoadPending(report.reportID); - const {reportActions} = usePaginatedReportActions(report.reportID); - const allReportTransactions = useReportTransactionsCollection(report.reportID); - const reportTransactions = getAllNonDeletedTransactions(allReportTransactions, reportActions, isOffline, true); - const shouldWaitForTransactions = shouldWaitForTransactionsUtil(report, reportTransactions, reportLoadingState, isReportLoadPending, isOffline); - - if (shouldWaitForTransactions) { - return ( - - ); - } - - if (shouldDisplayReportTableView(report, reportTransactions)) { - return ; - } - - return ; -} /** Choose the report surface without mounting transaction-based layout selection for ordinary chats. */ function ReportActions() { @@ -112,35 +37,4 @@ function ReportActions() { return ; } -type ReportActionsWithInboxTabDeferredMountProps = { - /** The report ID used by the deferred loading skeleton */ - reportID: string | undefined; - - /** Whether to defer mounting report actions during the initial Inbox tab navigation */ - shouldDefer: boolean; -}; - -function ReportActionsWithInboxTabDeferredMount({reportID, shouldDefer}: ReportActionsWithInboxTabDeferredMountProps) { - if (!shouldDefer) { - return ; - } - - return ( - - } - > - - - ); -} - -export {ReportActionsWithInboxTabDeferredMount}; export default ReportActions; diff --git a/src/pages/inbox/ReportActionsWithInboxTabDeferredMount.tsx b/src/pages/inbox/ReportActionsWithInboxTabDeferredMount.tsx new file mode 100644 index 000000000000..8e141281a433 --- /dev/null +++ b/src/pages/inbox/ReportActionsWithInboxTabDeferredMount.tsx @@ -0,0 +1,41 @@ +/** Defer mounting report actions until the initial Inbox tab transition settles. */ +import NavigationDeferredMount from '@components/NavigationDeferredMount'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import ReportActionsLoadingSkeleton from './report/ReportActionsLoadingSkeleton'; +import ReportActions from './ReportActions'; + +type ReportActionsWithInboxTabDeferredMountProps = { + /** The report ID used by the deferred loading skeleton */ + reportID: string | undefined; + + /** Whether to defer mounting report actions during the initial Inbox tab navigation */ + shouldDefer: boolean; +}; + +function ReportActionsWithInboxTabDeferredMount({reportID, shouldDefer}: ReportActionsWithInboxTabDeferredMountProps) { + if (!shouldDefer) { + return ; + } + + return ( + + } + > + + + ); +} + +export default ReportActionsWithInboxTabDeferredMount; diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index ab67b595b3e2..89abccfddf72 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -39,7 +39,7 @@ import ReportActionCompose from './report/ReportActionCompose/ReportActionCompos import {ReportActionEditMessageContextProvider, ReportScreenEditMessageProviderWithTransactionThread} from './report/ReportActionEditMessageContext'; import ReportFooter from './report/ReportFooter'; import useClearReportActionDraftsOnReportChange from './report/useClearReportActionDraftsOnReportChange'; -import {ReportActionsWithInboxTabDeferredMount} from './ReportActions'; +import ReportActionsWithInboxTabDeferredMount from './ReportActionsWithInboxTabDeferredMount'; import ReportDragAndDropProvider from './ReportDragAndDropProvider'; import ReportFetchHandler from './ReportFetchHandler'; import ReportHeader from './ReportHeader'; diff --git a/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx b/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx index d83abdd18d5e..a0e4ba9f5897 100644 --- a/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx +++ b/tests/unit/ReportActionsWithInboxTabDeferredMountTest.tsx @@ -3,7 +3,7 @@ import {render, screen} from '@testing-library/react-native'; import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; import useReportTransactionsCollection from '@hooks/useReportTransactionsCollection'; -import {ReportActionsWithInboxTabDeferredMount} from '@pages/inbox/ReportActions'; +import ReportActionsWithInboxTabDeferredMount from '@pages/inbox/ReportActionsWithInboxTabDeferredMount'; import type {ComponentType} from 'react';