diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index e357eed12835..362a31555578 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import type {IOUAction, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -9,6 +10,7 @@ import type {IOURequestType} from './actions/IOU'; import {getCurrencyUnit} from './CurrencyUtils'; import DateUtils from './DateUtils'; import Navigation from './Navigation/Navigation'; +import Performance from './Performance'; import {getReportTransactions} from './ReportUtils'; import {getCurrency, getTagArrayFromName} from './TransactionUtils'; @@ -37,6 +39,20 @@ function navigateToStartMoneyRequestStep(requestType: IOURequestType, iouType: I } } +function navigateToParticipantPage(iouType: ValueOf, transactionID: string, reportID: string) { + Performance.markStart(CONST.TIMING.OPEN_CREATE_EXPENSE_CONTACT); + switch (iouType) { + case CONST.IOU.TYPE.REQUEST: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); + break; + case CONST.IOU.TYPE.SEND: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.PAY, transactionID, reportID)); + break; + default: + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); + } +} + /** * Calculates the amount per user given a list of participants * @@ -210,4 +226,5 @@ export { updateIOUOwnerAndTotal, formatCurrentUserToAttendee, shouldStartLocationPermissionFlow, + navigateToParticipantPage, }; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index e7aebfacc092..3a819cdbe1eb 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -159,7 +159,7 @@ import { shouldCreateNewMoneyRequestReport as shouldCreateNewMoneyRequestReportReportUtils, updateReportPreview, } from '@libs/ReportUtils'; -import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; +import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils'; import {getSession} from '@libs/SessionUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; @@ -171,7 +171,6 @@ import { getCurrency, getDistanceInMeters, getMerchant, - getTransaction, getUpdatedTransaction, hasAnyTransactionWithoutRTERViolation, hasDuplicateTransactions, @@ -803,8 +802,15 @@ Onyx.connect({ * It is a helper function used only in this file. */ function dismissModalAndOpenReportInInboxTab(reportID?: string) { - if (isSearchTopmostFullScreenRoute() || !reportID) { + const isSearchPageTopmostFullScreenRoute = isSearchTopmostFullScreenRoute(); + if (isSearchPageTopmostFullScreenRoute || !reportID) { Navigation.dismissModal(); + if (isSearchPageTopmostFullScreenRoute) { + const query = buildCannedSearchQuery(); + InteractionManager.runAfterInteractions(() => { + Navigation.setParams({q: query}); + }); + } return; } Navigation.dismissModalWithReport({reportID}); @@ -8205,7 +8211,7 @@ function getHoldReportActionsAndTransactions(reportID: string | undefined) { Object.values(iouReportActions).forEach((action) => { const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined; - const transaction = getTransaction(transactionID); + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; if (transaction?.comment?.hold) { holdReportActions.push(action as OnyxTypes.ReportAction); diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 67b978b97ed4..4f4a6292392d 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -1,14 +1,20 @@ +import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {InteractionManager, View} from 'react-native'; +import type {FileObject} from '@components/AttachmentModal'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import ConfirmModal from '@components/ConfirmModal'; import DecisionModal from '@components/DecisionModal'; +import DragAndDropProvider from '@components/DragAndDrop/Provider'; +import DropZoneUI from '@components/DropZoneUI'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import NavigationTabBar from '@components/Navigation/NavigationTabBar'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import TopBar from '@components/Navigation/TopBar'; +import PDFThumbnail from '@components/PDFThumbnail'; import ScreenWrapper from '@components/ScreenWrapper'; import Search from '@components/Search'; import {useSearchContext} from '@components/Search/SearchContext'; @@ -21,6 +27,7 @@ import useActiveWorkspace from '@hooks/useActiveWorkspace'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -36,14 +43,19 @@ import { search, unholdMoneyRequestOnSearch, } from '@libs/actions/Search'; +import {resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils'; +import {navigateToParticipantPage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {hasVBBA} from '@libs/PolicyUtils'; +import {generateReportID} from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import variables from '@styles/variables'; +import {initMoneyRequest, setMoneyRequestReceipt} from '@userActions/IOU'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -70,9 +82,17 @@ function SearchPage({route}: SearchPageProps) { const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false); const [isDownloadExportModalVisible, setIsDownloadExportModalVisible] = useState(false); + // TODO: to be refactored in step 3 + const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); + const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState(); + const [attachmentInvalidReason, setAttachmentValidReason] = useState(); + const [pdfFile, setPdfFile] = useState(null); + const [isLoadingReceipt, setIsLoadingReceipt] = useState(false); const {q, name} = route.params; + const {canUseMultiFilesDragAndDrop} = usePermissions(); + const queryJSON = useMemo(() => buildSearchQueryJSON(q), [q]); // eslint-disable-next-line rulesdir/no-default-id-values @@ -93,6 +113,28 @@ function SearchPage({route}: SearchPageProps) { const {status, hash} = queryJSON ?? {}; const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {}); + // TODO: to be refactored in step 3 + /** + * 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); + }; + + // TODO: to be refactored in step 3 + const getConfirmModalPrompt = () => { + if (!attachmentInvalidReason) { + return ''; + } + if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') { + return translate(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)}); + } + return translate(attachmentInvalidReason); + }; + const headerButtonsOptions = useMemo(() => { if (selectedTransactionsKeys.length === 0 || !status || !hash) { return []; @@ -202,14 +244,24 @@ function SearchPage({route}: SearchPageProps) { const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(itemPolicyID, lastPaymentMethods); if (!lastPolicyPaymentMethod) { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: activeRoute})); + Navigation.navigate( + ROUTES.SEARCH_REPORT.getRoute({ + reportID: item.reportID, + backTo: activeRoute, + }), + ); return; } const hasPolicyVBBA = hasVBBA(itemPolicyID); if (lastPolicyPaymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && !hasPolicyVBBA) { - Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: activeRoute})); + Navigation.navigate( + ROUTES.SEARCH_REPORT.getRoute({ + reportID: item.reportID, + backTo: activeRoute, + }), + ); return; } } @@ -350,6 +402,50 @@ function SearchPage({route}: SearchPageProps) { }); }; + // TODO: to be refactored in step 3 + const hideReceiptModal = () => { + setIsAttachmentInvalid(false); + }; + + // TODO: to be refactored in step 3 + const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => { + validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => { + if (!isFileValid) { + 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); + } + resizeImageIfNeeded(originalFile).then((resizedFile) => { + setIsLoadingReceipt(false); + // Store the receipt on the transaction object in Onyx + const source = URL.createObjectURL(resizedFile as Blob); + const newReportID = generateReportID(); + initMoneyRequest(newReportID, undefined, true, undefined, CONST.IOU.REQUEST_TYPE.SCAN); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + setMoneyRequestReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, source, resizedFile.name || '', true); + navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID); + }); + }); + }; + + const initScanRequest = (e: DragEvent) => { + const file = e?.dataTransfer?.files[0]; + if (file) { + file.uri = URL.createObjectURL(file); + setReceiptAndNavigate(file); + } + }; + const createExportAll = useCallback(() => { if (selectedTransactionsKeys.length === 0 || !status || !hash) { return []; @@ -378,6 +474,24 @@ function SearchPage({route}: SearchPageProps) { const isDataLoaded = isSearchDataLoaded(currentSearchResults, lastNonEmptySearchResults, queryJSON); const shouldShowLoadingState = !isOffline && !isDataLoaded; + // TODO: to be refactored in step 3 + const PDFThumbnailView = pdfFile ? ( + { + setPdfFile(null); + setReceiptAndNavigate(pdfFile, true); + }} + onPassword={() => { + setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported'); + }} + onLoadError={() => { + setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment'); + }} + /> + ) : null; + // Handles video player cleanup: // 1. On mount: Resets player if navigating from report screen // 2. On unmount: Stops video when leaving this screen @@ -494,23 +608,44 @@ function SearchPage({route}: SearchPageProps) { shouldShowOfflineIndicatorInWideScreen={!!shouldShowOfflineIndicator} offlineIndicatorStyle={styles.mtAuto} > - - - + {isLoadingReceipt && } + + {PDFThumbnailView} + + + + + + )} { - Performance.markStart(CONST.TIMING.OPEN_CREATE_EXPENSE_CONTACT); - - switch (iouType) { - case CONST.IOU.TYPE.REQUEST: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); - break; - case CONST.IOU.TYPE.SEND: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.PAY, transactionID, reportID)); - break; - default: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); - } - }; - const navigateToConfirmationPage = () => { switch (iouType) { case CONST.IOU.TYPE.REQUEST: @@ -272,7 +257,7 @@ function IOURequestStepAmount({ ); }); } else { - navigateToParticipantPage(); + navigateToParticipantPage(iouType, transactionID, reportID); } }; @@ -309,7 +294,16 @@ function IOURequestStepAmount({ return; } - updateMoneyRequestAmountAndCurrency({transactionID, transactionThreadReportID: reportID, currency, amount: newAmount, taxAmount, policy, taxCode, policyCategories}); + updateMoneyRequestAmountAndCurrency({ + transactionID, + transactionThreadReportID: reportID, + currency, + amount: newAmount, + taxAmount, + policy, + taxCode, + policyCategories, + }); navigateBack(); }; diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 0e0ef27af99d..3c39fee66263 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -41,7 +41,7 @@ import {createBackupTransaction, removeBackupTransaction, restoreOriginalTransac import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import type {MileageRate} from '@libs/DistanceRequestUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {navigateToParticipantPage, shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; import {getPersonalPolicy, getPolicy, isPaidGroupPolicy} from '@libs/PolicyUtils'; @@ -271,19 +271,6 @@ function IOURequestStepDistance({ [action, transactionID, report?.reportID, reportID], ); - const navigateToParticipantPage = useCallback(() => { - switch (iouType) { - case CONST.IOU.TYPE.REQUEST: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); - break; - case CONST.IOU.TYPE.SEND: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.PAY, transactionID, reportID)); - break; - default: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); - } - }, [iouType, reportID, transactionID]); - const navigateToConfirmationPage = useCallback(() => { switch (iouType) { case CONST.IOU.TYPE.REQUEST: @@ -398,12 +385,13 @@ function IOURequestStepDistance({ ); }); } else { - navigateToParticipantPage(); + navigateToParticipantPage(iouType, transactionID, reportID); } }, [ transaction, backTo, report, + reportID, reportNameValuePairs, iouType, activePolicy, @@ -419,7 +407,6 @@ function IOURequestStepDistance({ backToReport, customUnitRateID, navigateToConfirmationPage, - navigateToParticipantPage, ]); const getError = () => { diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index c82e92f1f693..9f6047f94040 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -37,7 +37,7 @@ import getPhotoSource from '@libs/fileDownload/getPhotoSource'; import getCurrentPosition from '@libs/getCurrentPosition'; import getPlatform from '@libs/getPlatform'; import getReceiptsUploadFolderPath from '@libs/getReceiptsUploadFolderPath'; -import {shouldStartLocationPermissionFlow} from '@libs/IOUUtils'; +import {navigateToParticipantPage, shouldStartLocationPermissionFlow} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getIsUserSubmittedExpenseOrScannedReceipt, getManagerMcTestParticipant, getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; @@ -242,19 +242,6 @@ function IOURequestStepScan({ Navigation.goBack(); }; - const navigateToParticipantPage = useCallback(() => { - switch (iouType) { - case CONST.IOU.TYPE.REQUEST: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); - break; - case CONST.IOU.TYPE.SEND: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.PAY, transactionID, reportID)); - break; - default: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); - } - }, [iouType, reportID, transactionID]); - const navigateToConfirmationPage = useCallback( (isTestTransaction = false, reportIDParam: string | undefined = undefined) => { switch (iouType) { @@ -473,7 +460,7 @@ function IOURequestStepScan({ ); }); } else { - navigateToParticipantPage(); + navigateToParticipantPage(iouType, transactionID, reportID); } }, [ @@ -497,7 +484,6 @@ function IOURequestStepScan({ transactionTaxAmount, policy, backToReport, - navigateToParticipantPage, ], ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 7b8f4320a0ce..ea4e95cc38a0 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -39,7 +39,7 @@ import {isMobile, isMobileWebKit} from '@libs/Browser'; import {base64ToFile, readFileAsync, resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils'; import getCurrentPosition from '@libs/getCurrentPosition'; import getPlatform from '@libs/getPlatform'; -import {shouldStartLocationPermissionFlow} from '@libs/IOUUtils'; +import {navigateToParticipantPage, shouldStartLocationPermissionFlow} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {getIsUserSubmittedExpenseOrScannedReceipt, getManagerMcTestParticipant, getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; @@ -127,6 +127,7 @@ function IOURequestStepScan({ const isTabActive = useIsFocused(); const isEditing = action === CONST.IOU.ACTION.EDIT; + const defaultTaxCode = getDefaultTaxCode(policy, transaction); const transactionTaxCode = (transaction?.taxCode ? transaction?.taxCode : defaultTaxCode) ?? ''; const transactionTaxAmount = transaction?.taxAmount ?? 0; @@ -271,19 +272,6 @@ function IOURequestStepScan({ Navigation.goBack(backTo); }, [backTo]); - const navigateToParticipantPage = useCallback(() => { - switch (iouType) { - case CONST.IOU.TYPE.REQUEST: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID)); - break; - case CONST.IOU.TYPE.SEND: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.PAY, transactionID, reportID)); - break; - default: - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID)); - } - }, [iouType, reportID, transactionID]); - const navigateToConfirmationPage = useCallback( (isTestTransaction = false, reportIDParam: string | undefined = undefined) => { switch (iouType) { @@ -512,7 +500,7 @@ function IOURequestStepScan({ ); }); } else { - navigateToParticipantPage(); + navigateToParticipantPage(iouType, transactionID, reportID); } }, [ @@ -536,7 +524,6 @@ function IOURequestStepScan({ transactionTaxAmount, policy, backToReport, - navigateToParticipantPage, ], ); @@ -954,9 +941,9 @@ function IOURequestStepScan({ setReceiptAndNavigate(file); } }} - icon={Expensicons.SmartScan} + icon={isEditing ? Expensicons.ReplaceReceipt : Expensicons.SmartScan} dropStyles={styles.receiptDropOverlay} - dropTitle={translate('dropzone.scanReceipts')} + dropTitle={isEditing ? translate('dropzone.replaceReceipt') : translate('dropzone.scanReceipts')} dropTextStyles={styles.receiptDropText} dropInnerWrapperStyles={styles.receiptDropInnerWrapper} />