Skip to content

Commit 290ed93

Browse files
authored
Merge pull request Expensify#89247 from callstack-internal/test/search-money-request-report-tests
[No QA] Add tests for SearchMoneyRequestReportPage dismiss-on-removal logic
2 parents f6e7b99 + dc35174 commit 290ed93

3 files changed

Lines changed: 220 additions & 28 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {useIsFocused} from '@react-navigation/native';
2+
import {useEffect, useRef} from 'react';
3+
import {isMoneyRequestReport} from '@libs/ReportUtils';
4+
import Navigation from '@navigation/Navigation';
5+
import ONYXKEYS from '@src/ONYXKEYS';
6+
import useOnyx from './useOnyx';
7+
import usePrevious from './usePrevious';
8+
9+
/**
10+
* Dismisses the modal when a money request report is removed (e.g. deleted or merged).
11+
* Skips dismissal during route changes — the new report's data may not be loaded yet,
12+
* so the absent `report` should not be interpreted as removal.
13+
*/
14+
function useDismissOnMoneyRequestReportRemoval(reportIDFromRoute: string | undefined) {
15+
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`);
16+
const prevReport = usePrevious(report);
17+
const prevReportIDFromRoute = usePrevious(reportIDFromRoute);
18+
const isFocused = useIsFocused();
19+
const firstRenderRef = useRef(true);
20+
21+
useEffect(() => {
22+
if (firstRenderRef.current) {
23+
firstRenderRef.current = false;
24+
return;
25+
}
26+
27+
if (prevReportIDFromRoute !== reportIDFromRoute) {
28+
return;
29+
}
30+
31+
const isRemovalExpectedForReportType = !report && isMoneyRequestReport(prevReport);
32+
33+
if (isRemovalExpectedForReportType) {
34+
if (!isFocused) {
35+
return;
36+
}
37+
Navigation.dismissModal();
38+
}
39+
}, [report, isFocused, prevReport, prevReportIDFromRoute, reportIDFromRoute]);
40+
}
41+
42+
export default useDismissOnMoneyRequestReportRemoval;

src/pages/Search/SearchMoneyRequestReportPage.tsx

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import useShowSuperWideRHPVersion from '@components/WideRHPContextProvider/useSh
1313
import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper';
1414
import useActionListContextValue from '@hooks/useActionListContextValue';
1515
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
16+
import useDismissOnMoneyRequestReportRemoval from '@hooks/useDismissOnMoneyRequestReportRemoval';
1617
import useDocumentTitle from '@hooks/useDocumentTitle';
1718
import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay';
1819
import useNetwork from '@hooks/useNetwork';
@@ -39,7 +40,7 @@ import {
3940
isMoneyRequestAction,
4041
} from '@libs/ReportActionsUtils';
4142
import {getReportName} from '@libs/ReportNameUtils';
42-
import {isMoneyRequestReport, isMoneyRequestReportPendingDeletion, isValidReportIDFromPath} from '@libs/ReportUtils';
43+
import {isMoneyRequestReportPendingDeletion, isValidReportIDFromPath} from '@libs/ReportUtils';
4344
import {cancelSpansByPrefix} from '@libs/telemetry/activeSpans';
4445
import {doesDeleteNavigateBackUrlIncludeDuplicatesReview, getParentReportActionDeletionStatus, hasLoadedReportActions, isThreadReportDeleted} from '@libs/TransactionNavigationUtils';
4546
import Navigation from '@navigation/Navigation';
@@ -72,8 +73,6 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) {
7273
const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID);
7374
const {currentSearchResults: snapshot} = useSearchStateContext();
7475

75-
const firstRenderRef = useRef(true);
76-
7776
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`);
7877
const [deleteTransactionNavigateBackUrl] = useOnyx(ONYXKEYS.NVP_DELETE_TRANSACTION_NAVIGATE_BACK_URL);
7978

@@ -86,34 +85,10 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) {
8685
);
8786

