diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 4bb0c4186f42..166dd8eb2430 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -990,6 +990,7 @@ const CONST = { SUGGESTED_FOLLOWUPS: 'suggestedFollowups', BULK_EDIT: 'bulkEdit', BULK_EDIT_WORKSPACES: 'bulkEditWorkspaces', + NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', SUBMIT_2026: 'submit2026', BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay', VENDOR_MATCHING: 'vendorMatching', diff --git a/src/components/MoneyRequestConfirmationFields/Provider.tsx b/src/components/MoneyRequestConfirmationFields/Provider.tsx index 5afb6994daad..f5bce0e6d3b2 100644 --- a/src/components/MoneyRequestConfirmationFields/Provider.tsx +++ b/src/components/MoneyRequestConfirmationFields/Provider.tsx @@ -37,6 +37,9 @@ type ProviderProps = { /** Whether we're editing an existing split expense */ isEditingSplitBill?: boolean; + /** Whether the new manual expense flow beta is enabled */ + isNewManualExpenseFlowEnabled?: boolean; + /** Whether the surface is in a policy-expense chat */ isPolicyExpenseChat?: boolean; @@ -81,6 +84,7 @@ function Provider({ isReadOnly = false, didConfirm = false, isEditingSplitBill = false, + isNewManualExpenseFlowEnabled = false, isPolicyExpenseChat = false, isDistanceRequest = false, isPerDiemRequest = false, @@ -103,6 +107,7 @@ function Provider({ isReadOnly, didConfirm, isEditingSplitBill, + isNewManualExpenseFlowEnabled, isPolicyExpenseChat, isDistanceRequest, isPerDiemRequest, diff --git a/src/components/MoneyRequestConfirmationFields/context.ts b/src/components/MoneyRequestConfirmationFields/context.ts index 7b19cf0047ff..8e7dc5adac48 100644 --- a/src/components/MoneyRequestConfirmationFields/context.ts +++ b/src/components/MoneyRequestConfirmationFields/context.ts @@ -23,6 +23,7 @@ type ConfirmationFieldsContextValue = { isReadOnly: boolean; didConfirm: boolean; isEditingSplitBill: boolean; + isNewManualExpenseFlowEnabled: boolean; isPolicyExpenseChat: boolean; // Mode — *what kind* of expense is being confirmed diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 223b54f6b735..3ec4d336bef8 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -3,6 +3,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; import {MouseProvider} from '@hooks/useMouseContext'; +import usePermissions from '@hooks/usePermissions'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; @@ -13,6 +14,7 @@ import {isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; +import Navigation from '@libs/Navigation/Navigation'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {arePolicyRulesEnabled, isTaxTrackingEnabled} from '@libs/PolicyUtils'; import type {OptionData} from '@libs/ReportUtils'; @@ -30,6 +32,7 @@ import { import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -217,6 +220,8 @@ function MoneyRequestConfirmationList({ const transactionReport = useTransactionReportForConfirmation(transaction?.reportID); const {policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses(); const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseUtil(action); + const {isBetaEnabled} = usePermissions(); + const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const isInLandscapeMode = useIsInLandscapeMode(); @@ -320,7 +325,7 @@ function MoneyRequestConfirmationList({ }); const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; - const shouldForceTopEmptySections = iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest; + const shouldForceTopEmptySections = isNewManualExpenseFlowEnabled && (iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest); const isFocused = useIsFocused(); @@ -355,6 +360,7 @@ function MoneyRequestConfirmationList({ routeError, isTypeSplit, shouldShowReadOnlySplits, + isNewManualExpenseFlowEnabled, isDistanceRequest, }); @@ -384,6 +390,7 @@ function MoneyRequestConfirmationList({ receiptPath, isDistanceRequestWithPendingRoute, isPerDiemRequest, + isNewManualExpenseFlowEnabled, }); const selectedParticipants = selectedParticipantsProp.filter((participant) => participant.selected); @@ -440,7 +447,13 @@ function MoneyRequestConfirmationList({ return; } - onOpenParticipantPicker?.(); + if (isNewManualExpenseFlowEnabled) { + onOpenParticipantPicker?.(); + return; + } + + const newIOUType = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.CREATE : iouType; + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(newIOUType, transactionID, transaction?.reportID, Navigation.getActiveRoute(), action)); }; const {validate} = useConfirmationValidation({ @@ -470,6 +483,7 @@ function MoneyRequestConfirmationList({ isPerDiemRequest, isTimeRequest, routeError, + isNewManualExpenseFlowEnabled, isReadOnly, shouldShowDate: shouldShowConfirmationDate(shouldShowSmartScanFields, isDistanceRequest), }); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts index 3066182b7651..d599da7c823f 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts @@ -45,14 +45,17 @@ type UseConfirmationCtaTextParams = { /** Whether the transaction is a per-diem request */ isPerDiemRequest: boolean; + + /** Whether the new manual expense flow beta is enabled */ + isNewManualExpenseFlowEnabled: boolean; }; /** * Computes the primary confirm button label for the Money Request confirmation flow. * - * Picks between create / split / invoice / next variants based on the IOU type, - * bulk-expense count, and amount, returning a single-entry DropdownOption array shaped - * for the ButtonWithDropdownMenu consumer. + * Picks between create / create-with-amount / split / invoice / next variants based on + * the IOU type, manual-expense-flow beta, bulk-expense count, and amount, returning a + * single-entry DropdownOption array shaped for the ButtonWithDropdownMenu consumer. */ function useConfirmationCtaText({ expensesNumber, @@ -67,6 +70,7 @@ function useConfirmationCtaText({ receiptPath, isDistanceRequestWithPendingRoute, isPerDiemRequest, + isNewManualExpenseFlowEnabled, }: UseConfirmationCtaTextParams): Array> { const {translate} = useLocalize(); @@ -81,14 +85,27 @@ function useConfirmationCtaText({ } } else if (isTypeTrackExpense) { text = translate('iou.createExpense'); + if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) { + text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); + } } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.splitExpense'); } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute || isPerDiemRequest) { text = translate('iou.createExpense'); + if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) { + text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); + } } else if (isTypeSplit) { - text = translate('iou.splitExpense'); - } else { + text = translate('iou.splitAmount', formattedAmount); + if (isNewManualExpenseFlowEnabled) { + text = translate('iou.splitExpense'); + } + } else if (iouAmount === 0) { text = translate('iou.createExpense'); + } else if (isNewManualExpenseFlowEnabled) { + text = translate('iou.createExpense'); + } else { + text = translate('iou.createExpenseWithAmount', {amount: formattedAmount}); } return [ { diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 936b95d427d7..c931c4b51319 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -112,6 +112,9 @@ type UseConfirmationValidationParams = { /** Truthy when the route to the confirmation page has a known error */ routeError: string | null | undefined; + /** Whether the new manual expense flow is enabled */ + isNewManualExpenseFlowEnabled: boolean; + /** Whether the confirmation fields are read-only (date is not inline-editable) */ isReadOnly: boolean; @@ -160,6 +163,7 @@ function useConfirmationValidation({ isPerDiemRequest, isTimeRequest, routeError, + isNewManualExpenseFlowEnabled, isReadOnly, shouldShowDate, }: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} { @@ -182,10 +186,11 @@ function useConfirmationValidation({ return {errorKey: 'common.error.invalidAmount'}; } // isAmountSet only applies to manual expenses — scan, per diem, distance, and time set amount programmatically. - if (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && !transaction?.isAmountSet) { + if (isNewManualExpenseFlowEnabled && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && !transaction?.isAmountSet) { return {errorKey: 'common.error.fieldRequired'}; } if ( + isNewManualExpenseFlowEnabled && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && transaction?.isAmountSet && !isScanRequestUtil(transaction) && @@ -200,7 +205,7 @@ function useConfirmationValidation({ // (manual, distance, time, invoice, ...). Block confirmation when the user cleared it. Gating on the same // `shouldShowDate && !isReadOnly` condition that renders the inline picker keeps validation and UI in sync, // and skips read-only/scan flows where the date is populated server-side. - if (shouldShowDate && !isReadOnly && isCreatedMissing(transaction)) { + if (isNewManualExpenseFlowEnabled && shouldShowDate && !isReadOnly && isCreatedMissing(transaction)) { return {errorKey: 'common.error.fieldRequired'}; } const merchantValue = iouMerchant ?? ''; @@ -263,7 +268,7 @@ function useConfirmationValidation({ // In the new manual expense flow the tax amount is edited inline, so the standalone tax amount step's // guard (tax amount can't exceed the tax computed from the rate and the expense amount) runs here. // This also blocks creation when an invalid tax amount was persisted to the draft and then reloaded. - if (shouldShowTax && !isDistanceRequest) { + if (isNewManualExpenseFlowEnabled && shouldShowTax && !isDistanceRequest) { const decimals = getCurrencyDecimals(iouCurrencyCode); const maxTaxAmount = getCalculatedTaxAmount(policy, transaction, iouCurrencyCode, decimals); const currentTaxAmount = convertToFrontendAmountAsString(Math.abs(getTaxAmount(transaction, false)), decimals); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 9ed8fcec9d26..5009f49593e7 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -72,6 +72,9 @@ type UseFormErrorManagementParams = { /** Whether splits are rendered read-only (suppresses some field errors) */ shouldShowReadOnlySplits: boolean; + /** Whether the new manual expense flow is enabled (amount/date errors surface inline) */ + isNewManualExpenseFlowEnabled: boolean; + /** Whether the transaction is a distance request (its amount is read-only, so amount errors are not shown inline) */ isDistanceRequest: boolean; }; @@ -138,6 +141,7 @@ function useFormErrorManagement({ routeError, isTypeSplit, shouldShowReadOnlySplits, + isNewManualExpenseFlowEnabled, isDistanceRequest, }: UseFormErrorManagementParams): UseFormErrorManagementResult { const isFocused = useIsFocused(); @@ -242,15 +246,15 @@ function useFormErrorManagement({ if (formError === 'iou.error.invalidTaxAmount') { return undefined; } - // The amount/date/merchant fields surface these required/invalid errors inline, so + // In the new manual expense flow the amount/date/merchant fields surface these required/invalid errors inline, so // don't repeat them at the bottom of the form (which would show "This field is required" twice). - if (formError === 'common.error.fieldRequired' || formError === 'iou.error.invalidMerchant') { + if (isNewManualExpenseFlowEnabled && (formError === 'common.error.fieldRequired' || formError === 'iou.error.invalidMerchant')) { return undefined; } // `common.error.invalidAmount` is only surfaced inline when the editable amount input is rendered. Distance requests // disable that input (the amount falls back to a read-only menu row that doesn't show this error), so keep the // distance-amount validation error in the footer — otherwise an invalid distance expense would fail silently. - if (!isDistanceRequest && formError === 'common.error.invalidAmount') { + if (isNewManualExpenseFlowEnabled && !isDistanceRequest && formError === 'common.error.invalidAmount') { return undefined; } return formError ? translate(formError) : undefined; diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 50f09ada5cfe..5c4be87543cf 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -42,6 +42,7 @@ type AmountFieldProps = { distanceRateCurrency: string; iouCurrencyCode: string | undefined; isDistanceRequest: boolean; + isNewManualExpenseFlowEnabled: boolean; didConfirm: boolean; isReadOnly: boolean; shouldShowTimeRequestFields: boolean; @@ -65,6 +66,7 @@ function AmountField({ distanceRateCurrency, iouCurrencyCode, isDistanceRequest, + isNewManualExpenseFlowEnabled, didConfirm, isReadOnly, shouldShowTimeRequestFields, @@ -98,7 +100,10 @@ function AmountField({ const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); const isAmountFieldDisabled = didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest; - const isP2P = isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)); + const firstParticipant = transactionSlice?.participants?.at(0); + const isP2P = isNewManualExpenseFlowEnabled + ? isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)) + : !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat); // `common.error.fieldRequired` is shared with the date field, so only surface it on the amount input when the // amount itself is the missing value. const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && !transactionSlice?.isAmountSet; @@ -117,9 +122,9 @@ function AmountField({ // touches it). Once the user explicitly sets an amount – including 0 – isAmountSet becomes true and we show the // real value. This avoids showing "$0.00" as a pre-filled default. Scan and other non-manual flows populate // amount programmatically and never set isAmountSet. - const shouldShowEmptyAmount = !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; + const shouldShowEmptyAmount = isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); - const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants, true); + const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants, isNewManualExpenseFlowEnabled); // `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted // instance, so autofocus does not run again. We re-focus when the parent-owned participant picker closes @@ -127,7 +132,7 @@ function AmountField({ // expense flow. The setTimeout defers focus past the RHP entry / picker close animation so the input reliably // receives focus. useEffect(() => { - if (!autoFocus || isAmountFieldDisabled || isParticipantPickerVisible) { + if (!autoFocus || isAmountFieldDisabled || !isNewManualExpenseFlowEnabled || isParticipantPickerVisible) { return; } @@ -139,7 +144,7 @@ function AmountField({ } clearTimeout(focusTimeoutRef.current); }; - }, [autoFocus, isAmountFieldDisabled, isParticipantPickerVisible]); + }, [autoFocus, isAmountFieldDisabled, isNewManualExpenseFlowEnabled, isParticipantPickerVisible]); const showCurrencyPicker = () => { setIsCurrencyPickerVisible(true); @@ -302,7 +307,7 @@ function AmountField({ value={effectiveCurrency} onInputChange={updateCurrency} /> - {!isAmountFieldDisabled ? ( + {isNewManualExpenseFlowEnabled && !isAmountFieldDisabled ? ( void; transactionID: string | undefined; @@ -42,7 +43,19 @@ type DateFieldProps = { reportActionID: string | undefined; }; -function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, clearFormErrors, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) { +function DateField({ + shouldDisplayFieldError, + didConfirm, + isReadOnly, + isNewManualExpenseFlowEnabled, + formError, + clearFormErrors, + transactionID, + action, + iouType, + reportID, + reportActionID, +}: DateFieldProps) { const {isEditingSplitBill} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -104,7 +117,7 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, } }; - if (!isReadOnly) { + if (isNewManualExpenseFlowEnabled && !isReadOnly) { return ( ; }; -function DescriptionField({isReadOnly, didConfirm, isDescriptionRequired, transactionID, action, iouType, reportID, reportActionID, policy}: DescriptionFieldProps) { +function DescriptionField({ + isNewManualExpenseFlowEnabled, + isReadOnly, + didConfirm, + isDescriptionRequired, + transactionID, + action, + iouType, + reportID, + reportActionID, + policy, +}: DescriptionFieldProps) { const {isEditingSplitBill, scrollFocusedInputIntoView, onSubmitForm} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -103,7 +115,7 @@ function DescriptionField({isReadOnly, didConfirm, isDescriptionRequired, transa - {!isReadOnly ? ( + {isNewManualExpenseFlowEnabled && !isReadOnly ? ( (null); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); @@ -101,6 +101,9 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT }; useEffect(() => { + if (!isNewManualExpenseFlowEnabled) { + return; + } // Compare the numeric value rather than the formatted string. An in-progress edit such as "5.0" (or an // empty field) represents the same stored amount as the re-padded "5.00", so it must not be overwritten // while the user is typing. Only refresh the field when the stored tax amount genuinely differs (e.g. the @@ -113,14 +116,14 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT } } numberFormRef.current?.updateNumber(taxAmountInput); - }, [taxAmount, taxAmountInput]); + }, [isNewManualExpenseFlowEnabled, taxAmount, taxAmountInput]); useEffect(() => { - if (formError !== 'iou.error.invalidTaxAmount' || taxAmount > maxTaxAmount) { + if (!isNewManualExpenseFlowEnabled || formError !== 'iou.error.invalidTaxAmount' || taxAmount > maxTaxAmount) { return; } clearFormErrors(['iou.error.invalidTaxAmount']); - }, [formError, taxAmount, maxTaxAmount, clearFormErrors]); + }, [isNewManualExpenseFlowEnabled, formError, taxAmount, maxTaxAmount, clearFormErrors]); return ( <> @@ -144,7 +147,7 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT errorText={shouldDisplayTaxRateError ? translate(formError as TranslationPaths) : ''} sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.TAX_RATE_FIELD} /> - {canModifyTaxFields ? ( + {isNewManualExpenseFlowEnabled && canModifyTaxFields ? ( @@ -135,6 +135,7 @@ function ClassificationFields({ shouldDisplayFieldError={errorState.shouldDisplayFieldError} didConfirm={didConfirm} isReadOnly={isReadOnly} + isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled} formError={errorState.formError} clearFormErrors={errorState.clearFormErrors} transactionID={transactionID} diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx index 43429f939e10..fa9a352ad678 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/TransactionDetailsFields.tsx @@ -65,8 +65,20 @@ function TransactionDetailsFields({ fieldVisibility, isParticipantPickerVisible, }: TransactionDetailsFieldsProps) { - const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isPolicyExpenseChat, isManualDistanceRequest, isOdometerDistanceRequest, isGPSDistanceRequest} = - useConfirmationFields(); + const { + action, + iouType, + transactionID, + reportID, + reportActionID, + isReadOnly, + didConfirm, + isNewManualExpenseFlowEnabled, + isPolicyExpenseChat, + isManualDistanceRequest, + isOdometerDistanceRequest, + isGPSDistanceRequest, + } = useConfirmationFields(); const shouldAutoFocusAmountField = !canUseTouchScreen(); return ( @@ -79,6 +91,7 @@ function TransactionDetailsFields({ distanceRateCurrency={distanceData.distanceRateCurrency} iouCurrencyCode={iouCurrencyCode} isDistanceRequest={fieldVisibility.distance} + isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled} didConfirm={didConfirm} isReadOnly={isReadOnly} shouldShowTimeRequestFields={fieldVisibility.time} @@ -99,6 +112,7 @@ function TransactionDetailsFields({ {!isCompactMode && fieldVisibility.merchant && ( { + if (!isNewManualExpenseFlowEnabled) { + return []; + } + const reportParticipants = getMoneyRequestParticipantsFromReport(sourceReport, accountID).filter((participant) => participant.selected); if (reportParticipants.length > 0) { return reportParticipants; @@ -71,6 +78,7 @@ function useDefaultParticipants({sourceReport, transaction, iouType}: UseDefault const defaultTargetReport = shouldAutoReport ? getPolicyExpenseChat(accountID, defaultExpensePolicy?.id) : selfDMReport; return getMoneyRequestParticipantsFromReport(defaultTargetReport, accountID).filter((participant) => participant.selected); }, [ + isNewManualExpenseFlowEnabled, sourceReport, accountID, transaction?.isFromGlobalCreate, diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index a544fa46a080..13ae257fc4ed 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -54,6 +54,10 @@ type UseResetIOUTypeParams = { /** Whether to skip keyboard dismiss for per diem tab */ skipKeyboardDismissForPerDiem?: boolean; + + /** Whether the new manual expense flow beta is enabled. When true, the fresh transaction is seeded with + * participants from the current report so the embedded confirmation's auto-assign useEffect short-circuits. */ + isNewManualExpenseFlowEnabled?: boolean; }; /** @@ -71,6 +75,7 @@ function useResetIOUType({ policy, isTrackDistanceExpense = false, skipKeyboardDismissForPerDiem = false, + isNewManualExpenseFlowEnabled = false, }: UseResetIOUTypeParams): (newIOUType: IOURequestType) => void { const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.parentReportID}`); const [hasOnlyPersonalPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: hasOnlyPersonalPoliciesSelector}); @@ -88,13 +93,14 @@ function useResetIOUType({ isLoadingSelectedTab, }); - // Derive participants from the current report (or the global-create fallback) so the freshly-rebuilt - // transaction already includes them. This prevents the embedded confirmation's auto-assign useEffect from - // re-firing on every cleanup and dragging back unrelated draft state (receipt, billable, etc.). + // For the new manual flow, derive participants from the current report (or the global-create fallback) so the + // freshly-rebuilt transaction already includes them. This prevents the embedded confirmation's auto-assign + // useEffect from re-firing on every cleanup and dragging back unrelated draft state (receipt, billable, etc.). const resolvedDefaultParticipants = useDefaultParticipants({ sourceReport: report, transaction, iouType, + isNewManualExpenseFlowEnabled, }); const defaultParticipants = resolvedDefaultParticipants.length > 0 ? resolvedDefaultParticipants : undefined; diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index 459b737efd7e..9ba164bb7a0f 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -10,6 +10,7 @@ import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useResetIOUType from '@hooks/useResetIOUType'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -171,6 +172,9 @@ function IOURequestStartPage({ return undefined; }, [transaction?.iouRequestType, isStaleTransactionDraft, shouldUseTab, selectedTab, availableTabs]); + const {isBetaEnabled} = usePermissions(); + const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); + const resetIOUTypeIfChanged = useResetIOUType({ reportID, report, @@ -181,6 +185,7 @@ function IOURequestStartPage({ iouType, policy, skipKeyboardDismissForPerDiem: true, + isNewManualExpenseFlowEnabled, }); useEffect(() => { @@ -194,9 +199,10 @@ function IOURequestStartPage({ }, []); const navigateBack = () => { - // The confirmation is embedded with its header hidden, so this back button is the only way to abandon - // the flow. Cancel any active span unconditionally (mirrors IOURequestStepConfirmation.navigateBack). - // No-op when no tracking session is active. + // In the new manual expense beta the confirmation is embedded with its header hidden, + // so this back button is the only way to abandon the flow. Cancel any active span + // unconditionally (mirrors IOURequestStepConfirmation.navigateBack). No-op when no + // tracking session is active. cancelTracking(); // Restore the pre-inserted fullscreen tab while the RHP is still on top so the clean @@ -225,7 +231,17 @@ function IOURequestStartPage({ const shouldShowWorkspaceSelectForPerDiem = moreThanOnePerDiemExist && !hasCurrentPolicyPerDiemEnabled; let manualTabContent: React.ReactNode; - if (isScanRequest(transaction)) { + if (!isNewManualExpenseFlowEnabled) { + manualTabContent = ( + + ); + } else if (isScanRequest(transaction)) { // When switching from the Scan tab, the shared draft is briefly still a scan request (with the uploaded // receipt) until the tab-switch reset rebuilds it as manual. Mounting the embedded confirmation against that // stale scan draft does throwaway work (scan loader, reading the receipt blob and a heavy first render) that @@ -250,14 +266,14 @@ function IOURequestStartPage({ allPolicies={iouType === CONST.IOU.TYPE.INVOICE ? allPolicies : undefined} > - {/* The confirmation screen is shown on the start page for the manual tab, so we do not want to disable the drag and drop provider in that case */} - + {/* If the new manual expense flow is enabled, the confirmation screen is shown on the start page, so we do not want to disable the drag and drop provider in that case */} + 0; const hasDefaultParticipants = defaultParticipants.length > 0; - return !hasTransactionParticipants && !hasDefaultParticipants && isManualRequest; - }, [transaction?.transactionID, transaction?.participants, defaultParticipants.length, isManualRequest]); + return !hasTransactionParticipants && !hasDefaultParticipants && isNewManualExpenseFlowEnabled && isManualRequest; + }, [transaction?.transactionID, transaction?.participants, defaultParticipants.length, isNewManualExpenseFlowEnabled, isManualRequest]); const activeTransactionID = transaction?.transactionID; const [manuallyOpenedParticipantPickerForTransactionID, setManuallyOpenedParticipantPickerForTransactionID] = useState(); const [dismissedAutoOpenParticipantPickerForTransactionID, setDismissedAutoOpenParticipantPickerForTransactionID] = useState(); @@ -440,7 +443,7 @@ function IOURequestStepConfirmation({ } else if (firstDefault?.reportID) { setTransactionReport(transaction.transactionID, {reportID: firstDefault.reportID}, true); } - }, [transaction?.transactionID, transaction?.participants, defaultParticipants, isManualRequest, navigation]); + }, [transaction?.transactionID, transaction?.participants, defaultParticipants, isNewManualExpenseFlowEnabled, isManualRequest, navigation]); const isPolicyExpenseChat = useMemo(() => { const hasPolicyExpenseChat = (participantList: typeof defaultParticipants) => @@ -1005,22 +1008,24 @@ function IOURequestStepConfirmation({ /> )} - Navigation.dismissModal()} - /> + {isNewManualExpenseFlowEnabled && ( + Navigation.dismissModal()} + /> + )} diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 2250747dbecc..e0945288fbfd 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -3,9 +3,10 @@ import FormHelpMessage from '@components/FormHelpMessage'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useParticipantSubmission from '@hooks/useParticipantSubmission'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; +import {getIsWorkspacesOnlyForTransaction, isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseIOUUtils, navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {endSpan} from '@libs/telemetry/activeSpans'; import {getRequestType, isFromCreditCardImport, isPerDiemRequest, isTimeRequest as isTimeRequestUtil} from '@libs/TransactionUtils'; @@ -51,6 +52,8 @@ function IOURequestStepParticipants({ const isPerDiem = isPerDiemRequest(initialTransaction); const isTime = isTimeRequestUtil(initialTransaction); const isTransactionFromCreditCardImport = isFromCreditCardImport(initialTransaction); + const {isBetaEnabled} = usePermissions(); + const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); let headerTitle = translate('iou.chooseRecipient'); if (action === CONST.IOU.ACTION.CATEGORIZE) { @@ -123,9 +126,9 @@ function IOURequestStepParticipants({ navigateToStartMoneyRequestStep(iouRequestType, iouTypeValue, initialTransactionID, reportID, action); }; - // The amount step is skipped, so we need to include the recents for all the cases. + // In new flow - the amount step is skipped, so we need to include the recents for all the cases. // Submit-only implies workspaces-only (we still hide individuals/recents in the Submit-to-employer picker). - const isWorkspacesOnly = isWorkspacesOnlyFromRoute; + const isWorkspacesOnly = isWorkspacesOnlyFromRoute || (isNewManualExpenseFlowEnabled ? false : getIsWorkspacesOnlyForTransaction(initialTransaction, iouRequestType)); const selectedParticipant = isSplitRequest ? undefined : participants?.find((participant) => participant.selected && !participant.isSender); // Participants with a reportID are found in the list and highlighted via initiallySelectedReportID. // Those without one (e.g. users to invite who don't have an account yet) must be passed explicitly diff --git a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts index 7f3fee9ebb84..f7e9d2ec191a 100644 --- a/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts +++ b/src/pages/iou/request/step/IOURequestStepReport/hooks/useReportSelectionActions.ts @@ -2,6 +2,7 @@ import {useSearchSelectionActions} from '@components/Search/SearchContext'; import type {ListItem} from '@components/SelectionList/types'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import {setCustomUnitID, setCustomUnitRateID} from '@libs/actions/IOU/MoneyRequest'; import {clearSubrates} from '@libs/actions/IOU/PerDiem'; @@ -110,6 +111,8 @@ function useReportSelectionActions({ const [selfDMReportID] = useOnyx(ONYXKEYS.SELF_DM_REPORT_ID); const [selfDMReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(selfDMReportID)}`); const {removeTransaction} = useSearchSelectionActions(); + const {isBetaEnabled} = usePermissions(); + const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW); const [isTrackIntentUser] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {selector: isTrackIntentUserSelector}); const targetTransactionIDs = transaction?.transactionID ? [transaction.transactionID] : []; @@ -164,7 +167,18 @@ function useReportSelectionActions({ return; } - Navigation.goBack(backTo); + if (isNewManualExpenseFlowEnabled) { + Navigation.goBack(backTo); + return; + } + + const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportOrDraftReportFromValue?.chatReportID); + // If the backTo parameter is set, we should navigate back to the confirmation screen that is already on the stack. + if (backTo) { + Navigation.goBack(iouConfirmationPageRoute, {compareParams: false}); + } else { + Navigation.navigate(iouConfirmationPageRoute); + } }; const handleRegularReportSelection = (item: TransactionGroupListItem, report: OnyxEntry) => { diff --git a/tests/unit/hooks/useConfirmationCtaText.test.tsx b/tests/unit/hooks/useConfirmationCtaText.test.tsx index c2e0598ee32d..2e7d3d2b2f6f 100644 --- a/tests/unit/hooks/useConfirmationCtaText.test.tsx +++ b/tests/unit/hooks/useConfirmationCtaText.test.tsx @@ -26,6 +26,7 @@ const baseParams: Params = { receiptPath: '', isDistanceRequestWithPendingRoute: false, isPerDiemRequest: false, + isNewManualExpenseFlowEnabled: false, }; function Wrapper({children}: {children: React.ReactNode}) { @@ -60,11 +61,14 @@ describe('useConfirmationCtaText', () => { expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); }); - it('uses createExpense copy for submit with non-zero amount', () => { - const {result} = renderHook(() => useConfirmationCtaText({...baseParams, formattedAmount: '$42.00'}), {wrapper: Wrapper}); + it('uses createExpense copy when new manual expense flow is enabled', () => { + const {result} = renderHook(() => useConfirmationCtaText({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); - // The amount is no longer appended to the CTA copy. - expect(result.current.at(0)?.text).not.toContain('$42.00'); + }); + + it('includes formatted amount in createExpenseWithAmount copy', () => { + const {result} = renderHook(() => useConfirmationCtaText({...baseParams, formattedAmount: '$42.00'}), {wrapper: Wrapper}); + expect(result.current.at(0)?.text).toContain('$42.00'); }); it('uses next copy for invoice without invoicing details', () => { @@ -89,7 +93,7 @@ describe('useConfirmationCtaText', () => { expect(result.current.at(0)?.text).toContain('$50.00'); }); - it('uses createExpense copy for track expense with non-zero amount', () => { + it('includes formatted amount for track expense with non-zero amount', () => { const {result} = renderHook( () => useConfirmationCtaText({ @@ -101,8 +105,7 @@ describe('useConfirmationCtaText', () => { }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); - expect(result.current.at(0)?.text).not.toContain('$1.23'); + expect(result.current.at(0)?.text).toContain('$1.23'); }); it('uses createExpense for distance request with pending route', () => { @@ -119,7 +122,7 @@ describe('useConfirmationCtaText', () => { expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); }); - it('uses createExpense copy for per-diem request with non-zero amount', () => { + it('uses createExpenseWithAmount for per-diem request with non-zero amount', () => { const {result} = renderHook( () => useConfirmationCtaText({ @@ -130,11 +133,10 @@ describe('useConfirmationCtaText', () => { }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text.toLowerCase()).toContain('expense'); - expect(result.current.at(0)?.text).not.toContain('$2.00'); + expect(result.current.at(0)?.text).toContain('$2.00'); }); - it('uses splitExpense copy for split with non-zero amount', () => { + it('uses splitAmount with formatted amount for split with non-zero amount when manual flow is disabled', () => { const {result} = renderHook( () => useConfirmationCtaText({ @@ -142,11 +144,11 @@ describe('useConfirmationCtaText', () => { isTypeSplit: true, iouAmount: 500, formattedAmount: '$5.00', + isNewManualExpenseFlowEnabled: false, }), {wrapper: Wrapper}, ); - expect(result.current.at(0)?.text.toLowerCase()).toContain('split'); - expect(result.current.at(0)?.text).not.toContain('$5.00'); + expect(result.current.at(0)?.text).toContain('$5.00'); }); it('uses createExpense for default zero-amount fallback', () => { diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index b8ef4863d2a3..e71872c02a0e 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -99,6 +99,7 @@ const baseParams = { isPerDiemRequest: false, isTimeRequest: false, routeError: undefined, + isNewManualExpenseFlowEnabled: false, isReadOnly: false, shouldShowDate: true, } satisfies UseConfirmationValidationParams; @@ -243,12 +244,13 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); - it('returns fieldRequired for manual expense when amount is not set with a policy expense chat participant', () => { + it('returns fieldRequired for manual expense when amount is not set in new manual expense flow with a policy expense chat participant', () => { const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant( POLICY_EXPENSE_CHAT_PARTICIPANT, { + isNewManualExpenseFlowEnabled: true, iouAmount: 0, }, {isAmountSet: false}, @@ -258,10 +260,11 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); }); - it('does not return fieldRequired for scan expense when amount is not set', () => { + it('does not return fieldRequired for scan expense when amount is not set in new manual expense flow', () => { const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, + isNewManualExpenseFlowEnabled: true, transaction: createTransactionBase({ amount: 1000, iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, @@ -272,10 +275,11 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); - it('does not return fieldRequired for per diem expense when amount is not set', () => { + it('does not return fieldRequired for per diem expense when amount is not set in new manual expense flow', () => { const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, + isNewManualExpenseFlowEnabled: true, isPerDiemRequest: true, transaction: createTransactionBase({ amount: 5000, @@ -297,8 +301,9 @@ describe('useConfirmationValidation', () => { expect(result.current.validate(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)).toEqual({errorKey: null}); }); - describe('amount validation — manual expense (isAmountSet)', () => { + describe('amount validation — new manual expense flow (isAmountSet)', () => { const newManualFlowParams = { + isNewManualExpenseFlowEnabled: true, iouAmount: 0, }; @@ -417,10 +422,26 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); }); + + it('does not return fieldRequired when the new manual expense flow beta is disabled', () => { + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + P2P_PARTICIPANT, + { + isNewManualExpenseFlowEnabled: false, + iouAmount: 0, + }, + {isAmountSet: false}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); }); describe('amount validation — P2P zero amount guard', () => { - it('returns invalidAmount for P2P manual submit with zero amount', () => { + it('returns invalidAmount for P2P manual submit with zero amount when flow is disabled', () => { const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, {iouAmount: 0}, {amount: 0, isAmountSet: true}))); expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); }); @@ -489,6 +510,7 @@ describe('useConfirmationValidation', () => { describe('amount validation — programmatic request types (scan, distance, time, per diem)', () => { const newManualFlowParams = { ...baseParams, + isNewManualExpenseFlowEnabled: true, }; it('does not return fieldRequired for scan expense when amount is not set', () => { @@ -662,6 +684,7 @@ describe('useConfirmationValidation', () => { P2P_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, + isNewManualExpenseFlowEnabled: true, iouAmount: 0, selectedParticipants: splitParticipants, }, @@ -679,6 +702,7 @@ describe('useConfirmationValidation', () => { POLICY_EXPENSE_CHAT_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, + isNewManualExpenseFlowEnabled: true, iouAmount: 0, }, {isAmountSet: false}, @@ -696,6 +720,7 @@ describe('useConfirmationValidation', () => { POLICY_EXPENSE_CHAT_PARTICIPANT, { iouType: CONST.IOU.TYPE.SPLIT, + isNewManualExpenseFlowEnabled: true, iouAmount: 0, selectedParticipants: splitParticipants, }, @@ -727,9 +752,10 @@ describe('useConfirmationValidation', () => { }); }); - describe('date validation — inline required date', () => { + describe('date validation — inline required date in new manual expense flow', () => { const newManualFlowParams = { ...baseParams, + isNewManualExpenseFlowEnabled: true, }; it('returns fieldRequired for manual expense when the date is removed', () => { @@ -808,5 +834,12 @@ describe('useConfirmationValidation', () => { ); expect(result.current.validate()).toEqual({errorKey: null}); }); + + it('does not return fieldRequired when the new manual expense flow beta is disabled', () => { + const {result} = renderHook(() => + useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {isNewManualExpenseFlowEnabled: false}, {created: '', isAmountSet: true})), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); }); }); diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index 02bbcb3fbec3..2159d066cdce 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -44,6 +44,7 @@ const baseParams: Params = { routeError: undefined, isTypeSplit: false, shouldShowReadOnlySplits: false, + isNewManualExpenseFlowEnabled: false, isDistanceRequest: false, }; @@ -113,25 +114,37 @@ describe('useFormErrorManagement', () => { expect(result.current.errorMessage).toBeUndefined(); }); - it('errorMessage suppresses required/invalid amount errors (surfaced inline)', () => { - const {result: required} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); + it('errorMessage suppresses required/invalid amount errors in the new manual expense flow (surfaced inline)', () => { + const {result: required} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); act(() => required.current.setFormError('common.error.fieldRequired')); expect(required.current.errorMessage).toBeUndefined(); - const {result: invalid} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); + const {result: invalid} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); act(() => invalid.current.setFormError('common.error.invalidAmount')); expect(invalid.current.errorMessage).toBeUndefined(); }); - it('errorMessage still shows the invalid amount error for a distance request (no inline surface)', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, isDistanceRequest: true}), {wrapper: Wrapper}); + it('errorMessage still shows required/invalid amount errors when the new manual expense flow is disabled', () => { + const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: false}), {wrapper: Wrapper}); act(() => result.current.setFormError('common.error.invalidAmount')); expect(result.current.errorMessage).toBeDefined(); }); - it('errorMessage suppresses the invalid merchant error (surfaced inline)', () => { - const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); + it('errorMessage still shows the invalid amount error for a distance request in the new manual expense flow (no inline surface)', () => { + const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true, isDistanceRequest: true}), {wrapper: Wrapper}); + act(() => result.current.setFormError('common.error.invalidAmount')); + expect(result.current.errorMessage).toBeDefined(); + }); + + it('errorMessage suppresses the invalid merchant error in the new manual expense flow (surfaced inline)', () => { + const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: true}), {wrapper: Wrapper}); act(() => result.current.setFormError('iou.error.invalidMerchant')); expect(result.current.errorMessage).toBeUndefined(); }); + + it('errorMessage still shows the invalid merchant error when the new manual expense flow is disabled', () => { + const {result} = renderHook(() => useFormErrorManagement({...baseParams, isNewManualExpenseFlowEnabled: false}), {wrapper: Wrapper}); + act(() => result.current.setFormError('iou.error.invalidMerchant')); + expect(result.current.errorMessage).toBeDefined(); + }); });