From ed00b6f92bef161662ae944327f8b3b81b199ba3 Mon Sep 17 00:00:00 2001 From: "TaduJR (via MelvinBot)" Date: Tue, 10 Nov 2026 11:10:31 +0000 Subject: [PATCH 1/3] Replace expense confirmation validation with requested field-error changes Co-authored-by: TaduJR --- .../confirmAction.ts | 36 ++- .../getConfirmationFieldErrors.ts | 144 +++++++++ .../hooks/useConfirmationFieldErrors.ts | 84 ++++++ .../hooks/useConfirmationListData.ts | 58 ++-- .../hooks/useConfirmationValidation.ts | 134 +++------ .../hooks/useFormErrorManagement.ts | 152 +++------- .../sections/AmountField.tsx | 25 +- .../sections/DateField.tsx | 17 +- .../sections/MerchantField.tsx | 26 +- .../variants/ScanConfirmationList.tsx | 12 +- .../fieldGroupTypes.ts | 6 + .../fieldGroups/ClassificationFields.tsx | 2 +- .../detailsFields/DistanceDetailsFields.tsx | 5 +- .../detailsFields/ManualDetailsFields.tsx | 8 +- .../detailsFields/TimeDetailsFields.tsx | 5 +- src/languages/de.ts | 2 + src/languages/el.ts | 2 + src/languages/en.ts | 2 + src/languages/es.ts | 2 + src/languages/fr.ts | 2 + src/languages/it.ts | 2 + src/languages/ja.ts | 2 + src/languages/nl.ts | 2 + src/languages/pl.ts | 2 + src/languages/pt-BR.ts | 2 + src/languages/zh-hans.ts | 2 + src/libs/MoneyRequestUtils.ts | 26 +- src/libs/ValidationUtils.ts | 10 + src/libs/actions/IOU/Split.ts | 5 + .../step/DynamicIOURequestStepDistance.tsx | 2 +- .../step/IOURequestStepDistanceMap.tsx | 2 +- tests/actions/IOUTest/SplitTest.ts | 21 ++ tests/ui/MoneyRequestReportFooter.tsx | 3 +- tests/unit/MoneyRequestUtilsTest.ts | 30 ++ tests/unit/ValidationUtilsTest.ts | 21 ++ .../AmountField.test.tsx | 1 + .../DateField.test.tsx | 2 +- .../MerchantField.test.tsx | 41 ++- .../ScanConfirmationList.test.tsx | 84 ++++++ .../getConfirmationFieldErrors.test.ts | 250 ++++++++++++++++ tests/unit/hooks/confirmAction.test.ts | 36 ++- .../confirmationRequiredFieldsFlow.test.ts | 221 ++++++++++++++ .../hooks/useConfirmationFieldErrors.test.ts | 195 ++++++++++++ .../hooks/useConfirmationValidation.test.ts | 281 ++++++++++++++---- .../hooks/useFormErrorManagement.test.tsx | 266 +++-------------- 45 files changed, 1633 insertions(+), 600 deletions(-) create mode 100644 src/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.ts create mode 100644 src/components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors.ts create mode 100644 tests/unit/components/MoneyRequestConfirmationList/ScanConfirmationList.test.tsx create mode 100644 tests/unit/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.test.ts create mode 100644 tests/unit/hooks/confirmationRequiredFieldsFlow.test.ts create mode 100644 tests/unit/hooks/useConfirmationFieldErrors.test.ts diff --git a/src/components/MoneyRequestConfirmationList/confirmAction.ts b/src/components/MoneyRequestConfirmationList/confirmAction.ts index ceae1d385c2f..0206969f74af 100644 --- a/src/components/MoneyRequestConfirmationList/confirmAction.ts +++ b/src/components/MoneyRequestConfirmationList/confirmAction.ts @@ -15,6 +15,8 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValidationResult} from './hooks/useConfirmationValidation'; + type BuildConfirmActionParams = { /** IOU type being confirmed (submit / split / track / pay / invoice) */ iouType: IOUType; @@ -34,11 +36,17 @@ type BuildConfirmActionParams = { /** Whether the current user is a delegate without permission to pay */ isDelegateAccessRestricted: boolean; - /** Pure validator that returns the first failing translation key, or null on success */ - validate: (paymentType?: PaymentMethodType) => {errorKey: TranslationPaths; shouldSetDidConfirmSplit?: boolean} | {errorKey: null} | null; + /** Returns the first check that blocks confirming, `{errorKey: null}` when none does, or null when validation can't run yet */ + validate: (paymentType?: PaymentMethodType) => ValidationResult | null; + + /** Sets the form error for a check that blocks confirming */ + setConfirmError: (error: TranslationPaths) => void; + + /** Drops the form error the last confirm set, since validation checks it again on the next confirm */ + clearConfirmError: () => void; - /** Setter for the form-level error key */ - setFormError: (error: TranslationPaths | '') => void; + /** Shows the errors of the amount, date and merchant fields, which render on the fields rather than in the form error */ + revealFieldErrors: () => void; /** Setter for the "user already confirmed split" flag */ setDidConfirmSplit: (value: boolean) => void; @@ -56,10 +64,10 @@ type BuildConfirmActionParams = { /** * Owns the click-confirm action for the Money Request confirmation flow. * - * Validation always runs first and its result drives form-error state. Past it there are three - * branches: (1) invoice-without-company-info routes to the company info step; (2) non-PAY types - * invoke `onConfirm`; (3) PAY types run delegate-access gating and invoke `onSendMoney` with the - * chosen payment method. + * Validation always runs first. A field error is revealed on its field, dropping a form error an earlier confirm + * set, and any other failure becomes the form error. Past validation there are three branches: (1) invoice-without-company-info routes to the + * company info step; (2) non-PAY types invoke `onConfirm`; (3) PAY types run delegate-access gating and + * invoke `onSendMoney` with the chosen payment method. */ function buildConfirmAction({ iouType, @@ -69,7 +77,9 @@ function buildConfirmAction({ formError, isDelegateAccessRestricted, validate, - setFormError, + setConfirmError, + clearConfirmError, + revealFieldErrors, setDidConfirmSplit, showDelegateNoAccessModal, onConfirm, @@ -81,11 +91,17 @@ function buildConfirmAction({ return; } + if ('isFieldError' in result) { + clearConfirmError(); + revealFieldErrors(); + return; + } + if (result.errorKey) { if (result.shouldSetDidConfirmSplit) { setDidConfirmSplit(true); } - setFormError(result.errorKey); + setConfirmError(result.errorKey); return; } diff --git a/src/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.ts b/src/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.ts new file mode 100644 index 000000000000..c875ecd0aa1f --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.ts @@ -0,0 +1,144 @@ +/** + * The errors the amount, date and merchant fields of the confirmation show under their inputs. They are computed + * from the transaction on every render, so an error goes away as soon as its field is fixed and nothing has to + * remember to clear it. The same map blocks confirmation, flags each field and drives the summary above the confirm + * button, so those three can't disagree about what is missing. + */ +import {isP2PRecipient, isParticipantP2P} from '@libs/IOUUtils'; +import {isConfirmationAmountMissing, isConfirmationDateMissing, isConfirmationMerchantMissing, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; +import {isScanRequest} from '@libs/TransactionUtils'; +import {isInvalidMerchantValue, isMerchantBlank, isValidInputLength} from '@libs/ValidationUtils'; + +import type {IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type * as OnyxTypes from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +type ConfirmationFieldErrors = { + amount?: TranslationPaths; + date?: TranslationPaths; + merchant?: TranslationPaths; +}; + +type GetConfirmationFieldErrorsParams = { + /** Total amount of the expense, in the smallest currency unit */ + iouAmount: number; + + iouType: IOUType; + + iouMerchant: string | undefined; + + /** Whether this surface offers manual entry of the amount / merchant / date. False for test receipts and moved tracked expenses. */ + canEnterScanFieldsManually: boolean; + + /** Whether the expense goes to a workspace chat, the only destination where a manually entered Scan needs a merchant */ + isPolicyExpenseChat: boolean; + + /** Whether the date field is rendered. A hidden date can't be filled in, so it is never reported missing. */ + shouldShowDate: boolean; + + /** Whether the confirmation is read-only. A read-only date is populated server-side, so it can't be missing. */ + isReadOnly: boolean; + + isEditingSplitBill: boolean | undefined; + + isMerchantRequired: boolean | undefined; + + isDistanceRequest: boolean; + + isTimeRequest: boolean; + + currentUserAccountID: number; +}; + +/** Returned whenever no field error is shown, so the error state passed down the footer keeps its identity while nothing is flagged. */ +const NO_FIELD_ERRORS: Readonly = Object.freeze({}); + +function getAmountFieldError(transaction: OnyxEntry, params: GetConfirmationFieldErrorsParams): TranslationPaths | undefined { + const {iouAmount, iouType, canEnterScanFieldsManually, isPolicyExpenseChat, isEditingSplitBill, isDistanceRequest, isTimeRequest, currentUserAccountID} = params; + const firstParticipant = transaction?.participants?.at(0); + const isP2P = isParticipantP2P(firstParticipant); + + // This runs before the $0 check so an amount the user never entered reads as missing, whoever the expense goes to. + if (isConfirmationAmountMissing(transaction, canEnterScanFieldsManually, isPolicyExpenseChat)) { + return 'common.error.fieldRequired'; + } + + // A manual expense to a person can't be $0, but scan, time and distance amounts are exempt since they're read off the receipt or computed. + if (!isScanRequest(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) { + return 'common.error.invalidAmount'; + } + + // A typed Scan amount is checked like a manual one, because a Scan can pick its 1:1 recipient first and so never hits the participant picker's negative amount guard. + const canAmountBeTyped = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually; + if ( + canAmountBeTyped && + transaction?.isAmountSet && + !isTimeRequest && + !isDistanceRequest && + !isEditingSplitBill && + !isValidMoneyRequestAmount(iouAmount, iouType, !isP2PRecipient(firstParticipant, currentUserAccountID), isP2P) + ) { + return 'common.error.invalidAmount'; + } + + return undefined; +} + +function getMerchantFieldError(transaction: OnyxEntry, params: GetConfirmationFieldErrorsParams): TranslationPaths | undefined { + const {iouMerchant, canEnterScanFieldsManually, isPolicyExpenseChat, isMerchantRequired} = params; + const merchant = iouMerchant ?? ''; + + // The confirmation saves the merchant as typed, so its length is checked before trimming. + if (!isValidInputLength(merchant, CONST.MERCHANT_NAME_MAX_BYTES).isValid) { + return 'iou.error.invalidMerchant'; + } + + // The amount, merchant and date of a Scan are all-or-nothing, so once one is entered a blank merchant is missing. + if (isConfirmationMerchantMissing(transaction, canEnterScanFieldsManually, isPolicyExpenseChat)) { + return 'common.error.fieldRequired'; + } + + const isBlank = isMerchantBlank(transaction?.isMerchantSet, merchant); + const isTypedValueInvalid = !isBlank && isInvalidMerchantValue(merchant.trim()); + + if (!isMerchantRequired) { + // An optional merchant is only checked once the user typed one in. + return transaction?.isMerchantSet && isTypedValueInvalid ? 'iou.error.invalidMerchant' : undefined; + } + + // A required merchant left blank reads the same as the other required fields, and only a typed one is "invalid". + if (isBlank) { + return 'common.error.fieldRequired'; + } + return isTypedValueInvalid ? 'iou.error.invalidMerchant' : undefined; +} + +/** Returns the error each inline field of the confirmation would show right now, keyed by field. Empty when nothing blocks. */ +function getConfirmationFieldErrors(transaction: OnyxEntry, params: GetConfirmationFieldErrorsParams): ConfirmationFieldErrors { + const {shouldShowDate, isReadOnly, canEnterScanFieldsManually, isPolicyExpenseChat} = params; + const amount = getAmountFieldError(transaction, params); + const date = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually, isPolicyExpenseChat) ? 'common.error.fieldRequired' : undefined; + const merchant = getMerchantFieldError(transaction, params); + + if (!amount && !date && !merchant) { + return NO_FIELD_ERRORS; + } + + return { + ...(amount && {amount}), + ...(date && {date}), + ...(merchant && {merchant}), + }; +} + +/** Whether a field error blocks confirming. A partially filled sibling receipt of a multi-scan blocks too, since Create submits every receipt at once. */ +function hasBlockingFieldErrors(fieldErrors: ConfirmationFieldErrors, partiallyManuallyFilledScanID: string | undefined): boolean { + return Object.keys(fieldErrors).length > 0 || !!partiallyManuallyFilledScanID; +} + +export default getConfirmationFieldErrors; +export {NO_FIELD_ERRORS, hasBlockingFieldErrors}; +export type {ConfirmationFieldErrors, GetConfirmationFieldErrorsParams}; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors.ts new file mode 100644 index 000000000000..870cca7dc9de --- /dev/null +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors.ts @@ -0,0 +1,84 @@ +/** + * Owns when the amount, date and merchant errors of the money request confirmation become visible, and the message + * above its confirm button: a route error, then the summary while one of those errors blocks confirming, then the form error. + */ +import type {ConfirmationFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import {hasBlockingFieldErrors, NO_FIELD_ERRORS} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; + +import useLocalize from '@hooks/useLocalize'; + +import {useState} from 'react'; + +type UseConfirmationFieldErrorsParams = { + /** The errors the amount, date and merchant fields would show now, from `useConfirmationValidation` */ + fieldErrors: ConfirmationFieldErrors; + + /** ID of a partially filled Scan among the transactions being confirmed. Can name a receipt other than this one. */ + partiallyManuallyFilledScanID: string | undefined; + + /** Brings another receipt of a multi-scan on screen */ + onSwitchToTransaction: ((transactionID: string) => void) | undefined; + + /** The footer message `useFormErrorManagement` builds from the form error */ + formErrorMessage: string | undefined; + + /** An error loading the distance route, which the footer shows over the summary and the form error */ + routeError: string | null | undefined; +}; + +type UseConfirmationFieldErrorsResult = { + /** The field errors to render. Empty until a confirm is blocked by one of them. */ + visibleFieldErrors: ConfirmationFieldErrors; + + /** The message above the confirm button: a route error, then the field errors summary while it applies, then the form error */ + footerErrorMessage: string | undefined; + + /** Shows the field errors after a confirm they blocked, bringing the receipt they belong to on screen */ + revealFieldErrors: () => void; +}; + +/** The errors themselves are derived from the transaction, so only the fact that a confirm was blocked is kept as state. */ +function useConfirmationFieldErrors({ + fieldErrors, + partiallyManuallyFilledScanID, + onSwitchToTransaction, + formErrorMessage, + routeError, +}: UseConfirmationFieldErrorsParams): UseConfirmationFieldErrorsResult { + const {translate} = useLocalize(); + const [shouldShowFieldErrors, setShouldShowFieldErrors] = useState(false); + + const hasFieldErrors = hasBlockingFieldErrors(fieldErrors, partiallyManuallyFilledScanID); + + // Hide the errors again once nothing is left to fix, so a field the user empties afterwards is only flagged on the next confirm. + if (shouldShowFieldErrors && !hasFieldErrors) { + setShouldShowFieldErrors(false); + } + + // Validation only checks the values of the receipt on screen, so its errors are fixed first and a partially filled sibling only counts once it has none. + const hasFieldErrorsOnScreen = Object.keys(fieldErrors).length > 0; + const isRequiredFieldMissing = hasFieldErrorsOnScreen ? Object.values(fieldErrors).includes('common.error.fieldRequired') : !!partiallyManuallyFilledScanID; + + const revealFieldErrors = () => { + setShouldShowFieldErrors(true); + + if (!hasFieldErrorsOnScreen && partiallyManuallyFilledScanID) { + onSwitchToTransaction?.(partiallyManuallyFilledScanID); + } + }; + + let footerErrorMessage = formErrorMessage; + if (routeError) { + footerErrorMessage = routeError; + } else if (shouldShowFieldErrors && hasFieldErrors) { + footerErrorMessage = translate(isRequiredFieldMissing ? 'iou.error.enterRequiredFields' : 'common.error.fixErrorsBeforeContinuing'); + } + + return { + visibleFieldErrors: shouldShowFieldErrors ? fieldErrors : NO_FIELD_ERRORS, + footerErrorMessage, + revealFieldErrors, + }; +} + +export default useConfirmationFieldErrors; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts index 773e0edad1d7..e3c99bf226ab 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts @@ -9,13 +9,12 @@ import usePrevious from '@hooks/usePrevious'; import {getCategoryDescriptionHint, isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; -import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; +import {isConfirmationMerchantRequired, shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {arePolicyRulesEnabled, isTaxTrackingEnabled} from '@libs/PolicyUtils'; import {getCategory, getCurrency, getMerchant, getRateID, hasValidModifiedAmount, isScanRequest as isScanRequestUtil} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import {useIsFocused} from '@react-navigation/native'; import {useEffect, useRef, useState} from 'react'; @@ -23,19 +22,13 @@ import {useEffect, useRef, useState} from 'react'; import type {ConfirmationDistanceState, ConfirmationListData, UseConfirmationListDataParams} from './types'; import useConfirmationAmount from './useConfirmationAmount'; +import useConfirmationFieldErrors from './useConfirmationFieldErrors'; import useConfirmationPolicyData from './useConfirmationPolicyData'; import useConfirmationValidation from './useConfirmationValidation'; import useFormErrorManagement from './useFormErrorManagement'; import useParticipantSection from './useParticipantSection'; import useTransactionReportForConfirmation from './useTransactionReportForConfirmation'; -/** - * The errors the amount / merchant / date fields render inline rather than in the footer. Raising one of these is - * only visible if those fields are on screen, so every reveal site reads this same set: the shared confirmation - * switches to the transaction whose fields need fixing, and the scan variant expands "Show more". - */ -const INLINE_FIELD_ERROR_KEYS = new Set(['common.error.fieldRequired', 'common.error.invalidAmount', 'iou.error.invalidMerchant']); - /** * Everything the confirmation surface needs regardless of which expense type is being confirmed: the participant * rows, the form errors, the validation gate, and the confirm button, all derived from an already resolved policy. @@ -129,6 +122,7 @@ function useConfirmationListDataWithPolicy({ // In the share flow, the caller knows it is a scan before the draft is initialized. const isScanRequest = isScanRequestProp || isScanRequestUtil(transaction); + const isMerchantRequired = isConfirmationMerchantRequired({isPolicyExpenseChat, isScanRequest, isEditingSplitBill, shouldShowMerchant}); const {amountToBeUsed, formattedAmount, formattedAmountPerAttendee} = useConfirmationAmount({ transaction, @@ -160,14 +154,12 @@ function useConfirmationListDataWithPolicy({ const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const shouldShowReadOnlySplits = isPolicyExpenseChat || isReadOnly || isScanRequest; - // Both the validation gate and the clear gate below key off this, so it is computed once here rather than - // being re-derived per hook, where the two could be updated independently. + // The date field is rendered under the same condition, so a hidden date is never reported missing. const shouldShowDate = shouldShowConfirmationDate(shouldShowSmartScanFields, isDistanceRequest); - const {formError, setFormError, clearFormErrors, shouldDisplayFieldError, isMerchantEmpty, isMerchantFieldValid, isMerchantRequired, errorMessage} = useFormErrorManagement({ + const {formError, setFormError, clearFormErrors, setConfirmError, clearConfirmError, shouldDisplayFieldError, errorMessage} = useFormErrorManagement({ transaction, transactionReport, - iouMerchant, iouCategory, iouAttendees, policy, @@ -175,19 +167,11 @@ function useConfirmationListDataWithPolicy({ policyCategories, currentUserPersonalDetails, isEditingSplitBill, - isPolicyExpenseChat, - isScanRequest, - canEnterScanFieldsManually, - partiallyManuallyFilledScanID, - shouldShowMerchant, hasSmartScanFailed, didConfirmSplit, - routeError, isTypeSplit, shouldShowReadOnlySplits, isDistanceRequest, - isReadOnly, - shouldShowDate, }); const isCategoryRequired = !!policy?.requiresCategory && !isTypeInvoice; @@ -228,7 +212,7 @@ function useConfirmationListDataWithPolicy({ onOpenParticipantPicker, }); - const {validate} = useConfirmationValidation({ + const {validate, fieldErrors} = useConfirmationValidation({ transaction, transactionReport, transactionID, @@ -246,9 +230,6 @@ function useConfirmationListDataWithPolicy({ currentUserPersonalDetails, isEditingSplitBill, isMerchantRequired, - isMerchantFieldValid, - isMerchantEmpty, - shouldDisplayFieldError, shouldShowTax, isDistanceRequest, isDistanceRequestWithPendingRoute, @@ -264,14 +245,13 @@ function useConfirmationListDataWithPolicy({ isTaxAmountEmpty, }); - // The partially filled receipt may not be the one on screen, so bring it into view to show its inline errors. - const validateAndRevealFields: typeof validate = (paymentType) => { - const result = validate(paymentType); - if (result?.errorKey && INLINE_FIELD_ERROR_KEYS.has(result.errorKey) && partiallyManuallyFilledScanID && partiallyManuallyFilledScanID !== transactionID) { - onSwitchToTransaction?.(partiallyManuallyFilledScanID); - } - return result; - }; + const {visibleFieldErrors, footerErrorMessage, revealFieldErrors} = useConfirmationFieldErrors({ + fieldErrors, + partiallyManuallyFilledScanID, + onSwitchToTransaction, + formErrorMessage: errorMessage, + routeError, + }); const confirm = buildConfirmAction({ iouType, @@ -280,8 +260,10 @@ function useConfirmationListDataWithPolicy({ routeError, formError, isDelegateAccessRestricted, - validate: validateAndRevealFields, - setFormError, + validate, + setConfirmError, + clearConfirmError, + revealFieldErrors, setDidConfirmSplit, showDelegateNoAccessModal, onConfirm, @@ -298,7 +280,7 @@ function useConfirmationListDataWithPolicy({ shouldShowTax, hasParticipantSection: sections.length > 0, }; - const errorState = {shouldDisplayFieldError, formError, clearFormErrors, setFormError}; + const errorState = {shouldDisplayFieldError, formError, fieldErrors: visibleFieldErrors, clearFormErrors, setFormError}; return { /** Handed straight to `ConfirmationListLayout`. The variant adds its `listFooterContent`, its `fieldFlags`, and its controllers as children. */ @@ -319,7 +301,7 @@ function useConfirmationListDataWithPolicy({ isConfirmed, isConfirming, receiptOptions, - errorMessage, + errorMessage: footerErrorMessage, expensesNumber, showRemoveExpenseConfirmModal, transaction, @@ -429,4 +411,4 @@ function useConfirmationListData(props: MoneyRequestConfirmationListProps & Pick } export default useConfirmationListData; -export {INLINE_FIELD_ERROR_KEYS, useConfirmationListDataWithPolicy}; +export {useConfirmationListDataWithPolicy}; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 4d46fba083fd..5b33298fd3b6 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -1,26 +1,18 @@ +import type {ConfirmationFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import getConfirmationFieldErrors, {hasBlockingFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; + import {useCurrencyListActions} from '@hooks/useCurrencyList'; import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {isCategoryMissing} from '@libs/CategoryUtils'; import {convertToFrontendAmountAsString} from '@libs/CurrencyUtils'; -import {isP2PRecipient} from '@libs/IOUUtils'; -import {isConfirmationAmountMissing, isConfirmationDateMissing, isTaxAmountInvalid, isValidMoneyRequestAmount, validateAmount} from '@libs/MoneyRequestUtils'; +import {isTaxAmountInvalid, validateAmount} from '@libs/MoneyRequestUtils'; import type {getTagLists as getTagListsFn} from '@libs/PolicyUtils'; import {canSubmitPerDiemExpenseFromWorkspace, isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; import {hasEnabledTags, hasMatchingTag} from '@libs/TagsOptionsListUtils'; import {isValidTimeExpenseAmount} from '@libs/TimeTrackingUtils'; -import { - areRequiredFieldsEmpty, - getCalculatedTaxAmount, - getTag, - getTaxAmount, - hasTaxRateWithMatchingValue, - isMerchantMissing, - isPartiallyEnteredScanExpense, - isScanRequest as isScanRequestUtil, -} from '@libs/TransactionUtils'; -import {isValidInputLength} from '@libs/ValidationUtils'; +import {areRequiredFieldsEmpty, getCalculatedTaxAmount, getTag, getTaxAmount, hasTaxRateWithMatchingValue} from '@libs/TransactionUtils'; import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; @@ -32,7 +24,21 @@ import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {OnyxEntry} from 'react-native-onyx'; -type ValidationResult = {errorKey: TranslationPaths; shouldSetDidConfirmSplit?: boolean} | {errorKey: null}; +type ValidationResult = + | { + errorKey: TranslationPaths; + shouldSetDidConfirmSplit?: boolean; + } + | {isFieldError: true} + | {errorKey: null}; + +type UseConfirmationValidationResult = { + /** Returns the first check that blocks confirmation, `{errorKey: null}` when none does, or null when validation can't run yet */ + validate: (paymentType?: PaymentMethodType) => ValidationResult | null; + + /** The errors the amount, date and merchant fields would show now. `validate` blocks on the same map. */ + fieldErrors: ConfirmationFieldErrors; +}; type UseConfirmationValidationParams = { /** Transaction being validated */ @@ -85,15 +91,6 @@ type UseConfirmationValidationParams = { /** Whether the merchant field is required for this flow */ isMerchantRequired: boolean | undefined; - /** Whether the merchant value passes full validation (length, required, disallowed values) */ - isMerchantFieldValid: boolean; - - /** Whether the merchant field is empty / partial (from {@link useFormErrorManagement}) */ - isMerchantEmpty: boolean; - - /** When editing a split bill, whether per-field errors should be shown (SmartScan failure paths) */ - shouldDisplayFieldError: boolean; - /** Whether the tax section is enabled for this policy */ shouldShowTax: boolean; @@ -115,7 +112,7 @@ type UseConfirmationValidationParams = { /** Truthy when the route to the confirmation page has a known error */ routeError: string | null | undefined; - /** Whether this surface offers manual entry of the amount / merchant / date. False for splits, test receipts and moved tracked expenses. */ + /** Whether this surface offers manual entry of the amount / merchant / date. False for test receipts and moved tracked expenses. */ canEnterScanFieldsManually: boolean; /** Whether the expense goes to a workspace chat, the only destination where a manually entered Scan needs a merchant */ @@ -136,8 +133,11 @@ type UseConfirmationValidationParams = { /** * Runs the set of pure validation checks for the Money Request confirmation - * flow and returns either a translation key describing the first failure, or - * a success signal. + * flow and returns a translation key describing the first failure, a field + * error signal when the amount, date or merchant field blocks, or a success + * signal. The amount, date and merchant checks come from + * `getConfirmationFieldErrors`, whose map is returned too so those fields render + * exactly the errors that block confirmation. * * Side effects (setting form error, firing onConfirm / onSendMoney, * navigating to the company info route, showing the delegate-no-access modal) @@ -166,9 +166,6 @@ function useConfirmationValidation({ currentUserPersonalDetails, isEditingSplitBill, isMerchantRequired, - isMerchantFieldValid, - isMerchantEmpty, - shouldDisplayFieldError, shouldShowTax, isDistanceRequest, isDistanceRequestWithPendingRoute, @@ -182,13 +179,23 @@ function useConfirmationValidation({ isReadOnly, shouldShowDate, isTaxAmountEmpty, -}: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} { +}: UseConfirmationValidationParams): UseConfirmationValidationResult { const {getCurrencyDecimals} = useCurrencyListActions(); const selectedParticipantsCount = selectedParticipants.length; - // The Scan confirmation reveals the amount / merchant / date fields behind "Show more". Each is optional there: - // a field left blank is still read off the receipt, but one the user does fill in is subject to the same - // validation as a manually entered one. - const shouldValidateEnteredAmount = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually; + const fieldErrors = getConfirmationFieldErrors(transaction, { + iouAmount, + iouType, + iouMerchant, + canEnterScanFieldsManually, + isPolicyExpenseChat, + shouldShowDate, + isReadOnly, + isEditingSplitBill, + isMerchantRequired, + isDistanceRequest, + isTimeRequest, + currentUserAccountID: currentUserPersonalDetails.accountID, + }); const validate = (paymentType?: PaymentMethodType): ValidationResult | null => { if (!!routeError || !transactionID) { return null; @@ -198,59 +205,8 @@ function useConfirmationValidation({ return {errorKey: 'iou.error.noParticipantSelected'}; } - const firstParticipant = transaction?.participants?.at(0); - const isP2P = !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat && !firstParticipant?.isSelfDM); - - // P2P manual submit: $0 is invalid unless scan/time/distance (same guard as legacy inline confirm). - if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) { - return {errorKey: 'common.error.invalidAmount'}; - } - // `isConfirmationAmountMissing` only applies to manually entered amounts. Per diem, distance and time set the - // amount programmatically, and a scan reads it off the receipt whenever the user leaves the field blank. - if (isConfirmationAmountMissing(transaction, canEnterScanFieldsManually, isPolicyExpenseChat)) { - return {errorKey: 'common.error.fieldRequired'}; - } - // The three fields are all-or-nothing, so a partially filled set is blocked and each blank field flags inline. - if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually, isPolicyExpenseChat)) { - return {errorKey: 'common.error.fieldRequired'}; - } - // On a multi-scan the same rule has to hold for the receipts that are not on screen, since Create submits - // all of them at once. The caller brings the offending one into view so its blank fields raise this inline. - if (partiallyManuallyFilledScanID) { - return {errorKey: 'common.error.fieldRequired'}; - } - if ( - shouldValidateEnteredAmount && - transaction?.isAmountSet && - !isTimeRequest && - !isDistanceRequest && - !isEditingSplitBill && - // P2P chats don't support negative amounts. The Scan flow lets the user pick a 1:1 recipient before entering - // the amount, so the participant picker's negative-amount guard never applies and the block has to live here. - !isValidMoneyRequestAmount(iouAmount, iouType, !isP2PRecipient(firstParticipant, currentUserPersonalDetails.accountID), isP2P) - ) { - return {errorKey: 'common.error.invalidAmount'}; - } - // The date is an inline, clearable required field for every type that shows it (manual, distance, time, - // invoice, ...). Block confirmation when the user cleared it. - if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually, isPolicyExpenseChat)) { - return {errorKey: 'common.error.fieldRequired'}; - } - const merchantValue = iouMerchant ?? ''; - const {isValid: isMerchantLengthValid} = isValidInputLength(merchantValue, CONST.MERCHANT_NAME_MAX_BYTES); - - if (!isMerchantLengthValid) { - return {errorKey: 'iou.error.invalidMerchant'}; - } - if (isMerchantRequired) { - if (!isEditingSplitBill && !isMerchantFieldValid) { - return {errorKey: 'iou.error.invalidMerchant'}; - } - if (isEditingSplitBill && (isMerchantEmpty || (shouldDisplayFieldError && isMerchantMissing(transaction)))) { - return {errorKey: 'iou.error.invalidMerchant'}; - } - } else if (transaction?.isMerchantSet && !isMerchantFieldValid) { - return {errorKey: 'iou.error.invalidMerchant'}; + if (hasBlockingFieldErrors(fieldErrors, partiallyManuallyFilledScanID)) { + return {isFieldError: true}; } if (iouCategory.length > CONST.API_TRANSACTION_CATEGORY_MAX_LENGTH) { @@ -358,8 +314,8 @@ function useConfirmationValidation({ return {errorKey: null}; }; - return {validate}; + return {validate, fieldErrors}; } export default useConfirmationValidation; -export type {UseConfirmationValidationParams}; +export type {UseConfirmationValidationParams, ValidationResult}; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 0c27fd8991af..3ef709d9656e 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -1,10 +1,8 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; -import {isConfirmationAmountMissing, isConfirmationDateMissing, isConfirmationMerchantMissing} from '@libs/MoneyRequestUtils'; import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; -import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils'; -import {isInvalidMerchantValue, isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils'; +import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields} from '@libs/TransactionUtils'; import {getIsViolationFixed} from '@libs/Violations/ViolationsUtils'; import CONST from '@src/CONST'; @@ -16,7 +14,7 @@ import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import type {OnyxEntry} from 'react-native-onyx'; import {useIsFocused} from '@react-navigation/native'; -import {useEffect, useRef} from 'react'; +import {useEffect, useRef, useState} from 'react'; type UseFormErrorManagementParams = { /** Transaction being confirmed */ @@ -25,9 +23,6 @@ type UseFormErrorManagementParams = { /** Report the IOU is being created on */ transactionReport: OnyxEntry; - /** Current merchant value entered for the IOU */ - iouMerchant: string | undefined; - /** Currently selected category */ iouCategory: string; @@ -48,30 +43,12 @@ type UseFormErrorManagementParams = { /** Whether we are editing an existing split bill */ isEditingSplitBill: boolean | undefined; - /** Whether this is a policy-expense chat (drives merchant requirement) */ - isPolicyExpenseChat: boolean; - - /** Whether the IOU was started from a SmartScan flow */ - isScanRequest: boolean; - - /** Whether this surface offers manual entry of the amount / merchant / date. False for splits, test receipts and moved tracked expenses. */ - canEnterScanFieldsManually: boolean; - - /** ID of a partially filled Scan among the transactions being confirmed. Can name a receipt other than this one. */ - partiallyManuallyFilledScanID?: string; - - /** Whether the merchant field should be visible in the UI */ - shouldShowMerchant: boolean; - /** Whether SmartScan failed to read the receipt */ hasSmartScanFailed: boolean | undefined; /** Whether the user has already confirmed the split */ didConfirmSplit: boolean; - /** Truthy when the route to the confirmation page has a known error */ - routeError: string | null | undefined; - /** Whether the current IOU type is split */ isTypeSplit: boolean; @@ -80,12 +57,6 @@ type UseFormErrorManagementParams = { /** Whether the transaction is a distance request (its amount is read-only, so amount errors are not shown inline) */ isDistanceRequest: boolean; - - /** Whether the date field is rendered (a hidden date can't be missing, so it never keeps the required error alive) */ - shouldShowDate: boolean; - - /** Whether the confirmation is read-only (a read-only date is populated server-side, so it can't be missing) */ - isReadOnly: boolean; }; type UseFormErrorManagementResult = { @@ -101,22 +72,19 @@ type UseFormErrorManagementResult = { /** Clears the current form error if it matches one of the provided keys */ clearFormErrors: (errors: string[]) => void; - /** Whether per-field errors should be shown (only true when editing a split bill) */ - shouldDisplayFieldError: boolean; - - /** Whether the merchant field is currently empty / partial */ - isMerchantEmpty: boolean; + /** Sets the form error for a check that blocked confirming, and remembers that the confirm set it */ + setConfirmError: (error: TranslationPaths) => void; - /** Whether the merchant field is required for this flow */ - isMerchantRequired: boolean; + /** Drops the form error only while it is the one the last confirm set, so an error raised outside validation stays */ + clearConfirmError: () => void; - /** Whether the current merchant value passes validation */ - isMerchantFieldValid: boolean; + /** Whether per-field errors should be shown (only true when editing a split bill) */ + shouldDisplayFieldError: boolean; /** Whether a previously surfaced violation has been resolved */ isViolationFixed: boolean; - /** User-visible error message derived from `routeError` and `debouncedFormError` */ + /** User-visible message for the form error, or for `debouncedFormError` on an editable split. A route error is put ahead of it by `useConfirmationFieldErrors`. */ errorMessage: string | undefined; }; @@ -124,17 +92,17 @@ type UseFormErrorManagementResult = { * Owns the form-error state for the Money Request confirmation flow. * * Holds a debounced form-error string, exposes setters and clearing helpers used by the - * controllers, and derives merchant validity, the violation-fixed flag, and the user- - * visible error message. `shouldDisplayFieldError` is gated on edit-split-bill mode so - * field-level errors only render in that flow. `errorMessage` prefers `routeError`, - * then suppresses errors that are already surfaced inline (missingAttendees, the tax amount - * error, and the manual-flow amount/date/merchant required/invalid errors, except the distance-amount - * error which has no inline surface) so they don't show twice. + * controllers, and derives the violation-fixed flag and the user-visible error message. + * `shouldDisplayFieldError` is gated on edit-split-bill mode so field-level errors only render in + * that flow. `errorMessage` leaves out the errors their own field already shows (missingAttendees, + * the tax amount error, and the invalid amount an edited split flags as the user types) so they + * don't show twice. + * The amount, date and merchant errors of a blocked confirm aren't held here at all: they are derived + * by `getConfirmationFieldErrors` and shown through `useConfirmationFieldErrors`. */ function useFormErrorManagement({ transaction, transactionReport, - iouMerchant, iouCategory, iouAttendees, policy, @@ -142,19 +110,11 @@ function useFormErrorManagement({ policyCategories, currentUserPersonalDetails, isEditingSplitBill, - isPolicyExpenseChat, - isScanRequest, - canEnterScanFieldsManually, - partiallyManuallyFilledScanID, - shouldShowMerchant, hasSmartScanFailed, didConfirmSplit, - routeError, isTypeSplit, shouldShowReadOnlySplits, isDistanceRequest, - shouldShowDate, - isReadOnly, }: UseFormErrorManagementParams): UseFormErrorManagementResult { const isFocused = useIsFocused(); const {translate} = useLocalize(); @@ -169,27 +129,25 @@ function useFormErrorManagement({ setFormError(''); }; - const shouldDisplayFieldError: boolean = - !!isEditingSplitBill && - ((!!hasSmartScanFailed && hasMissingSmartscanFields(transaction, transactionReport)) || (didConfirmSplit && areRequiredFieldsEmpty(transaction, transactionReport))); + // Validation raises a confirm's own error again on the next confirm, so a confirm blocked by a field error can drop it, but an error set outside validation would not come back and has to stay. + const [confirmErrorKey, setConfirmErrorKey] = useState(''); - const isMerchantEmpty = !iouMerchant || isMerchantMissing(transaction); - const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || !!isEditingSplitBill) && shouldShowMerchant; - const isMerchantFieldValid = (() => { - const merchantValue = iouMerchant ?? ''; - const trimmedMerchant = merchantValue.trim(); - const {isValid} = isValidInputLength(merchantValue, CONST.MERCHANT_NAME_MAX_BYTES); - - if (!isValid) { - return false; - } + const setConfirmError = (error: TranslationPaths) => { + setConfirmErrorKey(error); + setFormError(error); + }; - if (!trimmedMerchant || isUntypedPlaceholderMerchant(transaction?.isMerchantSet, trimmedMerchant)) { - return !isMerchantRequired; + const clearConfirmError = () => { + if (!formError || formError !== confirmErrorKey) { + return; } + setConfirmErrorKey(''); + setFormError(''); + }; - return !isInvalidMerchantValue(trimmedMerchant); - })(); + const shouldDisplayFieldError: boolean = + !!isEditingSplitBill && + ((!!hasSmartScanFailed && hasMissingSmartscanFields(transaction, transactionReport)) || (didConfirmSplit && areRequiredFieldsEmpty(transaction, transactionReport))); const isViolationFixed = getIsViolationFixed(formError, { category: iouCategory, @@ -214,27 +172,6 @@ function useFormErrorManagement({ formErrorRef.current = formError; }, [formError]); - useEffect(() => { - if (formErrorRef.current !== 'iou.error.invalidMerchant' || !isMerchantFieldValid) { - return; - } - setFormError(''); - }, [isMerchantFieldValid, setFormError]); - - // These reuse the very predicates `useConfirmationValidation` raises `common.error.fieldRequired` from, so the - // clear side can never drift from the validation side and strand a required error that can no longer be cleared (#96568). - const isAmountRequiredMissing = isConfirmationAmountMissing(transaction, canEnterScanFieldsManually, isPolicyExpenseChat); - const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually, isPolicyExpenseChat); - const isMerchantRequiredMissing = isConfirmationMerchantMissing(transaction, canEnterScanFieldsManually, isPolicyExpenseChat); - useEffect(() => { - // The predicates above only see the transaction on screen, so the ID keeps the error alive while another - // receipt of a multi-scan is still partially filled. - if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isMerchantRequiredMissing || !!partiallyManuallyFilledScanID) { - return; - } - setFormError(''); - }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, partiallyManuallyFilledScanID, setFormError]); - useEffect(() => { const currentFormError = formErrorRef.current; if (shouldDisplayFieldError && didConfirmSplit) { @@ -260,27 +197,17 @@ function useFormErrorManagement({ } }, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isViolationFixed, setFormError]); - // The amount/date/merchant fields surface these required/invalid errors inline, so repeating them at the bottom of - // the form would show "This field is required" twice. - // `common.error.invalidAmount` is the one exception: it is only surfaced inline while the editable amount input is - // rendered. Distance requests disable that input, and the read-only menu row it falls back to doesn't show the error, - // so the distance-amount error stays in the footer. Otherwise an invalid distance expense would fail silently. - const isSuppressedInline = (error: TranslationPaths | ''): boolean => - error === 'common.error.fieldRequired' || error === 'iou.error.invalidMerchant' || (!isDistanceRequest && error === 'common.error.invalidAmount'); + // An edited split sets this error as the user types and its amount input shows it, so the footer skips it; a distance amount has no input, so its error stays. + const isShownOnAmountField = (error: TranslationPaths | ''): boolean => !isDistanceRequest && error === 'common.error.invalidAmount'; const computeErrorMessage = (): string | undefined => { - if (routeError) { - return routeError; - } - // This runs ahead of the split branch below because splits render those same inline fields, so they duplicate the same way. - if (isSuppressedInline(formError)) { + // This runs ahead of the split branch below because splits render the same amount input, so the error duplicates the same way. + if (isShownOnAmountField(formError)) { return undefined; } if (isTypeSplit && !shouldShowReadOnlySplits) { - // Splits render the debounced value, so the suppression has to be re-checked against it. `formError` clears - // the instant the user fixes the field while `debouncedFormError` lags by USE_DEBOUNCED_STATE_DELAY, and - // without this the suppressed error would pop into the footer for that window (#96565). - return debouncedFormError && !isSuppressedInline(debouncedFormError) ? translate(debouncedFormError) : undefined; + // The debounced value lags the live one after a fix, so it is checked too, or the amount error would flash into the footer for that moment (#96565). + return debouncedFormError && !isShownOnAmountField(debouncedFormError) ? translate(debouncedFormError) : undefined; } // Don't show error at the bottom of the form for missing attendees — the field surfaces it inline. if (formError === 'violations.missingAttendees') { @@ -299,10 +226,9 @@ function useFormErrorManagement({ debouncedFormError, setFormError, clearFormErrors, + setConfirmError, + clearConfirmError, shouldDisplayFieldError, - isMerchantEmpty, - isMerchantRequired, - isMerchantFieldValid, isViolationFixed, errorMessage, }; diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 2f825c5ca7a8..485f19065166 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -20,7 +20,6 @@ import { import {convertToBackendAmount, convertToFrontendAmountAsString, getLocalizedCurrencySymbol} from '@libs/CurrencyUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {calculateAmount, isMovingTransactionFromTrackExpense, isP2PRecipient, isParticipantP2P} from '@libs/IOUUtils'; -import {isConfirmationAmountMissing} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import {shouldEnableNegative} from '@libs/ReportUtils'; @@ -59,6 +58,10 @@ type AmountFieldProps = { shouldShowTimeRequestFields: boolean; shouldDisplayFieldError: boolean; formError: string; + + /** The amount entry of the errors a blocked confirm revealed, from `getConfirmationFieldErrors`. Distance and time leave it out, since their amount is computed. */ + fieldError?: TranslationPaths; + policy: OnyxEntry; clearFormErrors: (errors: string[]) => void; setFormError: (error: TranslationPaths | '') => void; @@ -73,13 +76,14 @@ function AmountField({ shouldShowTimeRequestFields, shouldDisplayFieldError, formError, + fieldError, policy, clearFormErrors, setFormError, }: AmountFieldProps) { const isScreenFocused = useIsFocused(); const navigation = useNavigation(); - const {isEditingSplitBill, canEnterScanFieldsManually, isPolicyExpenseChat, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); + const {isEditingSplitBill, canEnterScanFieldsManually, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); // Filled by the forms that offer a receipt from beside the amount field rather than from a full-width empty // state, with their compact add-receipt button. const {amountTrailingAction, shouldUseDropdownRows} = useExpenseFormLayout(); @@ -114,20 +118,13 @@ function AmountField({ // so its row is locked the same way until the scan finishes. const canOpenAmountPage = !isReadOnly && !isDistanceRequest && !shouldShowTimeRequestFields && !isScanningReceipt; const isP2P = isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)); - // `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. `isConfirmationAmountMissing` is the same predicate validation raises the - // error from, so a scan expense (where the amount is read off the receipt whenever the user leaves the field - // blank) can't show a phantom required error under a field that is deliberately empty. - const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && isConfirmationAmountMissing(transactionSlice, canEnterScanFieldsManually, isPolicyExpenseChat); - const shouldShowAmountInvalidError = formError === 'common.error.invalidAmount'; - const shouldShowAmountMissingError = shouldDisplayFieldError && amountIsMissing; + // Editing a split bill flags an invalid amount as the user types, through the form error rather than the field errors. + const amountErrorKey = formError === 'common.error.invalidAmount' ? 'common.error.invalidAmount' : fieldError; let amountFieldErrorText = ''; - if (shouldShowAmountInvalidError) { - amountFieldErrorText = translate('common.error.invalidAmount'); - } else if (shouldShowAmountRequiredError) { - amountFieldErrorText = translate('common.error.fieldRequired'); - } else if (shouldShowAmountMissingError) { + if (amountErrorKey) { + amountFieldErrorText = translate(amountErrorKey); + } else if (shouldDisplayFieldError && amountIsMissing) { amountFieldErrorText = translate('common.error.enterAmount'); } diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index e7ad6857b7d9..5e3d6bd60c22 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -16,12 +16,13 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatReportUtil} from '@libs/ReportUtils'; -import {hasAnyManuallyEnteredScanField, isPartiallyEnteredScanExpense} from '@libs/TransactionUtils'; +import {hasAnyManuallyEnteredScanField} from '@libs/TransactionUtils'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import {DYNAMIC_ROUTES} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/MoneyRequestDateForm'; @@ -41,7 +42,10 @@ type DateFieldProps = { shouldDisplayFieldError: boolean; didConfirm: boolean; isReadOnly: boolean; - formError: string; + + /** The date entry of the errors a blocked confirm revealed, from `getConfirmationFieldErrors` */ + fieldError: TranslationPaths | undefined; + transactionID: string | undefined; action: IOUAction; iouType: Exclude; @@ -49,9 +53,9 @@ type DateFieldProps = { reportActionID: string | undefined; }; -function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) { +function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, fieldError, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) { const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isEditingSplitBill, canEnterScanFieldsManually, isPolicyExpenseChat} = useConfirmationFields(); + const {isEditingSplitBill, canEnterScanFieldsManually} = useConfirmationFields(); const {shouldUseDropdownRows} = useExpenseFormLayout(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -82,10 +86,7 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen && !hasAnyManuallyEnteredScanField(dateState); const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : ''; - - // A draft always carries a date, so on a partially filled Scan the all-or-nothing rule stands in for `createdMissing`. - const isDateRequiredMissing = isPartiallyEnteredScanExpense(dateState, canEnterScanFieldsManually, isPolicyExpenseChat) ? !dateState?.isCreatedSet : createdMissing; - const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateRequiredMissing ? translate('common.error.fieldRequired') : ''; + const inlineDateErrorText = fieldError ? translate(fieldError) : ''; const handleDateChange = (newDate: string) => { if (!transactionID) { diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx index c7cdb188d164..f1cf6cd68060 100644 --- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx @@ -8,13 +8,13 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearMoneyRequestMerchant, setMoneyRequestMerchant} from '@libs/actions/IOU/MoneyRequest'; -import {isConfirmationMerchantMissing} from '@libs/MoneyRequestUtils'; import {hasAnyManuallyEnteredScanField} from '@libs/TransactionUtils'; import {isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import React, {useState} from 'react'; import {View} from 'react-native'; @@ -29,11 +29,13 @@ import useTransactionSelector from './useTransactionSelector'; type MerchantFieldProps = { isMerchantRequired: boolean | undefined; shouldDisplayFieldError: boolean; - formError: string; + + /** The merchant entry of the errors a blocked confirm revealed, from `getConfirmationFieldErrors` */ + fieldError: TranslationPaths | undefined; }; -function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: MerchantFieldProps) { - const {transactionID, isReadOnly, didConfirm, isEditingSplitBill, canEnterScanFieldsManually, isPolicyExpenseChat} = useConfirmationFields(); +function MerchantField({isMerchantRequired, shouldDisplayFieldError, fieldError}: MerchantFieldProps) { + const {transactionID, isReadOnly, didConfirm, isEditingSplitBill, canEnterScanFieldsManually} = useConfirmationFields(); const {shouldUseDropdownRows} = useExpenseFormLayout(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -78,17 +80,8 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: return translate('common.error.characterLimitExceedCounter', byteLength, CONST.MERCHANT_NAME_MAX_BYTES); } - if (formError === 'iou.error.invalidMerchant') { - return translate('iou.error.invalidMerchant'); - } - - // `common.error.fieldRequired` is shared with the amount and date fields, so only surface it here when the - // merchant is the one that is missing. - if ( - formError === 'common.error.fieldRequired' && - (isConfirmationMerchantMissing(merchantState, canEnterScanFieldsManually, isPolicyExpenseChat) || (isMerchantRequired && !displayMerchantValue)) - ) { - return translate('common.error.fieldRequired'); + if (fieldError) { + return translate(fieldError); } if (shouldDisplayFieldError && isMerchantRequired && !displayMerchantValue) { @@ -114,7 +107,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: setDraftSplitTransaction(transactionID, baseTransaction, {merchant: '', isMerchantSet: false}, getCurrencyDecimals, getCurrencySymbol); return; } - setDraftSplitTransaction(transactionID, baseTransaction, {merchant: newMerchant}, getCurrencyDecimals, getCurrencySymbol); + setDraftSplitTransaction(transactionID, baseTransaction, {merchant: newMerchant, isMerchantSet: true}, getCurrencyDecimals, getCurrencySymbol); return; } @@ -143,6 +136,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: accessibilityLabel={translate('common.merchant')} errorText={merchantErrorText} rightHandSideComponent={shouldShowAutomaticHint ? : undefined} + sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.MERCHANT_FIELD} /> ); diff --git a/src/components/MoneyRequestConfirmationList/variants/ScanConfirmationList.tsx b/src/components/MoneyRequestConfirmationList/variants/ScanConfirmationList.tsx index a076c6946d32..6629f0caf2c0 100644 --- a/src/components/MoneyRequestConfirmationList/variants/ScanConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList/variants/ScanConfirmationList.tsx @@ -1,6 +1,6 @@ import ConfirmationListLayout from '@components/MoneyRequestConfirmationList/ConfirmationListLayout'; import FieldAutoSelector from '@components/MoneyRequestConfirmationList/FieldAutoSelector'; -import useConfirmationListData, {INLINE_FIELD_ERROR_KEYS} from '@components/MoneyRequestConfirmationList/hooks/useConfirmationListData'; +import useConfirmationListData from '@components/MoneyRequestConfirmationList/hooks/useConfirmationListData'; import SplitBillController from '@components/MoneyRequestConfirmationList/SplitBillController'; import TaxController from '@components/MoneyRequestConfirmationList/TaxController'; import type {MoneyRequestConfirmationListProps} from '@components/MoneyRequestConfirmationList/types'; @@ -17,7 +17,7 @@ import React, {useState} from 'react'; * screen and the optional fields collapse behind a show-more button, so it owns that state. */ function ScanConfirmationList(props: MoneyRequestConfirmationListProps) { - const {transaction, canEnterScanFieldsManually = false, partiallyManuallyFilledScanID} = props; + const {transaction, canEnterScanFieldsManually = false} = props; const isInLandscapeMode = useIsInLandscapeMode(); @@ -40,13 +40,9 @@ function ScanConfirmationList(props: MoneyRequestConfirmationListProps) { }); }; - // Reveal the collapsed fields when the user already filled one of them in, so those values aren't hidden behind - // "Show more", or when one of them raises an inline error, or opening the section and pressing Create looks like - // it did nothing. Done during render so the expense never paints collapsed first and it survives a remount. - // In a multi-scan the error stays set while another receipt is partially filled, so only expand the receipt it belongs to. + // The fields expand during render when they hold a value or an error, so the expense never paints collapsed, even after a remount, and Create never looks like it did nothing. const hasManuallyEnteredFields = canEnterScanFieldsManually && hasAnyManuallyEnteredScanField(transaction); - const doesFormErrorBelongToThisReceipt = !partiallyManuallyFilledScanID || partiallyManuallyFilledScanID === transactionID; - const hasInlineFieldError = INLINE_FIELD_ERROR_KEYS.has(data.footerProps.errorState.formError) && doesFormErrorBelongToThisReceipt; + const hasInlineFieldError = Object.keys(data.footerProps.errorState.fieldErrors).length > 0; if (transactionID && !showMoreFields && (hasManuallyEnteredFields || hasInlineFieldError)) { setRevealedTransactionIDs([...revealedTransactionIDs, transactionID]); } diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts index f88b812d17c4..fdb9c7f7e297 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroupTypes.ts @@ -1,3 +1,5 @@ +import type {ConfirmationFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; + import type {MileageRate} from '@libs/DistanceRequestUtils'; import type {TranslationPaths} from '@src/languages/types'; @@ -54,6 +56,10 @@ type VisibilityFlags = { type ErrorState = { shouldDisplayFieldError: boolean; formError: TranslationPaths | ''; + + /** The amount, date and merchant errors to show under those inputs. Empty until a confirm is blocked by one of them. */ + fieldErrors: ConfirmationFieldErrors; + clearFormErrors: (errors: string[]) => void; setFormError: (error: TranslationPaths | '') => void; }; diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/ClassificationFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/ClassificationFields.tsx index 99913742f874..7311e9570ad4 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/ClassificationFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/ClassificationFields.tsx @@ -147,7 +147,7 @@ function ClassificationFields({ shouldDisplayFieldError={errorState.shouldDisplayFieldError} didConfirm={didConfirm} isReadOnly={isReadOnly} - formError={errorState.formError} + fieldError={errorState.fieldErrors.date} transactionID={transactionID} action={action} iouType={iouType} diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/DistanceDetailsFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/DistanceDetailsFields.tsx index dba2566b1df6..a883153c405e 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/DistanceDetailsFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/DistanceDetailsFields.tsx @@ -46,7 +46,10 @@ function DistanceDetailsFields({policy, amountDisplay, distanceData, isDescripti isDistanceRequest shouldShowTimeRequestFields={false} policy={policy} - {...errorState} + shouldDisplayFieldError={errorState.shouldDisplayFieldError} + formError={errorState.formError} + clearFormErrors={errorState.clearFormErrors} + setFormError={errorState.setFormError} /> )} diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/ManualDetailsFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/ManualDetailsFields.tsx index 6a0a048fbec2..19b6c84be5c6 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/ManualDetailsFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/ManualDetailsFields.tsx @@ -40,7 +40,11 @@ function ManualDetailsFields({policy, amountDisplay, requiredFlags, errorState}: isDistanceRequest={false} shouldShowTimeRequestFields={false} policy={policy} - {...errorState} + shouldDisplayFieldError={errorState.shouldDisplayFieldError} + formError={errorState.formError} + fieldError={errorState.fieldErrors.amount} + clearFormErrors={errorState.clearFormErrors} + setFormError={errorState.setFormError} /> )} @@ -48,7 +52,7 @@ function ManualDetailsFields({policy, amountDisplay, requiredFlags, errorState}: )} diff --git a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/TimeDetailsFields.tsx b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/TimeDetailsFields.tsx index ea76bcd46d1c..eec98ec1d3d4 100644 --- a/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/TimeDetailsFields.tsx +++ b/src/components/MoneyRequestConfirmationListFooter/fieldGroups/detailsFields/TimeDetailsFields.tsx @@ -40,7 +40,10 @@ function TimeDetailsFields({policy, amountDisplay, isDescriptionRequired, descri isDistanceRequest={false} shouldShowTimeRequestFields policy={policy} - {...errorState} + shouldDisplayFieldError={errorState.shouldDisplayFieldError} + formError={errorState.formError} + clearFormErrors={errorState.clearFormErrors} + setFormError={errorState.setFormError} /> )} diff --git a/src/languages/de.ts b/src/languages/de.ts index d4075d6bcb6d..22213bec633a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -237,6 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Bitte gib eine vollständige Telefonnummer ein (z. B. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Dieses Feld ist erforderlich', + fixErrorsBeforeContinuing: 'Bitte behebe die Fehler im Formular, bevor du fortfährst.', requestModified: 'Diese Anfrage wird gerade von einem anderen Mitglied bearbeitet', characterLimitExceedCounter: (length: number, limit: number) => `Zeichenlimit überschritten (${length}/${limit})`, dateInvalid: 'Bitte wähle ein gültiges Datum aus', @@ -1755,6 +1756,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Bitte gib mindestens zwei verschiedene Adressen ein', splitExpenseMultipleParticipantsErrorMessage: 'Eine Ausgabe kann nicht zwischen einem Workspace und anderen Mitgliedern aufgeteilt werden. Bitte aktualisiere deine Auswahl.', invalidMerchant: 'Bitte gib einen gültigen Händler ein', + enterRequiredFields: 'Bitte fülle die Pflichtfelder aus.', atLeastOneAttendee: 'Mindestens eine teilnehmende Person muss ausgewählt werden', invalidQuantity: 'Bitte gib eine gültige Menge ein', quantityGreaterThanZero: 'Die Menge muss größer als null sein', diff --git a/src/languages/el.ts b/src/languages/el.ts index ba409a1044fa..4de669a7bc18 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -241,6 +241,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Παρακαλούμε εισαγάγετε έναν πλήρη αριθμό τηλεφώνου (π.χ. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Αυτό το πεδίο είναι υποχρεωτικό', + fixErrorsBeforeContinuing: 'Παρακαλώ διορθώστε τα σφάλματα στη φόρμα πριν συνεχίσετε.', requestModified: 'Αυτό το αίτημα τροποποιείται από άλλο μέλος', characterLimitExceedCounter: (length: number, limit: number) => `Υπέρβαση ορίου χαρακτήρων (${length}/${limit})`, dateInvalid: 'Παρακαλώ επιλέξτε έγκυρη ημερομηνία', @@ -1823,6 +1824,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Παρακαλούμε εισαγάγετε τουλάχιστον δύο διαφορετικές διευθύνσεις', splitExpenseMultipleParticipantsErrorMessage: 'Μια δαπάνη δεν μπορεί να κατανεμηθεί μεταξύ ενός χώρου εργασίας και άλλων μελών. Παρακαλούμε ενημερώστε την επιλογή σας.', invalidMerchant: 'Παρακαλούμε εισαγάγετε έγκυρο έμπορο', + enterRequiredFields: 'Παρακαλούμε συμπληρώστε τα υποχρεωτικά πεδία.', atLeastOneAttendee: 'Πρέπει να επιλεγεί τουλάχιστον ένας συμμετέχων', invalidQuantity: 'Παρακαλούμε εισαγάγετε μια έγκυρη ποσότητα', quantityGreaterThanZero: 'Η ποσότητα πρέπει να είναι μεγαλύτερη από το μηδέν', diff --git a/src/languages/en.ts b/src/languages/en.ts index aee6f5b227fb..f5a382acbf42 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -308,6 +308,7 @@ const translations = { acceptTerms: 'You must accept the Terms of Service to continue', phoneNumber: `Please enter a complete phone number\n(e.g. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'This field is required', + fixErrorsBeforeContinuing: 'Please fix the errors in the form before continuing.', requestModified: 'This request is being modified by another member', characterLimitExceedCounter: (length: number, limit: number) => `Character limit exceeded (${length}/${limit})`, dateInvalid: 'Please select a valid date', @@ -1913,6 +1914,7 @@ const translations = { atLeastTwoDifferentWaypoints: 'Please enter at least two different addresses', splitExpenseMultipleParticipantsErrorMessage: 'An expense cannot be split between a workspace and other members. Please update your selection.', invalidMerchant: 'Please enter a valid merchant', + enterRequiredFields: 'Please enter the required fields.', atLeastOneAttendee: 'At least one attendee must be selected', invalidQuantity: 'Please enter a valid quantity', quantityGreaterThanZero: 'Quantity must be greater than zero', diff --git a/src/languages/es.ts b/src/languages/es.ts index 37fdf70e4c27..5247d6c6af36 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -228,6 +228,7 @@ const translations: TranslationDeepObject = { acceptTerms: 'Debes aceptar los Términos de Servicio para continuar', phoneNumber: `Por favor, introduce un número de teléfono completo\n(ej. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Este campo es obligatorio', + fixErrorsBeforeContinuing: 'Por favor, corrige los errores en el formulario antes de continuar.', requestModified: 'Esta solicitud está siendo modificada por otro miembro', characterLimitExceedCounter: (length, limit) => `Se superó el límite de caracteres (${length}/${limit})`, dateInvalid: 'Por favor, selecciona una fecha válida', @@ -1761,6 +1762,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Por favor, introduce al menos dos direcciones diferentes', splitExpenseMultipleParticipantsErrorMessage: 'Solo puedes dividir un gasto entre un único espacio de trabajo o con miembros individuales. Por favor, actualiza tu selección.', invalidMerchant: 'Por favor, introduce un comerciante válido', + enterRequiredFields: 'Por favor, completa los campos obligatorios.', atLeastOneAttendee: 'Debe seleccionarse al menos un asistente', invalidQuantity: 'Por favor, introduce una cantidad válida', quantityGreaterThanZero: 'La cantidad debe ser mayor que cero', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d487a103bad5..a3832449efbd 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -237,6 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Veuillez saisir un numéro de téléphone complet (p. ex. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Ce champ est obligatoire', + fixErrorsBeforeContinuing: 'Veuillez corriger les erreurs dans le formulaire avant de continuer.', requestModified: 'Cette demande est en cours de modification par un autre membre', characterLimitExceedCounter: (length: number, limit: number) => `Limite de caractères dépassée (${length}/${limit})`, dateInvalid: 'Veuillez sélectionner une date valide', @@ -1763,6 +1764,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Veuillez saisir au moins deux adresses différentes', splitExpenseMultipleParticipantsErrorMessage: "Une dépense ne peut pas être répartie entre un espace de travail et d'autres membres. Veuillez modifier votre sélection.", invalidMerchant: 'Veuillez saisir un commerçant valide', + enterRequiredFields: 'Veuillez remplir les champs obligatoires.', atLeastOneAttendee: 'Au moins un participant doit être sélectionné', invalidQuantity: 'Veuillez saisir une quantité valide', quantityGreaterThanZero: 'La quantité doit être supérieure à zéro', diff --git a/src/languages/it.ts b/src/languages/it.ts index 79c3e93cecfa..521bb9ab7fc0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -237,6 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Inserisci un numero di telefono completo (ad es. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Questo campo è obbligatorio', + fixErrorsBeforeContinuing: 'Per favore, correggi gli errori nel modulo prima di continuare.', requestModified: 'Questa richiesta viene modificata da un altro membro', characterLimitExceedCounter: (length: number, limit: number) => `Limite di caratteri superato (${length}/${limit})`, dateInvalid: 'Seleziona una data valida', @@ -1753,6 +1754,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Inserisci almeno due indirizzi diversi', splitExpenseMultipleParticipantsErrorMessage: 'Una spesa non può essere suddivisa tra uno spazio di lavoro e altri membri. Aggiorna la tua selezione.', invalidMerchant: 'Inserisci un esercente valido', + enterRequiredFields: 'Compila i campi obbligatori.', atLeastOneAttendee: 'Devi selezionare almeno un partecipante', invalidQuantity: 'Inserisci una quantità valida', quantityGreaterThanZero: 'La quantità deve essere maggiore di zero', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2eaad98ef385..846274c735c4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -237,6 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `完全な電話番号を入力してください (例:${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'この項目は必須です', + fixErrorsBeforeContinuing: '続行する前にフォームのエラーを修正してください。', requestModified: 'このリクエストは別のメンバーによって編集されています', characterLimitExceedCounter: (length: number, limit: number) => `文字数制限を超えています(${length}/${limit})`, dateInvalid: '有効な日付を選択してください', @@ -1735,6 +1736,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: '少なくとも2つの異なる住所を入力してください', splitExpenseMultipleParticipantsErrorMessage: '経費はワークスペースと他のメンバー間で分割できません。選択内容を更新してください。', invalidMerchant: '有効な加盟店名を入力してください', + enterRequiredFields: '必須項目を入力してください。', atLeastOneAttendee: '少なくとも 1 人の出席者を選択する必要があります', invalidQuantity: '有効な数量を入力してください', quantityGreaterThanZero: '数量は0より大きくなければなりません', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fc4e7c401466..140b1eb73a97 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -237,6 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Voer een volledig telefoonnummer in (bijv. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Dit veld is verplicht', + fixErrorsBeforeContinuing: 'Corrigeer de fouten in het formulier voordat je verdergaat.', requestModified: 'Dit verzoek wordt bewerkt door een ander lid', characterLimitExceedCounter: (length: number, limit: number) => `Limiet voor aantal tekens overschreden (${length}/${limit})`, dateInvalid: 'Selecteer een geldige datum', @@ -1749,6 +1750,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Voer minimaal twee verschillende adressen in', splitExpenseMultipleParticipantsErrorMessage: 'Een uitgave kan niet worden opgesplitst tussen een werkruimte en andere leden. Werk je selectie alsjeblieft bij.', invalidMerchant: 'Voer een geldige leverancier in', + enterRequiredFields: 'Vul de verplichte velden in.', atLeastOneAttendee: 'Er moet ten minste één deelnemer worden geselecteerd', invalidQuantity: 'Voer een geldige hoeveelheid in', quantityGreaterThanZero: 'Hoeveelheid moet groter zijn dan nul', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index df6e36b53e19..a89e9c77bff7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -243,6 +243,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Proszę podać pełny numer telefonu (np. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'To pole jest wymagane', + fixErrorsBeforeContinuing: 'Napraw błędy w formularzu przed kontynuowaniem.', requestModified: 'Inny członek właśnie modyfikuje to żądanie', characterLimitExceedCounter: (length: number, limit: number) => `Przekroczono limit znaków (${length}/${limit})`, dateInvalid: 'Wybierz prawidłową datę', @@ -1789,6 +1790,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Wprowadź co najmniej dwa różne adresy', splitExpenseMultipleParticipantsErrorMessage: 'Wydatek nie może być podzielony między przestrzeń roboczą a innych członków. Zaktualizuj swój wybór.', invalidMerchant: 'Wprowadź prawidłowego sprzedawcę', + enterRequiredFields: 'Wypełnij wymagane pola.', atLeastOneAttendee: 'Co najmniej jeden uczestnik musi zostać wybrany', invalidQuantity: 'Wprowadź prawidłową ilość', quantityGreaterThanZero: 'Ilość musi być większa niż zero', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index eb282b53d640..c420680d2e87 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -237,6 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Insira um número de telefone completo (p.ex., ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Este campo é obrigatório', + fixErrorsBeforeContinuing: 'Por favor, corrija os erros no formulário antes de continuar.', requestModified: 'Esta solicitação está sendo modificada por outra pessoa', characterLimitExceedCounter: (length: number, limit: number) => `Limite de caracteres excedido (${length}/${limit})`, dateInvalid: 'Selecione uma data válida', @@ -1750,6 +1751,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Insira pelo menos dois endereços diferentes', splitExpenseMultipleParticipantsErrorMessage: 'Uma despesa não pode ser dividida entre um espaço de trabalho e outros membros. Atualize sua seleção.', invalidMerchant: 'Insira um comerciante válido', + enterRequiredFields: 'Preencha os campos obrigatórios.', atLeastOneAttendee: 'Pelo menos um participante deve ser selecionado', invalidQuantity: 'Insira uma quantidade válida', quantityGreaterThanZero: 'A quantidade deve ser maior que zero', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 981594baab8d..d969991e224f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -237,6 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `请输入完整的电话号码 (例如:${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: '此字段为必填项', + fixErrorsBeforeContinuing: '请先修复表单中的错误,然后再继续。', requestModified: '此请求正在被另一位成员修改', characterLimitExceedCounter: (length: number, limit: number) => `超出字符限制(${length}/${limit})`, dateInvalid: '请选择一个有效日期', @@ -1686,6 +1687,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: '请输入至少两个不同的地址', splitExpenseMultipleParticipantsErrorMessage: '无法将一笔报销同时分摊到一个工作区和其他成员。请更新你的选择。', invalidMerchant: '请输入有效的商家', + enterRequiredFields: '请填写必填字段。', atLeastOneAttendee: '必须选择至少一位参加者', invalidQuantity: '请输入有效的数量', quantityGreaterThanZero: '数量必须大于零', diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 865478e05009..9983747bc2cb 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -246,14 +246,29 @@ function shouldShowConfirmationDate(shouldShowSmartScanFields: boolean, isDistan return shouldShowSmartScanFields || isDistanceRequest; } +type ConfirmationMerchantRequirementParams = { + isPolicyExpenseChat: boolean; + isScanRequest: boolean; + isEditingSplitBill: boolean | undefined; + shouldShowMerchant: boolean; +}; + +/** + * Whether the merchant is required on the money request confirmation surface. Only a workspace chat requires one, and + * a scan leaves it to SmartScan unless its split is being edited. + */ +function isConfirmationMerchantRequired(params: ConfirmationMerchantRequirementParams): boolean { + return params.isPolicyExpenseChat && (!params.isScanRequest || !!params.isEditingSplitBill) && params.shouldShowMerchant; +} + /** * Whether the required amount is still missing on the money request confirmation surface. * `isAmountSet` is only ever set by the manual flow (scan, per diem, distance and time populate the amount * programmatically and never set it), so the manual gate is part of the predicate rather than of each call site. * A Scan the user started filling in counts too: its three revealed fields are all-or-nothing, so once any of them * carries a value the blank ones are missing rather than SmartScan's to read. - * This is the single source of truth shared by the validation that raises `common.error.fieldRequired`, the effect - * that clears it once the field is filled, and the amount field that renders it inline, so the three never drift. + * `getConfirmationFieldErrors` reads it to build the amount error, and the field, the blocked confirm and the summary + * above the confirm button all read that one map, so an amount reported missing is always the one shown as missing. */ function isConfirmationAmountMissing(transaction: OnyxEntry, canEnterScanFieldsManually = false, shouldRequireMerchant = true): boolean { if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually, shouldRequireMerchant)) { @@ -272,9 +287,9 @@ function isConfirmationMerchantMissing(transaction: OnyxEntry, @@ -294,6 +309,7 @@ export { isConfirmationAmountMissing, isConfirmationDateMissing, isConfirmationMerchantMissing, + isConfirmationMerchantRequired, shouldShowConfirmationDate, replaceAllDigits, stripCommaFromAmount, diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index bd35e8830789..1e5b7b0211a3 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -898,6 +898,15 @@ function isUntypedPlaceholderMerchant(isMerchantSet: boolean | undefined, mercha return !isMerchantSet && isInvalidMerchantValue(merchant); } +/** + * Checks if the merchant field is empty from the user's point of view: nothing but whitespace, or a placeholder the + * flow seeded and the user never typed. + */ +function isMerchantBlank(isMerchantSet: boolean | undefined, merchant?: string): boolean { + const trimmedMerchant = merchant?.trim() ?? ''; + return !trimmedMerchant || isUntypedPlaceholderMerchant(isMerchantSet, trimmedMerchant); +} + /** * Validates a 4-digit PIN for UK/EU Expensify Card. * PIN must be exactly 4 digits and not in the list of invalid/weak PINs. @@ -972,6 +981,7 @@ export { getMerchantError, getMerchantErrorMessage, isUntypedPlaceholderMerchant, + isMerchantBlank, isValidPIN, containsHtmlTag, }; diff --git a/src/libs/actions/IOU/Split.ts b/src/libs/actions/IOU/Split.ts index 2bf7661a0655..2fe63422cba3 100644 --- a/src/libs/actions/IOU/Split.ts +++ b/src/libs/actions/IOU/Split.ts @@ -1377,6 +1377,11 @@ function setDraftSplitTransaction( }) : null; + // getUpdatedTransaction only applies the fields an expense stores, so the draft's own flag for a typed merchant is copied over here. + if (updatedTransaction && Object.hasOwn(transactionChanges, 'isMerchantSet')) { + updatedTransaction.isMerchantSet = transactionChanges.isMerchantSet; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, updatedTransaction); } diff --git a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx index 1da17f4cfbe5..e5c4d24efdcc 100644 --- a/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepDistance.tsx @@ -474,7 +474,7 @@ function DynamicIOURequestStepDistance({ } if (isWaypointsNullIslandError) { return { - isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`, + isWaypointsNullIslandError: translate('common.error.fixErrorsBeforeContinuing'), } as Errors; } if (duplicateWaypointsError) { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 12ff396036c9..0cb499029c8b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -260,7 +260,7 @@ function IOURequestStepDistanceMap({ } if (isWaypointsNullIslandError) { return { - isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`, + isWaypointsNullIslandError: translate('common.error.fixErrorsBeforeContinuing'), } as Errors; } if (duplicateWaypointsError) { diff --git a/tests/actions/IOUTest/SplitTest.ts b/tests/actions/IOUTest/SplitTest.ts index 427bcdd4598b..9915d62fbcef 100644 --- a/tests/actions/IOUTest/SplitTest.ts +++ b/tests/actions/IOUTest/SplitTest.ts @@ -10701,6 +10701,27 @@ describe('setDraftSplitTransaction', () => { // Then the action no longer falls back by itself, so the null merge clears the draft expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`)).toBeFalsy(); }); + + it('remembers whether the merchant was typed, which the expense edit itself does not carry', async () => { + // Given a split draft whose merchant the user never typed + const transactionID = 'typed-merchant'; + const draftTransaction = createRandomTransaction(1); + await Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, draftTransaction); + await waitForBatchedUpdates(); + + // When the user types a merchant, and later clears it again + setDraftSplitTransaction(transactionID, draftTransaction, {merchant: '(none)', isMerchantSet: true}, getCurrencyDecimalsLocal, getCurrencySymbolLocal); + await waitForBatchedUpdates(); + const typedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); + setDraftSplitTransaction(transactionID, typedDraft, {merchant: '', isMerchantSet: false}, getCurrencyDecimalsLocal, getCurrencySymbolLocal); + await waitForBatchedUpdates(); + const clearedDraft = await getOnyxValue(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); + + // Then the draft records each state, so a placeholder the user typed can be told apart from one the split was seeded with + expect(typedDraft?.modifiedMerchant).toBe('(none)'); + expect(typedDraft?.isMerchantSet).toBe(true); + expect(clearedDraft?.isMerchantSet).toBe(false); + }); }); describe('initSplitExpenseItemData', () => { diff --git a/tests/ui/MoneyRequestReportFooter.tsx b/tests/ui/MoneyRequestReportFooter.tsx index 1c7c6cb4ec05..b087f9b3ab59 100644 --- a/tests/ui/MoneyRequestReportFooter.tsx +++ b/tests/ui/MoneyRequestReportFooter.tsx @@ -11,7 +11,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Transaction} from '@src/types/onyx'; @@ -122,7 +121,7 @@ const renderMoneyRequestConfirmationListFooter = async (transaction: Transaction shouldShowTax: false, hasParticipantSection: false, }, - errorState: {shouldDisplayFieldError: false, formError: 'common.error.fieldRequired' as TranslationPaths, clearFormErrors: jest.fn(), setFormError: jest.fn()}, + errorState: {shouldDisplayFieldError: false, formError: '' as const, fieldErrors: {}, clearFormErrors: jest.fn(), setFormError: jest.fn()}, receiptOptions: { receiptFilename: '', receiptPath: '', diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index b04f479ee702..608a7f831e4c 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -6,6 +6,7 @@ import { handleNegativeAmountFlipping, isConfirmationAmountMissing, isConfirmationDateMissing, + isConfirmationMerchantRequired, isValidMerchant, isValidMoneyRequestAmount, validateAmount, @@ -267,6 +268,35 @@ describe('ReportActionsUtils', () => { }); }); + describe('isConfirmationMerchantRequired', () => { + const workspaceManualExpense = {isPolicyExpenseChat: true, isScanRequest: false, isEditingSplitBill: false, shouldShowMerchant: true}; + + it('requires the merchant of a manual expense to a workspace', () => { + // Given a manual expense to a workspace chat that shows the merchant field + // When its merchant requirement is checked + // Then the merchant is required + expect(isConfirmationMerchantRequired(workspaceManualExpense)).toBe(true); + }); + + it.each([ + ['a scan, whose merchant SmartScan reads off the receipt', {isScanRequest: true}], + ['an expense to a person', {isPolicyExpenseChat: false}], + ['a confirmation that hides the merchant field', {shouldShowMerchant: false}], + ])('leaves the merchant optional on %s', (_case, overrides) => { + // Given a manual workspace expense that differs in this one way + // When its merchant requirement is checked + // Then the merchant is optional + expect(isConfirmationMerchantRequired({...workspaceManualExpense, ...overrides})).toBe(false); + }); + + it('requires the merchant while the split of a workspace scan is being edited', () => { + // Given a split of a scanned workspace expense that is being edited + // When its merchant requirement is checked + // Then the merchant is required, the same as on a manual workspace expense + expect(isConfirmationMerchantRequired({...workspaceManualExpense, isScanRequest: true, isEditingSplitBill: true})).toBe(true); + }); + }); + describe('isValidMerchant', () => { const iouReport = { reportID: '1', diff --git a/tests/unit/ValidationUtilsTest.ts b/tests/unit/ValidationUtilsTest.ts index 82fd9b7829cd..c9b9eaea1e33 100644 --- a/tests/unit/ValidationUtilsTest.ts +++ b/tests/unit/ValidationUtilsTest.ts @@ -11,6 +11,7 @@ import { getAgeRequirementError, getInvalidAddressErrorTranslationPath, isInvalidMerchantValue, + isMerchantBlank, isPOBoxOrMailDrop, isRequiredFulfilled, isValidAccountRoute, @@ -836,6 +837,26 @@ describe('ValidationUtils', () => { }); }); + describe('isMerchantBlank', () => { + test('treats whitespace and an untyped placeholder as blank', () => { + // Given merchants the user hasn't typed anything real into + // When they are checked + // Then they count as blank, since the field shows nothing the user entered + expect(isMerchantBlank(false, '')).toBe(true); + expect(isMerchantBlank(false, ' ')).toBe(true); + expect(isMerchantBlank(false, undefined)).toBe(true); + expect(isMerchantBlank(false, CONST.TRANSACTION.DEFAULT_MERCHANT)).toBe(true); + }); + + test('keeps a placeholder value the user typed themselves, and any real merchant, non-blank', () => { + // Given a placeholder the user typed, and a real merchant + // When they are checked + // Then neither is blank: the typed placeholder is an invalid entry rather than a missing one + expect(isMerchantBlank(true, CONST.TRANSACTION.DEFAULT_MERCHANT)).toBe(false); + expect(isMerchantBlank(false, 'Hilton')).toBe(false); + }); + }); + describe('isInvalidMerchantValue', () => { test('Valid merchnt name', () => { expect(isInvalidMerchantValue('test name')).toBe(false); diff --git a/tests/unit/components/MoneyRequestConfirmationList/AmountField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/AmountField.test.tsx index a77d623f270c..fb2ef16ee928 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/AmountField.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/AmountField.test.tsx @@ -133,6 +133,7 @@ const amountFieldProps: React.ComponentProps = { shouldShowTimeRequestFields: false, shouldDisplayFieldError: false, formError: 'iou.error.invalidAmount', + fieldError: undefined, policy: undefined, clearFormErrors: jest.fn(), setFormError: jest.fn(), diff --git a/tests/unit/components/MoneyRequestConfirmationList/DateField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/DateField.test.tsx index d54b7cc0e956..c8067431182d 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/DateField.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/DateField.test.tsx @@ -53,7 +53,7 @@ const dateFieldProps: React.ComponentProps = { shouldDisplayFieldError: false, didConfirm: false, isReadOnly: false, - formError: '', + fieldError: undefined, transactionID: '1', action: CONST.IOU.ACTION.CREATE, iouType: CONST.IOU.TYPE.CREATE, diff --git a/tests/unit/components/MoneyRequestConfirmationList/MerchantField.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/MerchantField.test.tsx index cfdbf0dcf647..ac12cc151752 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/MerchantField.test.tsx +++ b/tests/unit/components/MoneyRequestConfirmationList/MerchantField.test.tsx @@ -1,4 +1,4 @@ -import {render, renderHook, screen} from '@testing-library/react-native'; +import {fireEvent, render, renderHook, screen} from '@testing-library/react-native'; import MenuItem from '@components/MenuItem'; import ConfirmationFieldsProvider from '@components/MoneyRequestConfirmationFields/Provider'; @@ -7,6 +7,8 @@ import MerchantField from '@components/MoneyRequestConfirmationList/sections/Mer import useThemeStyles from '@hooks/useThemeStyles'; +import {setDraftSplitTransaction} from '@userActions/IOU/Split'; + import CONST from '@src/CONST'; import React from 'react'; @@ -32,7 +34,7 @@ function renderMerchantField(props: Partial @@ -83,15 +85,42 @@ describe('MerchantField read-only dropdown row', () => { }); it.each([ - {formError: '', shouldDisplayFieldError: true, expectedError: 'common.error.fieldRequired'}, - {formError: 'iou.error.invalidMerchant', shouldDisplayFieldError: false, expectedError: 'iou.error.invalidMerchant'}, - ])('shows $expectedError on the locked merchant row', ({formError, shouldDisplayFieldError, expectedError}) => { + {fieldError: undefined, shouldDisplayFieldError: true, expectedError: 'common.error.fieldRequired'}, + {fieldError: 'iou.error.invalidMerchant' as const, shouldDisplayFieldError: false, expectedError: 'iou.error.invalidMerchant'}, + ])('shows $expectedError on the locked merchant row', ({fieldError, shouldDisplayFieldError, expectedError}) => { // Given a merchant validation failure that must remain visible even when the field is locked // When the bordered confirmation form displays that failure - renderMerchantField({formError, shouldDisplayFieldError}); + renderMerchantField({fieldError, shouldDisplayFieldError}); // Then the actual validation message is preserved and the row still has no edit control expect(screen.getByText(expectedError)).toBeOnTheScreen(); expect(screen.queryByRole('button')).toBeNull(); }); }); + +describe('MerchantField on an edited split', () => { + it('records that the merchant was typed, so a placeholder the user typed can be told apart from one the split was seeded with', () => { + // Given an editable split showing its merchant input + render( + + + , + ); + + // When the user types a merchant + fireEvent.changeText(screen.getByLabelText('common.merchant'), 'Hilton'); + + // Then the split draft gets the merchant together with the flag that it was typed + expect(setDraftSplitTransaction).toHaveBeenCalledWith('1', undefined, {merchant: 'Hilton', isMerchantSet: true}, expect.any(Function), expect.any(Function)); + }); +}); diff --git a/tests/unit/components/MoneyRequestConfirmationList/ScanConfirmationList.test.tsx b/tests/unit/components/MoneyRequestConfirmationList/ScanConfirmationList.test.tsx new file mode 100644 index 000000000000..63cc19429377 --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationList/ScanConfirmationList.test.tsx @@ -0,0 +1,84 @@ +import {render} from '@testing-library/react-native'; + +import ConfirmationListLayout from '@components/MoneyRequestConfirmationList/ConfirmationListLayout'; +import type {ConfirmationFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import type {MoneyRequestConfirmationListProps} from '@components/MoneyRequestConfirmationList/types'; +import ScanConfirmationList from '@components/MoneyRequestConfirmationList/variants/ScanConfirmationList'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; + +import React from 'react'; + +let mockFieldErrors: ConfirmationFieldErrors = {}; + +jest.mock('@components/MoneyRequestConfirmationList/hooks/useConfirmationListData', () => () => ({ + layoutProps: {transactionID: 'txn1'}, + footerProps: {errorState: {fieldErrors: mockFieldErrors}}, + taxControllerProps: {}, + splitBillControllerProps: {}, + fieldAutoSelectProps: {}, +})); +jest.mock('@components/MoneyRequestConfirmationList/ConfirmationListLayout', () => jest.fn(() => null)); +jest.mock('@components/MoneyRequestConfirmationList/TaxController', () => () => null); +jest.mock('@components/MoneyRequestConfirmationList/SplitBillController', () => () => null); +jest.mock('@components/MoneyRequestConfirmationList/FieldAutoSelector', () => () => null); +jest.mock('@components/MoneyRequestConfirmationListFooter/variants/ScanFooter', () => () => null); +jest.mock('@hooks/useIsInLandscapeMode', () => () => false); + +const mockedLayout = jest.mocked(ConfirmationListLayout); + +// A fresh scan the user hasn't typed anything into, so nothing on its own opens "Show more" +const untouchedScan = { + transactionID: 'txn1', + reportID: 'report1', + amount: 0, + currency: 'USD', + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + created: '2026-11-05', + comment: {}, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, +} as OnyxTypes.Transaction; + +const listProps: MoneyRequestConfirmationListProps = { + transaction: untouchedScan, + iouType: CONST.IOU.TYPE.SUBMIT, + action: CONST.IOU.ACTION.CREATE, + selectedParticipants: [], + onOpenParticipantPicker: () => {}, + receiptOptions: {shouldDisplayReceipt: true}, + canEnterScanFieldsManually: true, +}; + +function isLastRenderCompact(): boolean | undefined { + return mockedLayout.mock.calls.at(-1)?.at(0)?.isCompactMode; +} + +describe('ScanConfirmationList', () => { + beforeEach(() => { + mockFieldErrors = {}; + mockedLayout.mockClear(); + }); + + it('keeps the fields collapsed behind "Show more" while nothing needs them', () => { + // Given an untouched scan with no field errors + // When it renders + render(); + + // Then the receipt fills the screen and the fields stay collapsed, since SmartScan will fill them in + expect(isLastRenderCompact()).toBe(true); + }); + + it('opens "Show more" when a blocked confirm reveals field errors, so pressing Create does not look like it did nothing', () => { + // Given a scan whose fields are collapsed + const {rerender} = render(); + expect(isLastRenderCompact()).toBe(true); + + // When a confirm is blocked by its missing amount and the field errors are revealed + mockFieldErrors = {amount: 'common.error.fieldRequired'}; + rerender(); + + // Then the fields expand, so the amount shows its error under the input + expect(isLastRenderCompact()).toBe(false); + }); +}); diff --git a/tests/unit/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.test.ts b/tests/unit/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.test.ts new file mode 100644 index 000000000000..2dcda0fbacd3 --- /dev/null +++ b/tests/unit/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.test.ts @@ -0,0 +1,250 @@ +import type {GetConfirmationFieldErrorsParams} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import getConfirmationFieldErrors, {NO_FIELD_ERRORS} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; + +const WORKSPACE_PARTICIPANT = {accountID: 0, isPolicyExpenseChat: true, policyID: 'policy1'} as Participant; +const P2P_PARTICIPANT = {accountID: 2, isPolicyExpenseChat: false} as Participant; + +const baseParams: GetConfirmationFieldErrorsParams = { + iouAmount: 1000, + iouType: CONST.IOU.TYPE.SUBMIT, + iouMerchant: 'Coffee Shop', + canEnterScanFieldsManually: false, + isPolicyExpenseChat: true, + shouldShowDate: true, + isReadOnly: false, + isEditingSplitBill: false, + isMerchantRequired: true, + isDistanceRequest: false, + isTimeRequest: false, + currentUserAccountID: 1, +}; + +// A merchant that still holds the "Expense" placeholder the draft was seeded with +const blankRequiredMerchantParams: Partial = { + iouMerchant: CONST.TRANSACTION.DEFAULT_MERCHANT, +}; + +function createManualTransaction(overrides: Partial = {}): OnyxTypes.Transaction { + return { + transactionID: 'txn1', + reportID: 'report1', + amount: 1000, + currency: 'USD', + merchant: 'Coffee Shop', + created: '2026-11-05', + comment: {}, + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + isAmountSet: true, + isMerchantSet: true, + participants: [WORKSPACE_PARTICIPANT], + ...overrides, + }; +} + +function createScanDraft(overrides: Partial = {}): OnyxTypes.Transaction { + return createManualTransaction({ + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + amount: 0, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + isAmountSet: false, + isMerchantSet: false, + isCreatedSet: false, + ...overrides, + }); +} + +const scanParams: Partial = { + canEnterScanFieldsManually: true, + isMerchantRequired: false, + iouAmount: 0, + iouMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, +}; + +describe('getConfirmationFieldErrors', () => { + it('returns the shared empty map when every field is filled in', () => { + // Given a manual workspace expense with an amount, a merchant and a date + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(createManualTransaction(), baseParams); + + // Then nothing is reported, and the shared empty map comes back so the footer's error state keeps its identity + expect(fieldErrors).toBe(NO_FIELD_ERRORS); + }); + + it('flags both the amount and a blank required merchant on a manual workspace expense', () => { + // Given a manual workspace expense with neither an amount nor a merchant + const transaction = createManualTransaction({amount: 0, isAmountSet: false, merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantSet: false}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, ...blankRequiredMerchantParams, iouAmount: 0}); + + // Then both fields read as required, so the summary above the button applies to each of them + expect(fieldErrors).toEqual({amount: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}); + }); + + it('keeps the blank required merchant flagged once only the amount is filled in', () => { + // Given a manual workspace expense whose amount was entered after a blocked confirm, while the merchant is still blank + const transaction = createManualTransaction({merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantSet: false}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, ...blankRequiredMerchantParams}); + + // Then the merchant is still reported as missing, rather than the error going away with the amount's + expect(fieldErrors).toEqual({merchant: 'common.error.fieldRequired'}); + }); + + it('reports a required merchant the user typed but that is not allowed as invalid, not missing', () => { + // Given a workspace expense whose typed merchant fails validation + const transaction = createManualTransaction({merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isMerchantSet: true}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, iouMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT}); + + // Then the merchant asks for a valid value instead of saying the field is required + expect(fieldErrors).toEqual({merchant: 'iou.error.invalidMerchant'}); + }); + + it.each([ + ['the "(none)" placeholder the split was seeded with', CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, undefined, 'common.error.fieldRequired'], + ['a merchant the user cleared', '', false, 'common.error.fieldRequired'], + ['the "(none)" placeholder the user typed', CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, true, 'iou.error.invalidMerchant'], + ] as const)('reports %s on an edited split the same way as on a new expense', (_case, merchant, isMerchantSet, expectedError) => { + // Given a workspace split being edited with this merchant + const transaction = createManualTransaction({merchant, isMerchantSet}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, { + ...baseParams, + iouType: CONST.IOU.TYPE.SPLIT, + iouMerchant: merchant, + isEditingSplitBill: true, + }); + + // Then a merchant the field shows as blank reads as required, and only one the user typed reads as invalid + expect(fieldErrors).toEqual({merchant: expectedError}); + }); + + it('flags a placeholder value the user typed into an optional merchant', () => { + // Given an expense to a user, whose merchant is optional, where the user typed the "Expense" placeholder themselves + const transaction = createManualTransaction({merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantSet: true, participants: [P2P_PARTICIPANT]}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, { + ...baseParams, + iouMerchant: CONST.TRANSACTION.DEFAULT_MERCHANT, + isPolicyExpenseChat: false, + isMerchantRequired: false, + }); + + // Then the merchant is invalid, since a typed placeholder is an entry the user made rather than a blank field + expect(fieldErrors).toEqual({merchant: 'iou.error.invalidMerchant'}); + }); + + it('drops the merchant error once the recipient changes from a workspace chat to a user', () => { + // Given an untouched manual draft that still carries the placeholder merchant + const transaction = createManualTransaction({merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantSet: false, participants: [P2P_PARTICIPANT]}); + + // When the recipient is a user, so the merchant is no longer required + const fieldErrors = getConfirmationFieldErrors(transaction, { + ...baseParams, + ...blankRequiredMerchantParams, + isPolicyExpenseChat: false, + isMerchantRequired: false, + }); + + // Then the merchant is no longer reported + expect(fieldErrors).toBe(NO_FIELD_ERRORS); + }); + + it('keeps the date flagged until it is filled in, even once the amount is', () => { + // Given a manual expense whose amount was entered while the date is still empty + const transaction = createManualTransaction({created: ''}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, baseParams); + + // Then the date is still reported + expect(fieldErrors).toEqual({date: 'common.error.fieldRequired'}); + }); + + it.each([ + {name: 'hidden', overrides: {shouldShowDate: false}}, + {name: 'read-only', overrides: {isReadOnly: true}}, + ])('never reports a $name date as missing, since it cannot be filled in', ({overrides}) => { + // Given an expense with no date on a confirmation that doesn't let the user enter one + const transaction = createManualTransaction({created: ''}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, ...overrides}); + + // Then the date doesn't block confirmation + expect(fieldErrors).toBe(NO_FIELD_ERRORS); + }); + + it('leaves an untouched scan to SmartScan', () => { + // Given a scan whose amount, merchant and date the user left blank + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(createScanDraft(), {...baseParams, ...scanParams}); + + // Then nothing is reported, since SmartScan reads all three off the receipt + expect(fieldErrors).toBe(NO_FIELD_ERRORS); + }); + + it('flags only the amount on a scan whose merchant and date were entered by hand', () => { + // Given a scan headed for a workspace where the user typed the merchant and picked the date but left the amount blank + const transaction = createScanDraft({merchant: 'Hilton', isMerchantSet: true, created: '2026-11-05', isCreatedSet: true}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, ...scanParams, iouMerchant: 'Hilton'}); + + // Then the amount is required, because the three fields are all-or-nothing once one is entered + expect(fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); + }); + + it('flags the merchant and the date on a workspace scan where only the amount was entered', () => { + // Given a workspace scan with only the amount entered + const transaction = createScanDraft({amount: 2500, isAmountSet: true}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, ...scanParams, iouAmount: 2500}); + + // Then both blank fields are required + expect(fieldErrors).toEqual({date: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}); + }); + + it('reports an amount never entered on an expense to a person as required, not invalid', () => { + // Given a manual expense to a user whose amount field was left blank + const transaction = createManualTransaction({amount: 0, isAmountSet: false, participants: [P2P_PARTICIPANT]}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, iouAmount: 0, isPolicyExpenseChat: false, isMerchantRequired: false}); + + // Then the amount reads as missing, so the required fields summary covers it the same as on a workspace expense + expect(fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); + }); + + it('reports a zero amount to a person as invalid', () => { + // Given a manual expense to a user where the user typed a $0 amount + const transaction = createManualTransaction({amount: 0, isAmountSet: true, participants: [P2P_PARTICIPANT]}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, iouAmount: 0, isPolicyExpenseChat: false, isMerchantRequired: false}); + + // Then the amount is invalid, as a 1:1 expense can't be $0 + expect(fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); + }); + + it('does not check the amount of a distance expense, which is computed rather than typed', () => { + // Given a distance expense whose computed amount is zero while its route loads + const transaction = createManualTransaction({amount: 0, iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_MAP, participants: [P2P_PARTICIPANT]}); + + // When its field errors are computed + const fieldErrors = getConfirmationFieldErrors(transaction, {...baseParams, iouAmount: 0, isDistanceRequest: true, isPolicyExpenseChat: false, isMerchantRequired: false}); + + // Then the amount field isn't flagged, since its errors belong to the footer for this type + expect(fieldErrors).toBe(NO_FIELD_ERRORS); + }); +}); diff --git a/tests/unit/hooks/confirmAction.test.ts b/tests/unit/hooks/confirmAction.test.ts index 19156aabe73c..fe385713ef73 100644 --- a/tests/unit/hooks/confirmAction.test.ts +++ b/tests/unit/hooks/confirmAction.test.ts @@ -24,7 +24,9 @@ function makeBase(overrides: Partial = {}): Params { formError: '', isDelegateAccessRestricted: false, validate: jest.fn(() => ({errorKey: null})), - setFormError: jest.fn(), + setConfirmError: jest.fn(), + clearConfirmError: jest.fn(), + revealFieldErrors: jest.fn(), setDidConfirmSplit: jest.fn(), showDelegateNoAccessModal: jest.fn(), onConfirm: jest.fn(), @@ -50,13 +52,33 @@ describe('buildConfirmAction', () => { const params = makeBase({validate: jest.fn(() => null)}); buildConfirmAction(params)({paymentType: undefined}); expect(params.onConfirm).not.toHaveBeenCalled(); - expect(params.setFormError).not.toHaveBeenCalled(); + expect(params.setConfirmError).not.toHaveBeenCalled(); }); it('sets form error when validation returns an errorKey', () => { - const params = makeBase({validate: jest.fn(() => ({errorKey: 'iou.error.invalidMerchant'}))}); + // Given a confirmation with no participant, an error that has no field of its own + const params = makeBase({validate: jest.fn(() => ({errorKey: 'iou.error.noParticipantSelected'}))}); + + // When the user confirms + buildConfirmAction(params)({paymentType: undefined}); + + // Then the error becomes the form error shown above the button, and no field errors are revealed + expect(params.setConfirmError).toHaveBeenCalledWith('iou.error.noParticipantSelected'); + expect(params.revealFieldErrors).not.toHaveBeenCalled(); + expect(params.onConfirm).not.toHaveBeenCalled(); + }); + + it('reveals a field error on its field and drops the error an earlier confirm set', () => { + // Given a confirmation whose amount field is missing a value, while an error from an earlier confirm is still set + const params = makeBase({formError: 'violations.categoryOutOfPolicy', validate: jest.fn(() => ({isFieldError: true as const}))}); + + // When the user confirms buildConfirmAction(params)({paymentType: undefined}); - expect(params.setFormError).toHaveBeenCalledWith('iou.error.invalidMerchant'); + + // Then the field errors are revealed and the earlier confirm's error is dropped, since validation checks it again on the next confirm + expect(params.revealFieldErrors).toHaveBeenCalledTimes(1); + expect(params.clearConfirmError).toHaveBeenCalledTimes(1); + expect(params.setConfirmError).not.toHaveBeenCalled(); expect(params.onConfirm).not.toHaveBeenCalled(); }); @@ -66,7 +88,7 @@ describe('buildConfirmAction', () => { }); buildConfirmAction(params)({paymentType: undefined}); expect(params.setDidConfirmSplit).toHaveBeenCalledWith(true); - expect(params.setFormError).toHaveBeenCalledWith('iou.error.genericSmartscanFailureMessage'); + expect(params.setConfirmError).toHaveBeenCalledWith('iou.error.genericSmartscanFailureMessage'); }); it('does not call onConfirm when formError is already set', () => { @@ -87,13 +109,13 @@ describe('buildConfirmAction', () => { it('blocks the company-info step for an invalid invoice instead of routing past validation (#96579)', () => { // Given an invoice whose sender workspace has no company info yet, and a cleared date mockHasInvoicingDetails.mockReturnValueOnce(false); - const params = makeBase({iouType: CONST.IOU.TYPE.INVOICE, validate: jest.fn(() => ({errorKey: 'common.error.fieldRequired'}))}); + const params = makeBase({iouType: CONST.IOU.TYPE.INVOICE, validate: jest.fn(() => ({isFieldError: true as const}))}); // When the user confirms buildConfirmAction(params)({paymentType: undefined}); // Then the error surfaces and the company info step - which sends the invoice without revalidating - is not reached - expect(params.setFormError).toHaveBeenCalledWith('common.error.fieldRequired'); + expect(params.revealFieldErrors).toHaveBeenCalledTimes(1); expect(mockNavigate).not.toHaveBeenCalled(); expect(params.onConfirm).not.toHaveBeenCalled(); }); diff --git a/tests/unit/hooks/confirmationRequiredFieldsFlow.test.ts b/tests/unit/hooks/confirmationRequiredFieldsFlow.test.ts new file mode 100644 index 000000000000..8c1b82aa8a73 --- /dev/null +++ b/tests/unit/hooks/confirmationRequiredFieldsFlow.test.ts @@ -0,0 +1,221 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import buildConfirmAction from '@components/MoneyRequestConfirmationList/confirmAction'; +import useConfirmationFieldErrors from '@components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors'; +import useConfirmationValidation from '@components/MoneyRequestConfirmationList/hooks/useConfirmationValidation'; +import useFormErrorManagement from '@components/MoneyRequestConfirmationList/hooks/useFormErrorManagement'; + +import {isConfirmationMerchantRequired} from '@libs/MoneyRequestUtils'; +import {getMerchant} from '@libs/TransactionUtils'; + +import CONST from '@src/CONST'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; +import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; + +import type * as ReactNavigationModule from '@react-navigation/native'; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListActions: () => ({getCurrencyDecimals: () => 2})})); +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn(), getActiveRoute: () => '/'})); +jest.mock('@userActions/Policy/Policy', () => ({hasInvoicingDetails: jest.fn(() => true)})); +let mockIsFocused = true; +jest.mock('@react-navigation/native', () => { + const actual = jest.requireActual('@react-navigation/native'); + return {...actual, useIsFocused: () => mockIsFocused}; +}); + +const WORKSPACE_PARTICIPANT = {accountID: 0, isPolicyExpenseChat: true, policyID: 'policy1'} as Participant; +const CURRENT_USER = {accountID: 1} as CurrentUserPersonalDetails; +const mockOnConfirm = jest.fn(); + +// A manual expense to a workspace, as the draft starts: no amount entered and the "Expense" placeholder as its merchant +const blankDraft: OnyxTypes.Transaction = { + transactionID: 'txn1', + reportID: 'report1', + amount: 0, + currency: 'USD', + merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, + created: '2026-11-05', + comment: {}, + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + isAmountSet: false, + isMerchantSet: false, + participants: [WORKSPACE_PARTICIPANT], +}; + +/** Wires the form error, validation, field error and confirm pieces together the way `useConfirmationListData` does, on a workspace that tracks tax. */ +function useManualWorkspaceConfirmation(transaction: OnyxTypes.Transaction, isTaxAmountEmpty = false) { + const iouMerchant = getMerchant(transaction); + const isMerchantRequired = isConfirmationMerchantRequired({isPolicyExpenseChat: true, isScanRequest: false, isEditingSplitBill: false, shouldShowMerchant: true}); + const {formError, setConfirmError, clearConfirmError, errorMessage} = useFormErrorManagement({ + transaction, + transactionReport: undefined, + iouCategory: '', + iouAttendees: [], + policy: undefined, + policyTags: undefined, + policyCategories: undefined, + currentUserPersonalDetails: CURRENT_USER, + isEditingSplitBill: false, + hasSmartScanFailed: false, + didConfirmSplit: false, + isTypeSplit: false, + shouldShowReadOnlySplits: true, + isDistanceRequest: false, + }); + const {validate, fieldErrors} = useConfirmationValidation({ + transaction, + transactionReport: undefined, + transactionID: transaction.transactionID, + iouType: CONST.IOU.TYPE.SUBMIT, + iouAmount: transaction.amount, + iouMerchant, + iouCategory: '', + iouCurrencyCode: 'USD', + iouAttendees: [], + policy: undefined, + policyTags: undefined, + policyTagLists: [], + policyCategories: undefined, + selectedParticipants: [WORKSPACE_PARTICIPANT], + currentUserPersonalDetails: CURRENT_USER, + isEditingSplitBill: false, + isMerchantRequired, + shouldShowTax: true, + isDistanceRequest: false, + isDistanceRequestWithPendingRoute: false, + isPerDiemRequest: false, + isMovingTransactionFromTrackExpense: false, + isTimeRequest: false, + routeError: undefined, + canEnterScanFieldsManually: false, + isPolicyExpenseChat: true, + isReadOnly: false, + shouldShowDate: true, + isTaxAmountEmpty, + }); + const {visibleFieldErrors, footerErrorMessage, revealFieldErrors} = useConfirmationFieldErrors({ + fieldErrors, + partiallyManuallyFilledScanID: undefined, + onSwitchToTransaction: undefined, + formErrorMessage: errorMessage, + routeError: undefined, + }); + const confirm = buildConfirmAction({ + iouType: CONST.IOU.TYPE.SUBMIT, + policy: undefined, + transactionID: transaction.transactionID, + routeError: undefined, + formError, + isDelegateAccessRestricted: false, + validate, + setConfirmError, + clearConfirmError, + revealFieldErrors, + setDidConfirmSplit: jest.fn(), + showDelegateNoAccessModal: jest.fn(), + onConfirm: mockOnConfirm, + }); + + return {confirm, validate, visibleFieldErrors, footerErrorMessage}; +} + +describe('required fields on a manual workspace expense', () => { + beforeEach(() => { + mockOnConfirm.mockClear(); + mockIsFocused = true; + }); + + it('keeps the blank merchant flagged and the summary shown after only the amount is entered', () => { + // Given a manual workspace expense with neither an amount nor a merchant + const {result, rerender} = renderHook((transaction: OnyxTypes.Transaction) => useManualWorkspaceConfirmation(transaction), {initialProps: blankDraft}); + + // When the user presses Create + act(() => result.current.confirm()); + + // Then both fields read as required and the summary sits above the button, as either may be scrolled out of view + expect(result.current.visibleFieldErrors).toEqual({amount: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}); + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + + // When the user enters only the amount + rerender({...blankDraft, amount: 2500, isAmountSet: true}); + + // Then the merchant is still flagged and the summary stays, since a required field is still missing + expect(result.current.visibleFieldErrors).toEqual({merchant: 'common.error.fieldRequired'}); + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + + // When the user presses Create again + act(() => result.current.confirm()); + + // Then the blank merchant still reads as required rather than invalid, so the summary still explains the block + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.visibleFieldErrors).toEqual({merchant: 'common.error.fieldRequired'}); + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('keeps the errors shown when the user leaves the page and comes back without filling anything in', () => { + // Given a blocked confirm on a manual workspace expense with neither an amount nor a merchant + const {result, rerender} = renderHook((transaction: OnyxTypes.Transaction) => useManualWorkspaceConfirmation(transaction), {initialProps: blankDraft}); + act(() => result.current.confirm()); + + // When the user opens another page, such as the category picker, and comes back, which clears the form error + mockIsFocused = false; + rerender(blankDraft); + mockIsFocused = true; + rerender(blankDraft); + + // Then both fields are still flagged and the summary still shows, since the fields are still blank + expect(result.current.visibleFieldErrors).toEqual({amount: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}); + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + }); + + it('clears the errors and confirms once every required field is filled in', () => { + // Given a blocked confirm on a manual workspace expense with neither an amount nor a merchant + const {result, rerender} = renderHook((transaction: OnyxTypes.Transaction) => useManualWorkspaceConfirmation(transaction), {initialProps: blankDraft}); + act(() => result.current.confirm()); + + // When the user enters both the amount and the merchant + rerender({...blankDraft, amount: 2500, isAmountSet: true, merchant: 'Office Depot', isMerchantSet: true}); + + // Then nothing is flagged any more + expect(result.current.visibleFieldErrors).toEqual({}); + expect(result.current.footerErrorMessage).toBeUndefined(); + + // When the user presses Create + act(() => result.current.confirm()); + + // Then the expense is confirmed + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + + it('does not let the tax error of a removed expense block the one left on screen', () => { + // Given a confirmation of several expenses where Create was blocked by the empty tax amount of the one on screen + const expenseWithEmptyTax = {...blankDraft, amount: 2500, isAmountSet: true, merchant: 'Hilton', isMerchantSet: true}; + const {result, rerender} = renderHook( + ({transaction, isTaxAmountEmpty}: {transaction: OnyxTypes.Transaction; isTaxAmountEmpty: boolean}) => useManualWorkspaceConfirmation(transaction, isTaxAmountEmpty), + { + initialProps: {transaction: expenseWithEmptyTax, isTaxAmountEmpty: true}, + }, + ); + act(() => result.current.confirm()); + expect(result.current.footerErrorMessage).toBe('iou.error.invalidAmount'); + + // When the user removes that expense, and Create on the one left is blocked by its blank merchant + const remainingExpense = {...expenseWithEmptyTax, transactionID: 'txn2', merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantSet: false}; + rerender({transaction: remainingExpense, isTaxAmountEmpty: false}); + act(() => result.current.confirm()); + + // Then only the required fields summary explains the block + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + + // When the user fills in the merchant and presses Create again + rerender({transaction: {...remainingExpense, merchant: 'Office Depot', isMerchantSet: true}, isTaxAmountEmpty: false}); + expect(result.current.footerErrorMessage).toBeUndefined(); + act(() => result.current.confirm()); + + // Then the expense is confirmed, since the tax error belonged to the expense that was removed + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/hooks/useConfirmationFieldErrors.test.ts b/tests/unit/hooks/useConfirmationFieldErrors.test.ts new file mode 100644 index 000000000000..a96231b8f44e --- /dev/null +++ b/tests/unit/hooks/useConfirmationFieldErrors.test.ts @@ -0,0 +1,195 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import type {ConfirmationFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import {NO_FIELD_ERRORS} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import useConfirmationFieldErrors from '@components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors'; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); + +type Props = { + fieldErrors: ConfirmationFieldErrors; + partiallyManuallyFilledScanID?: string; + onSwitchToTransaction?: (transactionID: string) => void; + formErrorMessage?: string; + routeError?: string; +}; + +const MISSING_AMOUNT: ConfirmationFieldErrors = {amount: 'common.error.fieldRequired'}; + +function renderFieldErrors(initialProps: Props) { + return renderHook( + ({fieldErrors, partiallyManuallyFilledScanID, onSwitchToTransaction, formErrorMessage, routeError}: Props) => + useConfirmationFieldErrors({fieldErrors, partiallyManuallyFilledScanID, onSwitchToTransaction, formErrorMessage, routeError}), + {initialProps}, + ); +} + +describe('useConfirmationFieldErrors', () => { + it('keeps the field errors hidden until a confirm is blocked by them', () => { + // Given a confirmation whose amount is missing, before the user pressed the confirm button + // When it renders + const {result} = renderFieldErrors({fieldErrors: MISSING_AMOUNT}); + + // Then neither the field nor the footer flags anything yet + expect(result.current.visibleFieldErrors).toBe(NO_FIELD_ERRORS); + expect(result.current.footerErrorMessage).toBeUndefined(); + }); + + it('shows the field errors and the footer summary once a confirm is blocked', () => { + // Given a confirmation whose amount is missing + const {result} = renderFieldErrors({fieldErrors: MISSING_AMOUNT}); + + // When the confirm is blocked by it + act(() => result.current.revealFieldErrors()); + + // Then the amount shows its error, and the summary above the button explains the block even if the amount is scrolled away + expect(result.current.visibleFieldErrors).toEqual(MISSING_AMOUNT); + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + }); + + it('asks to fix the errors when every field error is an invalid value rather than a missing one', () => { + // Given a confirmation whose only field error is an invalid amount, such as a negative amount to a person + const {result} = renderFieldErrors({fieldErrors: {amount: 'common.error.invalidAmount'}}); + + // When the confirm is blocked by it + act(() => result.current.revealFieldErrors()); + + // Then the field shows it, and the footer uses the line other forms show, since nothing is missing for "required fields" to describe + expect(result.current.visibleFieldErrors).toEqual({amount: 'common.error.invalidAmount'}); + expect(result.current.footerErrorMessage).toBe('common.error.fixErrorsBeforeContinuing'); + }); + + it('asks for the required fields when a missing value and an invalid one block together', () => { + // Given a confirmation with a missing date and an invalid merchant + const {result} = renderFieldErrors({fieldErrors: {date: 'common.error.fieldRequired', merchant: 'iou.error.invalidMerchant'}}); + + // When the confirm is blocked by them + act(() => result.current.revealFieldErrors()); + + // Then the footer asks for the required fields, since the missing value is what the user has to add first + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + }); + + it('keeps the errors shown while any field is still missing, and hides them once all are filled in', () => { + // Given a blocked confirm with both the amount and the merchant missing + const {result, rerender} = renderFieldErrors({fieldErrors: {amount: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}}); + act(() => result.current.revealFieldErrors()); + + // When only the amount is filled in + rerender({fieldErrors: {merchant: 'common.error.fieldRequired'}}); + + // Then the merchant and the summary still show + expect(result.current.visibleFieldErrors).toEqual({merchant: 'common.error.fieldRequired'}); + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + + // When the merchant is filled in as well + rerender({fieldErrors: NO_FIELD_ERRORS}); + + // Then nothing is flagged + expect(result.current.visibleFieldErrors).toBe(NO_FIELD_ERRORS); + expect(result.current.footerErrorMessage).toBeUndefined(); + + // When the user empties a field again afterwards + rerender({fieldErrors: MISSING_AMOUNT}); + + // Then it is only flagged on the next blocked confirm, as before the first one + expect(result.current.visibleFieldErrors).toBe(NO_FIELD_ERRORS); + expect(result.current.footerErrorMessage).toBeUndefined(); + }); + + it('puts the summary over a form error raised elsewhere, and leaves that error to block once the fields are filled in', () => { + // Given a distance expense whose rate error is in the footer, and whose date is missing + const rateError = 'iou.error.invalidRate'; + const {result, rerender} = renderFieldErrors({fieldErrors: {date: 'common.error.fieldRequired'}, formErrorMessage: rateError}); + + // When a confirm is blocked by the missing date + act(() => result.current.revealFieldErrors()); + + // Then the summary takes the footer, since the date may be scrolled out of view + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + + // When the date is filled in while the rate is still out of policy + rerender({fieldErrors: NO_FIELD_ERRORS, formErrorMessage: rateError}); + + // Then the rate error is back in the footer, as it was never cleared and still blocks confirming + expect(result.current.footerErrorMessage).toBe(rateError); + }); + + it('keeps a route error in the footer over the summary and any other form error', () => { + // Given a distance expense whose route failed to load, with its date missing and another form error set + const routeError = 'Could not load the route'; + const {result} = renderFieldErrors({fieldErrors: {date: 'common.error.fieldRequired'}, formErrorMessage: 'Please select a valid distance rate', routeError}); + + // When nothing has been confirmed yet + // Then the route error shows over the form error + expect(result.current.footerErrorMessage).toBe(routeError); + + // When the field errors are revealed + act(() => result.current.revealFieldErrors()); + + // Then the route error stays over the summary, since the expense can't be confirmed until the route loads + expect(result.current.footerErrorMessage).toBe(routeError); + }); + + it('brings the partially filled sibling receipt on screen when the receipt on screen is complete', () => { + // Given a multi-scan whose receipt on screen is complete while a sibling receipt is partially filled + const onSwitchToTransaction = jest.fn(); + const {result} = renderFieldErrors({fieldErrors: NO_FIELD_ERRORS, partiallyManuallyFilledScanID: 'txn2', onSwitchToTransaction}); + + // When the confirm is blocked by the sibling + act(() => result.current.revealFieldErrors()); + + // Then the sibling is brought on screen, so its blank fields show their errors instead of Create looking like it did nothing + expect(onSwitchToTransaction).toHaveBeenCalledWith('txn2'); + }); + + it('keeps an invalid value on the receipt on screen in view before moving to a partially filled sibling', () => { + // Given a multi-scan whose receipt on screen has a typed merchant that isn't allowed, while a sibling receipt is partially filled + const onSwitchToTransaction = jest.fn(); + const {result, rerender} = renderFieldErrors({fieldErrors: {merchant: 'iou.error.invalidMerchant'}, partiallyManuallyFilledScanID: 'txn2', onSwitchToTransaction}); + + // When the confirm is blocked + act(() => result.current.revealFieldErrors()); + + // Then the receipt on screen stays, since validation only checks its values, and the summary asks to fix the error it shows + expect(onSwitchToTransaction).not.toHaveBeenCalled(); + expect(result.current.footerErrorMessage).toBe('common.error.fixErrorsBeforeContinuing'); + + // When the user fixes the merchant and confirms again + rerender({fieldErrors: NO_FIELD_ERRORS, partiallyManuallyFilledScanID: 'txn2', onSwitchToTransaction}); + act(() => result.current.revealFieldErrors()); + + // Then the partially filled sibling is brought on screen and the summary asks for its required fields + expect(onSwitchToTransaction).toHaveBeenCalledWith('txn2'); + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + }); + + it('stays on the receipt on screen when one of its own fields is missing', () => { + // Given a multi-scan whose receipt on screen is missing its amount, while a sibling receipt is also partially filled + const onSwitchToTransaction = jest.fn(); + const {result} = renderFieldErrors({fieldErrors: MISSING_AMOUNT, partiallyManuallyFilledScanID: 'txn2', onSwitchToTransaction}); + + // When the confirm is blocked + act(() => result.current.revealFieldErrors()); + + // Then the user keeps the receipt they are on, which now shows its own errors + expect(onSwitchToTransaction).not.toHaveBeenCalled(); + expect(result.current.visibleFieldErrors).toEqual(MISSING_AMOUNT); + }); + + it('keeps the summary while a sibling receipt of a multi-scan is still partially filled', () => { + // Given a blocked confirm on a multi-scan whose partially filled receipt is not the one on screen + const {result, rerender} = renderFieldErrors({fieldErrors: NO_FIELD_ERRORS, partiallyManuallyFilledScanID: 'txn2'}); + act(() => result.current.revealFieldErrors()); + + // When the complete receipt is still the one on screen + // Then the summary still explains why the confirm is blocked + expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); + + // When no receipt is partially filled any more + rerender({fieldErrors: NO_FIELD_ERRORS, partiallyManuallyFilledScanID: undefined}); + + // Then the summary goes away + expect(result.current.footerErrorMessage).toBeUndefined(); + }); +}); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 4967c8d3892d..8253666f4fb5 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -91,9 +91,6 @@ const baseParams = { currentUserPersonalDetails: {accountID: 1} as CurrentUserPersonalDetails, isEditingSplitBill: false, isMerchantRequired: false, - isMerchantFieldValid: true, - isMerchantEmpty: false, - shouldDisplayFieldError: false, shouldShowTax: false, isDistanceRequest: false, isDistanceRequestWithPendingRoute: false, @@ -140,37 +137,110 @@ describe('useConfirmationValidation', () => { }); it('returns invalidMerchant when merchant exceeds max bytes', () => { + // Given a merchant one byte longer than the backend accepts const longMerchant = 'A'.repeat(CONST.MERCHANT_NAME_MAX_BYTES + 1); const {result} = renderHook(() => useConfirmationValidation({...baseParams, iouMerchant: longMerchant})); - expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidMerchant'}); + + // When the user confirms + // Then the merchant field blocks it as invalid, since the request would be rejected + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({merchant: 'iou.error.invalidMerchant'}); + }); + + it('returns fieldRequired when a required merchant is left blank, the same as the other required fields', () => { + // Given a workspace expense whose merchant still holds the "Expense" placeholder the draft was seeded with + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + iouMerchant: CONST.TRANSACTION.DEFAULT_MERCHANT, + transaction: createTransactionBase({amount: 100, merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantSet: false, participants: [P2P_PARTICIPANT]}), + isMerchantRequired: true, + }), + ); + + // When the user confirms + // Then the merchant reads as required, so the footer summary covers it like a missing amount or date + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({merchant: 'common.error.fieldRequired'}); }); - it('returns invalidMerchant when merchant is required but empty', () => { - const {result} = renderHook(() => useConfirmationValidation({...baseParams, isMerchantRequired: true, isMerchantEmpty: true, isMerchantFieldValid: false})); - expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidMerchant'}); + it('returns invalidMerchant when a required merchant was typed but is not valid', () => { + // Given a workspace expense where the user typed the disallowed "(none)" as the merchant + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + iouMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + transaction: createTransactionBase({amount: 100, merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isMerchantSet: true, participants: [P2P_PARTICIPANT]}), + isMerchantRequired: true, + }), + ); + + // When the user confirms + // Then the merchant asks for a valid value instead of saying the field is required + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({merchant: 'iou.error.invalidMerchant'}); + }); + + it('returns the field errors it blocks on, so the fields show exactly what stops confirmation', () => { + // Given a manual workspace expense with neither an amount nor a merchant + const {result} = renderHook(() => + useConfirmationValidation( + createValidationParamsForParticipant( + POLICY_EXPENSE_CHAT_PARTICIPANT, + {iouAmount: 0, iouMerchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantRequired: true, isPolicyExpenseChat: true}, + {amount: 0, isAmountSet: false, merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, isMerchantSet: false}, + ), + ), + ); + + // When its validation is read + // Then both fields are reported, and the confirm is blocked as a field error so they show them + expect(result.current.fieldErrors).toEqual({amount: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}); + expect(result.current.validate()).toEqual({isFieldError: true}); }); it('returns invalidMerchant when merchant is not required but was explicitly set to an invalid value', () => { + // Given an expense whose merchant is optional, but which the user typed with a value that fails validation const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, + iouMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isMerchantRequired: false, - isMerchantFieldValid: false, - transaction: createMock({transactionID: 'txn1', comment: {}, amount: 100, isMerchantSet: true}), + transaction: createMock({ + transactionID: 'txn1', + comment: {}, + amount: 100, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + isMerchantSet: true, + }), }), ); - expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidMerchant'}); + + // When the user confirms + // Then the merchant field blocks it, since an optional merchant is still checked once the user typed one + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({merchant: 'iou.error.invalidMerchant'}); }); it('returns null when merchant is not required and was not explicitly set', () => { + // Given an expense whose merchant is optional and still holds the "(none)" placeholder the user never typed const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, + iouMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isMerchantRequired: false, - isMerchantFieldValid: false, - transaction: createMock({transactionID: 'txn1', comment: {}, amount: 100, isMerchantSet: false}), + transaction: createMock({ + transactionID: 'txn1', + comment: {}, + amount: 100, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + isMerchantSet: false, + }), }), ); + + // When the user confirms + // Then nothing blocks it, since an untyped placeholder in an optional field is just a blank merchant expect(result.current.validate()).toEqual({errorKey: null}); }); @@ -312,6 +382,7 @@ describe('useConfirmationValidation', () => { }); it('returns fieldRequired for manual expense when amount is not set with a policy expense chat participant', () => { + // Given a manual workspace expense whose amount the user never entered const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant( @@ -323,7 +394,11 @@ describe('useConfirmationValidation', () => { ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the amount field blocks it as required, since a manual expense can't be created without an amount + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); }); it('does not return fieldRequired for scan expense when amount is not set', () => { @@ -372,6 +447,7 @@ describe('useConfirmationValidation', () => { describe('policy expense chat participant (workspace submit/create)', () => { it.each(IOU_TYPES_WITH_STANDARD_VALIDATION)('returns fieldRequired for unset manual amount when iouType is %s', (iouType) => { + // Given a manual workspace expense of this type whose amount the user never entered const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant( @@ -384,10 +460,15 @@ describe('useConfirmationValidation', () => { ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then every type blocks on the same required amount, so the rule doesn't depend on what is being created + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); }); it('returns fieldRequired for PAY with unset manual amount before payment method is checked', () => { + // Given a payment whose amount the user never entered const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant( @@ -400,8 +481,12 @@ describe('useConfirmationValidation', () => { ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); - expect(result.current.validate(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms, with or without a payment method + // Then the missing amount blocks first, so picking a payment method can't send money without an amount + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.validate(CONST.IOU.PAYMENT_TYPE.ELSEWHERE)).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); }); it('returns errorKey: null when manual amount is explicitly set to zero for submit', () => { @@ -414,19 +499,25 @@ describe('useConfirmationValidation', () => { }); it('returns invalidAmount when manual amount is explicitly set to zero for invoice', () => { + // Given an invoice to a workspace where the user typed a $0 amount const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...zeroAmountParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true}), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user confirms + // Then the amount field blocks it as invalid, since an invoice can't be for $0 even where a $0 expense is allowed + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); }); }); describe('P2P participant', () => { it.each([CONST.IOU.TYPE.SUBMIT, CONST.IOU.TYPE.CREATE, CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.SPLIT])( - 'returns invalidAmount (not fieldRequired) for unset manual amount when iouType is %s', + 'returns fieldRequired for unset manual amount when iouType is %s', (iouType) => { + // Given a manual expense of this type to a person, with no amount entered const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant( @@ -439,7 +530,11 @@ describe('useConfirmationValidation', () => { ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user confirms + // Then the amount reads as missing, the same as for a workspace, so the summary above the button covers it too + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); }, ); @@ -460,22 +555,37 @@ describe('useConfirmationValidation', () => { }); it('returns invalidAmount when manual amount is explicitly set to zero', () => { + // Given a manual expense to a person where the user typed a $0 amount const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, zeroAmountParams, {amount: 0, isAmountSet: true}))); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user confirms + // Then the amount field blocks it, since a 1:1 expense can't be $0 + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); }); it('returns invalidAmount when manual amount is explicitly set to zero for invoice', () => { + // Given an invoice to a person where the user typed a $0 amount const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, {...zeroAmountParams, iouType: CONST.IOU.TYPE.INVOICE}, {amount: 0, isAmountSet: true})), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user confirms + // Then the amount field blocks it as invalid, the same as any other 1:1 expense + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); }); }); describe('self-DM participant', () => { it('returns fieldRequired for unset manual amount', () => { + // Given a manual expense to the self DM whose amount the user never entered const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(SELF_DM_PARTICIPANT, zeroAmountParams, {isAmountSet: false}))); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the amount reads as required, like any manual expense whose amount was never entered + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); }); it('returns errorKey: null when manual amount is explicitly set to zero', () => { @@ -487,8 +597,13 @@ describe('useConfirmationValidation', () => { describe('amount validation — P2P zero amount guard', () => { it('returns invalidAmount for P2P manual submit with zero amount', () => { + // Given a manual expense submitted to a person for $0 const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(P2P_PARTICIPANT, {iouAmount: 0}, {amount: 0, isAmountSet: true}))); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user confirms + // Then the $0 guard blocks it on the amount field, since money can't be requested from a person for nothing + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); }); it('returns errorKey: null for policy expense chat participant with zero amount', () => { @@ -697,6 +812,7 @@ describe('useConfirmationValidation', () => { describe('amount validation — split bill editing', () => { it('returns invalidAmount when editing a split bill with zero amount and required fields otherwise filled', () => { + // Given a split between people being edited down to a $0 amount const splitParticipants = [P2P_PARTICIPANT, {accountID: 3, isPolicyExpenseChat: false} as Participant]; const {result} = renderHook(() => useConfirmationValidation({ @@ -712,11 +828,15 @@ describe('useConfirmationValidation', () => { transactionReport: createMock({type: CONST.REPORT.TYPE.IOU}), }), ); - // P2P zero-amount guard runs before the split-bill-specific invalidAmount check. - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user saves the split + // Then the 1:1 $0 guard on the amount field blocks it, since it runs before the split-specific amount check + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); }); - it('returns invalidAmount for split IOU type with unset manual amount and P2P participants', () => { + it('returns fieldRequired for split IOU type with unset manual amount and P2P participants', () => { + // Given a split between people whose amount the user never entered const splitParticipants = [P2P_PARTICIPANT, {accountID: 3, isPolicyExpenseChat: false} as Participant]; const {result} = renderHook(() => useConfirmationValidation( @@ -731,10 +851,15 @@ describe('useConfirmationValidation', () => { ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user confirms + // Then the amount reads as missing, since an amount the user never entered is checked before the 1:1 $0 guard + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); }); it('returns fieldRequired for split IOU type with unset manual amount and a policy expense chat participant', () => { + // Given a workspace split whose amount the user never entered const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant( @@ -747,10 +872,15 @@ describe('useConfirmationValidation', () => { ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the amount reads as required, like any manual expense whose amount was never entered + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.fieldRequired'}); }); it('returns invalidAmount for split IOU type with zero amount and isAmountSet true', () => { + // Given a split where the user typed a $0 amount const splitParticipants = [POLICY_EXPENSE_CHAT_PARTICIPANT, {accountID: 3, isPolicyExpenseChat: false} as Participant]; const {result} = renderHook(() => useConfirmationValidation( @@ -765,7 +895,11 @@ describe('useConfirmationValidation', () => { ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user confirms + // Then the amount field blocks it, since a split of $0 has nothing to divide + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); }); it('returns iou.error.invalidAmount when editing split bill with zero amount and isAmountSet true', () => { @@ -791,11 +925,17 @@ describe('useConfirmationValidation', () => { describe('date validation — inline required date', () => { it('returns fieldRequired for manual expense when the date is removed', () => { + // Given a manual expense whose date the user cleared const {result} = renderHook(() => useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, baseParams, {created: '', isAmountSet: true}))); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the date field blocks it as required, since an expense can't be created without a date + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({date: 'common.error.fieldRequired'}); }); it('returns fieldRequired for distance expense when the date is removed', () => { + // Given a distance expense whose date the user cleared const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, @@ -810,10 +950,15 @@ describe('useConfirmationValidation', () => { selectedParticipants: [POLICY_EXPENSE_CHAT_PARTICIPANT], }), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the date field blocks it, since the date also decides which distance rate applies + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({date: 'common.error.fieldRequired'}); }); it('returns fieldRequired for time expense when the date is removed', () => { + // Given a time expense whose date the user cleared const {result} = renderHook(() => useConfirmationValidation({ ...baseParams, @@ -828,16 +973,25 @@ describe('useConfirmationValidation', () => { selectedParticipants: [POLICY_EXPENSE_CHAT_PARTICIPANT], }), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the date field blocks it, the same as for a manual expense + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({date: 'common.error.fieldRequired'}); }); it('returns fieldRequired for invoice when the date is removed', () => { + // Given an invoice whose date the user cleared const {result} = renderHook(() => useConfirmationValidation( createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...baseParams, iouType: CONST.IOU.TYPE.INVOICE}, {created: '', isAmountSet: true}), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the date field blocks it, so the company info step that follows is never reached without a date + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({date: 'common.error.fieldRequired'}); }); it('does not return fieldRequired when the date is present', () => { @@ -912,7 +1066,6 @@ describe('useConfirmationValidation', () => { isPolicyExpenseChat: true, iouAmount: 0, iouMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - isMerchantEmpty: true, ...overrides, transaction: createTransactionBase({ iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, @@ -930,21 +1083,31 @@ describe('useConfirmationValidation', () => { }); it.each([ - ['merchant', {isMerchantSet: true, merchant: 'Starbucks'}, {iouMerchant: 'Starbucks', isMerchantEmpty: false}], - ['amount', {isAmountSet: true, amount: 1000}, {iouAmount: 1000}], - ['date', {isCreatedSet: true, created: '2025-01-15'}, {}], - ])('requires the other two once the %s is entered, since the three are all-or-nothing', (_field, transactionOverrides, overrides) => { + ['merchant', {isMerchantSet: true, merchant: 'Starbucks'}, {iouMerchant: 'Starbucks'}, {amount: 'common.error.fieldRequired', date: 'common.error.fieldRequired'}], + ['amount', {isAmountSet: true, amount: 1000}, {iouAmount: 1000}, {date: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}], + ['date', {isCreatedSet: true, created: '2025-01-15'}, {}, {amount: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}], + ])('requires the other two once the %s is entered, since the three are all-or-nothing', (_field, transactionOverrides, overrides, expectedFieldErrors) => { + // Given a workspace scan where the user entered only this one of the amount, merchant and date const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides))); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the two blank fields are required, since entering any of them means SmartScan no longer fills them in + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual(expectedFieldErrors); }); it.each([ ['merchant', {isAmountSet: true, amount: 1000, isCreatedSet: true, created: '2025-01-15'}, {iouAmount: 1000}], - ['amount', {isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, {iouMerchant: 'Starbucks', isMerchantEmpty: false}], - ['date', {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks'}, {iouAmount: 1000, iouMerchant: 'Starbucks', isMerchantEmpty: false}], - ])('still blocks confirmation while only the %s is left blank', (_field, transactionOverrides, overrides) => { + ['amount', {isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, {iouMerchant: 'Starbucks'}], + ['date', {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks'}, {iouAmount: 1000, iouMerchant: 'Starbucks'}], + ])('still blocks confirmation while only the %s is left blank', (blankField, transactionOverrides, overrides) => { + // Given a workspace scan where the user entered two of the three fields and left this one blank const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides))); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then the last blank field is still required, so a half-entered scan can't be submitted + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({[blankField]: 'common.error.fieldRequired'}); }); it('does not require the merchant when the expense goes to a chat that does not require one', () => { @@ -966,7 +1129,8 @@ describe('useConfirmationValidation', () => { const {result} = renderHook(() => useConfirmationValidation(params)); // Then the blank date still blocks confirmation - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({date: 'common.error.fieldRequired'}); }); it('passes again once the user clears the fields back to an untouched scan', () => { @@ -979,7 +1143,7 @@ describe('useConfirmationValidation', () => { useConfirmationValidation( createScanValidationParams( {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, - {iouAmount: 1000, iouMerchant: 'Starbucks', isMerchantEmpty: false}, + {iouAmount: 1000, iouMerchant: 'Starbucks'}, ), ), ); @@ -987,15 +1151,20 @@ describe('useConfirmationValidation', () => { }); it('validates the entered amount the same way a manually entered one is validated', () => { + // Given a scan to a person where the user filled in all three fields with a $0 amount const {result} = renderHook(() => useConfirmationValidation( createScanValidationParams( {isAmountSet: true, amount: 0, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, - {iouAmount: 0, iouMerchant: 'Starbucks', isMerchantEmpty: false}, + {iouAmount: 0, iouMerchant: 'Starbucks'}, ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + + // When the user confirms + // Then the typed amount is rejected like a manual one, since it replaces what SmartScan would have read + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); }); it('blocks a negative entered amount when the expense goes to a 1:1 user', () => { @@ -1004,11 +1173,12 @@ describe('useConfirmationValidation', () => { // When the user fills in a negative amount and confirms const {result} = renderHook(() => - useConfirmationValidation(createScanValidationParams(filledFields, {iouType: CONST.IOU.TYPE.CREATE, iouAmount: -1234, iouMerchant: 'Starbucks', isMerchantEmpty: false})), + useConfirmationValidation(createScanValidationParams(filledFields, {iouType: CONST.IOU.TYPE.CREATE, iouAmount: -1234, iouMerchant: 'Starbucks'})), ); // Then it is rejected, since P2P chats don't support negative amounts - expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({amount: 'common.error.invalidAmount'}); }); it.each([ @@ -1026,7 +1196,6 @@ describe('useConfirmationValidation', () => { iouType: CONST.IOU.TYPE.CREATE, iouAmount: -1234, iouMerchant: 'Starbucks', - isMerchantEmpty: false, selectedParticipants: [participant], }), ), @@ -1037,16 +1206,20 @@ describe('useConfirmationValidation', () => { }); it('blocks confirmation when another receipt in a multi-scan is the partially filled one', () => { - // The transaction on screen is a complete manual scan, but a sibling receipt is not. + // Given a multi-scan whose receipt on screen is complete while a sibling receipt is partially filled const {result} = renderHook(() => useConfirmationValidation( createScanValidationParams( {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, - {iouAmount: 1000, iouMerchant: 'Starbucks', isMerchantEmpty: false, partiallyManuallyFilledScanID: 'other-txn'}, + {iouAmount: 1000, iouMerchant: 'Starbucks', partiallyManuallyFilledScanID: 'other-txn'}, ), ), ); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + + // When the user confirms + // Then it is blocked as a field error with nothing flagged on screen, since Create submits every receipt at once and the sibling has to be brought on screen + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.fieldErrors).toEqual({}); }); it('lets a multi-scan through once no receipt is left partially filled', () => { @@ -1054,7 +1227,7 @@ describe('useConfirmationValidation', () => { useConfirmationValidation( createScanValidationParams( {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, - {iouAmount: 1000, iouMerchant: 'Starbucks', isMerchantEmpty: false, partiallyManuallyFilledScanID: undefined}, + {iouAmount: 1000, iouMerchant: 'Starbucks', partiallyManuallyFilledScanID: undefined}, ), ), ); diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index d21b7d7ff64f..b41a334e571e 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -29,7 +29,6 @@ type Params = Parameters[0]; const baseParams: Params = { transaction: createMock({transactionID: 'txn1', amount: 100, merchant: 'Coffee', comment: {}}), transactionReport: undefined, - iouMerchant: 'Coffee', iouCategory: '', iouAttendees: [], policy: undefined, @@ -37,32 +36,11 @@ const baseParams: Params = { policyCategories: undefined, currentUserPersonalDetails: {accountID: 1} as CurrentUserPersonalDetails, isEditingSplitBill: false, - isPolicyExpenseChat: false, - isScanRequest: false, - canEnterScanFieldsManually: false, - shouldShowMerchant: true, hasSmartScanFailed: false, didConfirmSplit: false, - routeError: undefined, isTypeSplit: false, shouldShowReadOnlySplits: false, isDistanceRequest: false, - shouldShowDate: false, - isReadOnly: false, -}; - -// A manual draft the user never typed a merchant into: `initMoneyRequest` seeds it with the "Expense" placeholder. -const placeholderMerchantTransaction = createMock({ - transactionID: 'txn1', - amount: 100, - merchant: CONST.TRANSACTION.DEFAULT_MERCHANT, - isMerchantSet: false, - comment: {}, -}); - -const placeholderMerchantParams: Partial = { - transaction: placeholderMerchantTransaction, - iouMerchant: CONST.TRANSACTION.DEFAULT_MERCHANT, }; function Wrapper({children}: {children: React.ReactNode}) { @@ -96,16 +74,6 @@ describe('useFormErrorManagement', () => { expect(result.current.shouldDisplayFieldError).toBe(true); }); - it('isMerchantRequired is true only for policyExpenseChat with non-scan request and shouldShowMerchant', () => { - const {result: required} = renderHook(() => useFormErrorManagement({...baseParams, isPolicyExpenseChat: true, isScanRequest: false, shouldShowMerchant: true}), {wrapper: Wrapper}); - const {result: notRequired} = renderHook( - () => useFormErrorManagement({...baseParams, isPolicyExpenseChat: true, isScanRequest: true, shouldShowMerchant: true, isEditingSplitBill: false}), - {wrapper: Wrapper}, - ); - expect(required.current.isMerchantRequired).toBe(true); - expect(notRequired.current.isMerchantRequired).toBe(false); - }); - it('clearFormErrors only clears the error when it matches the provided list', async () => { const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); act(() => result.current.setFormError('iou.error.invalidMerchant')); @@ -120,9 +88,42 @@ describe('useFormErrorManagement', () => { expect(result.current.formError).toBe(''); }); - it('errorMessage returns routeError when present', () => { - const {result} = renderHook(() => useFormErrorManagement({...baseParams, routeError: 'route.error.unreachable'}), {wrapper: Wrapper}); - expect(result.current.errorMessage).toBe('route.error.unreachable'); + it('drops the error a confirm set once a later confirm asks for it to be cleared', () => { + // Given a confirm that was blocked by an empty tax amount + const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); + act(() => result.current.setConfirmError('iou.error.invalidAmount')); + expect(result.current.formError).toBe('iou.error.invalidAmount'); + + // When a later confirm is blocked by a field error instead + act(() => result.current.clearConfirmError()); + + // Then the error is dropped, since validation checks it again on the next confirm + expect(result.current.formError).toBe(''); + }); + + it('keeps an error set outside validation when a confirm asks to clear its own', () => { + // Given a form error the distance rate check raised + const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); + act(() => result.current.setFormError('iou.error.invalidRate')); + + // When a confirm blocked by a field error asks to clear its own error + act(() => result.current.clearConfirmError()); + + // Then the distance rate error stays, since validation doesn't check it again and it still has to block confirming + expect(result.current.formError).toBe('iou.error.invalidRate'); + }); + + it('keeps an error that replaced the one a confirm set', () => { + // Given a confirm blocked by an empty tax amount, after which the distance rate check raised its own error + const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); + act(() => result.current.setConfirmError('iou.error.invalidAmount')); + act(() => result.current.setFormError('iou.error.invalidRate')); + + // When a confirm blocked by a field error asks to clear its own error + act(() => result.current.clearConfirmError()); + + // Then the distance rate error stays, since it is no longer the one the confirm set + expect(result.current.formError).toBe('iou.error.invalidRate'); }); it('errorMessage returns undefined for violations.missingAttendees on non-split flows', async () => { @@ -131,14 +132,15 @@ describe('useFormErrorManagement', () => { expect(result.current.errorMessage).toBeUndefined(); }); - it('errorMessage suppresses required/invalid amount errors (surfaced inline)', () => { - const {result: required} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); - act(() => required.current.setFormError('common.error.fieldRequired')); - expect(required.current.errorMessage).toBeUndefined(); + it('errorMessage leaves out the invalid amount error the amount input already shows', () => { + // Given an edited split, whose amount input flags an invalid amount through the form error as the user types + const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); - const {result: invalid} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); - act(() => invalid.current.setFormError('common.error.invalidAmount')); - expect(invalid.current.errorMessage).toBeUndefined(); + // When that error is set + act(() => result.current.setFormError('common.error.invalidAmount')); + + // Then the footer doesn't repeat it under the button + expect(result.current.errorMessage).toBeUndefined(); }); it('errorMessage still shows the invalid amount error for a distance request (no inline surface)', () => { @@ -200,184 +202,4 @@ describe('useFormErrorManagement', () => { jest.useRealTimers(); } }); - - it('errorMessage suppresses the invalid merchant error (surfaced inline)', () => { - const {result} = renderHook(() => useFormErrorManagement(baseParams), {wrapper: Wrapper}); - act(() => result.current.setFormError('iou.error.invalidMerchant')); - expect(result.current.errorMessage).toBeUndefined(); - }); - - it('treats the placeholder merchant of an untouched draft as empty, so it is only invalid while a merchant is required', () => { - const {result: required} = renderHook(() => useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isPolicyExpenseChat: true}), {wrapper: Wrapper}); - const {result: notRequired} = renderHook(() => useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isPolicyExpenseChat: false}), {wrapper: Wrapper}); - - expect(required.current.isMerchantFieldValid).toBe(false); - expect(notRequired.current.isMerchantFieldValid).toBe(true); - }); - - it('keeps a placeholder merchant the user typed themselves invalid', () => { - const {result} = renderHook( - () => - useFormErrorManagement({ - ...baseParams, - ...placeholderMerchantParams, - transaction: {...placeholderMerchantTransaction, isMerchantSet: true}, - isPolicyExpenseChat: false, - }), - {wrapper: Wrapper}, - ); - - expect(result.current.isMerchantFieldValid).toBe(false); - }); - - it('leaves the merchant optional on a scan the user has started filling in, since a blank field is still scanned', () => { - const scanParams: Partial = { - isScanRequest: true, - isPolicyExpenseChat: false, - iouMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - }; - const scanDraft = { - transactionID: 'txn1', - amount: 0, - merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, - comment: {}, - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - }; - - const {result: untouched} = renderHook(() => useFormErrorManagement({...baseParams, ...scanParams, transaction: createMock(scanDraft)}), { - wrapper: Wrapper, - }); - const {result: amountEntered} = renderHook( - () => useFormErrorManagement({...baseParams, ...scanParams, transaction: createMock({...scanDraft, amount: 1000, isAmountSet: true})}), - {wrapper: Wrapper}, - ); - - expect(untouched.current.isMerchantRequired).toBe(false); - expect(amountEntered.current.isMerchantRequired).toBe(false); - }); - - it('clears the invalid merchant error once the recipient changes from a workspace chat to a user (#96593)', () => { - // Given an untouched manual draft (still carrying the placeholder merchant) headed for a workspace chat - const {result, rerender} = renderHook( - ({isPolicyExpenseChat}: {isPolicyExpenseChat: boolean}) => useFormErrorManagement({...baseParams, ...placeholderMerchantParams, isPolicyExpenseChat}), - {wrapper: Wrapper, initialProps: {isPolicyExpenseChat: true}}, - ); - - // When confirming surfaces the "please enter a valid merchant" error - act(() => result.current.setFormError('iou.error.invalidMerchant')); - expect(result.current.formError).toBe('iou.error.invalidMerchant'); - - // Then switching the recipient to a user drops the merchant requirement and clears the error - rerender({isPolicyExpenseChat: false}); - expect(result.current.formError).toBe(''); - }); - - const manualRequiredParams = ({ - isAmountSet, - created, - shouldShowDate = true, - isReadOnly = false, - }: { - isAmountSet: boolean; - created: string; - shouldShowDate?: boolean; - isReadOnly?: boolean; - }): Params => ({ - ...baseParams, - shouldShowDate, - isReadOnly, - transaction: createMock({ - transactionID: 'txn1', - iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, - isAmountSet, - created, - comment: {}, - }), - }); - - it('clears the required error once the amount is set when the date field is hidden, so a hidden date cannot strand it', () => { - // Given a confirmation that doesn't render the date field (a scan flow before its fields show), with no date yet - const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { - wrapper: Wrapper, - initialProps: manualRequiredParams({isAmountSet: false, created: '', shouldShowDate: false}), - }); - act(() => result.current.setFormError('common.error.fieldRequired')); - - // When the amount is filled, the empty date must not keep the error alive since it has no inline surface - rerender(manualRequiredParams({isAmountSet: true, created: '', shouldShowDate: false})); - expect(result.current.formError).toBe(''); - }); - - it('clears the required error once the amount is set on a read-only confirmation, where the date is populated server-side', () => { - const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { - wrapper: Wrapper, - initialProps: manualRequiredParams({isAmountSet: false, created: '', isReadOnly: true}), - }); - act(() => result.current.setFormError('common.error.fieldRequired')); - - rerender(manualRequiredParams({isAmountSet: true, created: '', isReadOnly: true})); - expect(result.current.formError).toBe(''); - }); - - it('keeps the required error alive while another receipt of a multi-scan is still partially filled', () => { - // Given a confirmation showing a complete receipt while a sibling one is partially filled. Confirming switches to - // the sibling, and the error has to survive the render where the complete receipt is still the one on screen. - const completeReceiptParams = (partiallyManuallyFilledScanID?: string): Params => ({ - ...baseParams, - canEnterScanFieldsManually: true, - partiallyManuallyFilledScanID, - transaction: createMock({ - transactionID: 'txn1', - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - isAmountSet: true, - isMerchantSet: true, - isCreatedSet: true, - comment: {}, - }), - }); - - const partiallyFilledReceiptParams = (partiallyManuallyFilledScanID?: string): Params => ({ - ...completeReceiptParams(partiallyManuallyFilledScanID), - transaction: createMock({ - transactionID: 'txn1', - iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, - isAmountSet: false, - isMerchantSet: true, - isCreatedSet: true, - comment: {}, - }), - }); - - const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { - wrapper: Wrapper, - initialProps: partiallyFilledReceiptParams('txn1'), - }); - act(() => result.current.setFormError('common.error.fieldRequired')); - - // Moving to a receipt that has nothing missing would clear the error, but a sibling is still partially filled - rerender(completeReceiptParams('txn2')); - expect(result.current.formError).toBe('common.error.fieldRequired'); - - // Once no receipt is partially filled any more the error clears, so confirmation is no longer blocked - rerender(completeReceiptParams(undefined)); - expect(result.current.formError).toBe(''); - }); - - it('keeps the shared required error until both the amount and the date are filled (#96568)', () => { - // Given a manual expense confirmed with both the amount and the date empty - const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { - wrapper: Wrapper, - initialProps: manualRequiredParams({isAmountSet: false, created: ''}), - }); - act(() => result.current.setFormError('common.error.fieldRequired')); - expect(result.current.formError).toBe('common.error.fieldRequired'); - - // When only the amount is filled, the shared error must survive so the date keeps showing it inline - rerender(manualRequiredParams({isAmountSet: true, created: ''})); - expect(result.current.formError).toBe('common.error.fieldRequired'); - - // Then filling the date too clears it, so confirmation is no longer blocked - rerender(manualRequiredParams({isAmountSet: true, created: '2026-07-29'})); - expect(result.current.formError).toBe(''); - }); }); From c6820cbff267cf76cb7377c3986524225f9c5f2e Mon Sep 17 00:00:00 2001 From: "TaduJR (via MelvinBot)" Date: Tue, 10 Nov 2026 14:38:01 +0000 Subject: [PATCH 2/3] Apply Polyglot Parrot expense validation translations Co-authored-by: TaduJR --- src/languages/de.ts | 4 ++-- src/languages/el.ts | 4 ++-- src/languages/es.ts | 4 ++-- src/languages/fr.ts | 2 +- src/languages/it.ts | 4 ++-- src/languages/nl.ts | 4 ++-- src/languages/pl.ts | 4 ++-- src/languages/pt-BR.ts | 4 ++-- src/languages/zh-hans.ts | 2 +- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index c3e71fbde977..a07721ede573 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -237,7 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Bitte gib eine vollständige Telefonnummer ein (z. B. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Dieses Feld ist erforderlich', - fixErrorsBeforeContinuing: 'Bitte behebe die Fehler im Formular, bevor du fortfährst.', + fixErrorsBeforeContinuing: 'Bitte beheben Sie die Fehler im Formular, bevor Sie fortfahren.', requestModified: 'Diese Anfrage wird gerade von einem anderen Mitglied bearbeitet', characterLimitExceedCounter: (length: number, limit: number) => `Zeichenlimit überschritten (${length}/${limit})`, dateInvalid: 'Bitte wähle ein gültiges Datum aus', @@ -1756,7 +1756,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Bitte gib mindestens zwei verschiedene Adressen ein', splitExpenseMultipleParticipantsErrorMessage: 'Eine Ausgabe kann nicht zwischen einem Workspace und anderen Mitgliedern aufgeteilt werden. Bitte aktualisiere deine Auswahl.', invalidMerchant: 'Bitte gib einen gültigen Händler ein', - enterRequiredFields: 'Bitte fülle die Pflichtfelder aus.', + enterRequiredFields: 'Bitte füllen Sie die erforderlichen Felder aus.', atLeastOneAttendee: 'Mindestens eine teilnehmende Person muss ausgewählt werden', invalidQuantity: 'Bitte gib eine gültige Menge ein', quantityGreaterThanZero: 'Die Menge muss größer als null sein', diff --git a/src/languages/el.ts b/src/languages/el.ts index fc200dccf4df..3a8a3b38c3f3 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -241,7 +241,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Παρακαλούμε εισαγάγετε έναν πλήρη αριθμό τηλεφώνου (π.χ. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Αυτό το πεδίο είναι υποχρεωτικό', - fixErrorsBeforeContinuing: 'Παρακαλώ διορθώστε τα σφάλματα στη φόρμα πριν συνεχίσετε.', + fixErrorsBeforeContinuing: 'Παρακαλούμε διορθώστε τα σφάλματα στη φόρμα πριν συνεχίσετε.', requestModified: 'Αυτό το αίτημα τροποποιείται από άλλο μέλος', characterLimitExceedCounter: (length: number, limit: number) => `Υπέρβαση ορίου χαρακτήρων (${length}/${limit})`, dateInvalid: 'Παρακαλώ επιλέξτε έγκυρη ημερομηνία', @@ -1824,7 +1824,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Παρακαλούμε εισαγάγετε τουλάχιστον δύο διαφορετικές διευθύνσεις', splitExpenseMultipleParticipantsErrorMessage: 'Μια δαπάνη δεν μπορεί να κατανεμηθεί μεταξύ ενός χώρου εργασίας και άλλων μελών. Παρακαλούμε ενημερώστε την επιλογή σας.', invalidMerchant: 'Παρακαλούμε εισαγάγετε έγκυρο έμπορο', - enterRequiredFields: 'Παρακαλούμε συμπληρώστε τα υποχρεωτικά πεδία.', + enterRequiredFields: 'Παρακαλούμε εισαγάγετε τα απαιτούμενα πεδία.', atLeastOneAttendee: 'Πρέπει να επιλεγεί τουλάχιστον ένας συμμετέχων', invalidQuantity: 'Παρακαλούμε εισαγάγετε μια έγκυρη ποσότητα', quantityGreaterThanZero: 'Η ποσότητα πρέπει να είναι μεγαλύτερη από το μηδέν', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1110a5171907..04608a6f17c2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -228,7 +228,7 @@ const translations: TranslationDeepObject = { acceptTerms: 'Debes aceptar los Términos de Servicio para continuar', phoneNumber: `Por favor, introduce un número de teléfono completo\n(ej. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Este campo es obligatorio', - fixErrorsBeforeContinuing: 'Por favor, corrige los errores en el formulario antes de continuar.', + fixErrorsBeforeContinuing: 'Por favor, corrige los errores del formulario antes de continuar.', requestModified: 'Esta solicitud está siendo modificada por otro miembro', characterLimitExceedCounter: (length, limit) => `Se superó el límite de caracteres (${length}/${limit})`, dateInvalid: 'Por favor, selecciona una fecha válida', @@ -1762,7 +1762,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Por favor, introduce al menos dos direcciones diferentes', splitExpenseMultipleParticipantsErrorMessage: 'Solo puedes dividir un gasto entre un único espacio de trabajo o con miembros individuales. Por favor, actualiza tu selección.', invalidMerchant: 'Por favor, introduce un comerciante válido', - enterRequiredFields: 'Por favor, completa los campos obligatorios.', + enterRequiredFields: 'Por favor, introduce los campos obligatorios.', atLeastOneAttendee: 'Debe seleccionarse al menos un asistente', invalidQuantity: 'Por favor, introduce una cantidad válida', quantityGreaterThanZero: 'La cantidad debe ser mayor que cero', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 639160ff9bcb..bb51ebb32f68 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1764,7 +1764,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Veuillez saisir au moins deux adresses différentes', splitExpenseMultipleParticipantsErrorMessage: "Une dépense ne peut pas être répartie entre un espace de travail et d'autres membres. Veuillez modifier votre sélection.", invalidMerchant: 'Veuillez saisir un commerçant valide', - enterRequiredFields: 'Veuillez remplir les champs obligatoires.', + enterRequiredFields: 'Veuillez renseigner les champs obligatoires.', atLeastOneAttendee: 'Au moins un participant doit être sélectionné', invalidQuantity: 'Veuillez saisir une quantité valide', quantityGreaterThanZero: 'La quantité doit être supérieure à zéro', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6a9803056b81..fec5aafdc49a 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -237,7 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Inserisci un numero di telefono completo (ad es. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Questo campo è obbligatorio', - fixErrorsBeforeContinuing: 'Per favore, correggi gli errori nel modulo prima di continuare.', + fixErrorsBeforeContinuing: 'Correggi gli errori nel modulo prima di continuare.', requestModified: 'Questa richiesta viene modificata da un altro membro', characterLimitExceedCounter: (length: number, limit: number) => `Limite di caratteri superato (${length}/${limit})`, dateInvalid: 'Seleziona una data valida', @@ -1754,7 +1754,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Inserisci almeno due indirizzi diversi', splitExpenseMultipleParticipantsErrorMessage: 'Una spesa non può essere suddivisa tra uno spazio di lavoro e altri membri. Aggiorna la tua selezione.', invalidMerchant: 'Inserisci un esercente valido', - enterRequiredFields: 'Compila i campi obbligatori.', + enterRequiredFields: 'Inserisci i campi obbligatori.', atLeastOneAttendee: 'Devi selezionare almeno un partecipante', invalidQuantity: 'Inserisci una quantità valida', quantityGreaterThanZero: 'La quantità deve essere maggiore di zero', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index cf74e80955a6..fa1b498847b4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -237,7 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Voer een volledig telefoonnummer in (bijv. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Dit veld is verplicht', - fixErrorsBeforeContinuing: 'Corrigeer de fouten in het formulier voordat je verdergaat.', + fixErrorsBeforeContinuing: 'Los de fouten in het formulier op voordat je verdergaat.', requestModified: 'Dit verzoek wordt bewerkt door een ander lid', characterLimitExceedCounter: (length: number, limit: number) => `Limiet voor aantal tekens overschreden (${length}/${limit})`, dateInvalid: 'Selecteer een geldige datum', @@ -1750,7 +1750,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Voer minimaal twee verschillende adressen in', splitExpenseMultipleParticipantsErrorMessage: 'Een uitgave kan niet worden opgesplitst tussen een werkruimte en andere leden. Werk je selectie alsjeblieft bij.', invalidMerchant: 'Voer een geldige leverancier in', - enterRequiredFields: 'Vul de verplichte velden in.', + enterRequiredFields: 'Vul alsjeblieft de verplichte velden in.', atLeastOneAttendee: 'Er moet ten minste één deelnemer worden geselecteerd', invalidQuantity: 'Voer een geldige hoeveelheid in', quantityGreaterThanZero: 'Hoeveelheid moet groter zijn dan nul', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index aeccc942c099..ebc918581672 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -243,7 +243,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Proszę podać pełny numer telefonu (np. ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'To pole jest wymagane', - fixErrorsBeforeContinuing: 'Napraw błędy w formularzu przed kontynuowaniem.', + fixErrorsBeforeContinuing: 'Popraw błędy w formularzu przed kontynuowaniem.', requestModified: 'Inny członek właśnie modyfikuje to żądanie', characterLimitExceedCounter: (length: number, limit: number) => `Przekroczono limit znaków (${length}/${limit})`, dateInvalid: 'Wybierz prawidłową datę', @@ -1790,7 +1790,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Wprowadź co najmniej dwa różne adresy', splitExpenseMultipleParticipantsErrorMessage: 'Wydatek nie może być podzielony między przestrzeń roboczą a innych członków. Zaktualizuj swój wybór.', invalidMerchant: 'Wprowadź prawidłowego sprzedawcę', - enterRequiredFields: 'Wypełnij wymagane pola.', + enterRequiredFields: 'Wpisz wymagane pola.', atLeastOneAttendee: 'Co najmniej jeden uczestnik musi zostać wybrany', invalidQuantity: 'Wprowadź prawidłową ilość', quantityGreaterThanZero: 'Ilość musi być większa niż zero', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b700a0c75cff..aaf83a2a8ac5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -237,7 +237,7 @@ const translations: TranslationDeepObject = { phoneNumber: `Insira um número de telefone completo (p.ex., ${CONST.FORMATTED_EXAMPLE_PHONE_NUMBER})`, fieldRequired: 'Este campo é obrigatório', - fixErrorsBeforeContinuing: 'Por favor, corrija os erros no formulário antes de continuar.', + fixErrorsBeforeContinuing: 'Corrija os erros no formulário antes de continuar.', requestModified: 'Esta solicitação está sendo modificada por outra pessoa', characterLimitExceedCounter: (length: number, limit: number) => `Limite de caracteres excedido (${length}/${limit})`, dateInvalid: 'Selecione uma data válida', @@ -1751,7 +1751,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: 'Insira pelo menos dois endereços diferentes', splitExpenseMultipleParticipantsErrorMessage: 'Uma despesa não pode ser dividida entre um espaço de trabalho e outros membros. Atualize sua seleção.', invalidMerchant: 'Insira um comerciante válido', - enterRequiredFields: 'Preencha os campos obrigatórios.', + enterRequiredFields: 'Digite os campos obrigatórios.', atLeastOneAttendee: 'Pelo menos um participante deve ser selecionado', invalidQuantity: 'Insira uma quantidade válida', quantityGreaterThanZero: 'A quantidade deve ser maior que zero', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 9ee9346180a7..134a53c8562b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1687,7 +1687,7 @@ const translations: TranslationDeepObject = { atLeastTwoDifferentWaypoints: '请输入至少两个不同的地址', splitExpenseMultipleParticipantsErrorMessage: '无法将一笔报销同时分摊到一个工作区和其他成员。请更新你的选择。', invalidMerchant: '请输入有效的商家', - enterRequiredFields: '请填写必填字段。', + enterRequiredFields: '请输入必填字段。', atLeastOneAttendee: '必须选择至少一位参加者', invalidQuantity: '请输入有效的数量', quantityGreaterThanZero: '数量必须大于零', From ffccb27346957356bc5494937a30a55fff331a47 Mon Sep 17 00:00:00 2001 From: "TaduJR (via MelvinBot)" Date: Wed, 11 Nov 2026 06:12:27 +0000 Subject: [PATCH 3/3] Validate sibling receipt fields before expense confirmation Co-authored-by: TaduJR --- .../getConfirmationFieldErrors.ts | 35 ++++++++--- .../hooks/useConfirmationFieldErrors.ts | 19 +++--- .../hooks/useConfirmationListData.ts | 14 ++--- .../hooks/useConfirmationValidation.ts | 25 ++++---- .../hooks/useDistanceConfirmationListData.ts | 5 +- .../MoneyRequestConfirmationList/types.ts | 4 +- src/libs/MoneyRequestUtils.ts | 15 ++++- .../step/IOURequestStepConfirmation/index.tsx | 7 +-- .../getConfirmationFieldErrors.test.ts | 58 ++++++++++++++++++- .../confirmationRequiredFieldsFlow.test.ts | 41 +++++++++++-- .../hooks/useConfirmationFieldErrors.test.ts | 41 ++++++++----- .../hooks/useConfirmationValidation.test.ts | 54 ++++++++++++----- 12 files changed, 241 insertions(+), 77 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.ts b/src/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.ts index c875ecd0aa1f..115fb7d942f4 100644 --- a/src/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.ts +++ b/src/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.ts @@ -5,8 +5,8 @@ * button, so those three can't disagree about what is missing. */ import {isP2PRecipient, isParticipantP2P} from '@libs/IOUUtils'; -import {isConfirmationAmountMissing, isConfirmationDateMissing, isConfirmationMerchantMissing, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; -import {isScanRequest} from '@libs/TransactionUtils'; +import {getConfirmationAmount, isConfirmationAmountMissing, isConfirmationDateMissing, isConfirmationMerchantMissing, isValidMoneyRequestAmount} from '@libs/MoneyRequestUtils'; +import {getMerchant, isScanRequest} from '@libs/TransactionUtils'; import {isInvalidMerchantValue, isMerchantBlank, isValidInputLength} from '@libs/ValidationUtils'; import type {IOUType} from '@src/CONST'; @@ -53,6 +53,16 @@ type GetConfirmationFieldErrorsParams = { currentUserAccountID: number; }; +type GetSiblingFieldErrorsParams = Omit & { + /** The recipient on screen. Every receipt goes to it, even when another receipt's own draft wasn't updated. */ + participants: OnyxTypes.Transaction['participants']; +}; + +type SiblingFieldErrors = { + transactionID: string; + fieldErrors: ConfirmationFieldErrors; +}; + /** Returned whenever no field error is shown, so the error state passed down the footer keeps its identity while nothing is flagged. */ const NO_FIELD_ERRORS: Readonly = Object.freeze({}); @@ -134,11 +144,22 @@ function getConfirmationFieldErrors(transaction: OnyxEntry 0 || !!partiallyManuallyFilledScanID; +/** Returns the first other receipt of a multi-scan with a field error, since Create submits every receipt at once. */ +function getSiblingFieldErrors(siblingTransactions: OnyxTypes.Transaction[] | undefined, {participants, ...params}: GetSiblingFieldErrorsParams): SiblingFieldErrors | undefined { + for (const sibling of siblingTransactions ?? []) { + const fieldErrors = getConfirmationFieldErrors({...sibling, participants}, {...params, iouAmount: getConfirmationAmount(sibling), iouMerchant: getMerchant(sibling)}); + if (fieldErrors !== NO_FIELD_ERRORS) { + return {transactionID: sibling.transactionID, fieldErrors}; + } + } + return undefined; +} + +/** Whether a field error blocks confirming, on the receipt on screen or on another receipt of a multi-scan. */ +function hasBlockingFieldErrors(fieldErrors: ConfirmationFieldErrors, siblingFieldErrors: SiblingFieldErrors | undefined): boolean { + return Object.keys(fieldErrors).length > 0 || !!siblingFieldErrors; } export default getConfirmationFieldErrors; -export {NO_FIELD_ERRORS, hasBlockingFieldErrors}; -export type {ConfirmationFieldErrors, GetConfirmationFieldErrorsParams}; +export {NO_FIELD_ERRORS, getSiblingFieldErrors, hasBlockingFieldErrors}; +export type {ConfirmationFieldErrors, GetConfirmationFieldErrorsParams, SiblingFieldErrors}; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors.ts index 870cca7dc9de..efa7f476f248 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors.ts @@ -2,7 +2,7 @@ * Owns when the amount, date and merchant errors of the money request confirmation become visible, and the message * above its confirm button: a route error, then the summary while one of those errors blocks confirming, then the form error. */ -import type {ConfirmationFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import type {ConfirmationFieldErrors, SiblingFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; import {hasBlockingFieldErrors, NO_FIELD_ERRORS} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; import useLocalize from '@hooks/useLocalize'; @@ -13,8 +13,8 @@ type UseConfirmationFieldErrorsParams = { /** The errors the amount, date and merchant fields would show now, from `useConfirmationValidation` */ fieldErrors: ConfirmationFieldErrors; - /** ID of a partially filled Scan among the transactions being confirmed. Can name a receipt other than this one. */ - partiallyManuallyFilledScanID: string | undefined; + /** The first other receipt of a multi-scan with a field error, from `useConfirmationValidation` */ + siblingFieldErrors: SiblingFieldErrors | undefined; /** Brings another receipt of a multi-scan on screen */ onSwitchToTransaction: ((transactionID: string) => void) | undefined; @@ -40,7 +40,7 @@ type UseConfirmationFieldErrorsResult = { /** The errors themselves are derived from the transaction, so only the fact that a confirm was blocked is kept as state. */ function useConfirmationFieldErrors({ fieldErrors, - partiallyManuallyFilledScanID, + siblingFieldErrors, onSwitchToTransaction, formErrorMessage, routeError, @@ -48,22 +48,23 @@ function useConfirmationFieldErrors({ const {translate} = useLocalize(); const [shouldShowFieldErrors, setShouldShowFieldErrors] = useState(false); - const hasFieldErrors = hasBlockingFieldErrors(fieldErrors, partiallyManuallyFilledScanID); + const hasFieldErrors = hasBlockingFieldErrors(fieldErrors, siblingFieldErrors); // Hide the errors again once nothing is left to fix, so a field the user empties afterwards is only flagged on the next confirm. if (shouldShowFieldErrors && !hasFieldErrors) { setShouldShowFieldErrors(false); } - // Validation only checks the values of the receipt on screen, so its errors are fixed first and a partially filled sibling only counts once it has none. + // The receipt on screen is fixed first, so the summary only describes another receipt once this one has nothing left to fix. const hasFieldErrorsOnScreen = Object.keys(fieldErrors).length > 0; - const isRequiredFieldMissing = hasFieldErrorsOnScreen ? Object.values(fieldErrors).includes('common.error.fieldRequired') : !!partiallyManuallyFilledScanID; + const blockingFieldErrors = hasFieldErrorsOnScreen ? fieldErrors : (siblingFieldErrors?.fieldErrors ?? NO_FIELD_ERRORS); + const isRequiredFieldMissing = Object.values(blockingFieldErrors).includes('common.error.fieldRequired'); const revealFieldErrors = () => { setShouldShowFieldErrors(true); - if (!hasFieldErrorsOnScreen && partiallyManuallyFilledScanID) { - onSwitchToTransaction?.(partiallyManuallyFilledScanID); + if (!hasFieldErrorsOnScreen && siblingFieldErrors) { + onSwitchToTransaction?.(siblingFieldErrors.transactionID); } }; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts index e3c99bf226ab..1baebe868d0b 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts @@ -9,10 +9,10 @@ import usePrevious from '@hooks/usePrevious'; import {getCategoryDescriptionHint, isCategoryDescriptionRequired} from '@libs/CategoryUtils'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; -import {isConfirmationMerchantRequired, shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; +import {getConfirmationAmount, isConfirmationMerchantRequired, shouldShowConfirmationDate} from '@libs/MoneyRequestUtils'; import {hasEnabledOptions} from '@libs/OptionsListUtils'; import {arePolicyRulesEnabled, isTaxTrackingEnabled} from '@libs/PolicyUtils'; -import {getCategory, getCurrency, getMerchant, getRateID, hasValidModifiedAmount, isScanRequest as isScanRequestUtil} from '@libs/TransactionUtils'; +import {getCategory, getCurrency, getMerchant, getRateID, isScanRequest as isScanRequestUtil} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; @@ -57,7 +57,7 @@ function useConfirmationListDataWithPolicy({ isConfirming, shouldShowSmartScanFields = true, canEnterScanFieldsManually = false, - partiallyManuallyFilledScanID, + siblingTransactions, hasSmartScanFailed, shouldHideToSection = false, onConfirm, @@ -97,7 +97,7 @@ function useConfirmationListDataWithPolicy({ listRef.current?.scrollInputIntoView(input); }; - const iouAmount = hasValidModifiedAmount(transaction) ? Number(transaction?.modifiedAmount) : (transaction?.amount ?? 0); + const iouAmount = getConfirmationAmount(transaction); const iouCurrencyCode = getCurrency(transaction); const iouMerchant = getMerchant(transaction); const iouCategory = getCategory(transaction); @@ -212,7 +212,7 @@ function useConfirmationListDataWithPolicy({ onOpenParticipantPicker, }); - const {validate, fieldErrors} = useConfirmationValidation({ + const {validate, fieldErrors, siblingFieldErrors} = useConfirmationValidation({ transaction, transactionReport, transactionID, @@ -239,7 +239,7 @@ function useConfirmationListDataWithPolicy({ routeError, canEnterScanFieldsManually, isPolicyExpenseChat, - partiallyManuallyFilledScanID, + siblingTransactions, isReadOnly, shouldShowDate, isTaxAmountEmpty, @@ -247,7 +247,7 @@ function useConfirmationListDataWithPolicy({ const {visibleFieldErrors, footerErrorMessage, revealFieldErrors} = useConfirmationFieldErrors({ fieldErrors, - partiallyManuallyFilledScanID, + siblingFieldErrors, onSwitchToTransaction, formErrorMessage: errorMessage, routeError, diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 5b33298fd3b6..2c0aeca938de 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -1,5 +1,5 @@ -import type {ConfirmationFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; -import getConfirmationFieldErrors, {hasBlockingFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import type {ConfirmationFieldErrors, SiblingFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import getConfirmationFieldErrors, {getSiblingFieldErrors, hasBlockingFieldErrors, NO_FIELD_ERRORS} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -38,6 +38,9 @@ type UseConfirmationValidationResult = { /** The errors the amount, date and merchant fields would show now. `validate` blocks on the same map. */ fieldErrors: ConfirmationFieldErrors; + + /** The first other receipt of a multi-scan with a field error. It is only looked for once the receipt on screen has none, since that one is fixed first. */ + siblingFieldErrors: SiblingFieldErrors | undefined; }; type UseConfirmationValidationParams = { @@ -118,8 +121,8 @@ type UseConfirmationValidationParams = { /** Whether the expense goes to a workspace chat, the only destination where a manually entered Scan needs a merchant */ isPolicyExpenseChat: boolean; - /** ID of a partially filled Scan among the transactions being confirmed. Can name a receipt other than this one. */ - partiallyManuallyFilledScanID?: string; + /** The other receipts of a multi-scan, which Create submits together with this one */ + siblingTransactions?: OnyxTypes.Transaction[]; /** Whether the confirmation fields are read-only (date is not inline-editable) */ isReadOnly: boolean; @@ -175,17 +178,15 @@ function useConfirmationValidation({ routeError, canEnterScanFieldsManually, isPolicyExpenseChat, - partiallyManuallyFilledScanID, + siblingTransactions, isReadOnly, shouldShowDate, isTaxAmountEmpty, }: UseConfirmationValidationParams): UseConfirmationValidationResult { const {getCurrencyDecimals} = useCurrencyListActions(); const selectedParticipantsCount = selectedParticipants.length; - const fieldErrors = getConfirmationFieldErrors(transaction, { - iouAmount, + const fieldErrorParams = { iouType, - iouMerchant, canEnterScanFieldsManually, isPolicyExpenseChat, shouldShowDate, @@ -195,7 +196,9 @@ function useConfirmationValidation({ isDistanceRequest, isTimeRequest, currentUserAccountID: currentUserPersonalDetails.accountID, - }); + }; + const fieldErrors = getConfirmationFieldErrors(transaction, {...fieldErrorParams, iouAmount, iouMerchant}); + const siblingFieldErrors = fieldErrors === NO_FIELD_ERRORS ? getSiblingFieldErrors(siblingTransactions, {...fieldErrorParams, participants: transaction?.participants}) : undefined; const validate = (paymentType?: PaymentMethodType): ValidationResult | null => { if (!!routeError || !transactionID) { return null; @@ -205,7 +208,7 @@ function useConfirmationValidation({ return {errorKey: 'iou.error.noParticipantSelected'}; } - if (hasBlockingFieldErrors(fieldErrors, partiallyManuallyFilledScanID)) { + if (hasBlockingFieldErrors(fieldErrors, siblingFieldErrors)) { return {isFieldError: true}; } @@ -314,7 +317,7 @@ function useConfirmationValidation({ return {errorKey: null}; }; - return {validate, fieldErrors}; + return {validate, fieldErrors, siblingFieldErrors}; } export default useConfirmationValidation; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useDistanceConfirmationListData.ts b/src/components/MoneyRequestConfirmationList/hooks/useDistanceConfirmationListData.ts index 2d6c6361f5e0..5be7e9ea23e5 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useDistanceConfirmationListData.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useDistanceConfirmationListData.ts @@ -3,7 +3,8 @@ import type {MoneyRequestConfirmationListProps} from '@components/MoneyRequestCo import useBlockDistanceRequest from '@hooks/useBlockDistanceRequest'; import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils'; -import {getCurrency, hasValidModifiedAmount, isManualDistanceRequest as isManualDistanceRequestUtil} from '@libs/TransactionUtils'; +import {getConfirmationAmount} from '@libs/MoneyRequestUtils'; +import {getCurrency, isManualDistanceRequest as isManualDistanceRequestUtil} from '@libs/TransactionUtils'; import {useConfirmationListDataWithPolicy} from './useConfirmationListData'; import useConfirmationPolicyData from './useConfirmationPolicyData'; @@ -20,7 +21,7 @@ function useDistanceConfirmationListData(props: MoneyRequestConfirmationListProp const policyData = useConfirmationPolicyData({transaction, policyID, action, iouType, isPerDiemRequest}); const {policy, policyForMovingExpenses} = policyData; - const iouAmount = hasValidModifiedAmount(transaction) ? Number(transaction?.modifiedAmount) : (transaction?.amount ?? 0); + const iouAmount = getConfirmationAmount(transaction); const iouCurrencyCode = getCurrency(transaction); const distanceState = useDistanceRequestState({ diff --git a/src/components/MoneyRequestConfirmationList/types.ts b/src/components/MoneyRequestConfirmationList/types.ts index 3ad91016263c..b8cc9043c0c3 100644 --- a/src/components/MoneyRequestConfirmationList/types.ts +++ b/src/components/MoneyRequestConfirmationList/types.ts @@ -74,8 +74,8 @@ type MoneyRequestConfirmationListProps = { /** Whether manual entry of amount / merchant / date is offered. */ canEnterScanFieldsManually?: boolean; - /** ID of a partially filled Scan among the transactions being confirmed. */ - partiallyManuallyFilledScanID?: string; + /** The other transactions being confirmed with this one, which Create submits together with it. */ + siblingTransactions?: OnyxTypes.Transaction[]; /** Brings another confirmed transaction on screen. */ onSwitchToTransaction?: (transactionID: string) => void; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 9983747bc2cb..d6a7cc1f875b 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -10,7 +10,14 @@ import type {ManuallyEnteredScanFields} from './TransactionUtils'; import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils'; import replaceAllDigits from './replaceAllDigits'; import {isExpenseReport, isExpenseRequest, isPolicyExpenseChat} from './ReportUtils'; -import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isCreatedMissing, isExpenseUnreported, isPartiallyEnteredScanExpense} from './TransactionUtils'; +import { + doesMoneyRequestDraftHaveUserInput, + hasValidModifiedAmount, + haveWaypointAddressesChanged, + isCreatedMissing, + isExpenseUnreported, + isPartiallyEnteredScanExpense, +} from './TransactionUtils'; import {getMerchantError} from './ValidationUtils'; /** @@ -246,6 +253,11 @@ function shouldShowConfirmationDate(shouldShowSmartScanFields: boolean, isDistan return shouldShowSmartScanFields || isDistanceRequest; } +/** The amount the money request confirmation works with: an edited amount when there is one, otherwise the original. */ +function getConfirmationAmount(transaction: OnyxEntry): number { + return hasValidModifiedAmount(transaction) ? Number(transaction?.modifiedAmount) : (transaction?.amount ?? 0); +} + type ConfirmationMerchantRequirementParams = { isPolicyExpenseChat: boolean; isScanRequest: boolean; @@ -306,6 +318,7 @@ function isConfirmationDateMissing( export { addLeadingZero, + getConfirmationAmount, isConfirmationAmountMissing, isConfirmationDateMissing, isConfirmationMerchantMissing, diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx index d7fca9bb98a0..b7d2f02f5a28 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation/index.tsx @@ -52,7 +52,6 @@ import { isDistanceRequest as isDistanceRequestTransactionUtils, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, isOdometerDistanceRequest as isOdometerDistanceRequestTransactionUtils, - isPartiallyEnteredScanExpense, isScanning, isScanRequest, } from '@libs/TransactionUtils'; @@ -369,8 +368,8 @@ function IOURequestStepConfirmationContent({ } } - // The confirmation only validates the transaction it shows, so find the partially filled one across all receipts. - const partiallyManuallyFilledScanID = transactions.find((item) => isPartiallyEnteredScanExpense(item, canEnterScanFieldsManually, isPolicyExpenseChat))?.transactionID; + // The pager keeps showing the previous transaction while the next one loads, so comparing against the current ID would leave the incoming receipt out of validation. + const siblingTransactions = transactions.filter((item) => item.transactionID !== transaction?.transactionID); const isFromGlobalCreate = transaction?.isFromGlobalCreate === true || transaction?.isFromFloatingActionButton === true; @@ -653,7 +652,7 @@ function IOURequestStepConfirmationContent({ isPerDiemRequest, shouldShowSmartScanFields, canEnterScanFieldsManually, - partiallyManuallyFilledScanID, + siblingTransactions, onSwitchToTransaction: setCurrentTransactionID, action, isConfirmed, diff --git a/tests/unit/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.test.ts b/tests/unit/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.test.ts index 2dcda0fbacd3..ec0096762c14 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.test.ts +++ b/tests/unit/components/MoneyRequestConfirmationList/getConfirmationFieldErrors.test.ts @@ -1,5 +1,5 @@ import type {GetConfirmationFieldErrorsParams} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; -import getConfirmationFieldErrors, {NO_FIELD_ERRORS} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import getConfirmationFieldErrors, {getSiblingFieldErrors, NO_FIELD_ERRORS} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; @@ -248,3 +248,59 @@ describe('getConfirmationFieldErrors', () => { expect(fieldErrors).toBe(NO_FIELD_ERRORS); }); }); + +describe('getSiblingFieldErrors', () => { + const siblingParams = {...baseParams, canEnterScanFieldsManually: true, isMerchantRequired: false, participants: [WORKSPACE_PARTICIPANT]}; + const completeFields = {amount: 2500, isAmountSet: true, merchant: 'Hilton', isMerchantSet: true, isCreatedSet: true}; + + it('finds nothing without other receipts, or when they were left for SmartScan', () => { + // Given a single expense, and a multi-scan whose other receipt the user never touched + // When the other receipts are checked + // Then nothing blocks, since SmartScan fills in a receipt nobody typed into + expect(getSiblingFieldErrors(undefined, siblingParams)).toBeUndefined(); + expect(getSiblingFieldErrors([createScanDraft()], siblingParams)).toBeUndefined(); + }); + + it('reports a partially filled receipt with the fields it is missing', () => { + // Given a multi-scan to a workspace whose other receipt only has its amount entered + const sibling = createScanDraft({transactionID: 'txn2', amount: 2500, isAmountSet: true}); + + // When the other receipts are checked + // Then that receipt is reported with its blank fields, so it can be brought on screen + expect(getSiblingFieldErrors([sibling], siblingParams)).toEqual({transactionID: 'txn2', fieldErrors: {date: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}}); + }); + + it('reports a complete receipt that holds an invalid merchant, using its own values', () => { + // Given a multi-scan whose other receipt is fully filled in with a merchant that isn't allowed + const sibling = createScanDraft({...completeFields, transactionID: 'txn2', merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT}); + + // When the other receipts are checked with the valid merchant of the receipt on screen in the params + // Then the other receipt's own merchant is what gets checked + expect(getSiblingFieldErrors([sibling], siblingParams)).toEqual({transactionID: 'txn2', fieldErrors: {merchant: 'iou.error.invalidMerchant'}}); + }); + + it('checks an amount against the recipient on screen, even when the draft of the other receipt has none', () => { + // Given a multi-scan whose other receipt has a negative amount and no recipient of its own in its draft + const sibling = createScanDraft({...completeFields, transactionID: 'txn2', amount: -1234, participants: undefined}); + const params = {...siblingParams, iouType: CONST.IOU.TYPE.CREATE, isPolicyExpenseChat: false}; + + // When the recipient on screen is a person, and then a workspace + const toPerson = getSiblingFieldErrors([sibling], {...params, participants: [P2P_PARTICIPANT]}); + const toWorkspace = getSiblingFieldErrors([sibling], {...params, participants: [WORKSPACE_PARTICIPANT]}); + + // Then the negative amount only blocks for a person, because every receipt goes to the same recipient + expect(toPerson).toEqual({transactionID: 'txn2', fieldErrors: {amount: 'common.error.invalidAmount'}}); + expect(toWorkspace).toBeUndefined(); + }); + + it('returns the first receipt that has a field error', () => { + // Given a multi-scan with a valid receipt followed by two receipts holding invalid merchants + const valid = createScanDraft({...completeFields, transactionID: 'txn2'}); + const firstInvalid = createScanDraft({...completeFields, transactionID: 'txn3', merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT}); + const secondInvalid = createScanDraft({...completeFields, transactionID: 'txn4', merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT}); + + // When the other receipts are checked + // Then the first one with an error is the one to bring on screen + expect(getSiblingFieldErrors([valid, firstInvalid, secondInvalid], siblingParams)?.transactionID).toBe('txn3'); + }); +}); diff --git a/tests/unit/hooks/confirmationRequiredFieldsFlow.test.ts b/tests/unit/hooks/confirmationRequiredFieldsFlow.test.ts index 8c1b82aa8a73..82647f77e18c 100644 --- a/tests/unit/hooks/confirmationRequiredFieldsFlow.test.ts +++ b/tests/unit/hooks/confirmationRequiredFieldsFlow.test.ts @@ -45,7 +45,10 @@ const blankDraft: OnyxTypes.Transaction = { }; /** Wires the form error, validation, field error and confirm pieces together the way `useConfirmationListData` does, on a workspace that tracks tax. */ -function useManualWorkspaceConfirmation(transaction: OnyxTypes.Transaction, isTaxAmountEmpty = false) { +function useManualWorkspaceConfirmation( + transaction: OnyxTypes.Transaction, + {isTaxAmountEmpty = false, siblingTransactions}: {isTaxAmountEmpty?: boolean; siblingTransactions?: OnyxTypes.Transaction[]} = {}, +) { const iouMerchant = getMerchant(transaction); const isMerchantRequired = isConfirmationMerchantRequired({isPolicyExpenseChat: true, isScanRequest: false, isEditingSplitBill: false, shouldShowMerchant: true}); const {formError, setConfirmError, clearConfirmError, errorMessage} = useFormErrorManagement({ @@ -64,7 +67,7 @@ function useManualWorkspaceConfirmation(transaction: OnyxTypes.Transaction, isTa shouldShowReadOnlySplits: true, isDistanceRequest: false, }); - const {validate, fieldErrors} = useConfirmationValidation({ + const {validate, fieldErrors, siblingFieldErrors} = useConfirmationValidation({ transaction, transactionReport: undefined, transactionID: transaction.transactionID, @@ -94,10 +97,11 @@ function useManualWorkspaceConfirmation(transaction: OnyxTypes.Transaction, isTa isReadOnly: false, shouldShowDate: true, isTaxAmountEmpty, + siblingTransactions, }); const {visibleFieldErrors, footerErrorMessage, revealFieldErrors} = useConfirmationFieldErrors({ fieldErrors, - partiallyManuallyFilledScanID: undefined, + siblingFieldErrors, onSwitchToTransaction: undefined, formErrorMessage: errorMessage, routeError: undefined, @@ -194,7 +198,7 @@ describe('required fields on a manual workspace expense', () => { // Given a confirmation of several expenses where Create was blocked by the empty tax amount of the one on screen const expenseWithEmptyTax = {...blankDraft, amount: 2500, isAmountSet: true, merchant: 'Hilton', isMerchantSet: true}; const {result, rerender} = renderHook( - ({transaction, isTaxAmountEmpty}: {transaction: OnyxTypes.Transaction; isTaxAmountEmpty: boolean}) => useManualWorkspaceConfirmation(transaction, isTaxAmountEmpty), + ({transaction, isTaxAmountEmpty}: {transaction: OnyxTypes.Transaction; isTaxAmountEmpty: boolean}) => useManualWorkspaceConfirmation(transaction, {isTaxAmountEmpty}), { initialProps: {transaction: expenseWithEmptyTax, isTaxAmountEmpty: true}, }, @@ -218,4 +222,33 @@ describe('required fields on a manual workspace expense', () => { // Then the expense is confirmed, since the tax error belonged to the expense that was removed expect(mockOnConfirm).toHaveBeenCalledTimes(1); }); + + it('does not submit from a valid expense while another expense still holds an invalid value', () => { + // Given a confirmation of two expenses where Create was blocked by a typed merchant that isn't allowed + const invalidExpense: OnyxTypes.Transaction = {...blankDraft, amount: 2500, isAmountSet: true, merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, isMerchantSet: true}; + const validExpense: OnyxTypes.Transaction = {...invalidExpense, transactionID: 'txn2', merchant: 'Hilton'}; + const {result, rerender} = renderHook( + ({transaction, siblingTransactions}: {transaction: OnyxTypes.Transaction; siblingTransactions: OnyxTypes.Transaction[]}) => + useManualWorkspaceConfirmation(transaction, {siblingTransactions}), + {initialProps: {transaction: invalidExpense, siblingTransactions: [validExpense]}}, + ); + act(() => result.current.confirm()); + expect(result.current.footerErrorMessage).toBe('common.error.fixErrorsBeforeContinuing'); + + // When the user pages to the valid expense and presses Create there + rerender({transaction: validExpense, siblingTransactions: [invalidExpense]}); + act(() => result.current.confirm()); + + // Then nothing is submitted and the summary still explains why, since Create submits both expenses + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(result.current.footerErrorMessage).toBe('common.error.fixErrorsBeforeContinuing'); + + // When the other expense's merchant is fixed and the user presses Create + rerender({transaction: validExpense, siblingTransactions: [{...invalidExpense, merchant: 'Office Depot'}]}); + expect(result.current.footerErrorMessage).toBeUndefined(); + act(() => result.current.confirm()); + + // Then both expenses are confirmed + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/hooks/useConfirmationFieldErrors.test.ts b/tests/unit/hooks/useConfirmationFieldErrors.test.ts index a96231b8f44e..1ce98233feed 100644 --- a/tests/unit/hooks/useConfirmationFieldErrors.test.ts +++ b/tests/unit/hooks/useConfirmationFieldErrors.test.ts @@ -1,6 +1,6 @@ import {act, renderHook} from '@testing-library/react-native'; -import type {ConfirmationFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; +import type {ConfirmationFieldErrors, SiblingFieldErrors} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; import {NO_FIELD_ERRORS} from '@components/MoneyRequestConfirmationList/getConfirmationFieldErrors'; import useConfirmationFieldErrors from '@components/MoneyRequestConfirmationList/hooks/useConfirmationFieldErrors'; @@ -8,18 +8,20 @@ jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})) type Props = { fieldErrors: ConfirmationFieldErrors; - partiallyManuallyFilledScanID?: string; + siblingFieldErrors?: SiblingFieldErrors; onSwitchToTransaction?: (transactionID: string) => void; formErrorMessage?: string; routeError?: string; }; const MISSING_AMOUNT: ConfirmationFieldErrors = {amount: 'common.error.fieldRequired'}; +const PARTIALLY_FILLED_SIBLING: SiblingFieldErrors = {transactionID: 'txn2', fieldErrors: MISSING_AMOUNT}; +const INVALID_SIBLING: SiblingFieldErrors = {transactionID: 'txn2', fieldErrors: {amount: 'common.error.invalidAmount'}}; function renderFieldErrors(initialProps: Props) { return renderHook( - ({fieldErrors, partiallyManuallyFilledScanID, onSwitchToTransaction, formErrorMessage, routeError}: Props) => - useConfirmationFieldErrors({fieldErrors, partiallyManuallyFilledScanID, onSwitchToTransaction, formErrorMessage, routeError}), + ({fieldErrors, siblingFieldErrors, onSwitchToTransaction, formErrorMessage, routeError}: Props) => + useConfirmationFieldErrors({fieldErrors, siblingFieldErrors, onSwitchToTransaction, formErrorMessage, routeError}), {initialProps}, ); } @@ -134,7 +136,7 @@ describe('useConfirmationFieldErrors', () => { it('brings the partially filled sibling receipt on screen when the receipt on screen is complete', () => { // Given a multi-scan whose receipt on screen is complete while a sibling receipt is partially filled const onSwitchToTransaction = jest.fn(); - const {result} = renderFieldErrors({fieldErrors: NO_FIELD_ERRORS, partiallyManuallyFilledScanID: 'txn2', onSwitchToTransaction}); + const {result} = renderFieldErrors({fieldErrors: NO_FIELD_ERRORS, siblingFieldErrors: PARTIALLY_FILLED_SIBLING, onSwitchToTransaction}); // When the confirm is blocked by the sibling act(() => result.current.revealFieldErrors()); @@ -143,20 +145,33 @@ describe('useConfirmationFieldErrors', () => { expect(onSwitchToTransaction).toHaveBeenCalledWith('txn2'); }); + it('asks to fix the errors when the sibling receipt only holds an invalid value', () => { + // Given a multi-scan whose receipt on screen is complete while a sibling receipt holds an invalid amount + const onSwitchToTransaction = jest.fn(); + const {result} = renderFieldErrors({fieldErrors: NO_FIELD_ERRORS, siblingFieldErrors: INVALID_SIBLING, onSwitchToTransaction}); + + // When the confirm is blocked by the sibling + act(() => result.current.revealFieldErrors()); + + // Then the sibling is brought on screen and the summary doesn't ask for fields that aren't missing + expect(onSwitchToTransaction).toHaveBeenCalledWith('txn2'); + expect(result.current.footerErrorMessage).toBe('common.error.fixErrorsBeforeContinuing'); + }); + it('keeps an invalid value on the receipt on screen in view before moving to a partially filled sibling', () => { // Given a multi-scan whose receipt on screen has a typed merchant that isn't allowed, while a sibling receipt is partially filled const onSwitchToTransaction = jest.fn(); - const {result, rerender} = renderFieldErrors({fieldErrors: {merchant: 'iou.error.invalidMerchant'}, partiallyManuallyFilledScanID: 'txn2', onSwitchToTransaction}); + const {result, rerender} = renderFieldErrors({fieldErrors: {merchant: 'iou.error.invalidMerchant'}, siblingFieldErrors: PARTIALLY_FILLED_SIBLING, onSwitchToTransaction}); // When the confirm is blocked act(() => result.current.revealFieldErrors()); - // Then the receipt on screen stays, since validation only checks its values, and the summary asks to fix the error it shows + // Then the receipt on screen stays, so its error is seen, and the summary asks to fix the error it shows expect(onSwitchToTransaction).not.toHaveBeenCalled(); expect(result.current.footerErrorMessage).toBe('common.error.fixErrorsBeforeContinuing'); // When the user fixes the merchant and confirms again - rerender({fieldErrors: NO_FIELD_ERRORS, partiallyManuallyFilledScanID: 'txn2', onSwitchToTransaction}); + rerender({fieldErrors: NO_FIELD_ERRORS, siblingFieldErrors: PARTIALLY_FILLED_SIBLING, onSwitchToTransaction}); act(() => result.current.revealFieldErrors()); // Then the partially filled sibling is brought on screen and the summary asks for its required fields @@ -167,7 +182,7 @@ describe('useConfirmationFieldErrors', () => { it('stays on the receipt on screen when one of its own fields is missing', () => { // Given a multi-scan whose receipt on screen is missing its amount, while a sibling receipt is also partially filled const onSwitchToTransaction = jest.fn(); - const {result} = renderFieldErrors({fieldErrors: MISSING_AMOUNT, partiallyManuallyFilledScanID: 'txn2', onSwitchToTransaction}); + const {result} = renderFieldErrors({fieldErrors: MISSING_AMOUNT, siblingFieldErrors: PARTIALLY_FILLED_SIBLING, onSwitchToTransaction}); // When the confirm is blocked act(() => result.current.revealFieldErrors()); @@ -177,17 +192,17 @@ describe('useConfirmationFieldErrors', () => { expect(result.current.visibleFieldErrors).toEqual(MISSING_AMOUNT); }); - it('keeps the summary while a sibling receipt of a multi-scan is still partially filled', () => { + it('keeps the summary while a sibling receipt of a multi-scan still has a field error', () => { // Given a blocked confirm on a multi-scan whose partially filled receipt is not the one on screen - const {result, rerender} = renderFieldErrors({fieldErrors: NO_FIELD_ERRORS, partiallyManuallyFilledScanID: 'txn2'}); + const {result, rerender} = renderFieldErrors({fieldErrors: NO_FIELD_ERRORS, siblingFieldErrors: PARTIALLY_FILLED_SIBLING}); act(() => result.current.revealFieldErrors()); // When the complete receipt is still the one on screen // Then the summary still explains why the confirm is blocked expect(result.current.footerErrorMessage).toBe('iou.error.enterRequiredFields'); - // When no receipt is partially filled any more - rerender({fieldErrors: NO_FIELD_ERRORS, partiallyManuallyFilledScanID: undefined}); + // When no receipt has a field error any more + rerender({fieldErrors: NO_FIELD_ERRORS, siblingFieldErrors: undefined}); // Then the summary goes away expect(result.current.footerErrorMessage).toBeUndefined(); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 8253666f4fb5..f67439ada744 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -1205,33 +1205,55 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); - it('blocks confirmation when another receipt in a multi-scan is the partially filled one', () => { + const COMPLETE_SCAN_FIELDS = {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}; + + function createSiblingReceipt(overrides: Partial): OnyxTypes.Transaction { + return createTransactionBase({ + transactionID: 'other-txn', + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {source: 'https://example.com/receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + ...overrides, + }); + } + + it('blocks confirmation when another receipt in a multi-scan is partially filled', () => { // Given a multi-scan whose receipt on screen is complete while a sibling receipt is partially filled - const {result} = renderHook(() => - useConfirmationValidation( - createScanValidationParams( - {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, - {iouAmount: 1000, iouMerchant: 'Starbucks', partiallyManuallyFilledScanID: 'other-txn'}, - ), - ), - ); + const siblingTransactions = [createSiblingReceipt({isAmountSet: true, amount: 1000})]; + const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(COMPLETE_SCAN_FIELDS, {iouAmount: 1000, iouMerchant: 'Starbucks', siblingTransactions}))); // When the user confirms // Then it is blocked as a field error with nothing flagged on screen, since Create submits every receipt at once and the sibling has to be brought on screen expect(result.current.validate()).toEqual({isFieldError: true}); expect(result.current.fieldErrors).toEqual({}); + expect(result.current.siblingFieldErrors).toEqual({ + transactionID: 'other-txn', + fieldErrors: {date: 'common.error.fieldRequired', merchant: 'common.error.fieldRequired'}, + }); }); - it('lets a multi-scan through once no receipt is left partially filled', () => { + it('blocks confirmation when another receipt in a multi-scan is complete but holds an invalid amount', () => { + // Given a multi-scan to a person whose receipt on screen is valid while a sibling receipt holds a negative amount + const siblingTransactions = [createSiblingReceipt({...COMPLETE_SCAN_FIELDS, amount: -1234})]; const {result} = renderHook(() => - useConfirmationValidation( - createScanValidationParams( - {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, - {iouAmount: 1000, iouMerchant: 'Starbucks', partiallyManuallyFilledScanID: undefined}, - ), - ), + useConfirmationValidation(createScanValidationParams(COMPLETE_SCAN_FIELDS, {iouType: CONST.IOU.TYPE.CREATE, iouAmount: 1000, iouMerchant: 'Starbucks', siblingTransactions})), ); + + // When the user confirms from the valid receipt + // Then it is blocked, since Create would otherwise submit the invalid receipt along with this one + expect(result.current.validate()).toEqual({isFieldError: true}); + expect(result.current.siblingFieldErrors).toEqual({transactionID: 'other-txn', fieldErrors: {amount: 'common.error.invalidAmount'}}); + }); + + it('lets a multi-scan through once no other receipt has a field error', () => { + // Given a multi-scan whose other receipt is complete and valid + const siblingTransactions = [createSiblingReceipt(COMPLETE_SCAN_FIELDS)]; + const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(COMPLETE_SCAN_FIELDS, {iouAmount: 1000, iouMerchant: 'Starbucks', siblingTransactions}))); + + // When the user confirms + // Then nothing blocks it expect(result.current.validate()).toEqual({errorKey: null}); + expect(result.current.siblingFieldErrors).toBeUndefined(); }); it('requires nothing on surfaces that do not expose the scan fields (splits, test receipts)', () => {