From 1ce26dedf0d50d3a5246007e5d2c028612a59ea8 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:55:21 +0800 Subject: [PATCH 1/9] feat: Show Merchant, Date, Amount Fields in the Scan flow --- .../Provider.tsx | 5 ++ .../MoneyRequestConfirmationFields/context.ts | 2 + .../MoneyRequestConfirmationList.tsx | 10 +++ .../hooks/useConfirmationValidation.ts | 25 ++++-- .../hooks/useFormErrorManagement.ts | 11 ++- .../sections/AmountField.tsx | 11 ++- .../sections/DateField.tsx | 15 +++- .../sections/MerchantField.tsx | 6 ++ .../sections/selectors.ts | 3 +- src/libs/DebugUtils.ts | 2 + src/libs/TransactionUtils/index.ts | 20 +++++ src/libs/actions/IOU/MoneyRequest.ts | 4 +- .../step/IOURequestStepConfirmation.tsx | 18 +++- .../confirmation/ReceiptFileValidator.tsx | 16 +++- src/types/onyx/Transaction.ts | 7 ++ .../IOURequestStepConfirmationPageTest.tsx | 86 ++++++++++++++++++ tests/unit/ReceiptFileValidatorTest.tsx | 87 +++++++++++++++++++ tests/unit/TransactionUtilsTest.ts | 28 ++++++ .../hooks/useConfirmationValidation.test.ts | 65 ++++++++++++++ .../hooks/useFormErrorManagement.test.tsx | 30 +++++++ 20 files changed, 431 insertions(+), 20 deletions(-) create mode 100644 tests/unit/ReceiptFileValidatorTest.tsx diff --git a/src/components/MoneyRequestConfirmationFields/Provider.tsx b/src/components/MoneyRequestConfirmationFields/Provider.tsx index c0f273488179..ab054e83ceb5 100644 --- a/src/components/MoneyRequestConfirmationFields/Provider.tsx +++ b/src/components/MoneyRequestConfirmationFields/Provider.tsx @@ -40,6 +40,9 @@ type ProviderProps = { /** Whether the new manual expense flow beta is enabled */ isNewManualExpenseFlowEnabled?: 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; @@ -88,6 +91,7 @@ function Provider({ didConfirm = false, isEditingSplitBill = false, isNewManualExpenseFlowEnabled = false, + canEnterScanFieldsManually = false, isPolicyExpenseChat = false, isDistanceRequest = false, isPerDiemRequest = false, @@ -112,6 +116,7 @@ function Provider({ didConfirm, isEditingSplitBill, isNewManualExpenseFlowEnabled, + canEnterScanFieldsManually, isPolicyExpenseChat, isDistanceRequest, isPerDiemRequest, diff --git a/src/components/MoneyRequestConfirmationFields/context.ts b/src/components/MoneyRequestConfirmationFields/context.ts index adf31c4bece5..a123c6dee6c6 100644 --- a/src/components/MoneyRequestConfirmationFields/context.ts +++ b/src/components/MoneyRequestConfirmationFields/context.ts @@ -24,6 +24,8 @@ type ConfirmationFieldsContextValue = { didConfirm: boolean; isEditingSplitBill: boolean; isNewManualExpenseFlowEnabled: 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 diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 40ffec069526..185391c7c64b 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -142,6 +142,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; @@ -194,6 +200,7 @@ function MoneyRequestConfirmationList({ isPerDiemRequest = false, isPolicyExpenseChat = false, shouldShowSmartScanFields = true, + canEnterScanFieldsManually = false, isEditingSplitBill, isReceiptEditable, selectedParticipants: selectedParticipantsProp, @@ -372,6 +379,7 @@ function MoneyRequestConfirmationList({ isTypeSplit, shouldShowReadOnlySplits, isNewManualExpenseFlowEnabled, + canEnterScanFieldsManually, isDistanceRequest, }); @@ -497,6 +505,7 @@ function MoneyRequestConfirmationList({ isTimeRequest, routeError, isNewManualExpenseFlowEnabled, + canEnterScanFieldsManually, isReadOnly, shouldShowDate: shouldShowConfirmationDate(shouldShowSmartScanFields, isDistanceRequest), isTaxAmountEmpty, @@ -562,6 +571,7 @@ function MoneyRequestConfirmationList({ didConfirm={!!didConfirm} isEditingSplitBill={isEditingSplitBill} isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled} + canEnterScanFieldsManually={canEnterScanFieldsManually} isPolicyExpenseChat={isPolicyExpenseChat} isDistanceRequest={isDistanceRequest} isPerDiemRequest={isPerDiemRequest} diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 40510de6fa36..0f3ef6775954 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -14,6 +14,7 @@ import { getCalculatedTaxAmount, getTag, getTaxAmount, + hasManuallyEnteredScanFields, hasTaxRateWithMatchingValue, isCreatedMissing, isMerchantMissing, @@ -118,6 +119,9 @@ type UseConfirmationValidationParams = { /** Whether the new manual expense flow is enabled */ isNewManualExpenseFlowEnabled: boolean; + /** 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; @@ -171,12 +175,18 @@ function useConfirmationValidation({ isTimeRequest, routeError, isNewManualExpenseFlowEnabled, + 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" in the new manual expense + // flow. Filling in any one of them makes all three required, and subjects the amount to the same validation as a + // manually entered one. + const hasEnteredScanFields = canEnterScanFieldsManually && hasManuallyEnteredScanFields(transaction); + const shouldValidateEnteredAmount = isNewManualExpenseFlowEnabled && (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || hasEnteredScanFields); const validate = (paymentType?: PaymentMethodType): ValidationResult | null => { if (!!routeError || !transactionID) { return null; @@ -193,15 +203,20 @@ function useConfirmationValidation({ if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) { return {errorKey: 'common.error.invalidAmount'}; } - // isAmountSet only applies to manual expenses — scan, per diem, distance, and time set amount programmatically. - if (isNewManualExpenseFlowEnabled && transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && !transaction?.isAmountSet) { + // A scan the user started filling in requires all three of amount, merchant and date. They all report + // `common.error.fieldRequired`, which each of the three fields renders inline when it is the empty one, so + // every field the user still has to fill in lights up at once. + if (hasEnteredScanFields && (!transaction?.isAmountSet || !transaction?.isCreatedSet || isMerchantEmpty)) { + return {errorKey: 'common.error.fieldRequired'}; + } + // isAmountSet only applies to manually entered amounts — per diem, distance, and time set the amount + // programmatically, and so does a scan the user hasn't filled in themselves. + if (shouldValidateEnteredAmount && !transaction?.isAmountSet) { return {errorKey: 'common.error.fieldRequired'}; } if ( - isNewManualExpenseFlowEnabled && - transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && + shouldValidateEnteredAmount && transaction?.isAmountSet && - !isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && !isEditingSplitBill && diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 5b8480912985..65cea98036a5 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -2,7 +2,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; -import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils'; +import {areRequiredFieldsEmpty, getTag, hasManuallyEnteredScanFields, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils'; import {isInvalidMerchantValue, isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils'; import {getIsViolationFixed} from '@libs/Violations/ViolationsUtils'; @@ -75,6 +75,9 @@ type UseFormErrorManagementParams = { /** Whether the new manual expense flow is enabled (amount/date errors surface inline) */ isNewManualExpenseFlowEnabled: boolean; + /** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */ + canEnterScanFieldsManually: boolean; + /** Whether the transaction is a distance request (its amount is read-only, so amount errors are not shown inline) */ isDistanceRequest: boolean; }; @@ -142,6 +145,7 @@ function useFormErrorManagement({ isTypeSplit, shouldShowReadOnlySplits, isNewManualExpenseFlowEnabled, + canEnterScanFieldsManually, isDistanceRequest, }: UseFormErrorManagementParams): UseFormErrorManagementResult { const isFocused = useIsFocused(); @@ -162,7 +166,10 @@ function useFormErrorManagement({ ((!!hasSmartScanFailed && hasMissingSmartscanFields(transaction, transactionReport)) || (didConfirmSplit && areRequiredFieldsEmpty(transaction, transactionReport))); const isMerchantEmpty = !iouMerchant || isMerchantMissing(transaction); - const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || !!isEditingSplitBill) && shouldShowMerchant; + // A scan the user started filling in (amount, merchant or date) behaves like a manual expense with a receipt + // attached, so the merchant becomes required no matter which chat the expense is headed to. + const hasEnteredScanFields = canEnterScanFieldsManually && hasManuallyEnteredScanFields(transaction); + const isMerchantRequired = (isPolicyExpenseChat && (!isScanRequest || !!isEditingSplitBill) && shouldShowMerchant) || hasEnteredScanFields; const isMerchantFieldValid = (() => { const merchantValue = iouMerchant ?? ''; const trimmedMerchant = merchantValue.trim(); diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 18610711edce..85e43807273d 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -65,7 +65,8 @@ function AmountField({ autoFocus = false, isParticipantPickerVisible = false, }: AmountFieldProps) { - const {isEditingSplitBill, isNewManualExpenseFlowEnabled, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields(); + const {isEditingSplitBill, isNewManualExpenseFlowEnabled, canEnterScanFieldsManually, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = + useConfirmationFields(); const styles = useThemeStyles(); const {translate, preferredLocale} = useLocalize(); const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); @@ -103,9 +104,11 @@ function AmountField({ const decimals = getCurrencyDecimals(effectiveCurrency); // In the new manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user // touches it). Once the user explicitly sets an amount – including 0 – isAmountSet becomes true and we show the - // real value. This avoids showing "$0.00" as a pre-filled default. Scan and other non-manual flows populate - // amount programmatically and never set isAmountSet. - const shouldShowEmptyAmount = isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL; + // real value. This avoids showing "$0.00" as a pre-filled default. The Scan flow behaves the same way: its amount + // belongs to the receipt, so the field is empty until the user chooses to enter one instead of waiting for + // SmartScan. Per diem, distance and time flows populate the amount programmatically and never set isAmountSet. + const shouldShowEmptyAmount = + isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet && (transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually); const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants, isNewManualExpenseFlowEnabled); diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index 5fe6013b1a60..9e21467dadf6 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -59,7 +59,7 @@ function DateField({ reportActionID, }: DateFieldProps) { const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isEditingSplitBill} = useConfirmationFields(); + const {isEditingSplitBill, canEnterScanFieldsManually} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; @@ -79,16 +79,23 @@ function DateField({ const createdMissing = dateState?.isMissing ?? true; const transactionHasReceipt = dateState?.hasReceipt ?? false; + // A draft is seeded with today's date, but in the Scan flow the date belongs to the receipt, not to today, so the + // picker stays empty until the user picks one — the same way the amount field starts empty. + const shouldShowEmptyDate = canEnterScanFieldsManually && !dateState?.isCreatedSet; + const isDateEmpty = createdMissing || shouldShowEmptyDate; + const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : ''; - const inlineDateErrorText = formError === 'common.error.fieldRequired' && createdMissing ? translate('common.error.fieldRequired') : ''; + const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateEmpty ? translate('common.error.fieldRequired') : ''; const handleDateChange = (newDate: string) => { if (!transactionID) { return; } - if (newDate === iouCreated) { + // While the picker renders empty the persisted date is only a default, so a pick that matches it still has to + // be written — that write is what marks the date as chosen by the user. + if (newDate === iouCreated && !shouldShowEmptyDate) { return; } @@ -126,7 +133,7 @@ function DateField({ ): DateState | undefined => { if (!t) { @@ -33,6 +33,7 @@ const dateStateSelector = (t: OnyxEntry): DateState | undefined => iouCreated: getCreated(t), isMissing: isCreatedMissing(t), hasReceipt: hasReceipt(t), + isCreatedSet: t.isCreatedSet ?? false, }; }; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 96dd26eca237..c8cb3ead8297 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1192,6 +1192,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) splitsEndDate: CONST.RED_BRICK_ROAD_PENDING_ACTION, withdrawalID: CONST.RED_BRICK_ROAD_PENDING_ACTION, isAmountSet: CONST.RED_BRICK_ROAD_PENDING_ACTION, + isCreatedSet: CONST.RED_BRICK_ROAD_PENDING_ACTION, selectedRouteKey: CONST.RED_BRICK_ROAD_PENDING_ACTION, }, 'string', @@ -1357,6 +1358,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) }); case 'isAmountSet': case 'isMerchantSet': + case 'isCreatedSet': return validateBoolean(value); } } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 35529422e300..5210987a0cef 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -283,6 +283,24 @@ function isScanRequest(transaction: OnyxEntry): boolean { + return isScanRequest(transaction) && (!!transaction?.isAmountSet || !!transaction?.isMerchantSet || !!transaction?.isCreatedSet); +} + +/** + * Whether the user filled in every one of those fields. Only then is the receipt submitted as `open`, so SmartScan + * never overwrites what the user typed. A partially filled scan is still scanned — that way it can never be created + * with neither an amount of its own nor one read from the receipt. + */ +function hasAllManuallyEnteredScanFields(transaction: OnyxEntry): boolean { + return isScanRequest(transaction) && !!transaction?.isAmountSet && !!transaction?.isMerchantSet && !!transaction?.isCreatedSet; +} + function isPerDiemRequest(transaction: OnyxEntry): boolean { if (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.PER_DIEM) { return true; @@ -3745,6 +3763,8 @@ export { getTagArrayFromName, getTagForDisplay, getTransactionViolations, + hasAllManuallyEnteredScanFields, + hasManuallyEnteredScanFields, hasReceipt, hasUploadedReceipt, hasEReceipt, diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index b6fd46e96813..b2c7410a414f 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -950,7 +950,9 @@ function clearMoneyRequestMerchant(transactionID: string, isDraft = true) { } function setMoneyRequestCreated(transactionID: string, created: string, isDraft: boolean, shouldStopSmartscan = false) { - Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {created}); + // Mark that the user has explicitly picked the date. A draft is seeded with today's date, so this is the only way + // the Scan flow can tell a user-picked date apart from the default one. + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {created, isCreatedSet: !!created}); setMoneyRequestReceiptState(transactionID, isDraft, shouldStopSmartscan); } diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index f059484428cb..fe3f51c1bf26 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -272,6 +272,19 @@ function IOURequestStepConfirmationContent({ const isSharingTrackExpense = action === CONST.IOU.ACTION.SHARE; const isCategorizingTrackExpense = action === CONST.IOU.ACTION.CATEGORIZE; const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseIOUUtils(action); + // The new manual expense flow lets the user fill in the amount, merchant and date on the Scan tab instead of + // waiting for SmartScan, so the Scan confirmation reveals those fields behind "Show more" as well. This only + // applies to a scan being created: a tracked expense being moved already carries real values, and its emptiness + // can't be told from the `isAmountSet` / `isMerchantSet` / `isCreatedSet` flags a fresh draft uses. Splits are + // excluded too because StartSplitBill takes no amount/merchant/date (the details are filled in once the receipt + // has been scanned), and so are test receipts, whose values are fixed. + const canEnterScanFieldsManually = + isNewManualExpenseFlowEnabled && + requestType === CONST.IOU.REQUEST_TYPE.SCAN && + !isMovingTransactionFromTrackExpense && + iouType !== CONST.IOU.TYPE.SPLIT && + !transaction?.receipt?.isTestReceipt && + !transaction?.receipt?.isTestDriveReceipt; const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && Object.values(receiptFiles).length && isScanRequest(transaction); const headerTitle = useMemo(() => { @@ -883,7 +896,8 @@ function IOURequestStepConfirmationContent({ const showReceiptEmptyState = shouldShowReceiptEmptyState(iouType, action, policy, isPerDiemRequest); - const shouldShowSmartScanFields = !!transaction?.receipt?.isTestDriveReceipt || isMovingTransactionFromTrackExpense || requestType !== CONST.IOU.REQUEST_TYPE.SCAN; + const shouldShowSmartScanFields = + !!transaction?.receipt?.isTestDriveReceipt || isMovingTransactionFromTrackExpense || requestType !== CONST.IOU.REQUEST_TYPE.SCAN || canEnterScanFieldsManually; return ( <> @@ -1032,6 +1047,7 @@ function IOURequestStepConfirmationContent({ receiptStitchError={stitchError} isPerDiemRequest={isPerDiemRequest} shouldShowSmartScanFields={shouldShowSmartScanFields} + canEnterScanFieldsManually={canEnterScanFieldsManually} action={action} isConfirmed={isConfirmed} isConfirming={isConfirming} diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx index 78d22b71ec59..3d1011cd6e5f 100644 --- a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx +++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx @@ -3,6 +3,7 @@ import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import {hasAllManuallyEnteredScanFields} from '@libs/TransactionUtils'; import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; import {removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; @@ -35,6 +36,12 @@ type ReceiptFileValidatorProps = { * the validator. */ isReceiptReady: boolean; + /** + * Whether the Scan confirmation lets the user fill in the amount / merchant / date themselves (new manual expense + * flow). When it does, a scan the user filled in is submitted with an `open` receipt so SmartScan never overwrites + * those values. + */ + canEnterScanFieldsManually: boolean; onReceiptFilesChange: (files: Record) => void; }; @@ -55,6 +62,7 @@ function ReceiptFileValidator({ participants, draftTransactionIDs, isReceiptReady, + canEnterScanFieldsManually, onReceiptFilesChange, }: ReceiptFileValidatorProps) { // When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow. @@ -99,7 +107,11 @@ function ReceiptFileValidator({ receipt.isTestDriveReceipt = true; receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE; } else { - receipt.state = file && requestType === CONST.IOU.REQUEST_TYPE.MANUAL ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY; + // A scan whose amount / merchant / date the user filled in themselves is submitted the same way + // as a manual expense with an attached receipt: `open` keeps SmartScan from re-reading the receipt + // and overwriting what the user typed. + const shouldSkipSmartScan = requestType === CONST.IOU.REQUEST_TYPE.MANUAL || (canEnterScanFieldsManually && hasAllManuallyEnteredScanFields(item)); + receipt.state = file && shouldSkipSmartScan ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY; } newReceiptFiles = {...newReceiptFiles, [item.transactionID]: receipt}; @@ -142,7 +154,7 @@ function ReceiptFileValidator({ ignore = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes - }, [requestType, iouType, initialTransactionID, reportID, action, backToReport, report, transactions, participants, isReceiptReady, onReceiptFilesChange]); + }, [requestType, iouType, initialTransactionID, reportID, action, backToReport, report, transactions, participants, isReceiptReady, canEnterScanFieldsManually, onReceiptFilesChange]); return null; } diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index df3dcad516cb..84e1d32e5d47 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -579,6 +579,13 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** Whether the merchant has been explicitly set by the user */ isMerchantSet?: boolean; + /** + * Whether the date has been explicitly picked by the user. A draft transaction is seeded with today's date, + * which the Scan flow can't tell apart from a date the user picked, so the Scan confirmation keeps its date + * field empty until this flag is set. + */ + isCreatedSet?: boolean; + /** The original merchant name */ merchant: string; diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 11357a217d9c..cfef3bfd1a54 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -404,6 +404,92 @@ describe('IOURequestStepConfirmationPageTest', () => { await waitFor(() => expect(startSplitBill).toHaveBeenCalledTimes(1)); }); + describe('Scan flow — manually entered amount / merchant / date', () => { + const SCAN_TRANSACTION: Transaction = { + ...DEFAULT_SPLIT_TRANSACTION, + isAmountSet: undefined, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {filename: 'receipt1.jpg', source: 'path/to/receipt1.jpg', type: ''}, + }; + + async function renderScanConfirmation() { + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + await act(async () => { + await Onyx.set(ONYXKEYS.BETAS, [CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW]); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, SCAN_TRANSACTION); + }); + + render( + + + + + + + + + , + ); + + await waitForBatchedUpdatesWithAct(); + fireEvent.press(await screen.findByText(translateLocal('common.showMore'))); + await waitForBatchedUpdatesWithAct(); + } + + it('reveals the amount, merchant and date fields behind "Show more", all empty', async () => { + await renderScanConfirmation(); + + expect(screen.getByLabelText(translateLocal('iou.amount'))).toHaveDisplayValue(''); + expect(screen.getByLabelText(translateLocal('common.merchant'))).toHaveDisplayValue(''); + expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue(''); + }); + + it('requires all three fields once one of them is entered', async () => { + await renderScanConfirmation(); + + fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); + await waitForBatchedUpdatesWithAct(); + + expect(TrackExpense.requestMoney).not.toHaveBeenCalled(); + expect(screen.getAllByText(translateLocal('common.error.fieldRequired')).length).toBeGreaterThan(1); + }); + + it('submits the entered amount, merchant and date instead of waiting for SmartScan', async () => { + await renderScanConfirmation(); + + fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); + fireEvent.changeText(screen.getByLabelText(translateLocal('iou.amount')), '12.34'); + await waitForBatchedUpdatesWithAct(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, {created: '2025-01-15', isCreatedSet: true}); + }); + + fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); + await waitForBatchedUpdatesWithAct(); + + expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1); + expect(jest.mocked(TrackExpense.requestMoney).mock.calls.at(0)?.[0].transactionParams).toEqual( + expect.objectContaining({amount: 1234, merchant: 'Starbucks', created: '2025-01-15'}), + ); + }); + }); + it('should create a split expense for each scanned receipt', async () => { await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); diff --git a/tests/unit/ReceiptFileValidatorTest.tsx b/tests/unit/ReceiptFileValidatorTest.tsx new file mode 100644 index 000000000000..5a0ee0dfa492 --- /dev/null +++ b/tests/unit/ReceiptFileValidatorTest.tsx @@ -0,0 +1,87 @@ +import {render} from '@testing-library/react-native'; + +import ReceiptFileValidator from '@pages/iou/request/step/confirmation/ReceiptFileValidator'; + +import CONST from '@src/CONST'; +import type {Transaction} from '@src/types/onyx'; +import type {Receipt} from '@src/types/onyx/Transaction'; + +import React from 'react'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +// Stand in for reading the receipt back off disk: the validator only cares that the file is accessible. +jest.mock('@libs/fileDownload/validateReceiptFile', () => ({ + __esModule: true, + default: jest.fn((receiptFilename: string, receiptPath: string, _receiptType: string, onSuccess: (file: {name: string; uri: string}) => void) => { + onSuccess({name: receiptFilename, uri: receiptPath}); + return Promise.resolve(); + }), +})); + +const TRANSACTION_ID = '1'; + +function createScanDraft(values: Partial = {}): Transaction { + return { + transactionID: TRANSACTION_ID, + reportID: '1', + amount: 0, + currency: 'USD', + created: '2025-01-15', + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + comment: {}, + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + // A local file, the way a freshly captured or dropped receipt is stored before submission + receipt: {filename: 'receipt.jpg', source: 'file://receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + ...values, + }; +} + +async function getValidatedReceiptState(transaction: Transaction, canEnterScanFieldsManually: boolean) { + let receiptFiles: Record = {}; + render( + { + receiptFiles = files; + }} + />, + ); + await waitForBatchedUpdatesWithAct(); + return receiptFiles[TRANSACTION_ID]?.state; +} + +describe('ReceiptFileValidator', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('submits an untouched scan receipt for SmartScan', async () => { + expect(await getValidatedReceiptState(createScanDraft(), true)).toBe(CONST.IOU.RECEIPT_STATE.SCAN_READY); + }); + + it('submits a scan receipt whose details the user filled in as open, so SmartScan cannot overwrite them', async () => { + const transaction = createScanDraft({amount: 1234, isAmountSet: true, merchant: 'Starbucks', isMerchantSet: true, isCreatedSet: true}); + expect(await getValidatedReceiptState(transaction, true)).toBe(CONST.IOU.RECEIPT_STATE.OPEN); + }); + + it('still scans a receipt whose details the user only partially filled in', async () => { + expect(await getValidatedReceiptState(createScanDraft({merchant: 'Starbucks', isMerchantSet: true}), true)).toBe(CONST.IOU.RECEIPT_STATE.SCAN_READY); + }); + + it('keeps SmartScan on surfaces that do not expose the scan fields', async () => { + const transaction = createScanDraft({amount: 1234, isAmountSet: true, merchant: 'Starbucks', isMerchantSet: true, isCreatedSet: true}); + expect(await getValidatedReceiptState(transaction, false)).toBe(CONST.IOU.RECEIPT_STATE.SCAN_READY); + }); +}); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 0f3061e6a28f..76fb31fa70e8 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5248,6 +5248,34 @@ describe('doesMoneyRequestDraftHaveUserInput', () => { }); }); +describe('hasManuallyEnteredScanFields', () => { + function generateScanDraft(values: Partial = {}): Transaction { + return generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, ...values}); + } + + it('returns false for an untouched scan draft', () => { + expect(TransactionUtils.hasManuallyEnteredScanFields(undefined)).toBe(false); + expect(TransactionUtils.hasManuallyEnteredScanFields(generateScanDraft())).toBe(false); + }); + + it.each([['amount', {isAmountSet: true}] as const, ['merchant', {isMerchantSet: true}] as const, ['date', {isCreatedSet: true}] as const])( + 'returns true once the %s has been entered', + (_field, values) => { + expect(TransactionUtils.hasManuallyEnteredScanFields(generateScanDraft(values))).toBe(true); + }, + ); + + it('returns false for expense types that populate those fields programmatically', () => { + expect(TransactionUtils.hasManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, isAmountSet: true}))).toBe(false); + expect(TransactionUtils.hasManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, isAmountSet: true}))).toBe(false); + }); + + it('only reports all fields entered once every one of them is', () => { + expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true}))).toBe(false); + expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true, isCreatedSet: true}))).toBe(true); + }); +}); + describe('isTransactionSubmittable', () => { it('returns true for a transaction that is on hold', () => { const transaction = generateTransaction({comment: {hold: 'holdID'}}); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 09f271c7f414..890c07fe1bf7 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -102,6 +102,7 @@ const baseParams = { isTimeRequest: false, routeError: undefined, isNewManualExpenseFlowEnabled: false, + canEnterScanFieldsManually: false, isReadOnly: false, shouldShowDate: true, isTaxAmountEmpty: false, @@ -959,4 +960,68 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); }); + + describe('manually entered scan fields (amount / merchant / date)', () => { + function createScanValidationParams(transactionOverrides: Partial = {}, overrides: ValidationParamsOverrides = {}): UseConfirmationValidationParams { + return { + ...baseParams, + isNewManualExpenseFlowEnabled: true, + canEnterScanFieldsManually: true, + iouAmount: 0, + iouMerchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + isMerchantEmpty: true, + ...overrides, + transaction: createTransactionBase({ + 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, + participants: [P2P_PARTICIPANT], + ...transactionOverrides, + }), + }; + } + + it('does not require anything while the user leaves the scan fields untouched', () => { + const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams())); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + 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 remaining fields once the %s is entered', (_field, transactionOverrides, overrides) => { + const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides))); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + + it('passes once all three fields are entered', () => { + 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}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('validates the entered amount the same way a manually entered one is validated', () => { + 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}, + ), + ), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.invalidAmount'}); + }); + + it('requires nothing on surfaces that do not expose the scan fields (splits, test receipts)', () => { + const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams({isAmountSet: true, amount: 1000}, {canEnterScanFieldsManually: false, iouAmount: 1000}))); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + }); }); diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index afe3e85a7656..0c09fa94016e 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -46,6 +46,7 @@ const baseParams: Params = { isTypeSplit: false, shouldShowReadOnlySplits: false, isNewManualExpenseFlowEnabled: false, + canEnterScanFieldsManually: false, isDistanceRequest: false, }; @@ -186,6 +187,35 @@ describe('useFormErrorManagement', () => { expect(result.current.isMerchantFieldValid).toBe(false); }); + it('requires the merchant once the user starts filling in the scan fields, whoever the expense is headed to', () => { + const scanParams: Partial = { + canEnterScanFieldsManually: true, + isNewManualExpenseFlowEnabled: true, + 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(true); + expect(amountEntered.current.isMerchantFieldValid).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( From 60ff21b33026692ba2ca6bb7ed430d43539578c7 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:31:17 +0800 Subject: [PATCH 2/9] fix: git actions --- .../isDeployChecklistLocked/index.js | 255 +++++++++++++++++- .../javascript/proposalPoliceComment/index.js | 255 +++++++++++++++++- 2 files changed, 500 insertions(+), 10 deletions(-) diff --git a/.github/actions/javascript/isDeployChecklistLocked/index.js b/.github/actions/javascript/isDeployChecklistLocked/index.js index f0d47fc82f50..d228861485f1 100644 --- a/.github/actions/javascript/isDeployChecklistLocked/index.js +++ b/.github/actions/javascript/isDeployChecklistLocked/index.js @@ -3431,7 +3431,9 @@ var require_CONST = __commonJS({ REGISTER_AUTHENTICATION_KEY: "register_authentication_key", REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", - REPORT_CARD_FRAUD: "report_card_fraud" + REPORT_CARD_FRAUD: "report_card_fraud", + ISSUE_CARD: "issue_card", + UPDATE_CARD: "update_card" }, EXPENSIFY_CARD: { FEED_NAME: "Expensify Card", @@ -23108,9 +23110,22 @@ var require_ExpensiMark = __commonJS({ var Logger_1 = __importDefault(require_Logger()); var Utils = __importStar(require_utils()); var EXTRAS_DEFAULT = {}; + var ASCII_DIGIT_START = "0".charCodeAt(0); + var ASCII_DIGIT_END = "9".charCodeAt(0); + var ASCII_UPPERCASE_START = "A".charCodeAt(0); + var ASCII_UPPERCASE_END = "Z".charCodeAt(0); + var ASCII_LOWERCASE_START = "a".charCodeAt(0); + var ASCII_LOWERCASE_END = "z".charCodeAt(0); + var ASCII_WHITESPACE_END = " ".charCodeAt(0); + var NON_BREAKING_SPACE_CODE = 160; + var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; + var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join("|")}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); + var BOLD_MARKDOWN_REGEX = /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; + var STRIKETHROUGH_MARKDOWN_REGEX = /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; var SLACK_SPAN_NEW_LINE_TAG = ''; var VICTORY_CHART_REGEX = /]*\/>|]*>[\s\S]*?<\/VictoryChart>/gi; var VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0); @@ -23150,6 +23165,218 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function isAsciiAlphaNumeric(character) { + if (!character) { + return false; + } + const code = character.charCodeAt(0); + return code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END || code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END || code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END; + } + function isWordCharacter(character) { + return character === "_" || isAsciiAlphaNumeric(character); + } + function canOpenBoldMarkdown(text, position, isProtected) { + if (isProtected) { + return false; + } + const nextCharacter = text[position + 1]; + if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith("", tagStart + 1); + if (tagEnd === -1) { + return void 0; + } + const tag = text.slice(tagStart + 1, tagEnd).trim(); + const isClosingTag = tag.startsWith("/"); + const tagName = (_b = (_a = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + if (tagName && PROTECTED_TAG_NAMES.has(tagName)) { + if (isClosingTag) { + const matchingTagIndex = protectedTags.lastIndexOf(tagName); + if (matchingTagIndex !== -1) { + protectedTags.splice(matchingTagIndex, 1); + } + } else if (!tag.endsWith("/")) { + protectedTags.push(tagName); + } + } + return tagEnd + 1; + } + function isHostnameCharacter(character) { + return !!character && (isAsciiAlphaNumeric(character) || character === "-" || character === "."); + } + function isUrlBoundarySpace(character) { + const code = character.charCodeAt(0); + return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE; + } + function getProtocolAt(text, position) { + var _a; + const firstCharacter = (_a = text[position]) === null || _a === void 0 ? void 0 : _a.toLowerCase(); + if (firstCharacter !== "h" && firstCharacter !== "f") { + return void 0; + } + return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); + } + function findHostnameEnd(text, hostnameStart, dotPosition) { + let hostnameEnd = dotPosition + 1; + while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { + hostnameEnd++; + } + if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { + return void 0; + } + return hostnameEnd; + } + function extendUrlCandidateBoundaries(text, start, end) { + let candidateStart = start; + while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) { + candidateStart--; + } + let candidateEnd = end; + while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== "<") { + candidateEnd++; + } + return { start: candidateStart, end: candidateEnd }; + } + function findUrlCandidates(text) { + const candidates = []; + const protectedTags = []; + let index = 0; + let hostnameRunStart = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + hostnameRunStart = index; + continue; + } + if (protectedTags.length > 0) { + index++; + hostnameRunStart = index; + continue; + } + const matchedProtocol = getProtocolAt(text, index); + if (matchedProtocol) { + const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + candidates.push(candidate2); + index = candidate2.end; + hostnameRunStart = candidate2.end; + continue; + } + if (!isHostnameCharacter(text[index])) { + hostnameRunStart = index + 1; + index++; + continue; + } + if (text[index] !== ".") { + index++; + continue; + } + const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); + if (hostnameEnd === void 0) { + index++; + continue; + } + const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + candidates.push(candidate); + index = candidate.end; + hostnameRunStart = candidate.end; + } + return candidates; + } + function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { + if (!text.includes(marker)) { + return text; + } + const markers = []; + const protectedTags = []; + let index = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + continue; + } + if (text[index] === marker) { + markers.push({ position: index, isProtected: protectedTags.length > 0 }); + } + index++; + } + if (markers.length < 2) { + return text; + } + const output = []; + const candidateRegex = regexp; + let outputStart = 0; + let openingMarker; + for (const currentMarker of markers) { + const markerPosition = currentMarker.position; + if (openingMarker === void 0) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) { + continue; + } + if (openingMarker.isProtected) { + openingMarker = void 0; + continue; + } + const openingPosition = openingMarker.position; + const prefixLength = openingPosition > 0 ? 1 : 0; + const suffixLength = markerPosition + 1 < text.length ? 1 : 0; + const candidateStart = openingPosition - prefixLength; + const candidateEnd = markerPosition + 1 + suffixLength; + const candidate = text.slice(candidateStart, candidateEnd); + candidateRegex.lastIndex = 0; + const candidateMatch = candidateRegex.exec(candidate); + if (!candidateMatch) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + candidateRegex.lastIndex = 0; + const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement); + if (replacedCandidate !== candidate) { + const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length; + output.push(text.slice(outputStart, openingPosition)); + output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd)); + outputStart = markerPosition + 1; + openingMarker = void 0; + continue; + } + openingMarker = void 0; + } + if (output.length === 0) { + return text; + } + output.push(text.slice(outputStart)); + return output.join(""); + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
> (|<\/div>||\n<\/comment>|<\/comment>|

|<\/h1>|

|<\/h2>|

|<\/h3>|

|<\/h4>|

|<\/h5>|
|<\/h6>|

|<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -23570,7 +23797,7 @@ var require_ExpensiMark = __commonJS({ name: "autolink", process: (textToProcess, replacement) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -23675,7 +23902,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - regex: /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), replacement: (_extras, match, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -23685,7 +23912,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match, g1) => g1.includes("") || containsNonPairTag(g1) ? match : `${g1}` }, { @@ -24075,7 +24302,25 @@ ${g2} /** * Checks matched URLs for validity and replace valid links with html elements */ - modifyTextForUrlLinks(regex2, textToCheck, replacement) { + modifyTextForUrlLinks(regex2, textToCheck, replacement, shouldScanForUrls = false) { + if (shouldScanForUrls) { + const candidates = findUrlCandidates(textToCheck); + if (candidates.length === 0) { + return textToCheck; + } + const output = []; + const candidateRegex = regex2; + let outputStart = 0; + for (const { start, end } of candidates) { + const candidate = textToCheck.slice(start, end); + candidateRegex.lastIndex = 0; + output.push(textToCheck.slice(outputStart, start)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + outputStart = end; + } + output.push(textToCheck.slice(outputStart)); + return output.join(""); + } let match = regex2.exec(textToCheck); let replacedText = ""; let startIndex = 0; diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 3aad2c81f960..84529ca85767 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -24245,7 +24245,9 @@ var require_CONST = __commonJS({ REGISTER_AUTHENTICATION_KEY: "register_authentication_key", REPLACE_CARD: "replace_card", SHIP_CARD: "ship_card", - REPORT_CARD_FRAUD: "report_card_fraud" + REPORT_CARD_FRAUD: "report_card_fraud", + ISSUE_CARD: "issue_card", + UPDATE_CARD: "update_card" }, EXPENSIFY_CARD: { FEED_NAME: "Expensify Card", @@ -43922,9 +43924,22 @@ var require_ExpensiMark = __commonJS({ var Logger_1 = __importDefault(require_Logger()); var Utils = __importStar(require_utils2()); var EXTRAS_DEFAULT = {}; + var ASCII_DIGIT_START = "0".charCodeAt(0); + var ASCII_DIGIT_END = "9".charCodeAt(0); + var ASCII_UPPERCASE_START = "A".charCodeAt(0); + var ASCII_UPPERCASE_END = "Z".charCodeAt(0); + var ASCII_LOWERCASE_START = "a".charCodeAt(0); + var ASCII_LOWERCASE_END = "z".charCodeAt(0); + var ASCII_WHITESPACE_END = " ".charCodeAt(0); + var NON_BREAKING_SPACE_CODE = 160; + var URL_PROTOCOLS = ["https://", "http://", "ftps://", "ftp://"]; + var URL_CANDIDATE_PREFIX_CHARACTERS = "@_*~"; + var PROTECTED_TAG_NAMES = /* @__PURE__ */ new Set(["a", "code", "pre", "video"]); var MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); var MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join("|")}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, "gi"); + var BOLD_MARKDOWN_REGEX = /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; + var STRIKETHROUGH_MARKDOWN_REGEX = /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g; var SLACK_SPAN_NEW_LINE_TAG = ''; var VICTORY_CHART_REGEX = /]*\/>|]*>[\s\S]*?<\/VictoryChart>/gi; var VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0); @@ -43964,6 +43979,218 @@ var require_ExpensiMark = __commonJS({ } return text.replace(regexp, replacement); } + function isAsciiAlphaNumeric(character) { + if (!character) { + return false; + } + const code = character.charCodeAt(0); + return code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END || code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END || code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END; + } + function isWordCharacter(character) { + return character === "_" || isAsciiAlphaNumeric(character); + } + function canOpenBoldMarkdown(text, position, isProtected) { + if (isProtected) { + return false; + } + const nextCharacter = text[position + 1]; + if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith("", tagStart + 1); + if (tagEnd === -1) { + return void 0; + } + const tag = text.slice(tagStart + 1, tagEnd).trim(); + const isClosingTag = tag.startsWith("/"); + const tagName = (_b = (_a3 = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a3 === void 0 ? void 0 : _a3[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase(); + if (tagName && PROTECTED_TAG_NAMES.has(tagName)) { + if (isClosingTag) { + const matchingTagIndex = protectedTags.lastIndexOf(tagName); + if (matchingTagIndex !== -1) { + protectedTags.splice(matchingTagIndex, 1); + } + } else if (!tag.endsWith("/")) { + protectedTags.push(tagName); + } + } + return tagEnd + 1; + } + function isHostnameCharacter(character) { + return !!character && (isAsciiAlphaNumeric(character) || character === "-" || character === "."); + } + function isUrlBoundarySpace(character) { + const code = character.charCodeAt(0); + return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE; + } + function getProtocolAt(text, position) { + var _a3; + const firstCharacter = (_a3 = text[position]) === null || _a3 === void 0 ? void 0 : _a3.toLowerCase(); + if (firstCharacter !== "h" && firstCharacter !== "f") { + return void 0; + } + return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol); + } + function findHostnameEnd(text, hostnameStart, dotPosition) { + let hostnameEnd = dotPosition + 1; + while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === "-")) { + hostnameEnd++; + } + if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) { + return void 0; + } + return hostnameEnd; + } + function extendUrlCandidateBoundaries(text, start, end) { + let candidateStart = start; + while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) { + candidateStart--; + } + let candidateEnd = end; + while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== "<") { + candidateEnd++; + } + return { start: candidateStart, end: candidateEnd }; + } + function findUrlCandidates(text) { + const candidates = []; + const protectedTags = []; + let index = 0; + let hostnameRunStart = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + hostnameRunStart = index; + continue; + } + if (protectedTags.length > 0) { + index++; + hostnameRunStart = index; + continue; + } + const matchedProtocol = getProtocolAt(text, index); + if (matchedProtocol) { + const candidate2 = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length); + candidates.push(candidate2); + index = candidate2.end; + hostnameRunStart = candidate2.end; + continue; + } + if (!isHostnameCharacter(text[index])) { + hostnameRunStart = index + 1; + index++; + continue; + } + if (text[index] !== ".") { + index++; + continue; + } + const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index); + if (hostnameEnd === void 0) { + index++; + continue; + } + const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd); + candidates.push(candidate); + index = candidate.end; + hostnameRunStart = candidate.end; + } + return candidates; + } + function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) { + if (!text.includes(marker)) { + return text; + } + const markers = []; + const protectedTags = []; + let index = 0; + while (index < text.length) { + if (text[index] === "<") { + const nextIndex = updateProtectedTagStack(text, index, protectedTags); + if (nextIndex === void 0) { + break; + } + index = nextIndex; + continue; + } + if (text[index] === marker) { + markers.push({ position: index, isProtected: protectedTags.length > 0 }); + } + index++; + } + if (markers.length < 2) { + return text; + } + const output = []; + const candidateRegex = regexp; + let outputStart = 0; + let openingMarker; + for (const currentMarker of markers) { + const markerPosition = currentMarker.position; + if (openingMarker === void 0) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) { + continue; + } + if (openingMarker.isProtected) { + openingMarker = void 0; + continue; + } + const openingPosition = openingMarker.position; + const prefixLength = openingPosition > 0 ? 1 : 0; + const suffixLength = markerPosition + 1 < text.length ? 1 : 0; + const candidateStart = openingPosition - prefixLength; + const candidateEnd = markerPosition + 1 + suffixLength; + const candidate = text.slice(candidateStart, candidateEnd); + candidateRegex.lastIndex = 0; + const candidateMatch = candidateRegex.exec(candidate); + if (!candidateMatch) { + openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : void 0; + continue; + } + candidateRegex.lastIndex = 0; + const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement); + if (replacedCandidate !== candidate) { + const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length; + output.push(text.slice(outputStart, openingPosition)); + output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd)); + outputStart = markerPosition + 1; + openingMarker = void 0; + continue; + } + openingMarker = void 0; + } + if (output.length === 0) { + return text; + } + output.push(text.slice(outputStart)); + return output.join(""); + } function replaceBlockElementWithNewLine(htmlString) { let splitText = htmlString.replaceAll(/
    > (|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>)/gi, "
    > ").split(/|<\/div>||\n<\/comment>|<\/comment>|

    |<\/h1>|

    |<\/h2>|

    |<\/h3>|

    |<\/h4>|

    |<\/h5>|
    |<\/h6>|

    |<\/p>|

  • |<\/li>|
    |<\/blockquote>/); const stripHTML = (text) => str_1.default.stripHTML(text); @@ -44384,7 +44611,7 @@ var require_ExpensiMark = __commonJS({ name: "autolink", process: (textToProcess, replacement) => { const regex2 = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!|[^<]*(<\\/pre>|<\\/code>))`, "gi"); - return this.modifyTextForUrlLinks(regex2, textToProcess, replacement); + return this.modifyTextForUrlLinks(regex2, textToProcess, replacement, true); }, replacement: (_extras, _match, g1, g2) => { const href = str_1.default.sanitizeURL(g2); @@ -44489,7 +44716,7 @@ ${"
    ".repeat(i)}`, "\n"); // \B will match everything that \b doesn't, so it works // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb name: "bold", - regex: /(?]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, "*", canOpenBoldMarkdown), replacement: (_extras, match2, g1, g2) => { if (g1.includes("_")) { return `${g1}${g2}`; @@ -44499,7 +44726,7 @@ ${"
    ".repeat(i)}`, "\n"); }, { name: "strikethrough", - regex: /(?]*)\B~((?![\s~])[\s\S]*?[^\s~](?)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g, + process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, "~", canOpenStrikethroughMarkdown), replacement: (_extras, match2, g1) => g1.includes("") || containsNonPairTag(g1) ? match2 : `${g1}` }, { @@ -44889,7 +45116,25 @@ ${g2} /** * Checks matched URLs for validity and replace valid links with html elements */ - modifyTextForUrlLinks(regex2, textToCheck, replacement) { + modifyTextForUrlLinks(regex2, textToCheck, replacement, shouldScanForUrls = false) { + if (shouldScanForUrls) { + const candidates = findUrlCandidates(textToCheck); + if (candidates.length === 0) { + return textToCheck; + } + const output = []; + const candidateRegex = regex2; + let outputStart = 0; + for (const { start, end } of candidates) { + const candidate = textToCheck.slice(start, end); + candidateRegex.lastIndex = 0; + output.push(textToCheck.slice(outputStart, start)); + output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement)); + outputStart = end; + } + output.push(textToCheck.slice(outputStart)); + return output.join(""); + } let match2 = regex2.exec(textToCheck); let replacedText = ""; let startIndex = 0; From d4e8348d569c0c284ff41c14d35ca856cf10b012 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:17:23 +0800 Subject: [PATCH 3/9] fix: support partially filled scan submit --- .../Provider.tsx | 5 -- .../MoneyRequestConfirmationFields/context.ts | 2 - .../MoneyRequestConfirmationList.tsx | 7 --- .../hooks/useConfirmationValidation.ts | 18 ++----- .../hooks/useFormErrorManagement.ts | 18 ++----- .../sections/AmountField.tsx | 31 ++++------- .../sections/AutomaticFieldHint.tsx | 2 +- .../sections/DateField.tsx | 18 ++++--- .../sections/MerchantField.tsx | 6 ++- src/libs/TransactionUtils/index.ts | 16 ++---- src/libs/actions/IOU/MoneyRequest.ts | 10 ++++ .../IOURequestStepConfirmationPageTest.tsx | 53 ++++++++++++------- tests/unit/TransactionUtilsTest.ts | 28 ++++------ .../hooks/useConfirmationValidation.test.ts | 4 +- .../hooks/useFormErrorManagement.test.tsx | 7 +-- 15 files changed, 97 insertions(+), 128 deletions(-) diff --git a/src/components/MoneyRequestConfirmationFields/Provider.tsx b/src/components/MoneyRequestConfirmationFields/Provider.tsx index 05b3bf9c38e4..f63a832fd78e 100644 --- a/src/components/MoneyRequestConfirmationFields/Provider.tsx +++ b/src/components/MoneyRequestConfirmationFields/Provider.tsx @@ -43,9 +43,6 @@ type ProviderProps = { /** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */ canEnterScanFieldsManually?: boolean; - /** Whether the amount / merchant / date fields still advertise that SmartScan fills them in ("Automatic") */ - shouldShowAutomaticFieldHint?: boolean; - /** Whether the surface is in a policy-expense chat */ isPolicyExpenseChat?: boolean; @@ -98,7 +95,6 @@ function Provider({ isEditingSplitBill = false, isNewManualExpenseFlowEnabled = false, canEnterScanFieldsManually = false, - shouldShowAutomaticFieldHint = false, isPolicyExpenseChat = false, isScanRequest = false, isDistanceRequest = false, @@ -125,7 +121,6 @@ function Provider({ isEditingSplitBill, isNewManualExpenseFlowEnabled, canEnterScanFieldsManually, - shouldShowAutomaticFieldHint, isPolicyExpenseChat, isScanRequest, isDistanceRequest, diff --git a/src/components/MoneyRequestConfirmationFields/context.ts b/src/components/MoneyRequestConfirmationFields/context.ts index fca9eba1397f..7d957f9d7b96 100644 --- a/src/components/MoneyRequestConfirmationFields/context.ts +++ b/src/components/MoneyRequestConfirmationFields/context.ts @@ -26,8 +26,6 @@ type ConfirmationFieldsContextValue = { isNewManualExpenseFlowEnabled: boolean; /** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */ canEnterScanFieldsManually: boolean; - /** Whether the amount / merchant / date fields still advertise that SmartScan fills them in ("Automatic") */ - shouldShowAutomaticFieldHint: boolean; isPolicyExpenseChat: boolean; // Mode — *what kind* of expense is being confirmed diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 2ba9d1539231..13bddd6f07c7 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -26,7 +26,6 @@ import { getCurrency, getMerchant, getRateID, - hasManuallyEnteredScanFields, hasValidModifiedAmount, isDistanceRequest as isDistanceRequestUtil, isGPSDistanceRequest as isGPSDistanceRequestUtil, @@ -362,10 +361,6 @@ function MoneyRequestConfirmationList({ // 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. const shouldShowDate = shouldShowConfirmationDate(shouldShowSmartScanFields, isDistanceRequest); - // The Scan confirmation labels the amount, merchant and date fields "Automatic" to say SmartScan reads them off - // the receipt. Filling in any one of them opts the expense out of SmartScan entirely, so the label leaves all - // three at once rather than only the field that was filled in. - const shouldShowAutomaticFieldHint = canEnterScanFieldsManually && !hasManuallyEnteredScanFields(transaction); const {formError, setFormError, clearFormErrors, shouldDisplayFieldError, isMerchantEmpty, isMerchantFieldValid, isMerchantRequired, errorMessage} = useFormErrorManagement({ transaction, @@ -387,7 +382,6 @@ function MoneyRequestConfirmationList({ isTypeSplit, shouldShowReadOnlySplits, isNewManualExpenseFlowEnabled, - canEnterScanFieldsManually, isDistanceRequest, isReadOnly, shouldShowDate, @@ -582,7 +576,6 @@ function MoneyRequestConfirmationList({ isEditingSplitBill={isEditingSplitBill} isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled} canEnterScanFieldsManually={canEnterScanFieldsManually} - shouldShowAutomaticFieldHint={shouldShowAutomaticFieldHint} isPolicyExpenseChat={isPolicyExpenseChat} isScanRequest={isScanRequest} isDistanceRequest={isDistanceRequest} diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 939366a3072d..68d0ca2f78d1 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -14,7 +14,6 @@ import { getCalculatedTaxAmount, getTag, getTaxAmount, - hasManuallyEnteredScanFields, hasTaxRateWithMatchingValue, isMerchantMissing, isScanRequest as isScanRequestUtil, @@ -182,10 +181,9 @@ function useConfirmationValidation({ const {getCurrencyDecimals} = useCurrencyListActions(); const selectedParticipantsCount = selectedParticipants.length; // The Scan confirmation reveals the amount / merchant / date fields behind "Show more" in the new manual expense - // flow. Filling in any one of them makes all three required, and subjects the amount to the same validation as a - // manually entered one. - const hasEnteredScanFields = canEnterScanFieldsManually && hasManuallyEnteredScanFields(transaction); - const shouldValidateEnteredAmount = isNewManualExpenseFlowEnabled && (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || hasEnteredScanFields); + // flow. 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 = isNewManualExpenseFlowEnabled && (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually); const validate = (paymentType?: PaymentMethodType): ValidationResult | null => { if (!!routeError || !transactionID) { return null; @@ -202,14 +200,8 @@ function useConfirmationValidation({ if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) { return {errorKey: 'common.error.invalidAmount'}; } - // A scan the user started filling in requires all three of amount, merchant and date. They all report - // `common.error.fieldRequired`, which each of the three fields renders inline when it is the empty one, so - // every field the user still has to fill in lights up at once. - if (hasEnteredScanFields && (!transaction?.isAmountSet || !transaction?.isCreatedSet || isMerchantEmpty)) { - return {errorKey: 'common.error.fieldRequired'}; - } - // `isConfirmationAmountMissing` only applies to manually entered amounts — per diem, distance, and time set - // the amount programmatically, and so does a scan the user hasn't filled in themselves (handled above). + // `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 (isNewManualExpenseFlowEnabled && isConfirmationAmountMissing(transaction)) { return {errorKey: 'common.error.fieldRequired'}; } diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index c5e7ba2b6080..65b801dc932f 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -3,7 +3,7 @@ import useLocalize from '@hooks/useLocalize'; import {isConfirmationAmountMissing, isConfirmationDateMissing} from '@libs/MoneyRequestUtils'; import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils'; -import {areRequiredFieldsEmpty, getTag, hasManuallyEnteredScanFields, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils'; +import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils'; import {isInvalidMerchantValue, isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils'; import {getIsViolationFixed} from '@libs/Violations/ViolationsUtils'; @@ -76,9 +76,6 @@ type UseFormErrorManagementParams = { /** Whether the new manual expense flow is enabled (amount/date errors surface inline) */ isNewManualExpenseFlowEnabled: boolean; - /** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */ - canEnterScanFieldsManually: boolean; - /** Whether the transaction is a distance request (its amount is read-only, so amount errors are not shown inline) */ isDistanceRequest: boolean; @@ -152,7 +149,6 @@ function useFormErrorManagement({ isTypeSplit, shouldShowReadOnlySplits, isNewManualExpenseFlowEnabled, - canEnterScanFieldsManually, isDistanceRequest, shouldShowDate, isReadOnly, @@ -175,10 +171,7 @@ function useFormErrorManagement({ ((!!hasSmartScanFailed && hasMissingSmartscanFields(transaction, transactionReport)) || (didConfirmSplit && areRequiredFieldsEmpty(transaction, transactionReport))); const isMerchantEmpty = !iouMerchant || isMerchantMissing(transaction); - // A scan the user started filling in (amount, merchant or date) behaves like a manual expense with a receipt - // attached, so the merchant becomes required no matter which chat the expense is headed to. - const hasEnteredScanFields = canEnterScanFieldsManually && hasManuallyEnteredScanFields(transaction); - const isMerchantRequired = (isPolicyExpenseChat && (!isScanRequest || !!isEditingSplitBill) && shouldShowMerchant) || hasEnteredScanFields; + const isMerchantRequired = isPolicyExpenseChat && (!isScanRequest || !!isEditingSplitBill) && shouldShowMerchant; const isMerchantFieldValid = (() => { const merchantValue = iouMerchant ?? ''; const trimmedMerchant = merchantValue.trim(); @@ -229,15 +222,12 @@ function useFormErrorManagement({ // 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); - // A scan the user started filling in requires all three of amount, merchant and date, so the error stays until - // the last of them is filled in — the same condition validation raises it from. - const isScanFieldRequiredMissing = hasEnteredScanFields && (!transaction?.isAmountSet || !transaction?.isCreatedSet || isMerchantEmpty); useEffect(() => { - if (!isNewManualExpenseFlowEnabled || formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isScanFieldRequiredMissing) { + if (!isNewManualExpenseFlowEnabled || formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) { return; } setFormError(''); - }, [isNewManualExpenseFlowEnabled, isAmountRequiredMissing, isDateRequiredMissing, isScanFieldRequiredMissing, setFormError]); + }, [isNewManualExpenseFlowEnabled, isAmountRequiredMissing, isDateRequiredMissing, setFormError]); useEffect(() => { const currentFormError = formErrorRef.current; diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index fc5b7b3379fd..1db40cd6239b 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -66,19 +66,8 @@ function AmountField({ setFormError, isParticipantPickerVisible = false, }: AmountFieldProps) { - const { - isEditingSplitBill, - isNewManualExpenseFlowEnabled, - canEnterScanFieldsManually, - shouldShowAutomaticFieldHint, - isReadOnly, - didConfirm, - transactionID, - action, - iouType, - reportID, - reportActionID, - } = useConfirmationFields(); + const {isEditingSplitBill, isNewManualExpenseFlowEnabled, canEnterScanFieldsManually, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = + useConfirmationFields(); // The Scan confirmation keeps the amount unfocused: its fields sit behind "Show more", which the user also opens // to reach the rest of the expense, so focusing the amount would push them towards entering it manually. const shouldAutoFocusOnMount = !canUseTouchScreen() && !canEnterScanFieldsManually; @@ -103,13 +92,11 @@ function AmountField({ const isP2P = isNewManualExpenseFlowEnabled ? isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)) : !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat); - // `common.error.fieldRequired` is shared with the date and merchant fields, 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 populated programmatically and `isAmountSet` is - // never set) can't show a phantom required error under a perfectly good amount. A scan the user started filling in - // is the exception: its amount is empty until entered, and validation requires it alongside the merchant and date. - const shouldShowAmountRequiredError = - formError === 'common.error.fieldRequired' && (isConfirmationAmountMissing(transactionSlice) || (canEnterScanFieldsManually && !transactionSlice?.isAmountSet)); + // `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); const shouldShowAmountInvalidError = formError === 'common.error.invalidAmount'; let amountFieldErrorText = ''; @@ -129,6 +116,8 @@ function AmountField({ const shouldShowEmptyAmount = isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet && (transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually); const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); + // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. + const shouldShowAutomaticHint = canEnterScanFieldsManually && !transactionSlice?.isAmountSet; const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants, isNewManualExpenseFlowEnabled); // `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted @@ -336,7 +325,7 @@ function AmountField({ shouldShowCurrencyButton shouldShowBigNumberPad={false} onCurrencyButtonPress={showCurrencyPicker} - leadingRightHandSideComponent={shouldShowAutomaticFieldHint ? : undefined} + leadingRightHandSideComponent={shouldShowAutomaticHint ? : undefined} disabled={isAmountFieldDisabled} /> diff --git a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx index 5ede4047ea53..9ce5a14d0cda 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx @@ -1,7 +1,7 @@ /** * The "Automatic" hint the Scan confirmation shows inside the amount, merchant and date fields while SmartScan is * still the one filling them in. It mirrors the right label the category field carries for the same promise, and - * disappears from all three the moment the user fills in any one of them. + * each field drops it as soon as that field has a value of its own. */ import Icon from '@components/Icon'; import Text from '@components/Text'; diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index 8b9f46586696..c7efc21c2852 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -10,7 +10,7 @@ import usePolicy from '@hooks/usePolicy'; import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses'; import useThemeStyles from '@hooks/useThemeStyles'; -import {setMoneyRequestCreated, updateDistanceRateOnExpenseDateChange} from '@libs/actions/IOU/MoneyRequest'; +import {clearMoneyRequestCreated, setMoneyRequestCreated, updateDistanceRateOnExpenseDateChange} from '@libs/actions/IOU/MoneyRequest'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -47,7 +47,7 @@ type DateFieldProps = { function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, isNewManualExpenseFlowEnabled, formError, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) { const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isEditingSplitBill, canEnterScanFieldsManually, shouldShowAutomaticFieldHint} = useConfirmationFields(); + const {isEditingSplitBill, canEnterScanFieldsManually} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; @@ -70,11 +70,10 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, isNewManual // A draft is seeded with today's date, but in the Scan flow the date belongs to the receipt, not to today, so the // picker stays empty until the user picks one — the same way the amount field starts empty. const shouldShowEmptyDate = canEnterScanFieldsManually && !dateState?.isCreatedSet; - const isDateEmpty = createdMissing || shouldShowEmptyDate; const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : ''; - const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateEmpty ? translate('common.error.fieldRequired') : ''; + const inlineDateErrorText = formError === 'common.error.fieldRequired' && createdMissing ? translate('common.error.fieldRequired') : ''; const handleDateChange = (newDate: string) => { if (!transactionID) { @@ -92,6 +91,13 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, isNewManual return; } + // Clearing the date on a scan hands the field back to SmartScan rather than emptying it, the same way clearing + // the amount or the merchant does. + if (!newDate && canEnterScanFieldsManually) { + clearMoneyRequestCreated(transactionID, shouldUseTransactionDraft(action)); + return; + } + setMoneyRequestCreated(transactionID, newDate, shouldUseTransactionDraft(action), transactionHasReceipt); if (action !== CONST.IOU.ACTION.EDIT) { @@ -127,8 +133,8 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, isNewManual shouldDeferShowUntilPositioned // The hint only renders while the date is empty, and `TextInput` drops its right-hand-side // component whenever the clear button can appear — which it can't without a value to clear. - shouldHideClearButton={shouldShowAutomaticFieldHint} - rightHandSideComponent={shouldShowAutomaticFieldHint ? : undefined} + shouldHideClearButton={shouldShowEmptyDate} + rightHandSideComponent={shouldShowEmptyDate ? : undefined} /> ); diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx index 5824882668b6..e5da76f287f5 100644 --- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx @@ -32,7 +32,7 @@ type MerchantFieldProps = { }; function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: MerchantFieldProps) { - const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill, isNewManualExpenseFlowEnabled, shouldShowAutomaticFieldHint} = + const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill, isNewManualExpenseFlowEnabled, canEnterScanFieldsManually} = useConfirmationFields(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -45,6 +45,8 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: const merchantValue = merchantState?.merchant ?? ''; const displayMerchantValue = isUntypedPlaceholderMerchant(merchantState?.isMerchantSet, merchantValue) ? '' : merchantValue; const transactionHasReceipt = merchantState?.hasReceipt ?? false; + // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. + const shouldShowAutomaticHint = canEnterScanFieldsManually && !displayMerchantValue; // Mirror the persisted merchant in local state so the controlled input updates synchronously as the user types; // feeding the async Onyx value straight to `value` snaps the caret to the end on every keystroke (see #98647). @@ -135,7 +137,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: label={translate('common.merchant')} accessibilityLabel={translate('common.merchant')} errorText={merchantErrorText} - rightHandSideComponent={shouldShowAutomaticFieldHint ? : undefined} + rightHandSideComponent={shouldShowAutomaticHint ? : undefined} /> ); diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index ec71997d877c..84b00051ef97 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -284,18 +284,9 @@ function isScanRequest(transaction: OnyxEntry): boolean { - return isScanRequest(transaction) && (!!transaction?.isAmountSet || !!transaction?.isMerchantSet || !!transaction?.isCreatedSet); -} - -/** - * Whether the user filled in every one of those fields. Only then is the receipt submitted as `open`, so SmartScan - * never overwrites what the user typed. A partially filled scan is still scanned — that way it can never be created - * with neither an amount of its own nor one read from the receipt. + * Whether the user filled in every one of the amount / merchant / date fields the Scan confirmation reveals behind + * "Show more". Each of them is optional — a field left blank is read off the receipt — so only once all three carry a + * value of their own is the receipt submitted as `open`, where SmartScan never overwrites what the user typed. */ function hasAllManuallyEnteredScanFields(transaction: OnyxEntry): boolean { return isScanRequest(transaction) && !!transaction?.isAmountSet && !!transaction?.isMerchantSet && !!transaction?.isCreatedSet; @@ -3792,7 +3783,6 @@ export { getTagForDisplay, getTransactionViolations, hasAllManuallyEnteredScanFields, - hasManuallyEnteredScanFields, hasReceipt, hasUploadedReceipt, hasEReceipt, diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index 1018f5c7c9d3..29151cd57fec 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -975,6 +975,15 @@ function setMoneyRequestCreated(transactionID: string, created: string, isDraft: setMoneyRequestReceiptState(transactionID, isDraft, shouldStopSmartscan); } +/** + * Returns the date to the state it starts the Scan confirmation in: the field renders empty again (SmartScan is back + * to being the one that fills it), while the seeded `created` stays on the transaction as the fallback date, so a + * cleared field can never submit an expense with no date at all. + */ +function clearMoneyRequestCreated(transactionID: string, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {isCreatedSet: false}); +} + function setMoneyRequestDateAttribute(transactionID: string, start: string, end: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, { comment: {customUnit: {attributes: {dates: {start, end}}}}, @@ -1138,6 +1147,7 @@ export { setMoneyRequestDistanceRate, setMoneyRequestAmount, clearMoneyRequestAmount, + clearMoneyRequestCreated, clearMoneyRequestMerchant, setMoneyRequestCreated, setMoneyRequestDateAttribute, diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 91f940cce564..967b97bd6fc1 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -474,53 +474,66 @@ describe('IOURequestStepConfirmationPageTest', () => { expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue(''); }); - it('requires all three fields once one of them is entered', async () => { + it('submits a partially filled scan, leaving the blank fields to SmartScan', async () => { await renderScanConfirmation(); fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); + fireEvent.changeText(screen.getByLabelText(translateLocal('iou.amount')), '12.34'); await waitForBatchedUpdatesWithAct(); fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); await waitForBatchedUpdatesWithAct(); - expect(TrackExpense.requestMoney).not.toHaveBeenCalled(); - expect(screen.getAllByText(translateLocal('common.error.fieldRequired')).length).toBeGreaterThan(1); + // The date was never picked, so it stays "Automatic" instead of blocking confirmation. + expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen(); + expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1); }); - it('labels the amount, merchant and date fields "Automatic" until one of them is entered', async () => { + it('labels the amount, merchant and date fields "Automatic" until that field is entered', async () => { await renderScanConfirmation(); - // The category field carries the same label, so count the three that leave rather than expecting none left. + // The category field carries the same label, so count the ones that leave rather than expecting none left. const automaticLabelCount = screen.getAllByText(translateLocal('common.automatic')).length; expect(automaticLabelCount).toBeGreaterThanOrEqual(3); fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); await waitForBatchedUpdatesWithAct(); - expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 3); - }); + // Only the merchant's label goes: the amount and the date are still the ones SmartScan reads. + expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1); - it('stops requiring the three fields once the entered one is cleared again', async () => { - await renderScanConfirmation(); - - fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); - await waitForBatchedUpdatesWithAct(); - fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); + fireEvent.changeText(screen.getByLabelText(translateLocal('iou.amount')), '12.34'); await waitForBatchedUpdatesWithAct(); - expect(TrackExpense.requestMoney).not.toHaveBeenCalled(); - expect(screen.getAllByText(translateLocal('common.error.fieldRequired')).length).toBeGreaterThan(0); + expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 2); + }); - // Clearing the merchant makes it a plain scan again, so the error that blocked confirmation has to go with it. - fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), ''); - await waitForBatchedUpdatesWithAct(); + it('hands a cleared date back to SmartScan instead of emptying it', async () => { + await renderScanConfirmation(); - expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, {created: '2025-01-15', isCreatedSet: true}); + }); + expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue('2025-01-15'); - fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); + fireEvent(screen.getByLabelText(translateLocal('common.date')), 'onInputChange', ''); await waitForBatchedUpdatesWithAct(); + // The field reads as "Automatic" again, while the transaction keeps a date to fall back on. + expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue(''); expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen(); + + const draft = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + expect(draft?.created).toBe('2025-01-15'); + expect(draft?.isCreatedSet).toBe(false); }); it('submits the entered amount, merchant and date instead of waiting for SmartScan', async () => { diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 4038e82394b9..bc73b5d117e9 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5248,31 +5248,25 @@ describe('doesMoneyRequestDraftHaveUserInput', () => { }); }); -describe('hasManuallyEnteredScanFields', () => { +describe('hasAllManuallyEnteredScanFields', () => { function generateScanDraft(values: Partial = {}): Transaction { return generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, ...values}); } - it('returns false for an untouched scan draft', () => { - expect(TransactionUtils.hasManuallyEnteredScanFields(undefined)).toBe(false); - expect(TransactionUtils.hasManuallyEnteredScanFields(generateScanDraft())).toBe(false); + it('returns false while any of the three fields is still left to SmartScan', () => { + expect(TransactionUtils.hasAllManuallyEnteredScanFields(undefined)).toBe(false); + expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft())).toBe(false); + expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true}))).toBe(false); }); - it.each([['amount', {isAmountSet: true}] as const, ['merchant', {isMerchantSet: true}] as const, ['date', {isCreatedSet: true}] as const])( - 'returns true once the %s has been entered', - (_field, values) => { - expect(TransactionUtils.hasManuallyEnteredScanFields(generateScanDraft(values))).toBe(true); - }, - ); - - it('returns false for expense types that populate those fields programmatically', () => { - expect(TransactionUtils.hasManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, isAmountSet: true}))).toBe(false); - expect(TransactionUtils.hasManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, isAmountSet: true}))).toBe(false); + it('returns true once every one of them has been entered', () => { + expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true, isCreatedSet: true}))).toBe(true); }); - it('only reports all fields entered once every one of them is', () => { - expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true}))).toBe(false); - expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true, isCreatedSet: true}))).toBe(true); + it('returns false for expense types that populate those fields programmatically', () => { + const values = {isAmountSet: true, isMerchantSet: true, isCreatedSet: true}; + expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, ...values}))).toBe(false); + expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, ...values}))).toBe(false); }); }); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 890c07fe1bf7..1aa7b1cd9ff0 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -990,9 +990,9 @@ describe('useConfirmationValidation', () => { ['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 remaining fields once the %s is entered', (_field, transactionOverrides, overrides) => { + ])('leaves the other fields optional once the %s is entered, since a blank field is still scanned', (_field, transactionOverrides, overrides) => { const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides))); - expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + expect(result.current.validate()).toEqual({errorKey: null}); }); it('passes once all three fields are entered', () => { diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index d686bf30b76a..809740822b64 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -46,7 +46,6 @@ const baseParams: Params = { isTypeSplit: false, shouldShowReadOnlySplits: false, isNewManualExpenseFlowEnabled: false, - canEnterScanFieldsManually: false, isDistanceRequest: false, shouldShowDate: false, isReadOnly: false, @@ -243,9 +242,8 @@ describe('useFormErrorManagement', () => { expect(result.current.isMerchantFieldValid).toBe(false); }); - it('requires the merchant once the user starts filling in the scan fields, whoever the expense is headed to', () => { + it('leaves the merchant optional on a scan the user has started filling in, since a blank field is still scanned', () => { const scanParams: Partial = { - canEnterScanFieldsManually: true, isNewManualExpenseFlowEnabled: true, isScanRequest: true, isPolicyExpenseChat: false, @@ -268,8 +266,7 @@ describe('useFormErrorManagement', () => { ); expect(untouched.current.isMerchantRequired).toBe(false); - expect(amountEntered.current.isMerchantRequired).toBe(true); - expect(amountEntered.current.isMerchantFieldValid).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)', () => { From 7273e15431fd2e69f66169543e989b14e166ea3a Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:12:07 +0800 Subject: [PATCH 4/9] fix: automatic hint text --- src/components/DatePicker/index.tsx | 19 +++++++++++++----- src/components/DatePicker/types.ts | 6 ++++++ .../sections/AmountField.tsx | 11 +++++++++- .../sections/AutomaticFieldHint.tsx | 2 +- .../sections/DateField.tsx | 11 ++++++++-- .../sections/MerchantField.tsx | 7 +++++-- .../IOURequestStepConfirmationPageTest.tsx | 20 +++++++++++++++++++ 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index 4d360d7071e4..bf62b004fef2 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -49,6 +49,7 @@ function DatePicker({ shouldDeferShowUntilPositioned = false, shouldDismissKeyboardBeforeShow = false, rightHandSideComponent, + onPickerVisibilityChange, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); @@ -102,6 +103,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 @@ -117,7 +126,7 @@ function DatePicker({ const openPicker = () => { if (!shouldDeferShowUntilPositioned) { calculatePopoverPosition(); - setIsModalVisible(true); + setPickerVisibility(true); return; } @@ -126,16 +135,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; @@ -144,7 +153,7 @@ function DatePicker({ textInputRef.current?.blur(); ComposerFocusManager.blurActiveInput(); Keyboard.dismiss(); - }, [shouldDismissKeyboardBeforeShow]); + }, [shouldDismissKeyboardBeforeShow, setPickerVisibility]); const handlePress = useCallback>( (event) => { diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 362255f2dda6..33e11c1f7ccd 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -69,6 +69,12 @@ type DateInputWithPickerProps = DatePickerBaseProps & * @default false */ shouldDismissKeyboardBeforeShow?: boolean; + + /** + * Reports whether the calendar is open. Opening the picker blurs the input, so this — not `onFocus` — is the + * signal for "the user is on this field", and it is what drives the input's focused border. + */ + onPickerVisibilityChange?: (isVisible: boolean) => void; }; type DatePickerProps = { diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 9a79965befe2..d68534692955 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -85,6 +85,7 @@ function AmountField({ const amountIsMissing = transactionSlice?.isAmountMissing ?? false; const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); + const [isAmountInputFocused, setIsAmountInputFocused] = useState(false); const isAmountFieldDisabled = didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest; const isP2P = isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0)); @@ -112,7 +113,9 @@ function AmountField({ const shouldShowEmptyAmount = !transactionSlice?.isAmountSet && (transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually); const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. - const shouldShowAutomaticHint = canEnterScanFieldsManually && !transactionSlice?.isAmountSet; + // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for + // the first keystroke — it would otherwise sit next to the caret promising to fill in what is being typed. + const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !transactionSlice?.isAmountSet; const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants); // `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted @@ -319,6 +322,12 @@ function AmountField({ shouldShowCurrencyButton shouldShowBigNumberPad={false} onCurrencyButtonPress={showCurrencyPicker} + onFocus={() => { + setIsAmountInputFocused(true); + }} + onBlur={() => { + setIsAmountInputFocused(false); + }} leadingRightHandSideComponent={shouldShowAutomaticHint ? : undefined} disabled={isAmountFieldDisabled} /> diff --git a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx index 9ce5a14d0cda..9b1fe9f29ad9 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx @@ -1,7 +1,7 @@ /** * The "Automatic" hint the Scan confirmation shows inside the amount, merchant and date fields while SmartScan is * still the one filling them in. It mirrors the right label the category field carries for the same promise, and - * each field drops it as soon as that field has a value of its own. + * each field drops it as soon as the user takes the field over — on focus, or once it has a value of its own. */ import Icon from '@components/Icon'; import Text from '@components/Text'; diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index d19e6c22cda8..fce2e41ed886 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -25,7 +25,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/MoneyRequestDateForm'; import {format} from 'date-fns'; -import React from 'react'; +import React, {useState} from 'react'; import {View} from 'react-native'; import AutomaticFieldHint from './AutomaticFieldHint'; @@ -70,6 +70,12 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, // picker stays empty until the user picks one — the same way the amount field starts empty. const shouldShowEmptyDate = canEnterScanFieldsManually && !dateState?.isCreatedSet; + // Opening the calendar blurs the input, so the open picker — not focus — is this field's "the user is on it" + // signal, and it is what draws the focused border. The hint follows it so it can't sit next to an open calendar + // promising to fill in the date the user is picking. It stays tied to the empty value beyond that. + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen; + const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : ''; const inlineDateErrorText = formError === 'common.error.fieldRequired' && createdMissing ? translate('common.error.fieldRequired') : ''; @@ -133,7 +139,8 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, // The hint only renders while the date is empty, and `TextInput` drops its right-hand-side // component whenever the clear button can appear — which it can't without a value to clear. shouldHideClearButton={shouldShowEmptyDate} - rightHandSideComponent={shouldShowEmptyDate ? : undefined} + rightHandSideComponent={shouldShowAutomaticHint ? : undefined} + onPickerVisibilityChange={setIsDatePickerOpen} /> ); diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx index c0532e77a459..21cb818526d2 100644 --- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx @@ -44,8 +44,6 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: const merchantValue = merchantState?.merchant ?? ''; const displayMerchantValue = isUntypedPlaceholderMerchant(merchantState?.isMerchantSet, merchantValue) ? '' : merchantValue; const transactionHasReceipt = merchantState?.hasReceipt ?? false; - // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. - const shouldShowAutomaticHint = canEnterScanFieldsManually && !displayMerchantValue; // Mirror the persisted merchant in local state so the controlled input updates synchronously as the user types; // feeding the async Onyx value straight to `value` snaps the caret to the end on every keystroke (see #98647). @@ -54,6 +52,11 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: const [prevDisplayValue, setPrevDisplayValue] = useState(displayMerchantValue); const [prevTransactionID, setPrevTransactionID] = useState(transactionID); + // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. + // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for + // the first keystroke — it would otherwise sit next to the caret promising to fill in what is being typed. + const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !displayMerchantValue; + // Sync the mirror during render (not in an effect) to avoid an extra render pass. Reset on transaction change // even while focused; otherwise sync external updates (SmartScan, drafts) only when the field isn't being edited. if (transactionID !== prevTransactionID) { diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 2aa85ac7df29..5a0bd0fa8959 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -527,6 +527,26 @@ describe('IOURequestStepConfirmationPageTest', () => { expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 2); }); + it('drops the "Automatic" label while a field is focused, and brings it back if the field is left empty', async () => { + await renderScanConfirmation(); + + const automaticLabelCount = screen.getAllByText(translateLocal('common.automatic')).length; + + // Focusing is the user taking the field over, so the label goes before the first keystroke. + fireEvent(screen.getByLabelText(translateLocal('iou.amount')), 'focus'); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1); + + // Leaving it without entering anything hands the field back to SmartScan. + fireEvent(screen.getByLabelText(translateLocal('iou.amount')), 'blur'); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount); + + fireEvent(screen.getByLabelText(translateLocal('common.merchant')), 'focus'); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1); + }); + it('hands a cleared date back to SmartScan instead of emptying it', async () => { await renderScanConfirmation(); From c880eee84fcef5cf6a65c354bb88ddb33d1041d8 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Thu, 10 Sep 2026 02:50:44 +0800 Subject: [PATCH 5/9] fix: Derive the final receipt state before submission --- src/components/DatePicker/types.ts | 4 +- .../hooks/useConfirmationValidation.ts | 6 +- .../sections/AmountField.tsx | 2 +- .../sections/AutomaticFieldHint.tsx | 2 +- .../sections/DateField.tsx | 12 ++-- .../sections/MerchantField.tsx | 2 +- src/libs/TransactionUtils/index.ts | 4 +- src/libs/actions/IOU/MoneyRequestBuilder.ts | 5 ++ src/libs/actions/IOU/TrackExpense.ts | 8 ++- .../types/TrackExpenseTransactionParams.ts | 8 +++ .../step/IOURequestStepConfirmation.tsx | 1 + .../step/confirmation/useExpenseSubmission.ts | 28 ++++++++ tests/unit/hooks/useExpenseSubmission.test.ts | 70 +++++++++++++++++++ 13 files changed, 134 insertions(+), 18 deletions(-) diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index 33e11c1f7ccd..ac767dae7208 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -71,8 +71,8 @@ type DateInputWithPickerProps = DatePickerBaseProps & shouldDismissKeyboardBeforeShow?: boolean; /** - * Reports whether the calendar is open. Opening the picker blurs the input, so this — not `onFocus` — is the - * signal for "the user is on this field", and it is what drives the input's focused border. + * 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; }; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index a64a60be6b26..7af332bdeac5 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -175,8 +175,8 @@ function useConfirmationValidation({ }: 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 + // 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 => { @@ -195,7 +195,7 @@ function useConfirmationValidation({ 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 + // `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)) { return {errorKey: 'common.error.fieldRequired'}; diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index d68534692955..0e3e74bfc731 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -114,7 +114,7 @@ function AmountField({ const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals); // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for - // the first keystroke — it would otherwise sit next to the caret promising to fill in what is being typed. + // the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed. const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !transactionSlice?.isAmountSet; const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants); diff --git a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx index 9b1fe9f29ad9..dddbe0df3ea4 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx @@ -1,7 +1,7 @@ /** * The "Automatic" hint the Scan confirmation shows inside the amount, merchant and date fields while SmartScan is * still the one filling them in. It mirrors the right label the category field carries for the same promise, and - * each field drops it as soon as the user takes the field over — on focus, or once it has a value of its own. + * each field drops it as soon as the user takes the field over, on focus or once it has a value of its own. */ import Icon from '@components/Icon'; import Text from '@components/Text'; diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index fce2e41ed886..2556d4395d77 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -67,12 +67,12 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, const transactionHasReceipt = dateState?.hasReceipt ?? false; // A draft is seeded with today's date, but in the Scan flow the date belongs to the receipt, not to today, so the - // picker stays empty until the user picks one — the same way the amount field starts empty. + // picker stays empty until the user picks one, the same way the amount field starts empty. const shouldShowEmptyDate = canEnterScanFieldsManually && !dateState?.isCreatedSet; - // Opening the calendar blurs the input, so the open picker — not focus — is this field's "the user is on it" - // signal, and it is what draws the focused border. The hint follows it so it can't sit next to an open calendar - // promising to fill in the date the user is picking. It stays tied to the empty value beyond that. + // Opening the calendar blurs the input, so the open picker is this field's "the user is on it" signal rather + // than focus, and it is what draws the focused border. The hint follows it so it can't sit next to an open + // calendar promising to fill in the date the user is picking. It stays tied to the empty value beyond that. const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen; @@ -86,7 +86,7 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, } // While the picker renders empty the persisted date is only a default, so a pick that matches it still has to - // be written — that write is what marks the date as chosen by the user. + // be written. That write is what marks the date as chosen by the user. if (newDate === iouCreated && !shouldShowEmptyDate) { return; } @@ -137,7 +137,7 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, errorText={inlineDateErrorText || dateErrorText} shouldDeferShowUntilPositioned // The hint only renders while the date is empty, and `TextInput` drops its right-hand-side - // component whenever the clear button can appear — which it can't without a value to clear. + // component whenever the clear button can appear, which it can't without a value to clear. shouldHideClearButton={shouldShowEmptyDate} rightHandSideComponent={shouldShowAutomaticHint ? : undefined} onPickerVisibilityChange={setIsDatePickerOpen} diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx index 21cb818526d2..77349a75f7bf 100644 --- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx @@ -54,7 +54,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for - // the first keystroke — it would otherwise sit next to the caret promising to fill in what is being typed. + // the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed. const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !displayMerchantValue; // Sync the mirror during render (not in an effect) to avoid an extra render pass. Reset on transaction change diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 989706442e3f..96d8cd40b21c 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -285,8 +285,8 @@ function isScanRequest(transaction: OnyxEntry): boolean { return isScanRequest(transaction) && !!transaction?.isAmountSet && !!transaction?.isMerchantSet && !!transaction?.isCreatedSet; diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index a77f4cc65eff..48c1601007d2 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -130,6 +130,11 @@ type RequestMoneyTransactionParams = Omit & { linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction; linkedTrackedExpenseReportID?: string; receipt?: Receipt; + /** + * Overrides the state carried on `receipt` when the caller derives it at submit time. The Scan confirmation does, + * because a receipt validated before the user finished typing carries a state that is a field behind. + */ + receiptState?: ValueOf; waypoints?: WaypointCollection; comment?: string; originalTransactionID?: string; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index f7ae3d5fb8dd..d5986f010baf 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1702,6 +1702,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep merchant, comment = '', receipt, + receiptState, category, tag, taxCode = '', @@ -1918,7 +1919,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep createdIOUReportActionID, reportPreviewReportActionID: reportPreviewAction.reportActionID, receipt: isFileUploadable(receipt) ? receipt : undefined, - receiptState: receipt?.state, + receiptState: receiptState ?? receipt?.state, category, tag, taxCode, @@ -2484,6 +2485,7 @@ function trackExpense(params: CreateTrackExpenseParams) { distance, modifiedDistance, receipt, + receiptState, category, tag, taxCode = '', @@ -2512,6 +2514,8 @@ function trackExpense(params: CreateTrackExpenseParams) { // Pass an open receipt so the distance expense will show a map with the route optimistically const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN, name: 'receipt-generic.png'} : receipt; + // The generic distance receipt above carries its own state, so only a real receipt takes the caller's override. + const trackedReceiptState = validWaypoints ? undefined : receiptState; const sanitizedWaypoints = validWaypoints ? stringifyWaypointsForAPI(validWaypoints) : undefined; const retryParams: CreateTrackExpenseParams = { @@ -2875,7 +2879,7 @@ function trackExpense(params: CreateTrackExpenseParams) { // Tracked expenses in the CREATE flow are unreported and not tied to a policy policyID: undefined, receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined, - receiptState: trackedReceipt?.state, + receiptState: trackedReceiptState ?? trackedReceipt?.state, reimbursable, category, tag, diff --git a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts index 8ab23f2a1b6f..51b9791d298c 100644 --- a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts +++ b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts @@ -1,7 +1,10 @@ +import type CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {Receipt, WaypointCollection} from '@src/types/onyx/Transaction'; +import type {ValueOf} from 'type-fest'; + type GPSPoint = { lat: number; long: number; @@ -16,6 +19,11 @@ type TrackExpenseTransactionParams = { distance?: number; modifiedDistance?: number; receipt?: Receipt; + /** + * Overrides the state carried on `receipt` when the caller derives it at submit time. The Scan confirmation does, + * because a receipt validated before the user finished typing carries a state that is a field behind. + */ + receiptState?: ValueOf; category?: string; tag?: string; taxCode?: string; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index ba3cce2db3a4..03decdda7d85 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -614,6 +614,7 @@ function IOURequestStepConfirmationContent({ transaction, transactions, receiptFiles, + canEnterScanFieldsManually, report, reportID, policy, diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts index a41da639c66d..137b286e694f 100644 --- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts +++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts @@ -53,10 +53,12 @@ import { getSelectedRouteDistance, getTaxValue, getValidWaypoints, + hasAllManuallyEnteredScanFields, hasAppliedCommuterExclusion, isDistanceRequest as isDistanceRequestTransactionUtils, isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils, isManualDistanceRequest as isManualDistanceRequestTransactionUtils, + isScanRequest as isScanRequestTransactionUtils, } from '@libs/TransactionUtils'; import {resolveChatTargetForSubmitCleanup} from '@pages/iou/request/step/resolveChatTarget'; @@ -80,6 +82,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import {delegateEmailSelector} from '@selectors/Account'; import {hasSeenTourSelector} from '@selectors/Onboarding'; @@ -115,6 +118,12 @@ type UseExpenseSubmissionParams = { transactions: Transaction[]; receiptFiles: Record; + /** + * Whether the Scan confirmation lets the user fill in the amount / merchant / date themselves. When it does, the + * receipt state has to be re-derived at submit time, see `getReceiptWithCurrentState`. + */ + canEnterScanFieldsManually: boolean; + // Report data report: OnyxEntry; reportID: string; @@ -182,6 +191,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { transaction, transactions, receiptFiles, + canEnterScanFieldsManually, report, reportID, policy, @@ -399,6 +409,22 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { }); } + /** + * The receipt state sent to the backend decides whether SmartScan reads the receipt, and on a Scan the user filled + * in themselves it depends on the amount / merchant / date. `receiptFiles` bakes that state in during an async + * file validation pass, so it lags the field the user just typed. Deriving it from the live transaction at submit + * time instead keeps a submit that lands mid-validation from scanning over values the user entered, or from + * skipping the scan on a field they just cleared. Returning `undefined` leaves the validated receipt's own state + * in place, which is what every other flow submits. + */ + function getCurrentReceiptState(item: Transaction): ValueOf | undefined { + const receipt = receiptFiles[item.transactionID]; + if (!receipt || !canEnterScanFieldsManually || receipt.isTestReceipt || receipt.isTestDriveReceipt || !isScanRequestTransactionUtils(item)) { + return undefined; + } + return hasAllManuallyEnteredScanFields(item) ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY; + } + /** * Emits the `[Receipt] submitted` log for one expense as it leaves the confirmation page. */ @@ -537,6 +563,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { merchant: merchantToUse, comment: item?.comment?.comment?.trim() ?? '', receipt, + receiptState: getCurrentReceiptState(item), category: item.category, tag: item.tag, taxCode: transactionTaxCode, @@ -820,6 +847,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) { merchant: item.merchant, comment: item?.comment?.comment?.trim() ?? '', receipt: trackReceipt, + receiptState: getCurrentReceiptState(item), category: item.category, tag: item.tag, taxCode: transactionTaxCode, diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts index b6ecf24e6711..9b6b235e06f9 100644 --- a/tests/unit/hooks/useExpenseSubmission.test.ts +++ b/tests/unit/hooks/useExpenseSubmission.test.ts @@ -11,6 +11,7 @@ import useExpenseSubmission from '@pages/iou/request/step/confirmation/useExpens import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyCategories, Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {Receipt} from '@src/types/onyx/Transaction'; import Onyx from 'react-native-onyx'; @@ -203,6 +204,7 @@ function buildParams(overrides: Partial[ transaction, transactions: [transaction], receiptFiles: {}, + canEnterScanFieldsManually: false, report: {reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT} as Report, reportID: REPORT_ID, policy: createMock({id: 'policy-1'}), @@ -283,6 +285,74 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => { } }); + describe('receipt state on a Scan the user filled in', () => { + function buildScanParams(transactionOverrides: Partial, cachedReceiptState: Receipt['state']) { + const transaction = buildTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, ...transactionOverrides}); + return buildParams({ + transaction, + transactions: [transaction], + canEnterScanFieldsManually: true, + receiptFiles: {[TRANSACTION_ID]: createMock({state: cachedReceiptState})}, + }); + } + + async function submit(params: Parameters[0]) { + const {result} = renderHook(() => useExpenseSubmission(params)); + await waitForBatchedUpdatesWithAct(); + await act(async () => { + result.current.createTransaction(false, false); + }); + await waitForBatchedUpdatesWithAct(); + } + + it('submits `open` when all three fields are entered, even while the cached receipt still says SCAN_READY', async () => { + // Given a scan the user filled in whose receipt was validated before the last field was entered + await submit(buildScanParams({isAmountSet: true, isMerchantSet: true, isCreatedSet: true}, CONST.IOU.RECEIPT_STATE.SCAN_READY)); + + // Then SmartScan is told to leave the receipt alone rather than overwriting what the user typed + expect(mockRequestMoneyAction).toHaveBeenCalledWith( + expect.objectContaining({ + transactionParams: expect.objectContaining({receiptState: CONST.IOU.RECEIPT_STATE.OPEN}), + }), + ); + }); + + it('submits `scanready` once a field is cleared again, even while the cached receipt still says OPEN', async () => { + // Given a scan whose merchant the user cleared after having filled all three fields + await submit(buildScanParams({isAmountSet: true, isMerchantSet: false, isCreatedSet: true}, CONST.IOU.RECEIPT_STATE.OPEN)); + + // Then SmartScan is asked to read the receipt so the cleared field still gets filled in + expect(mockRequestMoneyAction).toHaveBeenCalledWith( + expect.objectContaining({ + transactionParams: expect.objectContaining({receiptState: CONST.IOU.RECEIPT_STATE.SCAN_READY}), + }), + ); + }); + + it('sends no override on surfaces that do not expose the scan fields, leaving the validated receipt state alone', async () => { + // Given a manual expense with an attached receipt, which the validator already marked `open` + const transaction = buildTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL}); + await submit( + buildParams({ + transaction, + transactions: [transaction], + canEnterScanFieldsManually: false, + receiptFiles: {[TRANSACTION_ID]: createMock({state: CONST.IOU.RECEIPT_STATE.OPEN})}, + }), + ); + + // Then no override is sent and the action keeps using the state the validator wrote onto the receipt + expect(mockRequestMoneyAction).toHaveBeenCalledWith( + expect.objectContaining({ + transactionParams: expect.objectContaining({ + receiptState: undefined, + receipt: expect.objectContaining({state: CONST.IOU.RECEIPT_STATE.OPEN}), + }), + }), + ); + }); + }); + it('calls cleanupAfterExpenseCreate and skips cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=false (orchestrator pre-navigated)', async () => { const {result} = renderHook(() => useExpenseSubmission(buildParams())); await waitForBatchedUpdatesWithAct(); From d4866409d60d32f441ada6d4e6477d2128a0fa4d Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:29:38 +0800 Subject: [PATCH 6/9] fix: scan flow --- src/components/DatePicker/index.tsx | 3 +- src/components/DatePicker/types.ts | 7 ++ .../MoneyRequestConfirmationList.tsx | 1 + .../hooks/useConfirmationValidation.ts | 11 +++- .../hooks/useFormErrorManagement.ts | 15 +++-- .../sections/AmountField.tsx | 15 +++-- .../sections/DateField.tsx | 14 +++- .../sections/MerchantField.tsx | 11 +++- .../sections/selectors.ts | 33 +++++++++- src/libs/MoneyRequestUtils.ts | 25 +++++++- src/libs/TransactionUtils/index.ts | 48 ++++++++++++-- src/libs/actions/IOU/MoneyRequestBuilder.ts | 2 + src/libs/actions/IOU/TrackExpense.ts | 7 ++ .../IOURequestStepConfirmationPageTest.tsx | 64 ++++++++++++++++--- tests/unit/TransactionUtilsTest.ts | 50 +++++++++++++++ .../hooks/useConfirmationValidation.test.ts | 17 ++++- .../hooks/useFormErrorManagement.test.tsx | 1 + 17 files changed, 286 insertions(+), 38 deletions(-) diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index bf62b004fef2..3cede0b289ea 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -50,6 +50,7 @@ function DatePicker({ shouldDismissKeyboardBeforeShow = false, rightHandSideComponent, onPickerVisibilityChange, + shouldHideCalendarIcon = false, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); @@ -232,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} diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index ac767dae7208..8f54da70932f 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -75,6 +75,13 @@ type DateInputWithPickerProps = DatePickerBaseProps & * 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 = { diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index e0961f024fc2..8ad7ce1f1c80 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -347,6 +347,7 @@ function MoneyRequestConfirmationList({ isEditingSplitBill, isPolicyExpenseChat, isScanRequest, + canEnterScanFieldsManually, shouldShowMerchant, hasSmartScanFailed, didConfirmSplit, diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 7af332bdeac5..0848587623d0 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -16,6 +16,7 @@ import { getTaxAmount, hasTaxRateWithMatchingValue, isMerchantMissing, + isPartiallyEnteredScanExpense, isScanRequest as isScanRequestUtil, } from '@libs/TransactionUtils'; import {isValidInputLength} from '@libs/ValidationUtils'; @@ -197,7 +198,13 @@ function useConfirmationValidation({ } // `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)) { + 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 ( @@ -212,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 ?? ''; diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 752f6eeb3c25..71949e84f827 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -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'; @@ -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; @@ -138,6 +141,7 @@ function useFormErrorManagement({ isEditingSplitBill, isPolicyExpenseChat, isScanRequest, + canEnterScanFieldsManually, shouldShowMerchant, hasSmartScanFailed, didConfirmSplit, @@ -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; diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx index 0e3e74bfc731..63ace08dda4a 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx @@ -16,7 +16,7 @@ import {calculateAmount, isMovingTransactionFromTrackExpense, isParticipantP2P} import {isConfirmationAmountMissing} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {shouldEnableNegative} from '@libs/ReportUtils'; -import {calculateTaxAmount, getTaxCode, getTaxValue} from '@libs/TransactionUtils'; +import {calculateTaxAmount, getTaxCode, getTaxValue, hasAnyManuallyEnteredScanField} from '@libs/TransactionUtils'; import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal'; @@ -93,7 +93,7 @@ function AmountField({ // 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); + const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && isConfirmationAmountMissing(transactionSlice, canEnterScanFieldsManually); const shouldShowAmountInvalidError = formError === 'common.error.invalidAmount'; let amountFieldErrorText = ''; @@ -115,7 +115,12 @@ function AmountField({ // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for // the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed. - const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !transactionSlice?.isAmountSet; + // Entering any one of the three fields drops the hint from all of them, since that is the point where the expense + // stops being scanned and the other two become the user's to fill in as well. + const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !hasAnyManuallyEnteredScanField(transactionSlice); + // The hint and the flip / currency buttons share the right-hand side of the input, so the field shows one or the + // other. The buttons come back as soon as the amount is the user's to enter. + const shouldShowAmountButtons = !shouldShowAutomaticHint; const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants); // `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted @@ -318,8 +323,8 @@ function AmountField({ errorText={amountFieldErrorText} onInputChange={handleAmountChange} allowNegativeInput={allowNegative} - shouldShowFlipButton - shouldShowCurrencyButton + shouldShowFlipButton={shouldShowAmountButtons} + shouldShowCurrencyButton={shouldShowAmountButtons} shouldShowBigNumberPad={false} onCurrencyButtonPress={showCurrencyPicker} onFocus={() => { diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index 2556d4395d77..a7b023706b47 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -15,6 +15,7 @@ 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 {setDraftSplitTransaction} from '@userActions/IOU/Split'; @@ -72,13 +73,17 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, // Opening the calendar blurs the input, so the open picker is this field's "the user is on it" signal rather // than focus, and it is what draws the focused border. The hint follows it so it can't sit next to an open - // calendar promising to fill in the date the user is picking. It stays tied to the empty value beyond that. + // calendar promising to fill in the date the user is picking. Entering any one of the three fields drops the hint + // from all of them, since that is the point where the expense stops being scanned. const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); - const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen; + const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen && !hasAnyManuallyEnteredScanField(dateState); const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : ''; - const inlineDateErrorText = formError === 'common.error.fieldRequired' && createdMissing ? translate('common.error.fieldRequired') : ''; + // On a half-filled Scan the date is required even though it is never blank in the draft, so the all-or-nothing + // predicate stands in for `createdMissing` there. + const isDateRequiredMissing = isPartiallyEnteredScanExpense(dateState, canEnterScanFieldsManually) ? !dateState?.isCreatedSet : createdMissing; + const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateRequiredMissing ? translate('common.error.fieldRequired') : ''; const handleDateChange = (newDate: string) => { if (!transactionID) { @@ -140,6 +145,9 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, // component whenever the clear button can appear, which it can't without a value to clear. shouldHideClearButton={shouldShowEmptyDate} rightHandSideComponent={shouldShowAutomaticHint ? : undefined} + // The calendar icon and the hint share the right-hand side, so the field shows one or the other. + // The icon comes back once the user opens the picker, the same way the amount field's buttons do. + shouldHideCalendarIcon={shouldShowAutomaticHint} onPickerVisibilityChange={setIsDatePickerOpen} /> diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx index 77349a75f7bf..ebcfaa46c1f3 100644 --- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx @@ -8,8 +8,10 @@ import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearMoneyRequestMerchant, setMoneyRequestMerchant} from '@libs/actions/IOU/MoneyRequest'; +import {isConfirmationMerchantMissing} from '@libs/MoneyRequestUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; +import {hasAnyManuallyEnteredScanField} from '@libs/TransactionUtils'; import {isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils'; import {setDraftSplitTransaction} from '@userActions/IOU/Split'; @@ -55,7 +57,9 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty. // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for // the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed. - const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !displayMerchantValue; + // Entering any one of the three fields drops the hint from all of them, since that is the point where the expense + // stops being scanned and the other two become the user's to fill in as well. + const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !hasAnyManuallyEnteredScanField(merchantState); // Sync the mirror during render (not in an effect) to avoid an extra render pass. Reset on transaction change // even while focused; otherwise sync external updates (SmartScan, drafts) only when the field isn't being edited. @@ -83,8 +87,9 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: } // `common.error.fieldRequired` is shared with the amount and date fields, so only surface it here when the - // merchant is the required value that is still missing. - if (formError === 'common.error.fieldRequired' && isMerchantRequired && !displayMerchantValue) { + // merchant is the required value that is still missing. On a half-filled Scan it is required even though the + // surface does not otherwise demand a merchant, because the three fields are all-or-nothing there. + if (formError === 'common.error.fieldRequired' && (isConfirmationMerchantMissing(merchantState, canEnterScanFieldsManually) || (isMerchantRequired && !displayMerchantValue))) { return translate('common.error.fieldRequired'); } diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts index 557eb76dbdc8..6afd59c09990 100644 --- a/src/components/MoneyRequestConfirmationList/sections/selectors.ts +++ b/src/components/MoneyRequestConfirmationList/sections/selectors.ts @@ -23,7 +23,16 @@ type Transaction = OnyxTypes.Transaction; // --- DateField --- -type DateState = {iouCreated: string; isMissing: boolean; hasReceipt: boolean; isCreatedSet: boolean}; +type DateState = { + iouCreated: string; + isMissing: boolean; + hasReceipt: boolean; + isCreatedSet: boolean; + // The Scan confirmation's amount / merchant / date are all-or-nothing, so the date field reads the other two. + iouRequestType: Transaction['iouRequestType']; + isAmountSet: boolean; + isMerchantSet: boolean; +}; const dateStateSelector = (t: OnyxEntry): DateState | undefined => { if (!t) { @@ -34,6 +43,9 @@ const dateStateSelector = (t: OnyxEntry): DateState | undefined => isMissing: isCreatedMissing(t), hasReceipt: hasReceipt(t), isCreatedSet: t.isCreatedSet ?? false, + iouRequestType: t.iouRequestType, + isAmountSet: t.isAmountSet ?? false, + isMerchantSet: t.isMerchantSet ?? false, }; }; @@ -110,7 +122,16 @@ const categoryStateSelector = (t: OnyxEntry): CategoryState | undef // --- MerchantField --- -type MerchantState = {merchant: string; isMerchantSet: boolean; isMissing: boolean; hasReceipt: boolean}; +type MerchantState = { + merchant: string; + isMerchantSet: boolean; + isMissing: boolean; + hasReceipt: boolean; + // The Scan confirmation's amount / merchant / date are all-or-nothing, so the merchant field reads the other two. + iouRequestType: Transaction['iouRequestType']; + isAmountSet: boolean; + isCreatedSet: boolean; +}; const merchantStateSelector = (t: OnyxEntry): MerchantState | undefined => { if (!t) { @@ -121,6 +142,9 @@ const merchantStateSelector = (t: OnyxEntry): MerchantState | undef isMerchantSet: t.isMerchantSet ?? false, isMissing: isMerchantMissing(t), hasReceipt: hasReceipt(t), + iouRequestType: t.iouRequestType, + isAmountSet: t.isAmountSet ?? false, + isCreatedSet: t.isCreatedSet ?? false, }; }; @@ -151,6 +175,9 @@ type AmountSlice = { isAmountMissing: boolean; isAmountSet: Transaction['isAmountSet']; taxCode: Transaction['taxCode']; + // The Scan confirmation's amount / merchant / date are all-or-nothing, so the amount field reads the other two. + isMerchantSet: boolean; + isCreatedSet: boolean; }; const amountSliceSelector = (t: OnyxEntry): AmountSlice | undefined => { @@ -168,6 +195,8 @@ const amountSliceSelector = (t: OnyxEntry): AmountSlice | undefined isAmountMissing: isAmountMissing(t), isAmountSet: t.isAmountSet, taxCode: t.taxCode, + isMerchantSet: t.isMerchantSet ?? false, + isCreatedSet: t.isCreatedSet ?? false, }; }; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index d01dae4a2960..1ded3e0adab7 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -5,10 +5,12 @@ import type {WaypointCollection} from '@src/types/onyx/Transaction'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +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} from './TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isCreatedMissing, isExpenseUnreported, isPartiallyEnteredScanExpense} from './TransactionUtils'; import {getMerchantError} from './ValidationUtils'; /** @@ -248,20 +250,36 @@ function shouldShowConfirmationDate(shouldShowSmartScanFields: boolean, isDistan * 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. */ -function isConfirmationAmountMissing(transaction: OnyxEntry>): boolean { +function isConfirmationAmountMissing(transaction: OnyxEntry, canEnterScanFieldsManually = false): boolean { + if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) { + return !transaction?.isAmountSet; + } return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && !transaction?.isAmountSet; } +/** + * Whether the merchant is still missing on a Scan the user started filling in. Shares the all-or-nothing rule the + * amount and date use, so the three blank fields of a half-filled scan all raise the same inline error. + */ +function isConfirmationMerchantMissing(transaction: OnyxEntry, canEnterScanFieldsManually = false): boolean { + return isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually) && !transaction?.isMerchantSet; +} + /** * Whether the required date is still missing on the money request confirmation surface. * Gating on the same `shouldShowConfirmationDate && !isReadOnly` condition that renders the inline date picker keeps * validation, clearing and the UI in sync, and skips read-only/scan flows where the date is populated server-side. * Shares the same drift-proofing purpose as `isConfirmationAmountMissing`. */ -function isConfirmationDateMissing(transaction: OnyxEntry, shouldShowDate: boolean, isReadOnly: boolean): boolean { +function isConfirmationDateMissing(transaction: OnyxEntry, shouldShowDate: boolean, isReadOnly: boolean, canEnterScanFieldsManually = false): boolean { + if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) { + return !transaction?.isCreatedSet; + } return shouldShowDate && !isReadOnly && isCreatedMissing(transaction); } @@ -269,6 +287,7 @@ export { addLeadingZero, isConfirmationAmountMissing, isConfirmationDateMissing, + isConfirmationMerchantMissing, shouldShowConfirmationDate, replaceAllDigits, stripCommaFromAmount, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 96d8cd40b21c..936f5515e45a 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -118,6 +118,12 @@ type TransactionParams = { created?: string; merchant?: string; receipt?: OnyxEntry; + + /** + * Overrides the state carried on `receipt` when the caller derives it at submit time. The Scan confirmation does, + * so the optimistic transaction does not read "Scanning..." for a receipt that is not being scanned. + */ + receiptState?: ValueOf; category?: string; tag?: string; taxCode?: string; @@ -283,15 +289,37 @@ function isScanRequest(transaction: OnyxEntry; + /** - * Whether the user filled in every one of the amount / merchant / date fields the Scan confirmation reveals behind - * "Show more". Each of them is optional, since a field left blank is read off the receipt, so only once all three - * carry a value of their own is the receipt submitted as `open`, where SmartScan never overwrites what the user typed. + * The amount / merchant / date fields the Scan confirmation reveals behind "Show more" are all-or-nothing: leaving + * all three blank hands the expense to SmartScan, and filling all three in submits it as a manual expense whose + * receipt is never scanned over. Only once all three carry a value of their own is the receipt submitted as `open`. */ -function hasAllManuallyEnteredScanFields(transaction: OnyxEntry): boolean { +function hasAllManuallyEnteredScanFields(transaction: OnyxEntry): boolean { return isScanRequest(transaction) && !!transaction?.isAmountSet && !!transaction?.isMerchantSet && !!transaction?.isCreatedSet; } +/** + * Whether the user filled in at least one of those three fields. Entering any one of them is what turns the expense + * from a scan into a manual one, so it is the point where the other two stop being SmartScan's to fill in and the + * "Automatic" label leaves all three. + */ +function hasAnyManuallyEnteredScanField(transaction: OnyxEntry): boolean { + return isScanRequest(transaction) && (!!transaction?.isAmountSet || !!transaction?.isMerchantSet || !!transaction?.isCreatedSet); +} + +/** + * Whether the user started filling the three fields in but stopped short. That is neither a scan nor a complete + * manual expense, so confirmation is blocked until the remaining fields are entered (or all three are cleared again). + * `canEnterScanFieldsManually` says whether the surface offers those fields at all: splits, moved tracked expenses + * and test receipts carry the same flags without ever having shown them, so they must not be held to this rule. + */ +function isPartiallyEnteredScanExpense(transaction: OnyxEntry, canEnterScanFieldsManually = false): boolean { + return canEnterScanFieldsManually && hasAnyManuallyEnteredScanField(transaction) && !hasAllManuallyEnteredScanFields(transaction); +} + function isPerDiemRequest(transaction: OnyxEntry): boolean { if (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.PER_DIEM) { return true; @@ -452,6 +480,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T created = '', merchant = '', receipt, + receiptState, // Prevent RBR flip and transaction jump: initialize category to 'Uncategorized' instead of // empty string so optimistic missing category violation isn't added then removed during backend sync category = CONST.SEARCH.CATEGORY_DEFAULT_VALUE, @@ -570,7 +599,12 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T created: created || DateUtils.getDBTime(), pendingAction, receipt: receipt?.source - ? {source: receipt.source, filename: receipt?.name ?? filename, state: receipt.state ?? CONST.IOU.RECEIPT_STATE.SCAN_READY, isTestDriveReceipt: receipt.isTestDriveReceipt} + ? { + source: receipt.source, + filename: receipt?.name ?? filename, + state: receiptState ?? receipt.state ?? CONST.IOU.RECEIPT_STATE.SCAN_READY, + isTestDriveReceipt: receipt.isTestDriveReceipt, + } : undefined, hasEReceipt: existingTransaction?.hasEReceipt, category, @@ -3784,6 +3818,8 @@ export { getTagForDisplay, getTransactionViolations, hasAllManuallyEnteredScanFields, + hasAnyManuallyEnteredScanField, + isPartiallyEnteredScanExpense, hasReceipt, hasUploadedReceipt, hasEReceipt, @@ -3920,3 +3956,5 @@ export { getDistanceRequestType, isUnreportedManagedCardTransaction, }; + +export type {ManuallyEnteredScanFields}; diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index 48c1601007d2..e3e0b90b277a 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -1320,6 +1320,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma created, merchant, receipt, + receiptState, category, tag, taxCode, @@ -1506,6 +1507,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma created, merchant, receipt, + receiptState, category, tag, taxCode, diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index d5986f010baf..7fca33b06c25 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -103,6 +103,7 @@ import type {Receipt, ReceiptSource} from '@src/types/onyx/Transaction'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import {fastMerge} from 'expensify-common'; import Onyx from 'react-native-onyx'; @@ -156,6 +157,9 @@ type GetTrackExpenseInformationTransactionParams = { created: string; merchant: string; receipt: OnyxEntry; + + /** Overrides the state carried on `receipt`, see `TrackExpenseTransactionParams.receiptState`. */ + receiptState?: ValueOf; category?: string; tag?: string; taxCode?: string; @@ -900,6 +904,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T distance, merchant, receipt, + receiptState, category, tag, taxCode, @@ -1121,6 +1126,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T created, merchant, receipt, + receiptState, category, tag, taxCode, @@ -2606,6 +2612,7 @@ function trackExpense(params: CreateTrackExpenseParams) { created, merchant, receipt: trackedReceipt, + receiptState: trackedReceiptState, category, tag, taxCode, diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index 5a0bd0fa8959..c22737263e77 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -493,7 +493,7 @@ describe('IOURequestStepConfirmationPageTest', () => { expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue(''); }); - it('submits a partially filled scan, leaving the blank fields to SmartScan', async () => { + it('blocks a partially filled scan and flags the field that is still blank', async () => { await renderScanConfirmation(); fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); @@ -503,12 +503,33 @@ describe('IOURequestStepConfirmationPageTest', () => { fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); await waitForBatchedUpdatesWithAct(); - // The date was never picked, so it stays "Automatic" instead of blocking confirmation. + // Entering two of the three turns this into a manual expense, so the untouched date is now required. + expect(screen.getByText(translateLocal('common.error.fieldRequired'))).toBeOnTheScreen(); + expect(TrackExpense.requestMoney).not.toHaveBeenCalled(); + }); + + it('stops requiring the blank fields once the half-filled ones are cleared back to an untouched scan', async () => { + await renderScanConfirmation(); + + fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); + await waitForBatchedUpdatesWithAct(); + // Both fields left blank are flagged, not just one. + expect(screen.getAllByText(translateLocal('common.error.fieldRequired'))).toHaveLength(2); + + // Clearing the merchant hands all three fields back to SmartScan, so nothing is required any more and the + // error must not be left stranded on a field the user has no reason to fill in. + fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), ''); + await waitForBatchedUpdatesWithAct(); + expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen(); + + fireEvent.press(screen.getByText(translateLocal('iou.createExpense'))); + await waitForBatchedUpdatesWithAct(); expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen(); - expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1); }); - it('labels the amount, merchant and date fields "Automatic" until that field is entered', async () => { + it('drops the "Automatic" label from all three fields as soon as any one of them is entered', async () => { await renderScanConfirmation(); // The category field carries the same label, so count the ones that leave rather than expecting none left. @@ -518,13 +539,14 @@ describe('IOURequestStepConfirmationPageTest', () => { fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); await waitForBatchedUpdatesWithAct(); - // Only the merchant's label goes: the amount and the date are still the ones SmartScan reads. - expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1); + // Entering one is the point where the expense stops being scanned, so none of the three is automatic now. + expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 3); - fireEvent.changeText(screen.getByLabelText(translateLocal('iou.amount')), '12.34'); + // Clearing it hands all three back to SmartScan, so the labels come back. + fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), ''); await waitForBatchedUpdatesWithAct(); - expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 2); + expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount); }); it('drops the "Automatic" label while a field is focused, and brings it back if the field is left empty', async () => { @@ -547,6 +569,32 @@ describe('IOURequestStepConfirmationPageTest', () => { expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1); }); + it('swaps the "Automatic" label for the currency button once the amount is the user\'s to enter', async () => { + await renderScanConfirmation(); + + const currencyButton = new RegExp(translateLocal('common.selectCurrency')); + + // Blank and unfocused the amount belongs to SmartScan, so the row carries the label and none of the controls. + expect(screen.queryByLabelText(currencyButton)).not.toBeOnTheScreen(); + + fireEvent(screen.getByLabelText(translateLocal('iou.amount')), 'focus'); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByLabelText(currencyButton)).toBeOnTheScreen(); + + // Blurring an amount the user never entered hands the field back, and the controls go with the label. + fireEvent(screen.getByLabelText(translateLocal('iou.amount')), 'blur'); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByLabelText(currencyButton)).not.toBeOnTheScreen(); + + // Entering another of the three fields also makes the amount the user's, so the controls stay put. + fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks'); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByLabelText(currencyButton)).toBeOnTheScreen(); + }); + it('hands a cleared date back to SmartScan instead of emptying it', async () => { await renderScanConfirmation(); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index b36e7a86b9ff..cb4737e1ea92 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -5270,6 +5270,56 @@ describe('hasAllManuallyEnteredScanFields', () => { }); }); +describe('hasAnyManuallyEnteredScanField / isPartiallyEnteredScanExpense', () => { + function generateScanDraft(values: Partial = {}): Transaction { + return generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, ...values}); + } + + it('reports nothing entered while all three fields are left to SmartScan', () => { + expect(TransactionUtils.hasAnyManuallyEnteredScanField(generateScanDraft())).toBe(false); + expect(TransactionUtils.isPartiallyEnteredScanExpense(generateScanDraft(), true)).toBe(false); + }); + + it.each([ + ['amount', {isAmountSet: true}], + ['merchant', {isMerchantSet: true}], + ['date', {isCreatedSet: true}], + ])('treats the expense as half-filled once only the %s is entered', (_field, values) => { + expect(TransactionUtils.hasAnyManuallyEnteredScanField(generateScanDraft(values))).toBe(true); + expect(TransactionUtils.isPartiallyEnteredScanExpense(generateScanDraft(values), true)).toBe(true); + }); + + it('stops reporting a half-filled expense once all three are entered', () => { + const complete = generateScanDraft({isAmountSet: true, isMerchantSet: true, isCreatedSet: true}); + expect(TransactionUtils.hasAnyManuallyEnteredScanField(complete)).toBe(true); + expect(TransactionUtils.isPartiallyEnteredScanExpense(complete, true)).toBe(false); + }); + + it('holds no surface to the rule unless it actually offers the three fields', () => { + // Splits, moved tracked expenses and test receipts carry the same flags without ever having shown them. + expect(TransactionUtils.isPartiallyEnteredScanExpense(generateScanDraft({isAmountSet: true}), false)).toBe(false); + }); +}); + +describe('buildOptimisticTransaction receipt state', () => { + const receipt = {source: 'https://example.com/receipt.jpg', name: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}; + + it('keeps the receipt state the caller validated when no override is given', () => { + const transaction = TransactionUtils.buildOptimisticTransaction({ + transactionParams: {amount: 100, currency: 'USD', reportID: '1', comment: '', created: '2023-10-01', receipt}, + }); + expect(transaction.receipt?.state).toBe(CONST.IOU.RECEIPT_STATE.SCAN_READY); + }); + + it('prefers the override so a scan the user filled in never reads as "Scanning..."', () => { + const transaction = TransactionUtils.buildOptimisticTransaction({ + transactionParams: {amount: 100, currency: 'USD', reportID: '1', comment: '', created: '2023-10-01', receipt, receiptState: CONST.IOU.RECEIPT_STATE.OPEN}, + }); + expect(transaction.receipt?.state).toBe(CONST.IOU.RECEIPT_STATE.OPEN); + expect(TransactionUtils.isReceiptBeingScanned(transaction)).toBe(false); + }); +}); + describe('isTransactionSubmittable', () => { it('returns true for a transaction that is on hold', () => { const transaction = generateTransaction({comment: {hold: 'holdID'}}); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 30b892841d48..343b8ca18c0a 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -930,8 +930,22 @@ describe('useConfirmationValidation', () => { ['merchant', {isMerchantSet: true, merchant: 'Starbucks'}, {iouMerchant: 'Starbucks', isMerchantEmpty: false}], ['amount', {isAmountSet: true, amount: 1000}, {iouAmount: 1000}], ['date', {isCreatedSet: true, created: '2025-01-15'}, {}], - ])('leaves the other fields optional once the %s is entered, since a blank field is still scanned', (_field, transactionOverrides, overrides) => { + ])('requires the other two once the %s is entered, since the three are all-or-nothing', (_field, transactionOverrides, overrides) => { const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides))); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + + 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) => { + const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides))); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + + it('passes again once the user clears the fields back to an untouched scan', () => { + const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams({isAmountSet: false, isMerchantSet: false, isCreatedSet: false}))); expect(result.current.validate()).toEqual({errorKey: null}); }); @@ -960,6 +974,7 @@ describe('useConfirmationValidation', () => { }); it('requires nothing on surfaces that do not expose the scan fields (splits, test receipts)', () => { + // Those surfaces never showed the three fields, so a flag set elsewhere must not hold them to the rule. const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams({isAmountSet: true, amount: 1000}, {canEnterScanFieldsManually: false, iouAmount: 1000}))); expect(result.current.validate()).toEqual({errorKey: null}); }); diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index 6da7f23d320e..91fc3e78978b 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -39,6 +39,7 @@ const baseParams: Params = { isEditingSplitBill: false, isPolicyExpenseChat: false, isScanRequest: false, + canEnterScanFieldsManually: false, shouldShowMerchant: true, hasSmartScanFailed: false, didConfirmSplit: false, From b5b770d9fccd2e885913851f806fb8866107cf00 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:11:54 +0800 Subject: [PATCH 7/9] fix: selector test --- .../MoneyRequestConfirmationList/selectorsTest.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/unit/components/MoneyRequestConfirmationList/selectorsTest.ts b/tests/unit/components/MoneyRequestConfirmationList/selectorsTest.ts index bf5c4ebb9488..0f9e5b7a46a5 100644 --- a/tests/unit/components/MoneyRequestConfirmationList/selectorsTest.ts +++ b/tests/unit/components/MoneyRequestConfirmationList/selectorsTest.ts @@ -29,9 +29,18 @@ describe('MoneyRequestConfirmationList selectors', () => { isMerchantSet: true, isMissing: false, hasReceipt: false, + iouRequestType: undefined, + isAmountSet: false, + isCreatedSet: false, }); }); + it('carries the sibling scan fields, which the merchant field needs for the all-or-nothing rule', () => { + const transaction = createTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, isAmountSet: true, isCreatedSet: true}); + + expect(merchantStateSelector(transaction)).toEqual(expect.objectContaining({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, isAmountSet: true, isCreatedSet: true})); + }); + it('prefers modifiedMerchant over merchant', () => { const transaction = createTransaction({ merchant: 'Original Merchant', From 888fcff2f5084a3155a6f65d428e8bfc3dbce145 Mon Sep 17 00:00:00 2001 From: thelullabyy <182625428+thelullabyy@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:28:31 +0800 Subject: [PATCH 8/9] fix: bugs --- .../MoneyRequestConfirmationList.tsx | 42 +++++++++++++++++- .../hooks/useConfirmationValidation.ts | 12 +++++ .../hooks/useFormErrorManagement.ts | 14 +++++- src/components/NumberWithSymbolForm.tsx | 4 ++ .../step/IOURequestStepConfirmation.tsx | 8 ++++ .../hooks/useConfirmationValidation.test.ts | 25 +++++++++++ .../hooks/useFormErrorManagement.test.tsx | 44 +++++++++++++++++++ 7 files changed, 146 insertions(+), 3 deletions(-) diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index 8ad7ce1f1c80..2b6e0085a0b1 100644 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -31,6 +31,7 @@ import { import type {IOUAction, 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 {Participant} from '@src/types/onyx/IOU'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; @@ -135,6 +136,15 @@ type MoneyRequestConfirmationListProps = { */ canEnterScanFieldsManually?: boolean; + /** + * ID of a transaction whose Scan fields are half-filled, when there is one. Multi-scan confirms every receipt at + * once, so this can be a receipt other than the one on screen, and confirmation is blocked until it is completed. + */ + halfFilledScanID?: string; + + /** Brings another of the confirmed transactions on screen, so its inline errors are the ones the user sees */ + onSwitchToTransaction?: (transactionID: string) => void; + /** A flag for verifying that the current report is a sub-report of a expense chat */ isPolicyExpenseChat?: boolean; @@ -162,6 +172,12 @@ type MoneyRequestConfirmationListProps = { type MoneyRequestConfirmationListItem = (Participant & {keyForList: string}) | OptionData; +/** + * 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 the confirmation has to reveal them when it does. + */ +const INLINE_FIELD_ERROR_KEYS = new Set(['common.error.fieldRequired', 'common.error.invalidAmount', 'iou.error.invalidMerchant']); + function MoneyRequestConfirmationList({ transaction, onSendMoney, @@ -176,6 +192,8 @@ function MoneyRequestConfirmationList({ isPolicyExpenseChat = false, shouldShowSmartScanFields = true, canEnterScanFieldsManually = false, + halfFilledScanID, + onSwitchToTransaction, isEditingSplitBill, isReceiptEditable, selectedParticipants: selectedParticipantsProp, @@ -348,6 +366,7 @@ function MoneyRequestConfirmationList({ isPolicyExpenseChat, isScanRequest, canEnterScanFieldsManually, + halfFilledScanID, shouldShowMerchant, hasSmartScanFailed, didConfirmSplit, @@ -473,11 +492,22 @@ function MoneyRequestConfirmationList({ isTimeRequest, routeError, canEnterScanFieldsManually, + halfFilledScanID, isReadOnly, shouldShowDate, isTaxAmountEmpty, }); + // On a multi-scan the receipt that is half-filled may not be the one on screen, so bring it into view before its + // blank fields are asked to raise the error. + const validateAndRevealFields: typeof validate = (paymentType) => { + const result = validate(paymentType); + if (result?.errorKey && INLINE_FIELD_ERROR_KEYS.has(result.errorKey) && halfFilledScanID && halfFilledScanID !== transactionID) { + onSwitchToTransaction?.(halfFilledScanID); + } + return result; + }; + const confirm = buildConfirmAction({ iouType, policy, @@ -485,7 +515,7 @@ function MoneyRequestConfirmationList({ routeError, formError, isDelegateAccessRestricted, - validate, + validate: validateAndRevealFields, setFormError, setDidConfirmSplit, showDelegateNoAccessModal, @@ -498,6 +528,16 @@ function MoneyRequestConfirmationList({ onSendMoney, }); + // The amount / merchant / date render these errors inline, and compact mode keeps those fields behind "Show more", + // so an outstanding one has to open the section or pressing Create looks like it did nothing. Opening it during + // render rather than from the press keeps it open when a multi-scan switches to the half-filled receipt, since + // that remounts and resets the flag. Writing the flag itself (rather than reading the error alongside it) keeps + // the section open once the user starts filling the fields in and the error clears, and keeps the receipt sizing, + // which reads the same flag, from disagreeing with what is on screen. + if (INLINE_FIELD_ERROR_KEYS.has(formError) && !showMoreFields) { + setShowMoreFields(true); + } + const isCompactMode = !showMoreFields && isScanRequest && !isInLandscapeMode; const selectionListStyle = { containerStyle: [styles.flexBasisAuto], diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index 0848587623d0..60dd6ebadf11 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -117,6 +117,12 @@ type UseConfirmationValidationParams = { /** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */ canEnterScanFieldsManually: boolean; + /** + * ID of a half-filled Scan among the transactions being confirmed, when there is one. Multi-scan confirms every + * receipt at once, so this can name a receipt other than the one being validated here. + */ + halfFilledScanID?: string; + /** Whether the confirmation fields are read-only (date is not inline-editable) */ isReadOnly: boolean; @@ -170,6 +176,7 @@ function useConfirmationValidation({ isTimeRequest, routeError, canEnterScanFieldsManually, + halfFilledScanID, isReadOnly, shouldShowDate, isTaxAmountEmpty, @@ -207,6 +214,11 @@ function useConfirmationValidation({ if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) { 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 (halfFilledScanID) { + return {errorKey: 'common.error.fieldRequired'}; + } if ( shouldValidateEnteredAmount && transaction?.isAmountSet && diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index 71949e84f827..e5ad0136841c 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -57,6 +57,12 @@ type UseFormErrorManagementParams = { /** Whether the Scan confirmation lets the user fill in the amount / merchant / date themselves */ canEnterScanFieldsManually: boolean; + /** + * ID of a half-filled Scan among the transactions being confirmed, when there is one. On a multi-scan it can name + * a receipt other than the one on screen, which is why the required error must not be cleared against this one. + */ + halfFilledScanID?: string; + /** Whether the merchant field should be visible in the UI */ shouldShowMerchant: boolean; @@ -142,6 +148,7 @@ function useFormErrorManagement({ isPolicyExpenseChat, isScanRequest, canEnterScanFieldsManually, + halfFilledScanID, shouldShowMerchant, hasSmartScanFailed, didConfirmSplit, @@ -223,11 +230,14 @@ function useFormErrorManagement({ const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually); const isMerchantRequiredMissing = isConfirmationMerchantMissing(transaction, canEnterScanFieldsManually); useEffect(() => { - if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isMerchantRequiredMissing) { + // `halfFilledScanID` keeps the error alive while any other receipt of a multi-scan is still half-filled. The + // predicates above only see the transaction on screen, so without it the error would clear the moment a + // complete receipt is displayed, including during the render it takes to switch to the incomplete one. + if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isMerchantRequiredMissing || !!halfFilledScanID) { return; } setFormError(''); - }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, setFormError]); + }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, halfFilledScanID, setFormError]); useEffect(() => { const currentFormError = formErrorRef.current; diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx index 4863aba505af..f93e6bf195f8 100644 --- a/src/components/NumberWithSymbolForm.tsx +++ b/src/components/NumberWithSymbolForm.tsx @@ -508,6 +508,10 @@ function NumberWithSymbolForm({