8887
const [parentReportLoadingState] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${report?.parentReportID}`);
89-
const prevReport = usePrevious(report);
90-
const prevReportIDFromRoute = usePrevious(reportIDFromRoute);
9188
const {email: currentUserEmail, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
9289
const isFocused = useIsFocused();
9390

94-
// Dismiss modal when the money request report is removed (e.g. deleted or merged).
95-
useEffect(() => {
96-
// Skip first run so we don't dismiss on mount when report may still be loading.
97-
if (firstRenderRef.current) {
98-
firstRenderRef.current = false;
99-
return;
100-
}
101-
102-
// Route just changed — new report data may not be loaded yet, so don't treat as removal.
103-
if (prevReportIDFromRoute !== reportIDFromRoute) {
104-
return;
105-
}
106-
107-
// Report is gone now but we had a money request report before → it was removed.
108-
const isRemovalExpectedForReportType = !report && isMoneyRequestReport(prevReport);
109-
110-
if (isRemovalExpectedForReportType) {
111-
if (!isFocused) {
112-
return;
113-
}
114-
Navigation.dismissModal();
115-
}
116-
}, [report, isFocused, prevReport, prevReportIDFromRoute, reportIDFromRoute]);
91+
useDismissOnMoneyRequestReportRemoval(reportIDFromRoute);
11792

11893
useEffect(() => {
11994
// Update last visit time when the expense super wide RHP report is focused
@@ -210,6 +185,7 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) {
210185

211186
// Tracks initial mount to ensure openReport is called once for multi-transaction reports
212187
const isInitialMountRef = useRef(true);
188+
const prevReportIDFromRoute = usePrevious(reportIDFromRoute);
213189

214190
useEffect(() => {
215191
// Reset flag when reportID changes (screen stays mounted but navigates to different report)
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import type * as ReactNavigation from '@react-navigation/native';
2+
import {renderHook} from '@testing-library/react-native';
3+
import Onyx from 'react-native-onyx';
4+
import useDismissOnMoneyRequestReportRemoval from '@hooks/useDismissOnMoneyRequestReportRemoval';
5+
import Navigation from '@navigation/Navigation';
6+
import CONST from '@src/CONST';
7+
import ONYXKEYS from '@src/ONYXKEYS';
8+
import type {Report} from '@src/types/onyx';
9+
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';
10+
11+
const mockUseIsFocused = jest.fn().mockReturnValue(true);
12+
13+
jest.mock('@react-navigation/native', () => {
14+
const actualNav = jest.requireActual<typeof ReactNavigation>('@react-navigation/native');
15+
return {
16+
...actualNav,
17+
useIsFocused: () => mockUseIsFocused() as boolean,
18+
};
19+
});
20+
21+
jest.mock('@navigation/Navigation', () => ({
22+
dismissModal: jest.fn(),
23+
}));
24+
25+
const REPORT_A_ID = '1';
26+
const REPORT_B_ID = '2';
27+
28+
function buildMoneyRequestReport(id: string, overrides?: Partial<Report>): Report {
29+
return {
30+
reportID: id,
31+
type: CONST.REPORT.TYPE.EXPENSE,
32+
chatType: undefined,
33+
...overrides,
34+
} as Report;
35+
}
36+
37+
function buildChatReport(id: string): Report {
38+
return {
39+
reportID: id,
40+
type: CONST.REPORT.TYPE.CHAT,
41+
} as Report;
42+
}
43+
44+
describe('useDismissOnMoneyRequestReportRemoval', () => {
45+
beforeAll(() => {
46+
Onyx.init({keys: ONYXKEYS});
47+
});
48+
49+
beforeEach(async () => {
50+
jest.clearAllMocks();
51+
mockUseIsFocused.mockReturnValue(true);
52+
await Onyx.clear();
53+
await waitForBatchedUpdates();
54+
});
55+
56+
it('dismisses the modal when a focused money request report is removed', async () => {
57+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID));
58+
await waitForBatchedUpdates();
59+
60+
const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), {
61+
initialProps: {reportID: REPORT_A_ID},
62+
});
63+
64+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, null);
65+
await waitForBatchedUpdates();
66+
rerender({reportID: REPORT_A_ID});
67+
68+
expect(Navigation.dismissModal).toHaveBeenCalledTimes(1);
69+
});
70+
71+
it('does not dismiss the modal on first render even if the report is missing', async () => {
72+
renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), {
73+
initialProps: {reportID: REPORT_A_ID},
74+
});
75+
await waitForBatchedUpdates();
76+
77+
expect(Navigation.dismissModal).not.toHaveBeenCalled();
78+
});
79+
80+
it('does not dismiss the modal when the screen is unfocused', async () => {
81+
mockUseIsFocused.mockReturnValue(false);
82+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID));
83+
await waitForBatchedUpdates();
84+
85+
const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), {
86+
initialProps: {reportID: REPORT_A_ID},
87+
});
88+
89+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, null);
90+
await waitForBatchedUpdates();
91+
rerender({reportID: REPORT_A_ID});
92+
93+
expect(Navigation.dismissModal).not.toHaveBeenCalled();
94+
});
95+
96+
it('does not dismiss the modal when the previous report was not a money request report', async () => {
97+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildChatReport(REPORT_A_ID));
98+
await waitForBatchedUpdates();
99+
100+
const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), {
101+
initialProps: {reportID: REPORT_A_ID},
102+
});
103+
104+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, null);
105+
await waitForBatchedUpdates();
106+
rerender({reportID: REPORT_A_ID});
107+
108+
expect(Navigation.dismissModal).not.toHaveBeenCalled();
109+
});
110+
111+
// Regression for https://github.com/Expensify/App/pull/89150 — navigating between reports with the
112+
// prev/next arrows in the search/saved-search RHP should not dismiss the modal when the next report's
113+
// data has not yet loaded into Onyx.
114+
it('does not dismiss the modal when the route changes to a report whose data has not loaded yet', async () => {
115+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID));
116+
await waitForBatchedUpdates();
117+
118+
const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), {
119+
initialProps: {reportID: REPORT_A_ID},
120+
});
121+
122+
rerender({reportID: REPORT_B_ID});
123+
await waitForBatchedUpdates();
124+
125+
expect(Navigation.dismissModal).not.toHaveBeenCalled();
126+
});
127+
128+
// Reviewer scenario: while navigating with the arrows in a saved/canned search, the previous report's
129+
// status changes (e.g. open → processing) on the backend. The arrows must keep working — the modal must
130+
// not be dismissed in any of the intermediate render passes.
131+
it('keeps the arrows working when a report status changes mid-navigation in a saved search', async () => {
132+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID, {stateNum: CONST.REPORT.STATE_NUM.OPEN}));
133+
await waitForBatchedUpdates();
134+
135+
const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), {
136+
initialProps: {reportID: REPORT_A_ID},
137+
});
138+
139+
// Status change on the previous report (still defined, just mutated).
140+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, {stateNum: CONST.REPORT.STATE_NUM.SUBMITTED});
141+
await waitForBatchedUpdates();
142+
rerender({reportID: REPORT_A_ID});
143+
144+
// Arrow forward — report B is not in Onyx yet.
145+
rerender({reportID: REPORT_B_ID});
146+
await waitForBatchedUpdates();
147+
148+
// Report B data arrives.
149+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_B_ID}`, buildMoneyRequestReport(REPORT_B_ID, {stateNum: CONST.REPORT.STATE_NUM.OPEN}));
150+
await waitForBatchedUpdates();
151+
rerender({reportID: REPORT_B_ID});
152+
153+
// Arrow back — report A is still around.
154+
rerender({reportID: REPORT_A_ID});
155+
await waitForBatchedUpdates();
156+
157+
expect(Navigation.dismissModal).not.toHaveBeenCalled();
158+
});
159+
160+
it('does not dismiss the modal when the report is updated but still present', async () => {
161+
await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, buildMoneyRequestReport(REPORT_A_ID, {stateNum: CONST.REPORT.STATE_NUM.OPEN}));
162+
await waitForBatchedUpdates();
163+
164+
const {rerender} = renderHook(({reportID}: {reportID: string}) => useDismissOnMoneyRequestReportRemoval(reportID), {
165+
initialProps: {reportID: REPORT_A_ID},
166+
});
167+
168+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_A_ID}`, {stateNum: CONST.REPORT.STATE_NUM.APPROVED, statusNum: CONST.REPORT.STATUS_NUM.APPROVED});
169+
await waitForBatchedUpdates();
170+
rerender({reportID: REPORT_A_ID});
171+
172+
expect(Navigation.dismissModal).not.toHaveBeenCalled();
173+
});
174+
});

0 commit comments

Comments
 (0)