Skip to content

Commit e5efd1d

Browse files
authored
Merge pull request #98000 from cretadn22/refactor/66419-hasReportBeenForwardedSinceLastSubmit
Remove REPORT_ACTIONS Onyx reference from hasReportBeenForwardedSinceLastSubmit - 1
2 parents 2cc56d9 + 4fc88dc commit e5efd1d

8 files changed

Lines changed: 550 additions & 8 deletions

File tree

src/components/ReportActionItem/MoneyRequestView.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,8 @@ function MoneyRequestView({
382382
// Used for non-restricted fields such as: description, category, tag, billable, etc...
383383
const isReportArchived = useReportIsArchived(transactionThreadReport?.reportID);
384384
const isEditable = !!canUserPerformWriteActionReportUtils(transactionThreadReport, isReportArchived) && !readonly;
385-
const canEdit = isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, moneyRequestReport, policy) && isEditable;
385+
const canEdit =
386+
isMoneyRequestAction(parentReportAction) && canEditMoneyRequest(parentReportAction, transaction, isChatReportArchived, moneyRequestReport, policy, parentReportActions) && isEditable;
386387
const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(transactionThreadReport?.policyID)}`;
387388
const {personalCardsWithBrokenConnection} = useCardFeedErrors();
388389
const connectionLink = getBrokenConnectionUrlToFixPersonalCard(personalCardsWithBrokenConnection, environmentURL);

src/components/ReportActionItem/TransactionPreview/TransactionPreviewContent.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ function TransactionPreviewContent({
136136
const {shouldShowRBR, shouldShowMerchant, shouldShowSplitShare, shouldShowTag, shouldShowCategory, shouldShowSkeleton, shouldShowDescription} = conditionals;
137137

138138
const isIOUActionType = isMoneyRequestAction(action);
139-
const canEdit = isIOUActionType && canEditMoneyRequest(action, transaction, isChatReportArchived, report, policy);
139+
const canEdit = isIOUActionType && canEditMoneyRequest(action, transaction, isChatReportArchived, report, policy, reportActions);
140140
const companyCardPageURL = `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(report?.policyID)}`;
141141
const {personalCardsWithBrokenConnection} = useCardFeedErrors();
142142
const connectionLink = getBrokenConnectionUrlToFixPersonalCard(personalCardsWithBrokenConnection, environmentURL);

src/hooks/useShowNotFoundPageInIOUStep.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const useShowNotFoundPageInIOUStep = (action: IOUAction, iouType: IOUType, repor
3030
const [session] = useOnyx(ONYXKEYS.SESSION);
3131
const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`);
3232
const [iouReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(transaction?.reportID)}`);
33+
const [iouReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(transaction?.reportID)}`);
3334

3435
const reportActionsReportID = useMemo(() => {
3536
let actionsReportID;
@@ -59,7 +60,7 @@ const useShowNotFoundPageInIOUStep = (action: IOUAction, iouType: IOUType, repor
5960
} else if (isSplitExpense) {
6061
shouldShowNotFoundPage = !canEditSplitExpense;
6162
} else {
62-
shouldShowNotFoundPage = !isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction, transaction, false, iouReport, policy);
63+
shouldShowNotFoundPage = !isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction, transaction, false, iouReport, policy, iouReportActions);
6364
}
6465
}
6566

src/libs/ReportUtils.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2051,15 +2051,15 @@ function isReportOpenOrUnsubmitted(reportID: string | undefined, reports: OnyxCo
20512051
return report.stateNum === CONST.REPORT.STATE_NUM.OPEN;
20522052
}
20532053

