diff --git a/src/components/EReceiptThumbnail.tsx b/src/components/EReceiptThumbnail.tsx index 981c4d46e212..e3edc7691322 100644 --- a/src/components/EReceiptThumbnail.tsx +++ b/src/components/EReceiptThumbnail.tsx @@ -1,7 +1,7 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; @@ -24,10 +24,9 @@ type EReceiptThumbnailOnyxProps = { type IconSize = 'x-small' | 'small' | 'medium' | 'large'; -type EReceiptThumbnailProps = EReceiptThumbnailOnyxProps & { +type EReceiptThumbnailProps = { /** TransactionID of the transaction this EReceipt corresponds to. It's used by withOnyx HOC */ - // eslint-disable-next-line react/no-unused-prop-types - transactionID: string; + transactionID?: string; /** Border radius to be applied on the parent view. */ borderRadius?: number; @@ -54,7 +53,9 @@ const backgroundImages = { [CONST.ERECEIPT_COLORS.PINK]: eReceiptBGs.EReceiptBG_Pink, }; -function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptThumbnail = false, centerIconV = true, iconSize = 'large'}: EReceiptThumbnailProps) { +function EReceiptThumbnail({transactionID, borderRadius, fileExtension, isReceiptThumbnail = false, centerIconV = true, iconSize = 'large'}: EReceiptThumbnailProps) { + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const colorCode = isReceiptThumbnail ? StyleUtils.getFileExtensionColorCode(fileExtension) : StyleUtils.getEReceiptColorCode(transaction); @@ -158,9 +159,5 @@ function EReceiptThumbnail({transaction, borderRadius, fileExtension, isReceiptT } EReceiptThumbnail.displayName = 'EReceiptThumbnail'; -export default withOnyx({ - transaction: { - key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - }, -})(EReceiptThumbnail); -export type {IconSize, EReceiptThumbnailProps, EReceiptThumbnailOnyxProps}; +export default EReceiptThumbnail; +export type {EReceiptThumbnailOnyxProps, EReceiptThumbnailProps, IconSize}; diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 644e00378f28..24ba58d79e00 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -21,8 +21,8 @@ import * as TagsOptionsListUtils from '@libs/TagsOptionsListUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -821,6 +821,7 @@ function MoneyRequestConfirmationListFooter({ ? receiptThumbnailContent : shouldShowReceiptEmptyState && ( { if (!transactionID) { return; @@ -830,6 +831,7 @@ function MoneyRequestConfirmationListFooter({ ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, transactionID, reportID, Navigation.getActiveRouteWithoutParams()), ); }} + shouldAllowReceiptDrop /> ))} {primaryFields} diff --git a/src/components/ReceiptEmptyState.tsx b/src/components/ReceiptEmptyState.tsx index 08d83b6962af..7d2978d698bd 100644 --- a/src/components/ReceiptEmptyState.tsx +++ b/src/components/ReceiptEmptyState.tsx @@ -1,11 +1,22 @@ -import React from 'react'; -import {View} from 'react-native'; +import {Str} from 'expensify-common'; +import React, {useState} from 'react'; +import {PixelRatio, View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as FileUtils from '@libs/fileDownload/FileUtils'; +import {validateReceipt} from '@libs/ReceiptUtils'; +import ReceiptDropUI from '@pages/iou/ReceiptDropUI'; import variables from '@styles/variables'; +import * as IOU from '@userActions/IOU'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import type {FileObject} from './AttachmentModal'; +import ConfirmModal from './ConfirmModal'; +import FullScreenLoadingIndicator from './FullscreenLoadingIndicator'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; +import PDFThumbnail from './PDFThumbnail'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; type ReceiptEmptyStateProps = { @@ -18,13 +29,86 @@ type ReceiptEmptyStateProps = { disabled?: boolean; isThumbnail?: boolean; + + shouldAllowReceiptDrop?: boolean; + + transactionID: string | undefined; }; // Returns an SVG icon indicating that the user should attach a receipt -function ReceiptEmptyState({hasError = false, onPress, disabled = false, isThumbnail = false}: ReceiptEmptyStateProps) { +function ReceiptEmptyState({hasError = false, onPress, disabled = false, isThumbnail = false, shouldAllowReceiptDrop = false, transactionID}: ReceiptEmptyStateProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const theme = useTheme(); + const [receiptImageTopPosition, setReceiptImageTopPosition] = useState(0); + const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(); + const [attachmentInvalidReason, setAttachmentValidReason] = useState(); + const [pdfFile, setPdfFile] = useState(null); + const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); + + /** + * Sets the upload receipt error modal content when an invalid receipt is uploaded + */ + const setUploadReceiptError = (isInvalid: boolean, title: TranslationPaths, reason: TranslationPaths) => { + setIsAttachmentInvalid(isInvalid); + setAttachmentInvalidReasonTitle(title); + setAttachmentValidReason(reason); + setPdfFile(null); + }; + + const setReceipt = (originalFile: FileObject, isPdfValidated?: boolean) => { + validateReceipt(originalFile).then((result) => { + if (!result.isValid) { + if (result.title && result.reason) { + setUploadReceiptError(true, result.title, result.reason); + } + return; + } + + // If we have a pdf file and if it is not validated then set the pdf file for validation and return + if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) { + setPdfFile(originalFile); + return; + } + + // With the image size > 24MB, we use manipulateAsync to resize the image. + // It takes a long time so we should display a loading indicator while the resize image progresses. + if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) { + setIsLoadingReceipt(true); + } + + FileUtils.resizeImageIfNeeded(originalFile).then((file) => { + setIsLoadingReceipt(false); + const source = URL.createObjectURL(file as Blob); + if (transactionID) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + IOU.setMoneyRequestReceipt(transactionID, source, file.name || '', true); + } + }); + }); + }; + + const hideRecieptModal = () => { + setIsAttachmentInvalid(false); + }; + + const PDFThumbnailView = pdfFile ? ( + { + setPdfFile(null); + setReceipt(pdfFile, true); + }} + onPassword={() => { + setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported'); + }} + onLoadError={() => { + setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); + }} + /> + ) : null; const Wrapper = onPress ? PressableWithoutFeedback : View; @@ -38,12 +122,35 @@ function ReceiptEmptyState({hasError = false, onPress, disabled = false, isThumb style={[ styles.alignItemsCenter, styles.justifyContentCenter, - styles.moneyRequestViewImage, + styles.moneyRequestImage, isThumbnail ? styles.moneyRequestAttachReceiptThumbnail : styles.moneyRequestAttachReceipt, hasError && styles.borderColorDanger, ]} > - + {isLoadingReceipt && } + {PDFThumbnailView} + {shouldAllowReceiptDrop && !disabled && ( + { + const file = e?.dataTransfer?.files[0]; + if (file) { + file.uri = URL.createObjectURL(file); + setReceipt(file); + } + }} + receiptImageTopPosition={receiptImageTopPosition} + /> + )} + + setReceiptImageTopPosition(PixelRatio.roundToNearestPixel((nativeEvent.layout as DOMRect).top))}> ); } @@ -132,7 +133,7 @@ function ReceiptImage({ return ( , parentReportActions: OnyxEntry) => { - const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; + const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; const originalMessage = parentReportAction && ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction) : undefined; - return originalMessage?.IOUTransactionID ?? -1; + return originalMessage?.IOUTransactionID ?? CONST.DEFAULT_NUMBER_ID; }; function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = false, updatedTransaction, isFromReviewDuplicates = false}: MoneyRequestViewProps) { @@ -81,8 +81,8 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const session = useSession(); const {isOffline} = useNetwork(); const {translate, toLocaleDigit} = useLocalize(); - const parentReportID = report?.parentReportID ?? '-1'; - const policyID = report?.policyID ?? '-1'; + const parentReportID = report?.parentReportID; + const policyID = report?.policyID; const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReportID}`); const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${parentReport?.parentReportID}`, { selector: (chatReportValue) => chatReportValue && {reportID: chatReportValue.reportID, errorFields: chatReportValue.errorFields}, @@ -97,12 +97,12 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals }); const [transactionViolations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${getTransactionID(report, parentReportActions)}`); - const parentReportAction = parentReportActions?.[report?.parentReportActionID ?? '-1']; + const parentReportAction = report?.parentReportActionID ? parentReportActions?.[report.parentReportActionID] : undefined; const isTrackExpense = ReportUtils.isTrackExpenseReport(report); const moneyRequestReport = parentReport; const linkedTransactionID = useMemo(() => { const originalMessage = parentReportAction && ReportActionsUtils.isMoneyRequestAction(parentReportAction) ? ReportActionsUtils.getOriginalMessage(parentReportAction) : undefined; - return originalMessage?.IOUTransactionID ?? '-1'; + return originalMessage?.IOUTransactionID; }, [parentReportAction]); const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`); @@ -227,7 +227,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals if (newBillable === TransactionUtils.getBillable(transaction)) { return; } - IOU.updateMoneyRequestBillable(transaction?.transactionID ?? '-1', report?.reportID ?? '-1', newBillable, policy, policyTagList, policyCategories); + IOU.updateMoneyRequestBillable(transaction?.transactionID, report?.reportID, newBillable, policy, policyTagList, policyCategories); }, [transaction, report, policy, policyTagList, policyCategories], ); @@ -319,15 +319,17 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals shouldShowRightIcon={canEditDistance} titleStyle={styles.flex1} onPress={() => - Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', - Navigation.getReportRHPActiveRoute(), - ), - ) + transaction?.transactionID && report?.reportID + ? Navigation.navigate( + ROUTES.MONEY_REQUEST_STEP_DISTANCE.getRoute( + CONST.IOU.ACTION.EDIT, + iouType, + transaction.transactionID, + report.reportID, + Navigation.getReportRHPActiveRoute(), + ), + ) + : undefined } /> @@ -338,17 +340,20 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals interactive={canEditDistanceRate} shouldShowRightIcon={canEditDistanceRate} titleStyle={styles.flex1} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_DISTANCE_RATE.getRoute( CONST.IOU.ACTION.EDIT, iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', + transaction.transactionID, + report.reportID, Navigation.getReportRHPActiveRoute(), ), - ) - } + ); + }} brickRoadIndicator={getErrorForField('customUnitRateID') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('customUnitRateID')} /> @@ -418,18 +423,21 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals interactive={canEdit} shouldShowRightIcon={canEdit} titleStyle={styles.flex1} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_TAG.getRoute( CONST.IOU.ACTION.EDIT, iouType, orderWeight, - transaction?.transactionID ?? '', - report?.reportID ?? '-1', + transaction.transactionID, + report.reportID, Navigation.getReportRHPActiveRoute(), ), - ) - } + ); + }} brickRoadIndicator={tagError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={tagError} /> @@ -455,7 +463,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals errors={errors} errorRowStyles={[styles.mh4]} onClose={() => { - if (!transaction?.transactionID && linkedTransactionID === '-1') { + if (!transaction?.transactionID && !linkedTransactionID) { return; } @@ -470,7 +478,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals } } Transaction.clearError(transaction?.transactionID ?? linkedTransactionID); - ReportActions.clearAllRelatedReportActionErrors(report?.reportID ?? '-1', parentReportAction); + ReportActions.clearAllRelatedReportActionErrors(report?.reportID, parentReportAction); }} > {hasReceipt && ( @@ -496,17 +504,15 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals + transactionID={transaction?.transactionID} + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', - Navigation.getReportRHPActiveRoute(), - ), - ) - } + ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID, Navigation.getReportRHPActiveRoute()), + ); + }} /> )} @@ -521,18 +527,21 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals titleStyle={styles.textHeadlineH2} interactive={canEditAmount} shouldShowRightIcon={canEditAmount} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_AMOUNT.getRoute( CONST.IOU.ACTION.EDIT, iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', + transaction.transactionID, + report.reportID, '', Navigation.getReportRHPActiveRoute(), ), - ) - } + ); + }} brickRoadIndicator={getErrorForField('amount') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('amount')} /> @@ -545,17 +554,20 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals interactive={canEdit} shouldShowRightIcon={canEdit} titleStyle={styles.flex1} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.getRoute( CONST.IOU.ACTION.EDIT, iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', + transaction.transactionID, + report.reportID, Navigation.getReportRHPActiveRoute(), ), - ) - } + ); + }} wrapperStyle={[styles.pv2, styles.taskDescriptionMenuItem]} brickRoadIndicator={getErrorForField('comment') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('comment')} @@ -572,17 +584,20 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals interactive={canEditMerchant} shouldShowRightIcon={canEditMerchant} titleStyle={styles.flex1} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute( CONST.IOU.ACTION.EDIT, iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', + transaction.transactionID, + report.reportID, Navigation.getReportRHPActiveRoute(), ), - ) - } + ); + }} wrapperStyle={[styles.taskDescriptionMenuItem]} brickRoadIndicator={getErrorForField('merchant') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('merchant')} @@ -597,17 +612,14 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals interactive={canEditDate} shouldShowRightIcon={canEditDate} titleStyle={styles.flex1} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( - ROUTES.MONEY_REQUEST_STEP_DATE.getRoute( - CONST.IOU.ACTION.EDIT, - iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1' ?? '-1', - Navigation.getReportRHPActiveRoute(), - ), - ) - } + ROUTES.MONEY_REQUEST_STEP_DATE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID, report?.reportID, Navigation.getReportRHPActiveRoute()), + ); + }} brickRoadIndicator={getErrorForField('date') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('date')} /> @@ -620,17 +632,20 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals interactive={canEdit} shouldShowRightIcon={canEdit} titleStyle={styles.flex1} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute( CONST.IOU.ACTION.EDIT, iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', + transaction.transactionID, + report.reportID, Navigation.getReportRHPActiveRoute(), ), - ) - } + ); + }} brickRoadIndicator={getErrorForField('category') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('category')} /> @@ -655,17 +670,20 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals interactive={canEditTaxFields} shouldShowRightIcon={canEditTaxFields} titleStyle={styles.flex1} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_TAX_RATE.getRoute( CONST.IOU.ACTION.EDIT, iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', + transaction?.transactionID, + report?.reportID, Navigation.getReportRHPActiveRoute(), ), - ) - } + ); + }} brickRoadIndicator={getErrorForField('tax') ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={getErrorForField('tax')} /> @@ -679,17 +697,20 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals interactive={canEditTaxFields} shouldShowRightIcon={canEditTaxFields} titleStyle={styles.flex1} - onPress={() => + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } Navigation.navigate( ROUTES.MONEY_REQUEST_STEP_TAX_AMOUNT.getRoute( CONST.IOU.ACTION.EDIT, iouType, - transaction?.transactionID ?? '-1', - report?.reportID ?? '-1', + transaction?.transactionID, + report?.reportID, Navigation.getReportRHPActiveRoute(), ), - ) - } + ); + }} /> )} @@ -698,11 +719,14 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals title={translate('travel.viewTripDetails')} icon={Expensicons.Suitcase} onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } const reservations = transaction?.receipt?.reservationList?.length ?? 0; if (reservations > 1) { - Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(report?.reportID ?? '-1', transaction?.transactionID ?? '-1', Navigation.getReportRHPActiveRoute())); + Navigation.navigate(ROUTES.TRAVEL_TRIP_SUMMARY.getRoute(report.reportID, transaction.transactionID, Navigation.getReportRHPActiveRoute())); } - Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(report?.reportID ?? '-1', transaction?.transactionID ?? '-1', 0, Navigation.getReportRHPActiveRoute())); + Navigation.navigate(ROUTES.TRAVEL_TRIP_DETAILS.getRoute(report.reportID, transaction.transactionID, 0, Navigation.getReportRHPActiveRoute())); }} /> )} @@ -717,9 +741,12 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals }`} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} - onPress={() => - Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) - } + onPress={() => { + if (!transaction?.transactionID || !report?.reportID) { + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction.transactionID, report.reportID)); + }} interactive shouldRenderAsHTML /> diff --git a/src/libs/ReceiptUtils.ts b/src/libs/ReceiptUtils.ts index 5085f0e2384e..ce42e39a5534 100644 --- a/src/libs/ReceiptUtils.ts +++ b/src/libs/ReceiptUtils.ts @@ -1,7 +1,10 @@ import {Str} from 'expensify-common'; import findLast from 'lodash/findLast'; import type {OnyxEntry} from 'react-native-onyx'; +import type {TupleToUnion} from 'type-fest'; +import type {FileObject} from '@components/AttachmentModal'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import type {Transaction} from '@src/types/onyx'; import type {ReceiptError, ReceiptSource} from '@src/types/onyx/Transaction'; @@ -65,6 +68,55 @@ function getThumbnailAndImageURIs(transaction: OnyxEntry, receiptPa return {isThumbnail: true, fileExtension: Object.values(CONST.IOU.FILE_TYPES).find((type) => type === fileExtension), image: path, isLocalFile, filename}; } +type ValidateReceiptResult = { + isValid: boolean; + title?: TranslationPaths; + reason?: TranslationPaths; +}; +/** + * Validate a given receipt file for correctness and adherence to file constraints + */ +function validateReceipt(file: FileObject): Promise { + return FileUtils.validateImageForCorruption(file) + .then(() => { + const {fileExtension} = FileUtils.splitExtensionFromFileName(file?.name ?? ''); + const extension = fileExtension.toLowerCase() as TupleToUnion; + + if (!CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes(extension)) { + return { + isValid: false, + title: 'attachmentPicker.wrongFileType' as TranslationPaths, + reason: 'attachmentPicker.notAllowedExtension' as TranslationPaths, + }; + } + + if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { + return { + isValid: false, + title: 'attachmentPicker.attachmentTooLarge' as TranslationPaths, + reason: 'attachmentPicker.sizeExceededWithLimit' as TranslationPaths, + }; + } + + if ((file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { + return { + isValid: false, + title: 'attachmentPicker.attachmentTooSmall' as TranslationPaths, + reason: 'attachmentPicker.sizeNotMet' as TranslationPaths, + }; + } + + return {isValid: true}; + }) + .catch(() => { + return { + isValid: false, + title: 'attachmentPicker.attachmentError' as TranslationPaths, + reason: 'attachmentPicker.errorWhileSelectingCorruptedAttachment' as TranslationPaths, + }; + }); +} + // eslint-disable-next-line import/prefer-default-export -export {getThumbnailAndImageURIs}; +export {getThumbnailAndImageURIs, validateReceipt}; export type {ThumbnailAndImageURI}; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 7f35f781d8d7..c12c23290fdc 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3393,13 +3393,16 @@ function updateMoneyRequestDate( /** Updates the billable field of an expense */ function updateMoneyRequestBillable( - transactionID: string, - transactionThreadReportID: string, + transactionID: string | undefined, + transactionThreadReportID: string | undefined, value: boolean, policy: OnyxEntry, policyTagList: OnyxEntry, policyCategories: OnyxEntry, ) { + if (!transactionID || !transactionThreadReportID) { + return; + } const transactionChanges: TransactionChanges = { billable: value, }; @@ -5261,7 +5264,17 @@ function startSplitBill({ * @param sessionAccountID - accountID of the current user * @param sessionEmail - email of the current user */ -function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportAction, updatedTransaction: OnyxEntry, sessionAccountID: number, sessionEmail: string) { +function completeSplitBill( + chatReportID: string, + reportAction: OnyxTypes.ReportAction, + updatedTransaction: OnyxEntry, + sessionAccountID: number, + sessionEmail: string | undefined, +) { + if (!sessionEmail) { + return; + } + const currentUserEmailForIOUSplit = PhoneNumber.addSMSDomainIfPhoneNumber(sessionEmail); const transactionID = updatedTransaction?.transactionID ?? '-1'; const unmodifiedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; @@ -5501,7 +5514,10 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA Report.notifyNewAction(chatReportID, sessionAccountID); } -function setDraftSplitTransaction(transactionID: string, transactionChanges: TransactionChanges = {}, policy?: OnyxEntry) { +function setDraftSplitTransaction(transactionID: string | undefined, transactionChanges: TransactionChanges = {}, policy?: OnyxEntry) { + if (!transactionID) { + return; + } let draftSplitTransaction = allDraftSplitTransactions[`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`]; if (!draftSplitTransaction) { diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index 89517a753c26..a582a236555f 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -41,7 +41,7 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction, k // If there's a linked transaction, delete that too // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const linkedTransactionID = ReportActionUtils.getLinkedTransactionID(reportAction.reportActionID, originalReportID || '-1'); + const linkedTransactionID = ReportActionUtils.getLinkedTransactionID(reportAction.reportActionID, originalReportID); if (linkedTransactionID) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${linkedTransactionID}`, null); Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${reportAction.childReportID}`, null); @@ -81,7 +81,11 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction, k ignore: `undefined` means we want to check both parent and children report actions ignore: `parent` or `child` means we want to ignore checking parent or child report actions because they've been previously checked */ -function clearAllRelatedReportActionErrors(reportID: string, reportAction: ReportAction | null | undefined, ignore?: IgnoreDirection, keys?: string[]) { +function clearAllRelatedReportActionErrors(reportID: string | undefined, reportAction: ReportAction | null | undefined, ignore?: IgnoreDirection, keys?: string[]) { + if (!reportID) { + return; + } + const errorKeys = keys ?? Object.keys(reportAction?.errors ?? {}); if (!reportAction || errorKeys.length === 0) { return; @@ -101,7 +105,7 @@ function clearAllRelatedReportActionErrors(reportID: string, reportAction: Repor const childActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportAction.childReportID}`] ?? {}; Object.values(childActions).forEach((action) => { const childErrorKeys = Object.keys(action.errors ?? {}).filter((err) => errorKeys.includes(err)); - clearAllRelatedReportActionErrors(reportAction.childReportID ?? '-1', action, 'parent', childErrorKeys); + clearAllRelatedReportActionErrors(reportAction.childReportID, action, 'parent', childErrorKeys); }); } } diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index c8a007458242..bd91f61ac6c0 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -355,7 +355,7 @@ function updateWaypoints(transactionID: string, waypoints: WaypointCollection, i function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmissedPersonalDetails: PersonalDetails) { const currentTransactionViolations = transactionIDs.map((id) => ({transactionID: id, violations: allTransactionViolation?.[id] ?? []})); const currentTransactions = transactionIDs.map((id) => allTransactions?.[id]); - const transactionsReportActions = currentTransactions.map((transaction) => ReportActionsUtils.getIOUActionForReportID(transaction.reportID ?? '', transaction.transactionID ?? '')); + const transactionsReportActions = currentTransactions.map((transaction) => ReportActionsUtils.getIOUActionForReportID(transaction.reportID, transaction.transactionID)); const optimisticDissmidedViolationReportActions = transactionsReportActions.map(() => { return buildOptimisticDismissedViolationReportAction({reason: 'manual', violationName: CONST.VIOLATIONS.DUPLICATED_TRANSACTION}); }); @@ -363,13 +363,23 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss const optimisticData: OnyxUpdate[] = []; const failureData: OnyxUpdate[] = []; - const optimisticReportActions: OnyxUpdate[] = transactionsReportActions.map((action, index) => ({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, - value: { - [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: optimisticDissmidedViolationReportActions.at(index) as ReportAction, - }, - })); + const optimisticReportActions = transactionsReportActions + .map((action, index) => { + const reportAction = optimisticDissmidedViolationReportActions.at(index); + const reportActionID = reportAction?.reportActionID; + if (!reportActionID) { + return; + } + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID}`, + value: { + [reportActionID]: reportAction as ReportAction, + }, + }; + }) + .filter(Boolean) as OnyxUpdate[]; + const optimisticDataTransactionViolations: OnyxUpdate[] = currentTransactionViolations.map((transactionViolations) => ({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionViolations.transactionID}`, @@ -411,25 +421,41 @@ function dismissDuplicateTransactionViolation(transactionIDs: string[], dissmiss }, })); - const failureReportActions: OnyxUpdate[] = transactionsReportActions.map((action, index) => ({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, - value: { - [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: null, - }, - })); + const failureReportActions = transactionsReportActions + .map((action, index) => { + const reportActionID = optimisticDissmidedViolationReportActions.at(index)?.reportActionID; + if (!reportActionID) { + return; + } + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID}`, + value: { + [reportActionID]: null, + }, + }; + }) + .filter(Boolean) as OnyxUpdate[]; failureData.push(...failureDataTransactionViolations); failureData.push(...failureDataTransaction); failureData.push(...failureReportActions); - const successData: OnyxUpdate[] = transactionsReportActions.map((action, index) => ({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID ?? '-1'}`, - value: { - [optimisticDissmidedViolationReportActions.at(index)?.reportActionID ?? '']: null, - }, - })); + const successData = transactionsReportActions + .map((action, index) => { + const reportActionID = optimisticDissmidedViolationReportActions.at(index)?.reportActionID; + if (!reportActionID) { + return; + } + return { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${action?.childReportID}`, + value: { + [reportActionID]: null, + }, + }; + }) + .filter(Boolean) as OnyxUpdate[]; // We are creating duplicate resolved report actions for each duplicate transactions and all the report actions // should be correctly linked with their parent report but the BE is sometimes linking report actions to different // parent reports than the one we set optimistically, resulting in duplicate report actions. Therefore, we send the BE @@ -457,7 +483,10 @@ function abandonReviewDuplicateTransactions() { Onyx.set(ONYXKEYS.REVIEW_DUPLICATES, null); } -function clearError(transactionID: string) { +function clearError(transactionID: string | undefined) { + if (!transactionID) { + return; + } Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {errors: null, errorFields: {route: null, waypoints: null, routes: null}}); } diff --git a/src/pages/iou/SplitBillDetailsPage.tsx b/src/pages/iou/SplitBillDetailsPage.tsx index 13d841cc5441..852a02da5893 100644 --- a/src/pages/iou/SplitBillDetailsPage.tsx +++ b/src/pages/iou/SplitBillDetailsPage.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import DragAndDropProvider from '@components/DragAndDrop/Provider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -35,9 +36,9 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag const {translate} = useLocalize(); const theme = useTheme(); - const reportID = report?.reportID ?? '-1'; + const reportID = report?.reportID; const originalMessage = reportAction && ReportActionsUtils.isMoneyRequestAction(reportAction) ? ReportActionsUtils.getOriginalMessage(reportAction) : undefined; - const IOUTransactionID = originalMessage?.IOUTransactionID ? originalMessage.IOUTransactionID : '-1'; + const IOUTransactionID = originalMessage?.IOUTransactionID; const participantAccountIDs = originalMessage?.participantAccountIDs ?? []; const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${IOUTransactionID}`); @@ -56,7 +57,7 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag } else { participants = participantAccountIDs.map((accountID) => OptionsListUtils.getParticipantsOption({accountID, selected: true, reportID: ''}, personalDetails)); } - const actorAccountID = reportAction?.actorAccountID ?? -1; + const actorAccountID = reportAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID; const payeePersonalDetails = personalDetails?.[actorAccountID]; const participantsExcludingPayee = participants.filter((participant) => participant.accountID !== reportAction?.actorAccountID); @@ -64,6 +65,7 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag const hasSmartScanFailed = TransactionUtils.hasReceipt(transaction) && transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCANFAILED; const isEditingSplitBill = session?.accountID === actorAccountID && TransactionUtils.areRequiredFieldsEmpty(transaction); const [isConfirmed, setIsConfirmed] = useState(false); + const [isDraggingOver, setIsDraggingOver] = useState(false); const { amount: splitAmount, @@ -77,66 +79,71 @@ function SplitBillDetailsPage({route, report, reportAction}: SplitBillDetailsPag const onConfirm = useCallback(() => { setIsConfirmed(true); - IOU.completeSplitBill(reportID, reportAction, draftTransaction, session?.accountID ?? -1, session?.email ?? ''); + IOU.completeSplitBill(reportID, reportAction, draftTransaction, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email); }, [reportID, reportAction, draftTransaction, session?.accountID, session?.email]); return ( - + - Navigation.goBack(route.params.backTo)} - /> - - {isScanning && ( - - - } - description={translate('iou.receiptScanInProgressDescription')} - shouldStyleFlexGrow={false} + + Navigation.goBack(route.params.backTo)} + /> + + {isScanning && ( + + + } + description={translate('iou.receiptScanInProgressDescription')} + shouldStyleFlexGrow={false} + /> + + )} + {!!participants.length && ( + { + IOU.setDraftSplitTransaction(transaction?.transactionID, {billable}); + }} + isConfirmed={isConfirmed} /> - - )} - {!!participants.length && ( - { - IOU.setDraftSplitTransaction(transaction?.transactionID ?? '-1', {billable}); - }} - isConfirmed={isConfirmed} - /> - )} - + )} + + ); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index d2befa611d01..36d9753361b3 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -2,6 +2,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; +import DragAndDropProvider from '@components/DragAndDrop/Provider'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -107,6 +108,7 @@ function IOURequestStepConfirmation({ const gpsRequired = transaction?.amount === 0 && iouType !== CONST.IOU.TYPE.SPLIT && receiptFile; const [isConfirmed, setIsConfirmed] = useState(false); + const [isDraggingOver, setIsDraggingOver] = useState(false); const headerTitle = useMemo(() => { if (isCategorizingTrackExpense) { @@ -618,65 +620,68 @@ function IOURequestStepConfirmation({ - - - {isLoading && } - {!!gpsRequired && ( - setStartLocationPermissionFlow(false)} - onGrant={() => createTransaction(selectedParticipantList, true)} - onDeny={() => { - IOU.updateLastLocationPermissionPrompt(); - createTransaction(selectedParticipantList, false); - }} + + + + {isLoading && } + {!!gpsRequired && ( + setStartLocationPermissionFlow(false)} + onGrant={() => createTransaction(selectedParticipantList, true)} + onDeny={() => { + IOU.updateLastLocationPermissionPrompt(); + createTransaction(selectedParticipantList, false); + }} + /> + )} + - )} - - + + ); } diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index fcf734781b3a..227076b9f4f3 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -4,7 +4,6 @@ import React, {useCallback, useContext, useEffect, useMemo, useReducer, useRef, import {ActivityIndicator, PanResponder, PixelRatio, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type Webcam from 'react-webcam'; -import type {TupleToUnion} from 'type-fest'; import Hand from '@assets/images/hand.svg'; import ReceiptUpload from '@assets/images/receipt-upload.svg'; import Shutter from '@assets/images/shutter.svg'; @@ -35,6 +34,7 @@ import * as IOUUtils from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import {validateReceipt} from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; @@ -207,36 +207,6 @@ function IOURequestStepScan({ setPdfFile(null); }; - function validateReceipt(file: FileObject) { - return FileUtils.validateImageForCorruption(file) - .then(() => { - const {fileExtension} = FileUtils.splitExtensionFromFileName(file?.name ?? ''); - if ( - !CONST.API_ATTACHMENT_VALIDATIONS.ALLOWED_RECEIPT_EXTENSIONS.includes( - fileExtension.toLowerCase() as TupleToUnion, - ) - ) { - setUploadReceiptError(true, 'attachmentPicker.wrongFileType', 'attachmentPicker.notAllowedExtension'); - return false; - } - - if (!Str.isImage(file.name ?? '') && (file?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE) { - setUploadReceiptError(true, 'attachmentPicker.attachmentTooLarge', 'attachmentPicker.sizeExceededWithLimit'); - return false; - } - - if ((file?.size ?? 0) < CONST.API_ATTACHMENT_VALIDATIONS.MIN_SIZE) { - setUploadReceiptError(true, 'attachmentPicker.attachmentTooSmall', 'attachmentPicker.sizeNotMet'); - return false; - } - return true; - }) - .catch(() => { - setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); - return false; - }); - } - const navigateBack = useCallback(() => { Navigation.goBack(backTo); }, [backTo]); @@ -465,8 +435,11 @@ function IOURequestStepScan({ * Sets the Receipt objects and navigates the user to the next page */ const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { - validateReceipt(originalFile).then((isFileValid) => { - if (!isFileValid) { + validateReceipt(originalFile).then((result) => { + if (!result.isValid) { + if (result.title && result.reason) { + setUploadReceiptError(true, result.title, result.reason); + } return; }