Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/MoneyRequestConfirmationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {isCategoryDescriptionRequired} from '@libs/CategoryUtils';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import {isMovingTransactionFromTrackExpense as isMovingTransactionFromTrackExpenseUtil} from '@libs/IOUUtils';
import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils';
import Navigation from '@libs/Navigation/Navigation';
import {hasEnabledOptions} from '@libs/OptionsListUtils';
import {isTaxTrackingEnabled} from '@libs/PolicyUtils';
Expand Down Expand Up @@ -476,6 +477,8 @@ function MoneyRequestConfirmationList({
isTimeRequest,
routeError,
isNewManualExpenseFlowEnabled,
isReadOnly,
shouldShowDate: shouldShowConfirmationDate(shouldShowSmartScanFields, isDistanceRequest),
});

const confirm = buildConfirmAction({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ type UseConfirmationValidationParams = {

/** Whether the new manual expense flow is enabled */
isNewManualExpenseFlowEnabled: boolean;

/** Whether the confirmation fields are read-only (date is not inline-editable) */
isReadOnly: boolean;

/** Whether the date field is shown for this flow (mirrors the footer's date visibility) */
shouldShowDate: boolean;
};

/**
Expand Down Expand Up @@ -154,6 +160,8 @@ function useConfirmationValidation({
isTimeRequest,
routeError,
isNewManualExpenseFlowEnabled,
isReadOnly,
shouldShowDate,
}: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} {
const {getCurrencyDecimals} = useCurrencyListActions();
const selectedParticipantsCount = selectedParticipants.length;
Expand Down Expand Up @@ -189,8 +197,11 @@ function useConfirmationValidation({
) {
return {errorKey: 'common.error.invalidAmount'};
}
// The date is an inline required field in the new manual flow; block confirmation when the user cleared it.
if (isNewManualExpenseFlowEnabled && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && isCreatedMissing(transaction)) {
// The date is an inline, clearable required field in the new manual flow for every type that shows it
// (manual, distance, time, invoice, ...). Block confirmation when the user cleared it. Gating on the same
// `shouldShowDate && !isReadOnly` condition that renders the inline picker keeps validation and UI in sync,
// and skips read-only/scan flows where the date is populated server-side.
if (isNewManualExpenseFlowEnabled && shouldShowDate && !isReadOnly && isCreatedMissing(transaction)) {
return {errorKey: 'common.error.fieldRequired'};
}
const merchantValue = iouMerchant ?? '';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {derivedFlagsSliceSelector} from '@components/MoneyRequestConfirmationLis
import useTransactionSelector from '@components/MoneyRequestConfirmationList/sections/useTransactionSelector';
import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import {isBillableEnabledOnPolicy} from '@libs/MoneyRequestReportUtils';
import {shouldShowConfirmationDate} from '@libs/MoneyRequestUtils';
import {hasEnabledTags} from '@libs/TagsOptionsListUtils';
import {getCurrency, isManagedCardTransaction, isScanRequest, shouldShowAttendees as shouldShowAttendeesTransactionUtils} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -79,7 +80,7 @@ function useFooterDerivedFlags({

// In Send Money and Split Bill with Scan flow, we don't allow the Merchant or Date to be edited.
// For distance requests, don't show the merchant as there's already another "Distance" menu item.
const shouldShowDate = shouldShowSmartScanFields || isDistanceRequest;
const shouldShowDate = shouldShowConfirmationDate(shouldShowSmartScanFields, isDistanceRequest);

// Determines whether the tax fields can be modified.
// The tax fields can only be modified if the component is not in read-only mode
Expand Down
10 changes: 10 additions & 0 deletions src/libs/MoneyRequestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,18 @@ function isValidMerchant(merchant: string | undefined, transaction?: OnyxEntry<T
return valueByteLength <= CONST.MERCHANT_NAME_MAX_BYTES;
}

/**
* Determines whether the date field should be shown on the money request confirmation surface.
* This is the single source of truth shared by the confirmation footer (where the date field is rendered)
* and the confirmation-step validation (where a missing date is blocked), so the two never drift out of sync.
*/
function shouldShowConfirmationDate(shouldShowSmartScanFields: boolean, isDistanceRequest: boolean): boolean {
return shouldShowSmartScanFields || isDistanceRequest;
}

export {
addLeadingZero,
shouldShowConfirmationDate,
replaceAllDigits,
stripCommaFromAmount,
stripDecimalsFromAmount,
Expand Down
93 changes: 93 additions & 0 deletions tests/unit/hooks/useConfirmationValidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const baseParams = {
isTimeRequest: false,
routeError: undefined,
isNewManualExpenseFlowEnabled: false,
isReadOnly: false,
shouldShowDate: true,
} satisfies UseConfirmationValidationParams;

function createValidationParamsForParticipant(
Expand Down Expand Up @@ -704,4 +706,95 @@ describe('useConfirmationValidation', () => {
expect(result.current.validate()).toEqual({errorKey: 'iou.error.invalidAmount'});
});
});

describe('date validation — inline required date in new manual expense flow', () => {
const newManualFlowParams = {
...baseParams,
isNewManualExpenseFlowEnabled: true,
};

it('returns fieldRequired for manual expense when the date is removed', () => {
const {result} = renderHook(() =>
useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, newManualFlowParams, {created: '', isAmountSet: true})),
);
expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'});
});

it('returns fieldRequired for distance expense when the date is removed', () => {
const {result} = renderHook(() =>
useConfirmationValidation({
...newManualFlowParams,
isDistanceRequest: true,
transaction: createTransactionBase({
amount: 1000,
created: '',
iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE,
participants: [POLICY_EXPENSE_CHAT_PARTICIPANT],
comment: {type: CONST.TRANSACTION.TYPE.CUSTOM_UNIT},
}),
selectedParticipants: [POLICY_EXPENSE_CHAT_PARTICIPANT],
}),
);
expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'});
});

it('returns fieldRequired for time expense when the date is removed', () => {
const {result} = renderHook(() =>
useConfirmationValidation({
...newManualFlowParams,
isTimeRequest: true,
transaction: createTransactionBase({
amount: 1000,
created: '',
iouRequestType: CONST.IOU.REQUEST_TYPE.TIME,
participants: [POLICY_EXPENSE_CHAT_PARTICIPANT],
comment: {type: CONST.TRANSACTION.TYPE.TIME, units: {count: 1, rate: 100}},
}),
selectedParticipants: [POLICY_EXPENSE_CHAT_PARTICIPANT],
}),
);
expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'});
});

it('returns fieldRequired for invoice when the date is removed', () => {
const {result} = renderHook(() =>
useConfirmationValidation(
createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, iouType: CONST.IOU.TYPE.INVOICE}, {created: '', isAmountSet: true}),
),
);
expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'});
});

it('does not return fieldRequired when the date is present', () => {
const {result} = renderHook(() =>
useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, newManualFlowParams, {created: '2025-01-15', isAmountSet: true})),
);
expect(result.current.validate()).toEqual({errorKey: null});
});

it('does not return fieldRequired when the fields are read-only (date populated server-side)', () => {
const {result} = renderHook(() =>
useConfirmationValidation(
createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, isReadOnly: true}, {created: '', isAmountSet: true}),
),
);
expect(result.current.validate()).toEqual({errorKey: null});
});

it('does not return fieldRequired when the date field is not shown (pure scan flow)', () => {
const {result} = renderHook(() =>
useConfirmationValidation(
createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {...newManualFlowParams, shouldShowDate: false}, {created: '', isAmountSet: true}),
),
);
expect(result.current.validate()).toEqual({errorKey: null});
});

it('does not return fieldRequired when the new manual expense flow beta is disabled', () => {
const {result} = renderHook(() =>
useConfirmationValidation(createValidationParamsForParticipant(POLICY_EXPENSE_CHAT_PARTICIPANT, {isNewManualExpenseFlowEnabled: false}, {created: '', isAmountSet: true})),
);
expect(result.current.validate()).toEqual({errorKey: null});
});
});
});
Loading