Skip to content
24 changes: 18 additions & 6 deletions src/components/DatePicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ function DatePicker({
forwardedFSClass,
shouldDeferShowUntilPositioned = false,
shouldDismissKeyboardBeforeShow = false,
rightHandSideComponent,
onPickerVisibilityChange,
shouldHideCalendarIcon = false,
}: DateInputWithPickerProps) {
const icons = useMemoizedLazyExpensifyIcons(['Calendar']);
const styles = useThemeStyles();
Expand Down Expand Up @@ -101,6 +104,14 @@ function DatePicker({
[windowHeight],
);

const setPickerVisibility = useCallback(
(isVisible: boolean) => {
setIsModalVisible(isVisible);
onPickerVisibilityChange?.(isVisible);
},
[onPickerVisibilityChange],
);

const showDatePickerModal = useCallback(() => {
cancelAutoFocus();
// Blur the date input before showing the modal, so the focus won't be returned after the modal is closed
Expand All @@ -116,7 +127,7 @@ function DatePicker({
const openPicker = () => {
if (!shouldDeferShowUntilPositioned) {
calculatePopoverPosition();
setIsModalVisible(true);
setPickerVisibility(true);
return;
}

Expand All @@ -125,16 +136,16 @@ function DatePicker({
if (!openIntentRef.current) {
return;
}
setIsModalVisible(true);
setPickerVisibility(true);
});
};

openPicker();
}, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus]);
}, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]);

const closeDatePicker = useCallback(() => {
openIntentRef.current = false;
setIsModalVisible(false);
setPickerVisibility(false);

if (!shouldDismissKeyboardBeforeShow) {
return;
Expand All @@ -143,7 +154,7 @@ function DatePicker({
textInputRef.current?.blur();
ComposerFocusManager.blurActiveInput();
Keyboard.dismiss();
}, [shouldDismissKeyboardBeforeShow]);
}, [shouldDismissKeyboardBeforeShow, setPickerVisibility]);

const handlePress = useCallback<NonNullable<BaseTextInputProps['onPress']>>(
(event) => {
Expand Down Expand Up @@ -222,7 +233,7 @@ function DatePicker({
ref={combinedTextInputRef}
inputID={inputID}
forceActiveLabel
icon={selectedDate ? null : icons.Calendar}
icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar}
iconContainerStyle={styles.pr0}
label={label}
accessibilityLabel={label}
Expand All @@ -243,6 +254,7 @@ function DatePicker({
forwardedFSClass={forwardedFSClass}
autoComplete={autoComplete}
disableKeyboard
rightHandSideComponent={rightHandSideComponent}
/>
</View>

Expand Down
13 changes: 13 additions & 0 deletions src/components/DatePicker/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ type DateInputWithPickerProps = DatePickerBaseProps &
* @default false
*/
shouldDismissKeyboardBeforeShow?: boolean;

/**
* Reports whether the calendar is open. Opening the picker blurs the input, so this is the signal for "the
* user is on this field" rather than `onFocus`, and it is what drives the input's focused border.
*/
onPickerVisibilityChange?: (isVisible: boolean) => void;

/**
* Hides the trailing calendar icon the empty input shows by default. Use it when the caller renders its own
* `rightHandSideComponent` in that space and the two would otherwise sit side by side.
* @default false
*/
shouldHideCalendarIcon?: boolean;
};

type DatePickerProps = {
Expand Down
5 changes: 5 additions & 0 deletions src/components/MoneyRequestConfirmationFields/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ type ProviderProps = {
/** Whether we're editing an existing split expense */
isEditingSplitBill?: boolean;

/** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */
canEnterScanFieldsManually?: boolean;

/** Whether the surface is in a policy-expense chat */
isPolicyExpenseChat?: boolean;

Expand Down Expand Up @@ -87,6 +90,7 @@ function Provider({
isReadOnly = false,
didConfirm = false,
isEditingSplitBill = false,
canEnterScanFieldsManually = false,
isPolicyExpenseChat = false,
isScanRequest = false,
isDistanceRequest = false,
Expand All @@ -111,6 +115,7 @@ function Provider({
isReadOnly,
didConfirm,
isEditingSplitBill,
canEnterScanFieldsManually,
isPolicyExpenseChat,
isScanRequest,
isDistanceRequest,
Expand Down
2 changes: 2 additions & 0 deletions src/components/MoneyRequestConfirmationFields/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ type ConfirmationFieldsContextValue = {
isReadOnly: boolean;
didConfirm: boolean;
isEditingSplitBill: boolean;
/** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */
canEnterScanFieldsManually: boolean;
isPolicyExpenseChat: boolean;

// Mode — *what kind* of expense is being confirmed
Expand Down
10 changes: 10 additions & 0 deletions src/components/MoneyRequestConfirmationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ type MoneyRequestConfirmationListProps = {
/** Whether we should show the amount, date, and merchant fields. */
shouldShowSmartScanFields?: boolean;

/**
* Whether the Scan flow lets the user fill in the amount / merchant / date themselves instead of waiting for
* SmartScan (new manual expense flow). Filling in any one of them makes all three required.
*/
canEnterScanFieldsManually?: boolean;

/** A flag for verifying that the current report is a sub-report of a expense chat */
isPolicyExpenseChat?: boolean;

Expand Down Expand Up @@ -169,6 +175,7 @@ function MoneyRequestConfirmationList({
isPerDiemRequest = false,
isPolicyExpenseChat = false,
shouldShowSmartScanFields = true,
canEnterScanFieldsManually = false,
isEditingSplitBill,
isReceiptEditable,
selectedParticipants: selectedParticipantsProp,
Expand Down Expand Up @@ -340,6 +347,7 @@ function MoneyRequestConfirmationList({
isEditingSplitBill,
isPolicyExpenseChat,
isScanRequest,
canEnterScanFieldsManually,
shouldShowMerchant,
hasSmartScanFailed,
didConfirmSplit,
Expand Down Expand Up @@ -464,6 +472,7 @@ function MoneyRequestConfirmationList({
isMovingTransactionFromTrackExpense,
isTimeRequest,
routeError,
canEnterScanFieldsManually,
isReadOnly,
shouldShowDate,
isTaxAmountEmpty,
Expand Down Expand Up @@ -528,6 +537,7 @@ function MoneyRequestConfirmationList({
isReadOnly={isReadOnly}
didConfirm={!!didConfirm}
isEditingSplitBill={isEditingSplitBill}
canEnterScanFieldsManually={canEnterScanFieldsManually}
isPolicyExpenseChat={isPolicyExpenseChat}
isScanRequest={isScanRequest}
isDistanceRequest={isDistanceRequest}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getTaxAmount,
hasTaxRateWithMatchingValue,
isMerchantMissing,
isPartiallyEnteredScanExpense,
isScanRequest as isScanRequestUtil,
} from '@libs/TransactionUtils';
import {isValidInputLength} from '@libs/ValidationUtils';
Expand Down Expand Up @@ -113,6 +114,9 @@ type UseConfirmationValidationParams = {
/** Truthy when the route to the confirmation page has a known error */
routeError: string | null | undefined;

/** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */
canEnterScanFieldsManually: boolean;

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

Expand Down Expand Up @@ -165,12 +169,17 @@ function useConfirmationValidation({
isMovingTransactionFromTrackExpense,
isTimeRequest,
routeError,
canEnterScanFieldsManually,
isReadOnly,
shouldShowDate,
isTaxAmountEmpty,
}: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} {
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 validate = (paymentType?: PaymentMethodType): ValidationResult | null => {
if (!!routeError || !transactionID) {
return null;
Expand All @@ -187,13 +196,20 @@ function useConfirmationValidation({
if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) {
return {errorKey: 'common.error.invalidAmount'};
}
if (isConfirmationAmountMissing(transaction)) {
// `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)) {
return {errorKey: 'common.error.fieldRequired'};
}
// The amount / merchant / date the Scan confirmation reveals are all-or-nothing. Leaving all three blank hands
// the expense to SmartScan and filling all three in submits it as a manual expense, but a half-filled set is
// neither, so it is blocked here and each blank field raises the same error inline.
if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) {
return {errorKey: 'common.error.fieldRequired'};
}
if (
transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL &&
shouldValidateEnteredAmount &&
transaction?.isAmountSet &&
!isScanRequestUtil(transaction) &&
!isTimeRequest &&
!isDistanceRequest &&
!isEditingSplitBill &&
Expand All @@ -203,7 +219,7 @@ function useConfirmationValidation({
}
// 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)) {
if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually)) {
return {errorKey: 'common.error.fieldRequired'};
}
const merchantValue = iouMerchant ?? '';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';

import {isConfirmationAmountMissing, isConfirmationDateMissing} from '@libs/MoneyRequestUtils';
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';
Expand Down Expand Up @@ -54,6 +54,9 @@ type UseFormErrorManagementParams = {
/** Whether the IOU was started from a SmartScan flow */
isScanRequest: boolean;

/** Whether the Scan confirmation lets the user fill in the amount / merchant / date themselves */
canEnterScanFieldsManually: boolean;

/** Whether the merchant field should be visible in the UI */
shouldShowMerchant: boolean;

Expand Down Expand Up @@ -138,6 +141,7 @@ function useFormErrorManagement({
isEditingSplitBill,
isPolicyExpenseChat,
isScanRequest,
canEnterScanFieldsManually,
shouldShowMerchant,
hasSmartScanFailed,
didConfirmSplit,
Expand Down Expand Up @@ -215,14 +219,15 @@ function useFormErrorManagement({

// 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);
const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly);
const isAmountRequiredMissing = isConfirmationAmountMissing(transaction, canEnterScanFieldsManually);
const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually);
const isMerchantRequiredMissing = isConfirmationMerchantMissing(transaction, canEnterScanFieldsManually);
useEffect(() => {
if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) {
if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isMerchantRequiredMissing) {
return;
}
setFormError('');
}, [isAmountRequiredMissing, isDateRequiredMissing, setFormError]);
}, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, setFormError]);

useEffect(() => {
const currentFormError = formErrorRef.current;
Expand Down
Loading
Loading