Skip to content

Commit f5ec733

Browse files
authored
Merge pull request Expensify#87920 from ChavdaSachin/Manual-expense-flow-UI-refactor-r3
Feat: Manual expense flow UI refactor - r3
2 parents 04fb592 + 734a1a4 commit f5ec733

25 files changed

Lines changed: 738 additions & 178 deletions

src/components/MoneyRequestConfirmationList.tsx

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {useFocusEffect, useIsFocused} from '@react-navigation/native';
2-
import React, {useCallback, useEffect, useRef, useState} from 'react';
2+
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
33
// eslint-disable-next-line no-restricted-imports
44
import {InteractionManager, View} from 'react-native';
55
import type {OnyxEntry} from 'react-native-onyx';
66
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
77
import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode';
8+
import useLocalize from '@hooks/useLocalize';
89
import {MouseProvider} from '@hooks/useMouseContext';
910
import usePermissions from '@hooks/usePermissions';
1011
import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
@@ -64,6 +65,9 @@ type MoneyRequestConfirmationListProps = {
6465
/** Callback to inform parent modal of success */
6566
onConfirm?: (selectedParticipants: Participant[]) => void;
6667

68+
/** When set, used in the new manual expense flow to open the parent-owned participant picker instead of navigating away */
69+
onOpenParticipantPicker?: () => void;
70+
6771
/** Callback to parent modal to pay someone */
6872
onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void;
6973

@@ -164,6 +168,7 @@ function MoneyRequestConfirmationList({
164168
transaction,
165169
onSendMoney,
166170
onConfirm,
171+
onOpenParticipantPicker,
167172
iouType = CONST.IOU.TYPE.SUBMIT,
168173
isOdometerDistanceRequest = false,
169174
isLoadingReceipt = false,
@@ -204,6 +209,7 @@ function MoneyRequestConfirmationList({
204209
const {isDelegateAccessRestricted} = useDelegateNoAccessState();
205210
const {showDelegateNoAccessModal} = useDelegateNoAccessActions();
206211
const isInLandscapeMode = useIsInLandscapeMode();
212+
const {translate} = useLocalize();
207213

208214
const {isTestReceipt, shouldShowProductTrainingTooltip, renderProductTrainingTooltip} = useReceiptTraining({
209215
transaction,
@@ -257,7 +263,6 @@ function MoneyRequestConfirmationList({
257263
iouCurrencyCode,
258264
});
259265

260-
// A flag for showing the categories field
261266
const shouldShowCategories = isTrackExpense
262267
? !policy || shouldSelectPolicy || !!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {}))
263268
: (isPolicyExpenseChat || isTypeInvoice) && (!!iouCategory || hasEnabledOptions(Object.values(policyCategories ?? {})));
@@ -293,6 +298,9 @@ function MoneyRequestConfirmationList({
293298
prevSubRates,
294299
});
295300

301+
const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL;
302+
const shouldForceTopEmptySections = isNewManualExpenseFlowEnabled && (iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest);
303+
296304
const isFocused = useIsFocused();
297305

298306
const [didConfirm, setDidConfirm] = useState(isConfirmed);
@@ -307,7 +315,7 @@ function MoneyRequestConfirmationList({
307315
const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT;
308316
const shouldShowReadOnlySplits = isPolicyExpenseChat || isReadOnly || isScanRequest;
309317

310-
const {formError, setFormError, clearFormErrors, shouldDisplayFieldError, isMerchantEmpty, isMerchantRequired, errorMessage} = useFormErrorManagement({
318+
const {formError, setFormError, clearFormErrors, shouldDisplayFieldError, isMerchantEmpty, isMerchantFieldValid, isMerchantRequired, errorMessage} = useFormErrorManagement({
311319
transaction,
312320
transactionReport,
313321
iouMerchant,
@@ -360,6 +368,24 @@ function MoneyRequestConfirmationList({
360368
const selectedParticipants = selectedParticipantsProp.filter((participant) => participant.selected);
361369
const payeePersonalDetails = payeePersonalDetailsProp ?? currentUserPersonalDetails;
362370

371+
const participantRowErrors = useMemo(() => {
372+
if (formError !== 'iou.error.noParticipantSelected' && formError !== 'violations.missingAttendees') {
373+
return undefined;
374+
}
375+
return {participants: translate(formError)};
376+
}, [formError, translate]);
377+
378+
useEffect(() => {
379+
if (selectedParticipants.length === 0) {
380+
return;
381+
}
382+
clearFormErrors(['iou.error.noParticipantSelected']);
383+
}, [selectedParticipants.length, clearFormErrors]);
384+
385+
const dismissParticipantRowError = useCallback(() => {
386+
clearFormErrors(['iou.error.noParticipantSelected', 'violations.missingAttendees']);
387+
}, [clearFormErrors]);
388+
363389
const {splitParticipants, getSplitSectionHeader} = useSplitParticipants({
364390
isTypeSplit,
365391
shouldShowReadOnlySplits,
@@ -375,6 +401,8 @@ function MoneyRequestConfirmationList({
375401
const sections = useConfirmationSections({
376402
isTypeSplit,
377403
shouldHideToSection,
404+
shouldForceTopEmptySections,
405+
participantRowErrors,
378406
canEditParticipant,
379407
payeePersonalDetails,
380408
splitParticipants,
@@ -390,8 +418,13 @@ function MoneyRequestConfirmationList({
390418
return;
391419
}
392420

421+
if (isNewManualExpenseFlowEnabled) {
422+
onOpenParticipantPicker?.();
423+
return;
424+
}
425+
393426
const newIOUType = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.CREATE : iouType;
394-
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(newIOUType, transactionID, transaction.reportID, Navigation.getActiveRoute(), action));
427+
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(newIOUType, transactionID, transaction?.reportID, Navigation.getActiveRoute(), action));
395428
};
396429

397430
const {validate} = useConfirmationValidation({
@@ -412,15 +445,16 @@ function MoneyRequestConfirmationList({
412445
currentUserPersonalDetails,
413446
isEditingSplitBill,
414447
isMerchantRequired,
448+
isMerchantFieldValid,
415449
isMerchantEmpty,
416450
shouldDisplayFieldError,
417451
shouldShowTax,
418452
isDistanceRequest,
419453
isDistanceRequestWithPendingRoute,
420454
isPerDiemRequest,
421455
isTimeRequest,
422-
isNewManualExpenseFlowEnabled,
423456
routeError,
457+
isNewManualExpenseFlowEnabled,
424458
});
425459

426460
const confirm = buildConfirmAction({
@@ -443,13 +477,17 @@ function MoneyRequestConfirmationList({
443477
const focusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
444478
useFocusEffect(
445479
useCallback(() => {
480+
// Blurring the active element after transition fights AmountField focus in the new manual flow (RHP reopen).
481+
if (isNewManualExpenseFlowEnabled) {
482+
return undefined;
483+
}
446484
focusTimeoutRef.current = setTimeout(() => {
447485
InteractionManager.runAfterInteractions(() => {
448486
blurActiveElement();
449487
});
450488
}, CONST.ANIMATED_TRANSITION);
451489
return () => focusTimeoutRef.current && clearTimeout(focusTimeoutRef.current);
452-
}, []),
490+
}, [isNewManualExpenseFlowEnabled]),
453491
);
454492

455493
const isCompactMode = !showMoreFields && isScanRequest && !isInLandscapeMode;
@@ -489,6 +527,8 @@ function MoneyRequestConfirmationList({
489527
formattedAmount={formattedAmount}
490528
formattedAmountPerAttendee={formattedAmountPerAttendee}
491529
formError={formError}
530+
clearFormErrors={clearFormErrors}
531+
setFormError={setFormError}
492532
hasRoute={hasRoute}
493533
iouType={iouType}
494534
isCategoryRequired={isCategoryRequired}
@@ -532,6 +572,7 @@ function MoneyRequestConfirmationList({
532572
isDescriptionRequired={isDescriptionRequired}
533573
showMoreFields={showMoreFields}
534574
setShowMoreFields={setShowMoreFields}
575+
onSubmitForm={confirm}
535576
/>
536577
</View>
537578
);
@@ -603,6 +644,7 @@ function MoneyRequestConfirmationList({
603644
sections={sections}
604645
ListItem={BareUserListItem}
605646
onSelectRow={navigateToParticipantPage}
647+
onDismissError={dismissParticipantRowError}
606648
shouldSingleExecuteRowSelect
607649
shouldPreventDefaultFocusOnSelectRow
608650
shouldShowListEmptyContent={false}

src/components/MoneyRequestConfirmationList/confirmAction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ function buildConfirmAction({
7979
onConfirm,
8080
onSendMoney,
8181
}: BuildConfirmActionParams) {
82-
return ({paymentType: paymentMethod}: PaymentActionParams) => {
82+
return ({paymentType: paymentMethod}: PaymentActionParams = {}) => {
8383
// Routing short-circuit: invoices without company info go to the company info step before we validate anything.
8484
if (iouType === CONST.IOU.TYPE.INVOICE && !hasInvoicingDetails(policy) && transactionID && !routeError) {
8585
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.getRoute(iouType, transactionID, reportID, Navigation.getActiveRoute()));

src/components/MoneyRequestConfirmationList/hooks/useConfirmationSections.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ type UseConfirmationSectionsParams = {
1717
/** Whether the "to" section should be hidden (used when adding directly to a report) */
1818
shouldHideToSection: boolean;
1919

20+
/**
21+
* When true with `shouldHideToSection`, still render the "To" section (e.g. new manual flow so the user can open the participant picker).
22+
*/
23+
shouldForceTopEmptySections?: boolean;
24+
25+
/** Row-level errors for the participant section (e.g. missing recipient / attendees), shown on the list row */
26+
participantRowErrors?: Record<string, string>;
27+
2028
/** Whether participant rows should be interactive (allow editing the recipient) */
2129
canEditParticipant: boolean;
2230

@@ -44,6 +52,8 @@ type UseConfirmationSectionsParams = {
4452
function useConfirmationSections({
4553
isTypeSplit,
4654
shouldHideToSection,
55+
shouldForceTopEmptySections = false,
56+
participantRowErrors,
4757
canEditParticipant,
4858
payeePersonalDetails,
4959
splitParticipants,
@@ -67,18 +77,31 @@ function useConfirmationSections({
6777
},
6878
);
6979
// When adding an expense from within a report, hide the "To:" section since the destination is already the current report
70-
} else if (!shouldHideToSection) {
71-
const formattedSelectedParticipants = selectedParticipants.map((participant) => ({
72-
...participant,
73-
isSelected: false,
74-
keyForList: `${participant.keyForList ?? participant.accountID ?? participant.reportID}`,
75-
isInteractive: canEditParticipant,
76-
shouldShowRightCaret: canEditParticipant,
77-
}));
80+
} else if (!shouldHideToSection || shouldForceTopEmptySections) {
81+
const participantRows =
82+
selectedParticipants.length > 0
83+
? selectedParticipants.map((participant) => ({
84+
...participant,
85+
isSelected: false,
86+
keyForList: `${participant.keyForList ?? participant.accountID ?? participant.reportID}`,
87+
isInteractive: canEditParticipant,
88+
shouldShowRightCaret: canEditParticipant,
89+
...(participantRowErrors ? {errors: participantRowErrors} : {}),
90+
}))
91+
: [
92+
{
93+
keyForList: 'empty-participant-option',
94+
text: translate('iou.chooseRecipient'),
95+
isInteractive: canEditParticipant,
96+
shouldShowRightCaret: canEditParticipant,
97+
isBold: false,
98+
...(participantRowErrors ? {errors: participantRowErrors} : {}),
99+
},
100+
];
78101

79102
options.push({
80-
title: translate('common.to'),
81-
data: formattedSelectedParticipants,
103+
title: selectedParticipants.length > 0 ? translate('common.to') : undefined,
104+
data: participantRows,
82105
sectionIndex: 0,
83106
});
84107
}

src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {getTagLists as getTagListsFn} from '@libs/PolicyUtils';
77
import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils';
88
import {hasEnabledTags, hasMatchingTag} from '@libs/TagsOptionsListUtils';
99
import {isValidTimeExpenseAmount} from '@libs/TimeTrackingUtils';
10-
import {areRequiredFieldsEmpty, getTag, hasTaxRateWithMatchingValue, isMerchantMissing} from '@libs/TransactionUtils';
10+
import {areRequiredFieldsEmpty, getTag, hasTaxRateWithMatchingValue, isMerchantMissing, isScanRequest as isScanRequestUtil} from '@libs/TransactionUtils';
1111
import {isValidInputLength} from '@libs/ValidationUtils';
1212
import type {IOUType} from '@src/CONST';
1313
import CONST from '@src/CONST';
@@ -71,10 +71,13 @@ type UseConfirmationValidationParams = {
7171
/** Whether the merchant field is required for this flow */
7272
isMerchantRequired: boolean | undefined;
7373

74-
/** Whether the merchant field is currently empty / partial */
74+
/** Whether the merchant value passes full validation (length, required, disallowed values) */
75+
isMerchantFieldValid: boolean;
76+
77+
/** Whether the merchant field is empty / partial (from {@link useFormErrorManagement}) */
7578
isMerchantEmpty: boolean;
7679

77-
/** Whether per-field errors should be shown */
80+
/** When editing a split bill, whether per-field errors should be shown (SmartScan failure paths) */
7881
shouldDisplayFieldError: boolean;
7982

8083
/** Whether the tax section is enabled for this policy */
@@ -92,11 +95,11 @@ type UseConfirmationValidationParams = {
9295
/** Whether the transaction is a time-tracking request */
9396
isTimeRequest: boolean;
9497

95-
/** Whether the new manual expense flow beta is enabled */
96-
isNewManualExpenseFlowEnabled: boolean;
97-
9898
/** Truthy when the route to the confirmation page has a known error */
9999
routeError: string | null | undefined;
100+
101+
/** Whether the new manual expense flow is enabled */
102+
isNewManualExpenseFlowEnabled: boolean;
100103
};
101104

102105
/**
@@ -131,15 +134,16 @@ function useConfirmationValidation({
131134
currentUserPersonalDetails,
132135
isEditingSplitBill,
133136
isMerchantRequired,
137+
isMerchantFieldValid,
134138
isMerchantEmpty,
135139
shouldDisplayFieldError,
136140
shouldShowTax,
137141
isDistanceRequest,
138142
isDistanceRequestWithPendingRoute,
139143
isPerDiemRequest,
140144
isTimeRequest,
141-
isNewManualExpenseFlowEnabled,
142145
routeError,
146+
isNewManualExpenseFlowEnabled,
143147
}: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} {
144148
const {getCurrencyDecimals} = useCurrencyListActions();
145149
const selectedParticipantsCount = selectedParticipants.length;
@@ -152,21 +156,30 @@ function useConfirmationValidation({
152156
return {errorKey: 'iou.error.noParticipantSelected'};
153157
}
154158

155-
const amountForValidation = iouAmount;
156-
const isAmountMissingForManualFlow = amountForValidation === null || amountForValidation === undefined;
159+
const firstParticipant = transaction?.participants?.at(0);
160+
const isP2P = !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat);
157161

158-
if (iouType !== CONST.IOU.TYPE.PAY && isNewManualExpenseFlowEnabled && isAmountMissingForManualFlow) {
162+
// P2P manual submit: $0 is invalid unless scan/time/distance (same guard as legacy inline confirm).
163+
if (iouType !== CONST.IOU.TYPE.PAY && !isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) {
159164
return {errorKey: 'common.error.invalidAmount'};
160165
}
161-
166+
if (isNewManualExpenseFlowEnabled && !transaction?.isAmountSet) {
167+
return {errorKey: 'common.error.fieldRequired'};
168+
}
162169
const merchantValue = iouMerchant ?? '';
163170
const {isValid: isMerchantLengthValid} = isValidInputLength(merchantValue, CONST.MERCHANT_NAME_MAX_BYTES);
164171

165172
if (!isMerchantLengthValid) {
166173
return {errorKey: 'iou.error.invalidMerchant'};
167174
}
168-
169-
if (!isEditingSplitBill && isMerchantRequired && (isMerchantEmpty || (shouldDisplayFieldError && isMerchantMissing(transaction)))) {
175+
if (isMerchantRequired) {
176+
if (!isEditingSplitBill && !isMerchantFieldValid) {
177+
return {errorKey: 'iou.error.invalidMerchant'};
178+
}
179+
if (isEditingSplitBill && (isMerchantEmpty || (shouldDisplayFieldError && isMerchantMissing(transaction)))) {
180+
return {errorKey: 'iou.error.invalidMerchant'};
181+
}
182+
} else if (transaction?.isMerchantSet && !isMerchantFieldValid) {
170183
return {errorKey: 'iou.error.invalidMerchant'};
171184
}
172185

@@ -252,4 +265,4 @@ function useConfirmationValidation({
252265
}
253266

254267
export default useConfirmationValidation;
255-
export type {ValidationResult};
268+
export type {ValidationResult, UseConfirmationValidationParams};

0 commit comments

Comments
 (0)