Skip to content
Open
36 changes: 26 additions & 10 deletions src/components/MoneyRequestConfirmationList/confirmAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -69,7 +77,9 @@ function buildConfirmAction({
formError,
isDelegateAccessRestricted,
validate,
setFormError,
setConfirmError,
clearConfirmError,
revealFieldErrors,
setDidConfirmSplit,
showDelegateNoAccessModal,
onConfirm,
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConfirmationFieldErrors> = Object.freeze({});

function getAmountFieldError(transaction: OnyxEntry<OnyxTypes.Transaction>, 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<OnyxTypes.Transaction>, 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<OnyxTypes.Transaction>, 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};
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading