diff --git a/src/CONST.ts b/src/CONST.ts index 08c6a2509207..f7b983eb84f7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1352,6 +1352,7 @@ const CONST = { DATE: 'date', DESCRIPTION: 'description', MERCHANT: 'merchant', + RECEIPT: 'receipt', }, FOOTER: { EXPENSE_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/expense-management`, diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index bbb0662132d2..946b5e2ddec9 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -1,4 +1,4 @@ -import React, {useState, useCallback} from 'react'; +import React, {useState, useCallback, useRef} from 'react'; import PropTypes from 'prop-types'; import {View, Animated, Keyboard} from 'react-native'; import Str from 'expensify-common/lib/str'; @@ -25,6 +25,10 @@ import HeaderGap from './HeaderGap'; import SafeAreaConsumer from './SafeAreaConsumer'; import addEncryptedAuthTokenToURL from '../libs/addEncryptedAuthTokenToURL'; import reportPropTypes from '../pages/reportPropTypes'; +import * as Expensicons from './Icon/Expensicons'; +import useWindowDimensions from '../hooks/useWindowDimensions'; +import Navigation from '../libs/Navigation/Navigation'; +import ROUTES from '../ROUTES'; import useNativeDriver from '../libs/useNativeDriver'; /** @@ -94,6 +98,7 @@ const defaultProps = { }; function AttachmentModal(props) { + const onModalHideCallbackRef = useRef(null); const [isModalOpen, setIsModalOpen] = useState(props.defaultOpen); const [shouldLoadAttachment, setShouldLoadAttachment] = useState(false); const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false); @@ -106,6 +111,8 @@ function AttachmentModal(props) { const [isConfirmButtonDisabled, setIsConfirmButtonDisabled] = useState(false); const [confirmButtonFadeAnimation] = useState(new Animated.Value(1)); const [shouldShowDownloadButton, setShouldShowDownloadButton] = React.useState(true); + const {windowWidth} = useWindowDimensions(); + const [file, setFile] = useState( props.originalFileName ? { @@ -331,6 +338,10 @@ function AttachmentModal(props) { }} onModalHide={(e) => { props.onModalHide(e); + if (onModalHideCallbackRef.current) { + onModalHideCallbackRef.current(); + } + setShouldLoadAttachment(false); }} propagateSwipe @@ -339,12 +350,30 @@ function AttachmentModal(props) { downloadAttachment(source)} shouldShowCloseButton={!props.isSmallScreenWidth} shouldShowBackButton={props.isSmallScreenWidth} onBackButtonPress={closeModal} onCloseButtonPress={closeModal} + shouldShowThreeDotsButton={isAttachmentReceipt} + threeDotsAnchorPosition={styles.threeDotsPopoverOffsetAttachmentModal(windowWidth)} + threeDotsMenuItems={[ + { + icon: Expensicons.Camera, + text: props.translate('common.replace'), + onSelected: () => { + onModalHideCallbackRef.current = () => Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT)); + closeModal(); + }, + }, + { + icon: Expensicons.Download, + text: props.translate('common.download'), + onSelected: () => downloadAttachment(source), + }, + ]} + shouldOverlay /> {!_.isEmpty(props.report) ? ( diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js index bbf905cc1ac2..cf61a4cf4eb7 100755 --- a/src/components/HeaderWithBackButton/index.js +++ b/src/components/HeaderWithBackButton/index.js @@ -47,6 +47,7 @@ function HeaderWithBackButton({ }, threeDotsMenuItems = [], children = null, + shouldOverlay = false, }) { const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); @@ -137,6 +138,7 @@ function HeaderWithBackButton({ menuItems={threeDotsMenuItems} onIconPress={onThreeDotsButtonPress} anchorPosition={threeDotsAnchorPosition} + shouldOverlay={shouldOverlay} /> )} {shouldShowCloseButton && ( diff --git a/src/components/ThreeDotsMenu/index.js b/src/components/ThreeDotsMenu/index.js index b5637a4f3879..f0cee6fdea2f 100644 --- a/src/components/ThreeDotsMenu/index.js +++ b/src/components/ThreeDotsMenu/index.js @@ -45,6 +45,9 @@ const propTypes = { horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), + + /** Whether the popover menu should overlay the current view */ + shouldOverlay: PropTypes.bool, }; const defaultProps = { @@ -57,9 +60,10 @@ const defaultProps = { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, // we assume that popover menu opens below the button, anchor is at TOP }, + shouldOverlay: false, }; -function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment}) { +function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, menuItems, anchorPosition, anchorAlignment, shouldOverlay}) { const [isPopupMenuVisible, setPopupMenuVisible] = useState(false); const buttonRef = useRef(null); const {translate} = useLocalize(); @@ -106,7 +110,7 @@ function ThreeDotsMenu({iconTooltip, icon, iconFill, iconStyles, onIconPress, me anchorAlignment={anchorAlignment} onItemSelected={hidePopoverMenu} menuItems={menuItems} - withoutOverlay + withoutOverlay={!shouldOverlay} anchorRef={buttonRef} /> diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 02b5d7017c48..a9f07cf84d9f 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1859,6 +1859,43 @@ function payMoneyRequest(paymentType, chatReport, iouReport) { Navigation.dismissModal(chatReport.reportID); } +/** + * @param {String} transactionID + * @param {Object} receipt + * @param {String} filePath + */ +function replaceReceipt(transactionID, receipt, filePath) { + const transaction = lodashGet(allTransactions, 'transactionID', {}); + const oldReceipt = lodashGet(transaction, 'receipt', {}); + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + receipt: { + source: filePath, + state: CONST.IOU.RECEIPT_STATE.OPEN, + }, + filename: receipt.name, + }, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + receipt: oldReceipt, + filename: transaction.filename, + }, + }, + ]; + + API.write('ReplaceReceipt', {transactionID, receipt}, {optimisticData, failureData}); +} + /** * Initialize money request info and navigate to the MoneyRequest page * @param {String} iouType @@ -2017,4 +2054,5 @@ export { setMoneyRequestReceipt, createEmptyTransaction, navigateToNextPage, + replaceReceipt, }; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 0094d174df21..2123f1bf10da 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -15,6 +15,7 @@ import EditRequestDescriptionPage from './EditRequestDescriptionPage'; import EditRequestMerchantPage from './EditRequestMerchantPage'; import EditRequestCreatedPage from './EditRequestCreatedPage'; import EditRequestAmountPage from './EditRequestAmountPage'; +import EditRequestReceiptPage from './EditRequestReceiptPage'; import reportPropTypes from './reportPropTypes'; import * as IOU from '../libs/actions/IOU'; import * as CurrencyUtils from '../libs/CurrencyUtils'; @@ -171,6 +172,15 @@ function EditRequestPage({report, route, parentReport, policy, session}) { ); } + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { + return ( + + ); + } + return ; } diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js new file mode 100644 index 000000000000..47aa23a93432 --- /dev/null +++ b/src/pages/EditRequestReceiptPage.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ScreenWrapper from '../components/ScreenWrapper'; +import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import Navigation from '../libs/Navigation/Navigation'; +import useLocalize from '../hooks/useLocalize'; +import ReceiptSelector from './iou/ReceiptSelector'; +import DragAndDropProvider from '../components/DragAndDrop/Provider'; + +const propTypes = { + /** React Navigation route */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** The type of IOU report, i.e. bill, request, send */ + iouType: PropTypes.string, + + /** The report ID of the IOU */ + reportID: PropTypes.string, + }), + }).isRequired, + + /** The id of the transaction we're editing */ + transactionID: PropTypes.string.isRequired, +}; + +function EditRequestReceiptPage({route, transactionID}) { + const {translate} = useLocalize(); + + return ( + + + + + + + ); +} + +EditRequestReceiptPage.propTypes = propTypes; +EditRequestReceiptPage.displayName = 'EditRequestReceiptPage'; + +export default EditRequestReceiptPage; diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js index 2c2f70ec1beb..013d63dc4b98 100644 --- a/src/pages/iou/ReceiptSelector/index.js +++ b/src/pages/iou/ReceiptSelector/index.js @@ -21,6 +21,7 @@ import useLocalize from '../../../hooks/useLocalize'; import {DragAndDropContext} from '../../../components/DragAndDrop/Provider'; import * as ReceiptUtils from '../../../libs/ReceiptUtils'; import {iouPropTypes, iouDefaultProps} from '../propTypes'; +import Navigation from '../../../libs/Navigation/Navigation'; const propTypes = { /** Information shown to the user when a receipt is not valid */ @@ -47,6 +48,9 @@ const propTypes = { /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ iou: iouPropTypes, + + /** The id of the transaction we're editing */ + transactionID: PropTypes.string, }; const defaultProps = { @@ -57,6 +61,7 @@ const defaultProps = { }, report: {}, iou: iouDefaultProps, + transactionID: '', }; function ReceiptSelector(props) { @@ -83,6 +88,13 @@ function ReceiptSelector(props) { const filePath = URL.createObjectURL(file); IOU.setMoneyRequestReceipt(filePath, file.name); + + if (props.transactionID) { + IOU.replaceReceipt(props.transactionID, file, filePath); + Navigation.dismissModal(); + return; + } + IOU.navigateToNextPage(iou, iouType, reportID, report); }; diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js index 4ff32d940c9f..36f883ad08e5 100644 --- a/src/pages/iou/ReceiptSelector/index.native.js +++ b/src/pages/iou/ReceiptSelector/index.native.js @@ -23,6 +23,8 @@ import Log from '../../../libs/Log'; import * as CameraPermission from './CameraPermission'; import {iouPropTypes, iouDefaultProps} from '../propTypes'; import NavigationAwareCamera from './NavigationAwareCamera'; +import Navigation from '../../../libs/Navigation/Navigation'; +import * as FileUtils from '../../../libs/fileDownload/FileUtils'; const propTypes = { /** React Navigation route */ @@ -42,11 +44,15 @@ const propTypes = { /** Holds data related to Money Request view state, rather than the underlying Money Request data. */ iou: iouPropTypes, + + /** The id of the transaction we're editing */ + transactionID: PropTypes.string, }; const defaultProps = { report: {}, iou: iouDefaultProps, + transactionID: '', }; /** @@ -74,7 +80,7 @@ function getImagePickerOptions(type) { }; } -function ReceiptSelector(props) { +function ReceiptSelector({route, report, iou, transactionID}) { const devices = useCameraDevices('wide-angle-camera'); const device = devices.back; @@ -84,9 +90,9 @@ function ReceiptSelector(props) { const isAndroidBlockedPermissionRef = useRef(false); const appState = useRef(AppState.currentState); - const iouType = lodashGet(props.route, 'params.iouType', ''); - const reportID = lodashGet(props.route, 'params.reportID', ''); - const pageIndex = lodashGet(props.route, 'params.pageIndex', 1); + const iouType = lodashGet(route, 'params.iouType', ''); + const reportID = lodashGet(route, 'params.reportID', ''); + const pageIndex = lodashGet(route, 'params.pageIndex', 1); const {translate} = useLocalize(); @@ -195,14 +201,25 @@ function ReceiptSelector(props) { flash: flash ? 'on' : 'off', }) .then((photo) => { - IOU.setMoneyRequestReceipt(`file://${photo.path}`, photo.path); - IOU.navigateToNextPage(props.iou, iouType, reportID, props.report); + const filePath = `file://${photo.path}`; + IOU.setMoneyRequestReceipt(filePath, photo.path); + + if (transactionID) { + FileUtils.readFileAsync(filePath, photo.path).then((receipt) => { + IOU.replaceReceipt(transactionID, receipt, filePath); + }); + + Navigation.dismissModal(); + return; + } + + IOU.navigateToNextPage(iou, iouType, reportID, report); }) .catch((error) => { showCameraAlert(); Log.warn('Error taking photo', error); }); - }, [flash, iouType, props.iou, props.report, reportID, translate]); + }, [flash, iouType, iou, report, reportID, translate, transactionID]); CameraPermission.getCameraPermissionStatus().then((permissionStatus) => { setPermissions(permissionStatus); @@ -260,8 +277,18 @@ function ReceiptSelector(props) { onPress={() => { showImagePicker(launchImageLibrary) .then((receiptImage) => { - IOU.setMoneyRequestReceipt(receiptImage[0].uri, receiptImage[0].fileName); - IOU.navigateToNextPage(props.iou, iouType, reportID, props.report); + const filePath = receiptImage[0].uri; + IOU.setMoneyRequestReceipt(filePath, receiptImage[0].fileName); + + if (transactionID) { + FileUtils.readFileAsync(filePath, receiptImage[0].fileName).then((receipt) => { + IOU.replaceReceipt(transactionID, receipt, filePath); + }); + Navigation.dismissModal(); + return; + } + + IOU.navigateToNextPage(iou, iouType, reportID, report); }) .catch(() => { Log.info('User did not select an image from gallery'); diff --git a/src/styles/styles.js b/src/styles/styles.js index 475516c3cb15..edf9a78df9cd 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -3181,6 +3181,11 @@ const styles = (theme) => ({ horizontal: windowWidth - 10, }), + threeDotsPopoverOffsetAttachmentModal: (windowWidth) => ({ + ...getPopOverVerticalOffset(80), + horizontal: windowWidth - 140, + }), + invert: { // It's important to invert the Y AND X axis to prevent a react native issue that can lead to ANRs on android 13 transform: [{scaleX: -1}, {scaleY: -1}],