diff --git a/src/components/ReportActionItem/IOUAction.js b/src/components/ReportActionItem/IOUAction.js index affa1035eb64..b18577054f20 100644 --- a/src/components/ReportActionItem/IOUAction.js +++ b/src/components/ReportActionItem/IOUAction.js @@ -3,12 +3,18 @@ import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; import lodashGet from 'lodash/get'; import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; +import {withNetwork} from '../OnyxProvider'; +import compose from '../../libs/compose'; import IOUQuote from './IOUQuote'; import reportActionPropTypes from '../../pages/home/report/reportActionPropTypes'; +import networkPropTypes from '../networkPropTypes'; +import iouReportPropTypes from '../../pages/iouReportPropTypes'; import IOUPreview from './IOUPreview'; import Navigation from '../../libs/Navigation/Navigation'; import ROUTES from '../../ROUTES'; import styles from '../../styles/styles'; +import * as IOUUtils from '../../libs/IOUUtils'; const propTypes = { /** All the data of the action */ @@ -30,9 +36,16 @@ const propTypes = { hasOutstandingIOU: PropTypes.bool.isRequired, }), + /** IOU report data object */ + iouReport: iouReportPropTypes.isRequired, + + /** Array of report actions for this report */ + reportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)).isRequired, + /** Whether the IOU is hovered so we can modify its style */ isHovered: PropTypes.bool, + network: networkPropTypes.isRequired, }; const defaultProps = { @@ -52,6 +65,17 @@ const IOUAction = (props) => { && Boolean(props.action.originalMessage.IOUReportID) && props.chatReport.hasOutstandingIOU) || props.action.originalMessage.type === 'pay'; + let shouldShowPendingConversionMessage = false; + if ( + props.iouReport + && props.chatReport.hasOutstandingIOU + && props.isMostRecentIOUReportAction + && props.action.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD + && props.network.isOffline + ) { + shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(props.reportActions, props.iouReport); + } + return ( <> { pendingAction={lodashGet(props.action, 'pendingAction', null)} iouReportID={props.action.originalMessage.IOUReportID.toString()} chatReportID={props.chatReportID} + shouldShowPendingConversionMessage={shouldShowPendingConversionMessage} onPayButtonPressed={launchDetailsModal} onPreviewPressed={launchDetailsModal} containerStyles={[ @@ -83,8 +108,18 @@ IOUAction.propTypes = propTypes; IOUAction.defaultProps = defaultProps; IOUAction.displayName = 'IOUAction'; -export default withOnyx({ - chatReport: { - key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, - }, -})(IOUAction); +export default compose( + withOnyx({ + chatReport: { + key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`, + }, + iouReport: { + key: ({iouReportID}) => `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + }, + reportActions: { + key: ({chatReportID}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + canEvict: false, + }, + }), + withNetwork(), +)(IOUAction); diff --git a/src/components/ReportActionItem/IOUPreview.js b/src/components/ReportActionItem/IOUPreview.js index ceec76c279c6..1c997119d6d7 100644 --- a/src/components/ReportActionItem/IOUPreview.js +++ b/src/components/ReportActionItem/IOUPreview.js @@ -175,11 +175,18 @@ const IOUPreview = (props) => { ) : ( - - {props.iouReport.hasOutstandingIOU - ? props.translate('iou.owesyou', {manager: managerName}) - : props.translate('iou.paidyou', {manager: managerName})} - + <> + + {props.iouReport.hasOutstandingIOU + ? props.translate('iou.owesyou', {manager: managerName}) + : props.translate('iou.paidyou', {manager: managerName})} + + {props.shouldShowPendingConversionMessage && ( + + {props.translate('iou.pendingConversionMessage')} + + )} + )} {(isCurrentUserManager && !props.shouldHidePayButton diff --git a/src/languages/en.js b/src/languages/en.js index d849ce04ee14..6da4e57ad466 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -260,6 +260,7 @@ export default { split: ({amount}) => `Split ${amount}`, send: ({amount}) => `Send ${amount}`, noReimbursableExpenses: 'This report has an invalid amount', + pendingConversionMessage: 'Total will update when you\'re back online', error: { invalidSplit: 'Split amounts do not equal total amount', other: 'Unexpected error, please try again later', diff --git a/src/languages/es.js b/src/languages/es.js index 889b0ab8c6bf..0dfa7cd96437 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -260,6 +260,7 @@ export default { split: ({amount}) => `Dividir ${amount}`, send: ({amount}) => `Enviar ${amount}`, noReimbursableExpenses: 'El monto de este informe es inválido', + pendingConversionMessage: 'El total se actualizará cuando estés online', error: { invalidSplit: 'La suma de las partes no equivale al monto total', other: 'Error inesperado, por favor inténtalo más tarde', diff --git a/src/libs/IOUUtils.js b/src/libs/IOUUtils.js index 440155f755f6..5c1538682ce3 100644 --- a/src/libs/IOUUtils.js +++ b/src/libs/IOUUtils.js @@ -1,3 +1,4 @@ +import _ from 'underscore'; import CONST from '../CONST'; /** @@ -65,7 +66,77 @@ function updateIOUOwnerAndTotal(iouReport, actorEmail, amount, currency, type = return iouReportUpdate; } +/** + * Returns the list of IOU actions depending on the type and whether or not they are pending. + * Used below so that we can decide if an IOU report is pending currency conversion. + * + * @param {Array} reportActions + * @param {Object} iouReport + * @param {String} type - iouReportAction type. Can be oneOf(create, decline, cancel, pay, split) + * @param {String} pendingAction + * @param {Boolean} filterRequestsInDifferentCurrency + * + * @returns {Array} + */ +function getIOUReportActions(reportActions, iouReport, type = '', pendingAction = '', filterRequestsInDifferentCurrency = false) { + return _.chain(reportActions) + .filter(action => action.originalMessage + && action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU + && action.originalMessage.IOUReportID.toString() === iouReport.reportID.toString()) + .filter(action => (!_.isEmpty(type) ? action.originalMessage.type === type : true)) + .filter(action => (!_.isEmpty(pendingAction) ? action.pendingAction === pendingAction : true)) + .filter(action => (filterRequestsInDifferentCurrency ? action.originalMessage.currency !== iouReport.currency : true)) + .value(); +} + +/** + * Returns whether or not an IOU report contains money requests in a different currency + * that are either created or cancelled offline, and thus haven't been converted to the report's currency yet + * + * @param {Array} reportActions + * @param {Object} iouReport + * + * @returns {Boolean} + */ +function isIOUReportPendingCurrencyConversion(reportActions, iouReport) { + // Pending money requests that are in a different currency + const pendingRequestsInDifferentCurrency = _.chain(getIOUReportActions( + reportActions, + iouReport, + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + true, + )).map(action => action.originalMessage.IOUTransactionID) + .sort() + .value(); + + // Pending cancelled money requests that are in a different currency + const pendingCancelledRequestsInDifferentCurrency = _.chain(getIOUReportActions( + reportActions, + iouReport, + CONST.IOU.REPORT_ACTION_TYPE.CANCEL, + CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + true, + )).map(action => action.originalMessage.IOUTransactionID) + .sort() + .value(); + + const hasPendingRequests = Boolean(pendingRequestsInDifferentCurrency.length || pendingCancelledRequestsInDifferentCurrency.length); + + // If we have pending money requests made offline, check if all of them have been cancelled offline + // In order to do that, we can grab transactionIDs of all the created and cancelled money requests and check if they're identical + if (hasPendingRequests && _.isEqual(pendingRequestsInDifferentCurrency, pendingCancelledRequestsInDifferentCurrency)) { + return false; + } + + // Not all requests made offline had been cancelled, + // simply return if we have any pending created or cancelled requests + return hasPendingRequests; +} + export { calculateAmount, updateIOUOwnerAndTotal, + getIOUReportActions, + isIOUReportPendingCurrencyConversion, }; diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 7158f15f37bb..f1b96f61c4a4 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -142,6 +142,7 @@ class ReportActionItem extends Component { children = ( { + reportActions = []; + const chatReportID = ReportUtils.generateReportID(); + const amount = 1000; + const currency = 'USD'; + + iouReport = ReportUtils.buildOptimisticIOUReport( + ownerEmail, + managerEmail, + amount, + chatReportID, + currency, + 'en', + ); + + // The starting point of all tests is the IOUReport containing a single non-pending transaction in USD + // All requests in the tests are assumed to be offline, unless isOnline is specified + createIOUReportAction('create', amount, currency, {IOUTransactionID: '', isOnline: true}); +}); + +describe('isIOUReportPendingCurrencyConversion', () => { + test('Requesting money offline in a different currency will show the pending conversion message', () => { + // Request money offline in AED + createIOUReportAction('create', 100, 'AED'); + + // We requested money offline in a different currency, we don't know the total of the iouReport until we're back online + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); + }); + + test('IOUReport is not pending conversion when all requests made offline have been cancelled', () => { + // Create two requests offline + const moneyRequestA = createIOUReportAction('create', 1000, 'AED'); + const moneyRequestB = createIOUReportAction('create', 1000, 'AED'); + + // Cancel both requests + cancelMoneyRequest(moneyRequestA); + cancelMoneyRequest(moneyRequestB); + + // Both requests made offline have been cancelled, total won't update so no need to show a pending conversion message + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); + }); + + test('Cancelling a request made online shows the preview', () => { + // Request money online in AED + const moneyRequest = createIOUReportAction('create', 1000, 'AED', {isOnline: true}); + + // Cancel it offline + cancelMoneyRequest(moneyRequest); + + // We don't know what the total is because we need to subtract the converted amount of the offline request from the total + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); + }); + + test('Cancelling a request made offline while there\'s a previous one made online will not show the pending conversion message', () => { + // Request money online in AED + createIOUReportAction('create', 1000, 'AED', {isOnline: true}); + + // Another request offline + const moneyRequestOffline = createIOUReportAction('create', 1000, 'AED'); + + // Cancel the request made offline + cancelMoneyRequest(moneyRequestOffline); + + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); + }); + + test('Cancelling a request made online while we have one made offline will show the pending conversion message', () => { + // Request money online in AED + const moneyRequestOnline = createIOUReportAction('create', 1000, 'AED', {isOnline: true}); + + // Requet money again but offline + createIOUReportAction('create', 1000, 'AED'); + + // Cancel the request made online + cancelMoneyRequest(moneyRequestOnline); + + // We don't know what the total is because we need to subtract the converted amount of the offline request from the total + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(true); + }); + + test('Cancelling a request offline in the report\'s currency when we have requests in a different currency does not show the pending conversion message', () => { + // Request money in the report's curreny (USD) + const onlineMoneyRequestInUSD = createIOUReportAction('create', 1000, 'USD', {isOnline: true}); + + // Request money online in a different currency + createIOUReportAction('create', 2000, 'AED', {isOnline: true}); + + // Cancel the USD request offline + cancelMoneyRequest(onlineMoneyRequestInUSD); + + expect(IOUUtils.isIOUReportPendingCurrencyConversion(reportActions, iouReport)).toBe(false); + }); +}); +