2054-
function hasReportBeenForwardedSinceLastSubmit(report: OnyxEntry<Report>): boolean {
2054+
function hasReportBeenForwardedSinceLastSubmit(report: OnyxEntry<Report>, reportActions?: OnyxEntry<ReportActions>): boolean {
20552055
if (!report?.reportID) {
20562056
return false;
20572057
}
20582058

2059-
const reportActions = Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {});
2060-
const lastSubmittedAt = reportActions.filter(isSubmittedAction).reduce<string>((latest, action) => (action.created > latest ? action.created : latest), '');
2059+
const reportActionsArray = Object.values(reportActions ?? allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`] ?? {});
2060+
const lastSubmittedAt = reportActionsArray.filter(isSubmittedAction).reduce<string>((latest, action) => (action.created > latest ? action.created : latest), '');
20612061

2062-
return reportActions.some((action) => isForwardedAction(action) && action.created > lastSubmittedAt);
2062+
return reportActionsArray.some((action) => isForwardedAction(action) && action.created > lastSubmittedAt);
20632063
}
20642064

20652065
function isAwaitingFirstLevelApproval(report: OnyxEntry<Report>): boolean {
@@ -4999,6 +4999,7 @@ function canEditMoneyRequest(
49994999
isChatReportArchived = false,
50005000
report?: OnyxInputOrEntry<Report>,
50015001
policy?: OnyxEntry<Policy>,
5002+
reportActions?: OnyxEntry<ReportActions>,
50025003
): boolean {
50035004
const isDeleted = isDeletedAction(reportAction);
50045005

@@ -5078,7 +5079,7 @@ function canEditMoneyRequest(
50785079
if (reportPolicy?.type === CONST.POLICY.TYPE.CORPORATE && moneyRequestReport && isSubmitted && isCurrentUserSubmitter(moneyRequestReport)) {
50795080
const isForwarded =
50805081
getSubmitToAccountID(reportPolicy, moneyRequestReport, getLoginByAccountID(moneyRequestReport.ownerAccountID, allPersonalDetails)) !== moneyRequestReport.managerID ||
5081-
hasReportBeenForwardedSinceLastSubmit(moneyRequestReport);
5082+
hasReportBeenForwardedSinceLastSubmit(moneyRequestReport, reportActions);
50825083
return !isForwarded;
50835084
}
50845085

@@ -14250,6 +14251,7 @@ export {
1425014251
getPolicyIDsWithEmptyReportsForAccount,
1425114252
getActionErrorsByTransaction,
1425214253
hasActionWithErrorsForTransaction,
14254+
hasReportBeenForwardedSinceLastSubmit,
1425314255
hasAutomatedExpensifyAccountIDs,
1425414256
hasEmptyReportsForPolicy,
1425514257
hasHeldExpenses,

tests/ui/MoneyRequestViewTest.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,82 @@ describe('MoneyRequestView edit fields', () => {
316316
});
317317
});
318318

319+
it('should show amount as editable for the submitter when a submitted report has not been forwarded', async () => {
320+
const approverAccountID = 999;
321+
const approverEmail = 'approver@test.com';
322+
const corporatePolicy = {
323+
type: CONST.POLICY.TYPE.CORPORATE,
324+
role: CONST.POLICY.ROLE.USER,
325+
employeeList: {[currentUserEmail]: {email: currentUserEmail, role: CONST.POLICY.ROLE.USER, submitsTo: approverEmail}},
326+
};
327+
const threadReport = {
328+
...LHNTestUtils.getFakeReport(),
329+
parentReportID: expenseReportID,
330+
parentReportActionID,
331+
};
332+
333+
await setupTestData();
334+
await act(async () => {
335+
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[approverAccountID]: {accountID: approverAccountID, login: approverEmail, displayName: 'Approver'}});
336+
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, corporatePolicy);
337+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, {
338+
managerID: approverAccountID,
339+
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
340+
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
341+
});
342+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, {
343+
submitted: {...LHNTestUtils.getFakeReportAction(), reportActionID: 'submitted', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2026-04-21 17:00:00'},
344+
});
345+
});
346+
await waitForBatchedUpdatesWithAct();
347+
348+
renderMoneyRequestView(threadReport, corporatePolicy);
349+
await waitForBatchedUpdatesWithAct();
350+
351+
await waitFor(() => {
352+
expect(screen.getByTestId(/^menu-item-iou\.amount/)).toHaveTextContent('editable');
353+
});
354+
});
355+
356+
it('should show amount as readonly for the submitter after the report was forwarded since the last submit', async () => {
357+
const approverAccountID = 999;
358+
const approverEmail = 'approver@test.com';
359+
const corporatePolicy = {
360+
type: CONST.POLICY.TYPE.CORPORATE,
361+
role: CONST.POLICY.ROLE.USER,
362+
employeeList: {[currentUserEmail]: {email: currentUserEmail, role: CONST.POLICY.ROLE.USER, submitsTo: approverEmail}},
363+
};
364+
const threadReport = {
365+
...LHNTestUtils.getFakeReport(),
366+
parentReportID: expenseReportID,
367+
parentReportActionID,
368+
};
369+
370+
await setupTestData();
371+
await act(async () => {
372+
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[approverAccountID]: {accountID: approverAccountID, login: approverEmail, displayName: 'Approver'}});
373+
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, corporatePolicy);
374+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, {
375+
managerID: approverAccountID,
376+
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
377+
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
378+
});
379+
// The report was forwarded after the last submit, so the submitter loses edit access
380+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, {
381+
submitted: {...LHNTestUtils.getFakeReportAction(), reportActionID: 'submitted', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2026-04-21 17:00:00'},
382+
forwarded: {...LHNTestUtils.getFakeReportAction(), reportActionID: 'forwarded', actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '2026-04-21 17:10:00'},
383+
});
384+
});
385+
await waitForBatchedUpdatesWithAct();
386+
387+
renderMoneyRequestView(threadReport, corporatePolicy);
388+
await waitForBatchedUpdatesWithAct();
389+
390+
await waitFor(() => {
391+
expect(screen.getByTestId(/^menu-item-iou\.amount/)).toHaveTextContent('readonly');
392+
});
393+
});
394+
319395
it('should append "Non-reimbursable" to the Amount description when the transaction is non-reimbursable in a single-expense report', async () => {
320396
const threadReport = {
321397
...LHNTestUtils.getFakeReport(),
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import {act, render, screen, waitFor} from '@testing-library/react-native';
2+
3+
import ComposeProviders from '@components/ComposeProviders';
4+
import OnyxListItemProvider from '@components/OnyxListItemProvider';
5+
import TransactionPreviewContent from '@components/ReportActionItem/TransactionPreview/TransactionPreviewContent';
6+
7+
import CONST from '@src/CONST';
8+
import ONYXKEYS from '@src/ONYXKEYS';
9+
import type {PersonalDetailsList, Policy, Report, ReportAction, Transaction, TransactionViolation} from '@src/types/onyx';
10+
11+
import React from 'react';
12+
import Onyx from 'react-native-onyx';
13+
14+
import * as LHNTestUtils from '../utils/LHNTestUtils';
15+
import * as TestHelper from '../utils/TestHelper';
16+
import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';
17+
18+
TestHelper.setupGlobalFetchMock();
19+
20+
jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({
21+
__esModule: true,
22+
default: () => ({
23+
didScreenTransitionEnd: true,
24+
}),
25+
}));
26+
27+
// Expose the canEdit translation parameter in the rendered output so canEdit-dependent messages can be asserted.
28+
// The message must stay under CONST.REPORT_VIOLATIONS.RBR_MESSAGE_MAX_CHARACTERS_FOR_PREVIEW or the preview
29+
// swaps it for the generic "review required" message.
30+
jest.mock('@hooks/useLocalize', () =>
31+
jest.fn(() => ({
32+
translate: jest.fn((key: string, params?: Record<string, unknown>) => (params && 'canEdit' in params ? `smartscan#${String(params.canEdit)}` : key)),
33+
numberFormat: jest.fn((num: number) => num.toString()),
34+
toLocaleDigit: jest.fn((digit: string) => digit),
35+
localeCompare: jest.fn((a: string, b: string) => a.localeCompare(b)),
36+
})),
37+
);
38+
39+
const currentUserAccountID = 20;
40+
const currentUserEmail = 'submitter@test.com';
41+
const approverAccountID = 21;
42+
const approverEmail = 'approver@test.com';
43+
const policyID = 'policy_tpc_test';
44+
const expenseReportID = 'expense_tpc_123';
45+
const transactionID = 'txn_tpc_test';
46+
47+
const corporatePolicy = {
48+
id: policyID,
49+
type: CONST.POLICY.TYPE.CORPORATE,
50+
role: CONST.POLICY.ROLE.USER,
51+
name: 'Corporate Policy',
52+
owner: '',
53+
outputCurrency: CONST.CURRENCY.USD,
54+
isPolicyExpenseChatEnabled: true,
55+
employeeList: {
56+
[currentUserEmail]: {
57+
email: currentUserEmail,
58+
role: CONST.POLICY.ROLE.USER,
59+
submitsTo: approverEmail,
60+
},
61+
},
62+
} as Policy;
63+
64+
const expenseReport = {
65+
reportID: expenseReportID,
66+
type: CONST.REPORT.TYPE.EXPENSE,
67+
policyID,
68+
ownerAccountID: currentUserAccountID,
69+
managerID: approverAccountID,
70+
stateNum: CONST.REPORT.STATE_NUM.SUBMITTED,
71+
statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED,
72+
} as Report;
73+
74+
const moneyRequestAction = {
75+
...LHNTestUtils.getFakeReportAction(),
76+
reportActionID: 'tpc_iou_action',
77+
reportID: expenseReportID,
78+
actionName: CONST.REPORT.ACTIONS.TYPE.IOU,
79+
actorAccountID: currentUserAccountID,
80+
originalMessage: {
81+
type: CONST.IOU.REPORT_ACTION_TYPE.CREATE,
82+
IOUTransactionID: transactionID,
83+
amount: 5000,
84+
currency: CONST.CURRENCY.USD,
85+
},
86+
} as ReportAction;
87+
88+
const transaction = {
89+
transactionID,
90+
reportID: expenseReportID,
91+
amount: 5000,
92+
currency: CONST.CURRENCY.USD,
93+
created: '2026-04-20',
94+
merchant: 'Coffee Shop',
95+
comment: {},
96+
} as Transaction;
97+
98+
const smartscanFailedViolation: TransactionViolation = {
99+
name: CONST.VIOLATIONS.SMARTSCAN_FAILED,
100+
type: CONST.VIOLATION_TYPES.VIOLATION,
101+
showInReview: true,
102+
};
103+
104+
const personalDetails: PersonalDetailsList = {
105+
[currentUserAccountID]: {accountID: currentUserAccountID, login: currentUserEmail, displayName: 'Submitter'},
106+
[approverAccountID]: {accountID: approverAccountID, login: approverEmail, displayName: 'Approver'},
107+
};
108+
109+
const renderTransactionPreviewContent = () =>
110+
render(
111+
<ComposeProviders components={[OnyxListItemProvider]}>
112+
<TransactionPreviewContent
113+
action={moneyRequestAction}
114+
isWhisper={false}
115+
isHovered={false}
116+
chatReport={undefined}
117+
personalDetails={personalDetails}
118+
report={expenseReport}
119+
policy={corporatePolicy}
120+
transaction={transaction}
121+
violations={[smartscanFailedViolation]}
122+
transactionRawAmount={5000}
123+
offlineWithFeedbackOnClose={() => {}}
124+
containerStyles={[]}
125+
transactionPreviewWidth={303}
126+
isBillSplit={false}
127+
areThereDuplicates={false}
128+
sessionAccountID={currentUserAccountID}
129+
walletTermsErrors={undefined}
130+
reportPreviewAction={undefined}
131+
navigateToReviewFields={() => {}}
132+
routeName="Report"
133+
/>
134+
</ComposeProviders>,
135+
);
136+
137+
describe('TransactionPreviewContent', () => {
138+
beforeAll(() => {
139+
Onyx.init({
140+
keys: ONYXKEYS,
141+
evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
142+
});
143+
});
144+
145+
afterEach(async () => {
146+
await act(async () => {
147+
await Onyx.clear();
148+
});
149+
});
150+
151+
const seedOnyx = async (reportActions: Record<string, ReportAction>) => {
152+
await act(async () => {
153+
await Onyx.merge(ONYXKEYS.SESSION, {accountID: currentUserAccountID, email: currentUserEmail});
154+
await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, personalDetails);
155+
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, corporatePolicy);
156+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${expenseReportID}`, expenseReport);
157+
await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${expenseReportID}`, reportActions);
158+
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction);
159+
});
160+
await waitForBatchedUpdatesWithAct();
161+
};
162+
163+
it('shows the editable smartscan-failed message for the submitter when the report has not been forwarded', async () => {
164+
await seedOnyx({
165+
[moneyRequestAction.reportActionID]: moneyRequestAction,
166+
submitted: {...LHNTestUtils.getFakeReportAction(), reportActionID: 'submitted', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2026-04-21 17:00:00'},
167+
});
168+
169+
renderTransactionPreviewContent();
170+
await waitForBatchedUpdatesWithAct();
171+
172+
await waitFor(() => {
173+
expect(screen.getByText('smartscan#true')).toBeOnTheScreen();
174+
});
175+
});
176+
177+
it('shows the read-only smartscan-failed message for the submitter after the report was forwarded since the last submit', async () => {
178+
await seedOnyx({
179+
[moneyRequestAction.reportActionID]: moneyRequestAction,
180+
submitted: {...LHNTestUtils.getFakeReportAction(), reportActionID: 'submitted', actionName: CONST.REPORT.ACTIONS.TYPE.SUBMITTED, created: '2026-04-21 17:00:00'},
181+
forwarded: {...LHNTestUtils.getFakeReportAction(), reportActionID: 'forwarded', actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED, created: '2026-04-21 17:10:00'},
182+
});
183+
184+
renderTransactionPreviewContent();
185+
await waitForBatchedUpdatesWithAct();
186+
187+
await waitFor(() => {
188+
expect(screen.getByText('smartscan#false')).toBeOnTheScreen();
189+
});
190+
});
191+
});

0 commit comments

Comments
 (0)