Skip to content

Commit 6f243f7

Browse files
committed
fix: resolve comments
1 parent 94474db commit 6f243f7

6 files changed

Lines changed: 80 additions & 21 deletions

File tree

src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import useLocalize from '@hooks/useLocalize';
33

44
import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils';
55
import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils';
6-
import {isInvalidMerchantValue, isValidInputLength} from '@libs/ValidationUtils';
6+
import {isInvalidMerchantValue, isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils';
77
import {getIsViolationFixed} from '@libs/Violations/ViolationsUtils';
88

99
import CONST from '@src/CONST';
@@ -172,8 +172,7 @@ function useFormErrorManagement({
172172
return false;
173173
}
174174

175-
const isUntypedPlaceholder = !transaction?.isMerchantSet && isInvalidMerchantValue(trimmedMerchant);
176-
if (!trimmedMerchant || isUntypedPlaceholder) {
175+
if (!trimmedMerchant || isUntypedPlaceholderMerchant(transaction?.isMerchantSet, trimmedMerchant)) {
177176
return !isMerchantRequired;
178177
}
179178

src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
1010
import {clearMoneyRequestMerchant, setMoneyRequestMerchant} from '@libs/actions/IOU/MoneyRequest';
1111
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
1212
import Navigation from '@libs/Navigation/Navigation';
13-
import {isInvalidMerchantValue, isValidInputLength} from '@libs/ValidationUtils';
13+
import {isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils';
1414

1515
import {setDraftSplitTransaction} from '@userActions/IOU/Split';
1616

@@ -62,7 +62,7 @@ function MerchantField({
6262
const merchantState = useTransactionSelector(transactionID, merchantStateSelector);
6363

6464
const merchantValue = merchantState?.merchant ?? '';
65-
const displayMerchantValue = !merchantState?.isMerchantSet && isInvalidMerchantValue(merchantValue) ? '' : merchantValue;
65+
const displayMerchantValue = isUntypedPlaceholderMerchant(merchantState?.isMerchantSet, merchantValue) ? '' : merchantValue;
6666
const transactionHasReceipt = merchantState?.hasReceipt ?? false;
6767

6868
// Determine if the merchant error should be displayed

src/libs/ValidationUtils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,14 @@ function isInvalidMerchantValue(merchant?: string): boolean {
818818
return merchant === '' || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || merchant === CONST.TRANSACTION.DEFAULT_MERCHANT;
819819
}
820820

821+
/**
822+
* Checks if a merchant is a placeholder the user never typed: the flow seeded the "Expense" / "(none)" value,
823+
* so it should be treated as empty rather than as an invalid entry the user is responsible for.
824+
*/
825+
function isUntypedPlaceholderMerchant(isMerchantSet: boolean | undefined, merchant?: string): boolean {
826+
return !isMerchantSet && isInvalidMerchantValue(merchant);
827+
}
828+
821829
/**
822830
* Validates a 4-digit PIN for UK/EU Expensify Card.
823831
* PIN must be exactly 4 digits and not in the list of invalid/weak PINs.
@@ -926,6 +934,7 @@ export {
926934
isValidInputLength,
927935
isValidTaxIDEINNumber,
928936
isInvalidMerchantValue,
937+
isUntypedPlaceholderMerchant,
929938
isValidPIN,
930939
containsHtmlTag,
931940
};

src/pages/iou/request/IOURequestStartPage.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ function IOURequestStartPage({
224224

225225
const shouldShowWorkspaceSelectForPerDiem = moreThanOnePerDiemExist && !hasCurrentPolicyPerDiemEnabled;
226226

227+
// Every flow that reaches this page embeds the confirmation as its landing step except INVOICE, which stays on the
228+
// amount-first flow. (`shouldUseTab` also excludes SEND, but the money-request route params type it away, so PAY is
229+
// the only type this has to add back.)
230+
// The pay quick action still writes SKIP_CONFIRMATION, but IOURequestStepAmount is its only reader and no longer
231+
// mounts for PAY - the embedded confirmation carries the amount inline, so there is no separate step left to skip.
227232
const shouldEmbedConfirmation = isNewManualExpenseFlowEnabled && (shouldUseTab || iouType === CONST.IOU.TYPE.PAY);
228233

229234
let manualContent: React.ReactNode;
@@ -237,7 +242,10 @@ function IOURequestStartPage({
237242
reportDraft={reportDraft}
238243
/>
239244
);
240-
} else if (isScanRequest(transaction) || isPerDiemRequest(transaction)) {
245+
} else if (shouldUseTab && (isScanRequest(transaction) || isPerDiemRequest(transaction))) {
246+
// Only the tabbed flows can land here with a stale draft, and only they run the reset that clears it
247+
// (`resetIOUTypeIfChanged` is wired to `onTabSelected` below). PAY renders no tabs, so it must skip this
248+
// branch or a leftover scan/per-diem draft would strand it on a loader with no way out but the back button.
241249
// When switching from the Scan or Per diem tab, the shared draft is briefly still a scan/per-diem request
242250
// until the tab-switch reset rebuilds it as manual. Mounting the embedded confirmation against that stale
243251
// draft does throwaway work that is immediately discarded once the reset lands - for scan a heavy first

tests/ui/IOURequestStartPageManualTabTest.tsx

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const TRANSACTION_ID = 'transaction1';
2121
const CONFIRMATION_TEST_ID = 'EmbeddedConfirmation';
2222
const LOADER_TEST_ID = 'manualTabPendingReset';
2323
const AMOUNT_TEST_ID = 'EmbeddedAmount';
24+
const CURRENT_USER_EMAIL = 'invoice.sender@example.com';
2425

2526
jest.mock('@userActions/Tab');
2627
jest.mock('@rnmapbox/maps', () => ({
@@ -87,10 +88,21 @@ describe('IOURequestStartPage manual tab content', () => {
8788
});
8889
});
8990

91+
type RenderStartPageOptions = {
92+
/** The request type the shared draft still carries when the page mounts. */
93+
iouRequestType: IOURequestType;
94+
95+
/** The flow the page is started for - this is what decides whether tabs are rendered. */
96+
iouType?: IOUType;
97+
98+
/** Whether the new manual expense flow beta is on. */
99+
isNewManualExpenseFlowEnabled?: boolean;
100+
};
101+
90102
/**
91103
* Seeds the beta, the manual tab selection and a draft transaction of the given request type, then renders the page.
92104
*/
93-
async function renderStartPageWithDraftType(iouRequestType: IOURequestType, iouType: IOUType = CONST.IOU.TYPE.SUBMIT, isNewManualExpenseFlowEnabled = true) {
105+
async function renderStartPage({iouRequestType, iouType = CONST.IOU.TYPE.SUBMIT, isNewManualExpenseFlowEnabled = true}: RenderStartPageOptions) {
94106
await act(async () => {
95107
await Onyx.set(ONYXKEYS.BETAS, isNewManualExpenseFlowEnabled ? [CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW] : []);
96108
await Onyx.set(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.IOU_REQUEST_TYPE}`, CONST.TAB_REQUEST.MANUAL);
@@ -130,7 +142,7 @@ describe('IOURequestStartPage manual tab content', () => {
130142

131143
it('shows a loader instead of the embedded confirmation while a per diem draft is still pending its reset to manual', async () => {
132144
// Given the new manual expense flow beta and a draft that is still a per diem request
133-
await renderStartPageWithDraftType(CONST.IOU.REQUEST_TYPE.PER_DIEM);
145+
await renderStartPage({iouRequestType: CONST.IOU.REQUEST_TYPE.PER_DIEM});
134146

135147
// Then the manual tab waits for the reset instead of mounting the confirmation against the per diem draft
136148
expect(screen.getByTestId(LOADER_TEST_ID)).toBeOnTheScreen();
@@ -139,7 +151,7 @@ describe('IOURequestStartPage manual tab content', () => {
139151

140152
it('shows a loader instead of the embedded confirmation while a scan draft is still pending its reset to manual', async () => {
141153
// Given the new manual expense flow beta and a draft that is still a scan request
142-
await renderStartPageWithDraftType(CONST.IOU.REQUEST_TYPE.SCAN);
154+
await renderStartPage({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN});
143155

144156
// Then the manual tab waits for the reset instead of mounting the confirmation against the scan draft
145157
expect(screen.getByTestId(LOADER_TEST_ID)).toBeOnTheScreen();
@@ -148,7 +160,7 @@ describe('IOURequestStartPage manual tab content', () => {
148160

149161
it('shows the embedded confirmation once the draft is a manual request', async () => {
150162
// Given the new manual expense flow beta and a draft that has been reset to a manual request
151-
await renderStartPageWithDraftType(CONST.IOU.REQUEST_TYPE.MANUAL);
163+
await renderStartPage({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL});
152164

153165
// Then the confirmation is mounted and the pending-reset loader is gone
154166
expect(screen.getByTestId(CONFIRMATION_TEST_ID)).toBeOnTheScreen();
@@ -157,16 +169,45 @@ describe('IOURequestStartPage manual tab content', () => {
157169

158170
it('lands the tab-less pay flow directly on the embedded confirmation instead of the amount page', async () => {
159171
// Given the new manual expense flow beta and a pay flow, which renders no tabs
160-
await renderStartPageWithDraftType(CONST.IOU.REQUEST_TYPE.MANUAL, CONST.IOU.TYPE.PAY);
172+
await renderStartPage({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, iouType: CONST.IOU.TYPE.PAY});
161173

162174
// Then the details page is the landing page, so the amount page is never shown first
163175
expect(screen.getByTestId(CONFIRMATION_TEST_ID)).toBeOnTheScreen();
164176
expect(screen.queryByTestId(AMOUNT_TEST_ID)).not.toBeOnTheScreen();
165177
});
166178

179+
it('does not strand the tab-less pay flow on the pending-reset loader when the draft is still a scan request', async () => {
180+
// Given a pay flow that mounts against a leftover scan draft from an earlier expense in the same chat
181+
await renderStartPage({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, iouType: CONST.IOU.TYPE.PAY});
182+
183+
// Then the confirmation mounts anyway - the pay flow renders no tabs, so the reset the loader waits on never runs
184+
expect(screen.getByTestId(CONFIRMATION_TEST_ID)).toBeOnTheScreen();
185+
expect(screen.queryByTestId(LOADER_TEST_ID)).not.toBeOnTheScreen();
186+
});
187+
188+
it('keeps the amount page as the landing page for the invoice flow', async () => {
189+
// Given an invoice flow, which is the one type left off the embedded confirmation
190+
await act(async () => {
191+
// AccessOrNotFoundWrapper gates the invoice flow behind an admin workspace that can send invoices.
192+
await Onyx.set(ONYXKEYS.SESSION, {email: CURRENT_USER_EMAIL, accountID: 1});
193+
await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}policy1`, {
194+
id: 'policy1',
195+
name: 'Invoice workspace',
196+
type: CONST.POLICY.TYPE.TEAM,
197+
role: CONST.POLICY.ROLE.ADMIN,
198+
areInvoicesEnabled: true,
199+
});
200+
});
201+
await renderStartPage({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, iouType: CONST.IOU.TYPE.INVOICE});
202+
203+
// Then it still lands on the amount page first
204+
expect(screen.getByTestId(AMOUNT_TEST_ID)).toBeOnTheScreen();
205+
expect(screen.queryByTestId(CONFIRMATION_TEST_ID)).not.toBeOnTheScreen();
206+
});
207+
167208
it('keeps the amount page as the landing page for the pay flow when the beta is off', async () => {
168209
// Given a pay flow started without the new manual expense flow beta
169-
await renderStartPageWithDraftType(CONST.IOU.REQUEST_TYPE.MANUAL, CONST.IOU.TYPE.PAY, false);
210+
await renderStartPage({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, iouType: CONST.IOU.TYPE.PAY, isNewManualExpenseFlowEnabled: false});
170211

171212
// Then the legacy amount-first flow is preserved
172213
expect(screen.getByTestId(AMOUNT_TEST_ID)).toBeOnTheScreen();

tests/unit/hooks/useFormErrorManagement.test.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,16 @@ const baseParams: Params = {
5050
};
5151

5252
// A manual draft the user never typed a merchant into: `initMoneyRequest` seeds it with the "Expense" placeholder.
53+
const placeholderMerchantTransaction = createMock<OnyxTypes.Transaction>({
54+
transactionID: 'txn1',
55+
amount: 100,
56+
merchant: CONST.TRANSACTION.DEFAULT_MERCHANT,
57+
isMerchantSet: false,
58+
comment: {},
59+
});
60+
5361
const placeholderMerchantParams: Partial<Params> = {
54-
transaction: createMock<OnyxTypes.Transaction>({transactionID: 'txn1', amount: 100, merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantSet: false, comment: {}}),
62+
transaction: placeholderMerchantTransaction,
5563
iouMerchant: CONST.TRANSACTION.DEFAULT_MERCHANT,
5664
};
5765

@@ -169,13 +177,7 @@ describe('useFormErrorManagement', () => {
169177
useFormErrorManagement({
170178
...baseParams,
171179
...placeholderMerchantParams,
172-
transaction: createMock<OnyxTypes.Transaction>({
173-
transactionID: 'txn1',
174-
amount: 100,
175-
merchant: CONST.TRANSACTION.DEFAULT_MERCHANT,
176-
isMerchantSet: true,
177-
comment: {},
178-
}),
180+
transaction: {...placeholderMerchantTransaction, isMerchantSet: true},
179181
isPolicyExpenseChat: false,
180182
}),
181183
{wrapper: Wrapper},
@@ -184,7 +186,7 @@ describe('useFormErrorManagement', () => {
184186
expect(result.current.isMerchantFieldValid).toBe(false);
185187
});
186188

187-
it('clears the invalid merchant error once the recipient changes from a workspace chat to a user (#96593)', async () => {
189+
it('clears the invalid merchant error once the recipient changes from a workspace chat to a user (#96593)', () => {
188190
// Given an untouched manual draft (still carrying the placeholder merchant) headed for a workspace chat
189191
const {result, rerender} = renderHook(
190192
({isPolicyExpenseChat}: {isPolicyExpenseChat: boolean}) =>

0 commit comments

Comments
 (0)