diff --git a/src/CONST.js b/src/CONST.js index 4c19965837d9..a0c6cbf2bcf3 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -448,6 +448,7 @@ const CONST = { TASKEDITED: 'TASKEDITED', TASKCANCELLED: 'TASKCANCELLED', IOU: 'IOU', + MODIFIEDEXPENSE: 'MODIFIEDEXPENSE', REIMBURSEMENTQUEUED: 'REIMBURSEMENTQUEUED', RENAMED: 'RENAMED', CHRONOSOOOLIST: 'CHRONOSOOOLIST', @@ -1257,8 +1258,10 @@ const CONST = { }, EDIT_REQUEST_FIELD: { AMOUNT: 'amount', + CURRENCY: 'currency', DATE: 'date', DESCRIPTION: 'description', + MERCHANT: 'merchant', }, FOOTER: { EXPENSE_MANAGEMENT_URL: `${USE_EXPENSIFY_URL}/expense-management`, diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index a99a09063561..64b3b960581f 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -220,6 +220,8 @@ export default { NEW_TASK_FORM: 'newTaskForm', EDIT_TASK_FORM: 'editTaskForm', MONEY_REQUEST_DESCRIPTION_FORM: 'moneyRequestDescriptionForm', + MONEY_REQUEST_AMOUNT_FORM: 'moneyRequestAmountForm', + MONEY_REQUEST_CREATED_FORM: 'moneyRequestCreatedForm', NEW_CONTACT_METHOD_FORM: 'newContactMethodForm', PAYPAL_FORM: 'payPalForm', SETTINGS_STATUS_SET_FORM: 'settingsStatusSetForm', diff --git a/src/components/CurrencySymbolButton.js b/src/components/CurrencySymbolButton.js index 0c19ce0f63b3..bd94e5334e9b 100644 --- a/src/components/CurrencySymbolButton.js +++ b/src/components/CurrencySymbolButton.js @@ -3,9 +3,9 @@ import PropTypes from 'prop-types'; import Text from './Text'; import styles from '../styles/styles'; import Tooltip from './Tooltip'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import CONST from '../CONST'; +import useLocalize from '../hooks/useLocalize'; const propTypes = { /** Currency symbol of selected currency */ @@ -14,18 +14,25 @@ const propTypes = { /** Function to call when currency button is pressed */ onCurrencyButtonPress: PropTypes.func.isRequired, - ...withLocalizePropTypes, + /** Flag to indicate if the button should be disabled */ + disabled: PropTypes.bool, }; -function CurrencySymbolButton(props) { +const defaultProps = { + disabled: false, +}; + +function CurrencySymbolButton({onCurrencyButtonPress, currencySymbol, disabled}) { + const {translate} = useLocalize(); return ( - + - {props.currencySymbol} + {currencySymbol} ); @@ -33,5 +40,6 @@ function CurrencySymbolButton(props) { CurrencySymbolButton.propTypes = propTypes; CurrencySymbolButton.displayName = 'CurrencySymbolButton'; +CurrencySymbolButton.defaultProps = defaultProps; -export default withLocalize(CurrencySymbolButton); +export default CurrencySymbolButton; diff --git a/src/components/MoneyRequestDetails.js b/src/components/MoneyRequestDetails.js deleted file mode 100644 index a690c31c000c..000000000000 --- a/src/components/MoneyRequestDetails.js +++ /dev/null @@ -1,231 +0,0 @@ -import React from 'react'; -import {withOnyx} from 'react-native-onyx'; -import {View} from 'react-native'; -import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; -import iouReportPropTypes from '../pages/iouReportPropTypes'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import * as ReportUtils from '../libs/ReportUtils'; -import * as Expensicons from './Icon/Expensicons'; -import Text from './Text'; -import participantPropTypes from './participantPropTypes'; -import Avatar from './Avatar'; -import styles from '../styles/styles'; -import themeColors from '../styles/themes/default'; -import CONST from '../CONST'; -import withWindowDimensions from './withWindowDimensions'; -import compose from '../libs/compose'; -import ROUTES from '../ROUTES'; -import Icon from './Icon'; -import SettlementButton from './SettlementButton'; -import * as Policy from '../libs/actions/Policy'; -import ONYXKEYS from '../ONYXKEYS'; -import * as IOU from '../libs/actions/IOU'; -import * as CurrencyUtils from '../libs/CurrencyUtils'; -import MenuItemWithTopDescription from './MenuItemWithTopDescription'; -import DateUtils from '../libs/DateUtils'; -import reportPropTypes from '../pages/reportPropTypes'; -import * as UserUtils from '../libs/UserUtils'; -import OfflineWithFeedback from './OfflineWithFeedback'; - -const propTypes = { - /** The report currently being looked at */ - report: iouReportPropTypes.isRequired, - - /** The expense report or iou report (only will have a value if this is a transaction thread) */ - parentReport: iouReportPropTypes, - - /** The policy object for the current route */ - policy: PropTypes.shape({ - /** The name of the policy */ - name: PropTypes.string, - - /** The URL for the policy avatar */ - avatar: PropTypes.string, - }), - - /** The chat report this report is linked to */ - chatReport: reportPropTypes, - - /** Personal details so we can get the ones for the report participants */ - personalDetails: PropTypes.objectOf(participantPropTypes).isRequired, - - /** Whether we're viewing a report with a single transaction in it */ - isSingleTransactionView: PropTypes.bool, - - /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user email */ - email: PropTypes.string, - }), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - isSingleTransactionView: false, - chatReport: {}, - session: { - email: null, - }, - parentReport: {}, - policy: null, -}; - -function MoneyRequestDetails(props) { - // These are only used for the single transaction view and not for expense and iou reports - const {amount: transactionAmount, currency: transactionCurrency, comment: transactionDescription} = ReportUtils.getMoneyRequestAction(props.parentReportAction); - const formattedTransactionAmount = transactionAmount && transactionCurrency && CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); - const transactionDate = lodashGet(props.parentReportAction, ['created']); - const formattedTransactionDate = DateUtils.getDateStringFromISOTimestamp(transactionDate); - - const reportTotal = ReportUtils.getMoneyRequestTotal(props.report); - const formattedAmount = CurrencyUtils.convertToDisplayString(reportTotal, props.report.currency); - const moneyRequestReport = props.isSingleTransactionView ? props.parentReport : props.report; - const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); - const isExpenseReport = ReportUtils.isExpenseReport(moneyRequestReport); - const payeeName = isExpenseReport ? ReportUtils.getPolicyName(moneyRequestReport) : ReportUtils.getDisplayNameForParticipant(moneyRequestReport.managerID); - const payeeAvatar = isExpenseReport - ? ReportUtils.getWorkspaceAvatar(moneyRequestReport) - : UserUtils.getAvatar(lodashGet(props.personalDetails, [moneyRequestReport.managerID, 'avatar']), moneyRequestReport.managerID); - const isPayer = - Policy.isAdminOfFreePolicy([props.policy]) || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(props.session, 'accountID', null) === moneyRequestReport.managerID); - const shouldShowSettlementButton = - moneyRequestReport.reportID && !isSettled && !props.isSingleTransactionView && isPayer && !moneyRequestReport.isWaitingOnBankAccount && reportTotal !== 0; - const bankAccountRoute = ReportUtils.getBankAccountRoute(props.chatReport); - const shouldShowPaypal = Boolean(lodashGet(props.personalDetails, [moneyRequestReport.ownerAccountID, 'payPalMeAddress'])); - let description = `${props.translate('iou.amount')} • ${props.translate('iou.cash')}`; - if (isSettled) { - description += ` • ${props.translate('iou.settledExpensify')}`; - } else if (props.report.isWaitingOnBankAccount) { - description += ` • ${props.translate('iou.pending')}`; - } - - const {addWorkspaceRoomOrChatPendingAction, addWorkspaceRoomOrChatErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(props.report); - return ( - - - - {props.translate('common.to')} - - - - - - {payeeName} - - {isExpenseReport && ( - - {props.translate('workspace.common.workspace')} - - )} - - - - {!props.isSingleTransactionView && {formattedAmount}} - {!props.isSingleTransactionView && isSettled && ( - - - - )} - {shouldShowSettlementButton && !props.isSmallScreenWidth && ( - - IOU.payMoneyRequest(paymentType, props.chatReport, props.report)} - enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} - addBankAccountRoute={bankAccountRoute} - shouldShowPaymentOptions - /> - - )} - - - {shouldShowSettlementButton && props.isSmallScreenWidth && ( - IOU.payMoneyRequest(paymentType, props.chatReport, props.report)} - enablePaymentsRoute={ROUTES.BANK_ACCOUNT_NEW} - addBankAccountRoute={bankAccountRoute} - shouldShowPaymentOptions - /> - )} - - {props.isSingleTransactionView && ( - <> - Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} - /> - Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))} - /> - Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} - /> - - )} - - - ); -} - -MoneyRequestDetails.displayName = 'MoneyRequestDetails'; -MoneyRequestDetails.propTypes = propTypes; -MoneyRequestDetails.defaultProps = defaultProps; - -export default compose( - withWindowDimensions, - withLocalize, - withOnyx({ - chatReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`, - }, - session: { - key: ONYXKEYS.SESSION, - }, - parentReport: { - key: (props) => `${ONYXKEYS.COLLECTION.REPORT}${props.report.parentReportID}`, - }, - }), -)(MoneyRequestDetails); diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index dc8916bdaecb..598fe8d096d9 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -5,7 +5,9 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import reportPropTypes from '../../pages/reportPropTypes'; import ONYXKEYS from '../../ONYXKEYS'; -import withWindowDimensions from '../withWindowDimensions'; +import ROUTES from '../../ROUTES'; +import * as Policy from '../../libs/actions/Policy'; +import Navigation from '../../libs/Navigation/Navigation'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails'; import compose from '../../libs/compose'; import MenuItemWithTopDescription from '../MenuItemWithTopDescription'; @@ -20,6 +22,7 @@ import DateUtils from '../../libs/DateUtils'; import * as CurrencyUtils from '../../libs/CurrencyUtils'; import EmptyStateBackgroundImage from '../../../assets/images/empty-state_background-fade.png'; import useLocalize from '../../hooks/useLocalize'; +import useWindowDimensions from '../../hooks/useWindowDimensions'; const propTypes = { /** The report currently being looked at */ @@ -28,6 +31,21 @@ const propTypes = { /** The expense report or iou report (only will have a value if this is a transaction thread) */ parentReport: iouReportPropTypes, + /** The policy object for the current route */ + policy: PropTypes.shape({ + /** The name of the policy */ + name: PropTypes.string, + + /** The URL for the policy avatar */ + avatar: PropTypes.string, + }), + + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user email */ + email: PropTypes.string, + }), + /** Whether we should display the horizontal rule below the component */ shouldShowHorizontalRule: PropTypes.bool.isRequired, @@ -36,22 +54,38 @@ const propTypes = { const defaultProps = { parentReport: {}, + policy: null, + session: { + email: null, + }, }; -function MoneyRequestView(props) { - const parentReportAction = ReportActionsUtils.getParentReportAction(props.report); +function MoneyRequestView({report, parentReport, shouldShowHorizontalRule, policy, session}) { + const {isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + + const parentReportAction = ReportActionsUtils.getParentReportAction(report); const {amount: transactionAmount, currency: transactionCurrency, comment: transactionDescription} = ReportUtils.getMoneyRequestAction(parentReportAction); const formattedTransactionAmount = transactionAmount && transactionCurrency && CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency); const transactionDate = lodashGet(parentReportAction, ['created']); const formattedTransactionDate = DateUtils.getDateStringFromISOTimestamp(transactionDate); - const moneyRequestReport = props.parentReport; + const moneyRequestReport = parentReport; const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); - const {translate} = useLocalize(); + const isAdmin = Policy.isAdminOfFreePolicy([policy]) && ReportUtils.isExpenseReport(moneyRequestReport); + const isRequestor = ReportUtils.isMoneyRequestReport(moneyRequestReport) && lodashGet(session, 'accountID', null) === parentReportAction.actorAccountID; + const canEdit = !isSettled && (isAdmin || isRequestor); + + let description = `${translate('iou.amount')} • ${translate('iou.cash')}`; + if (isSettled) { + description += ` • ${translate('iou.settledExpensify')}`; + } else if (report.isWaitingOnBankAccount) { + description += ` • ${translate('iou.pending')}`; + } return ( - + Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} + disabled={isSettled || !canEdit} + shouldShowRightIcon={canEdit} + onPress={() => Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} /> Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))} + disabled={isSettled || !canEdit} + shouldShowRightIcon={canEdit} + onPress={() => Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION))} /> Navigation.navigate(ROUTES.getEditRequestRoute(props.report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} + disabled={isSettled || !canEdit} + shouldShowRightIcon={canEdit} + onPress={() => Navigation.navigate(ROUTES.getEditRequestRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} /> - {props.shouldShowHorizontalRule && } + {shouldShowHorizontalRule && } ); } @@ -93,11 +126,16 @@ MoneyRequestView.defaultProps = defaultProps; MoneyRequestView.displayName = 'MoneyRequestView'; export default compose( - withWindowDimensions, withCurrentUserPersonalDetails, withOnyx({ parentReport: { - key: (props) => `${ONYXKEYS.COLLECTION.REPORT}${props.report.parentReportID}`, + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, + }, + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, + }, + session: { + key: ONYXKEYS.SESSION, }, }), )(MoneyRequestView); diff --git a/src/components/TextInputWithCurrencySymbol.js b/src/components/TextInputWithCurrencySymbol.js index ef3fc3a1464a..06f2c62fedd8 100644 --- a/src/components/TextInputWithCurrencySymbol.js +++ b/src/components/TextInputWithCurrencySymbol.js @@ -31,6 +31,9 @@ const propTypes = { /** Function to call when selection in text input is changed */ onSelectionChange: PropTypes.func, + + /** Flag to indicate if the button should be disabled */ + disabled: PropTypes.bool, }; const defaultProps = { @@ -39,6 +42,7 @@ const defaultProps = { onCurrencyButtonPress: () => {}, selection: undefined, onSelectionChange: () => {}, + disabled: false, }; function TextInputWithCurrencySymbol(props) { @@ -55,6 +59,7 @@ function TextInputWithCurrencySymbol(props) { ); diff --git a/src/languages/en.js b/src/languages/en.js index 09651e6ec05e..229166a3f858 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -411,6 +411,7 @@ export default { other: 'Unexpected error, please try again later', genericCreateFailureMessage: 'Unexpected error requesting money, please try again later', genericDeleteFailureMessage: 'Unexpected error deleting the money request, please try again later', + genericEditFailureMessage: 'Unexpected error editing the money request, please try again later', }, }, notificationPreferencesPage: { diff --git a/src/languages/es.js b/src/languages/es.js index fa920c84ce35..0e24a386cd14 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -410,6 +410,7 @@ export default { other: 'Error inesperado, por favor inténtalo más tarde', genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde', genericDeleteFailureMessage: 'Error inesperado eliminando la solicitud de dinero. Por favor, inténtalo más tarde', + genericEditFailureMessage: 'Error inesperado al guardar la solicitud de dinero. Por favor, inténtalo más tarde', }, }, notificationPreferencesPage: { diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index b574bfd3d00e..7ea2cacbdde8 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -385,6 +385,8 @@ function getLastMessageTextForReport(report) { } else if (ReportActionUtils.isReportPreviewAction(lastReportAction)) { const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction)); lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastReportAction); + } else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) { + lastMessageTextFromReport = ReportUtils.getModifiedExpenseMessage(lastReportAction); } else { lastMessageTextFromReport = report ? report.lastMessageText || '' : ''; diff --git a/src/libs/ReportActionsUtils.js b/src/libs/ReportActionsUtils.js index 6d777533360d..161dc540fca3 100644 --- a/src/libs/ReportActionsUtils.js +++ b/src/libs/ReportActionsUtils.js @@ -93,6 +93,14 @@ function isReportPreviewAction(reportAction) { return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; } +/** + * @param {Object} reportAction + * @returns {Boolean} + */ +function isModifiedExpenseAction(reportAction) { + return lodashGet(reportAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE; +} + function isWhisperAction(action) { return (action.whisperedToAccountIDs || []).length > 0; } @@ -600,6 +608,7 @@ export { isSentMoneyReportAction, isDeletedParentAction, isReportPreviewAction, + isModifiedExpenseAction, getIOUReportIDFromReportActionPreview, isMessageDeleted, isWhisperAction, diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index ab8537d2bcdd..aa72fc88d8cc 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import {format} from 'date-fns'; import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import lodashIntersection from 'lodash/intersection'; @@ -13,6 +14,7 @@ import ROUTES from '../ROUTES'; import * as NumberUtils from './NumberUtils'; import * as NumberFormatUtils from './NumberFormatUtils'; import * as ReportActionsUtils from './ReportActionsUtils'; +import * as TransactionUtils from './TransactionUtils'; import Permissions from './Permissions'; import DateUtils from './DateUtils'; import linkingConfig from './Navigation/linkingConfig'; @@ -1304,6 +1306,89 @@ function getReportPreviewMessage(report, reportAction = {}) { return Localize.translateLocal('iou.payerOwesAmount', {payer: payerName, amount: formattedAmount}); } +/** + * Get the report action message when expense has been modified. + * + * @param {Object} reportAction + * @returns {String} + */ +function getModifiedExpenseMessage(reportAction) { + const reportActionOriginalMessage = lodashGet(reportAction, 'originalMessage', {}); + if (_.isEmpty(reportActionOriginalMessage)) { + return `changed the request`; + } + + const hasModifiedAmount = + _.has(reportActionOriginalMessage, 'oldAmount') && + _.has(reportActionOriginalMessage, 'oldCurrency') && + _.has(reportActionOriginalMessage, 'amount') && + _.has(reportActionOriginalMessage, 'currency'); + if (hasModifiedAmount) { + const oldCurrency = reportActionOriginalMessage.oldCurrency; + const oldAmount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage.oldAmount, oldCurrency); + + const currency = reportActionOriginalMessage.currency; + const amount = CurrencyUtils.convertToDisplayString(reportActionOriginalMessage.amount, currency); + + return `changed the request to ${amount} (previously ${oldAmount})`; + } + + const hasModifiedComment = _.has(reportActionOriginalMessage, 'oldComment') && _.has(reportActionOriginalMessage, 'newComment'); + if (hasModifiedComment) { + return `changed the request description to "${reportActionOriginalMessage.newComment}" (previously "${reportActionOriginalMessage.oldComment}")`; + } + + const hasModifiedMerchant = _.has(reportActionOriginalMessage, 'oldMerchant') && _.has(reportActionOriginalMessage, 'merchant'); + if (hasModifiedMerchant) { + return `changed the request merchant to "${reportActionOriginalMessage.merchant}" (previously "${reportActionOriginalMessage.oldMerchant}")`; + } + + const hasModifiedCreated = _.has(reportActionOriginalMessage, 'oldCreated') && _.has(reportActionOriginalMessage, 'created'); + if (hasModifiedCreated) { + // Take only the YYYY-MM-DD value as the original date includes timestamp + let formattedOldCreated = new Date(reportActionOriginalMessage.oldCreated); + formattedOldCreated = format(formattedOldCreated, CONST.DATE.FNS_FORMAT_STRING); + return `changed the request date to ${reportActionOriginalMessage.created} (previously ${formattedOldCreated})`; + } +} + +/** + * Given the updates user made to the request, compose the originalMessage + * object of the modified expense action. + * + * At the moment, we only allow changing one transaction field at a time. + * + * @param {Object} oldTransaction + * @param {Object} transactionChanges + * @param {Boolen} isFromExpenseReport + * @returns {Object} + */ +function getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, isFromExpenseReport) { + const originalMessage = {}; + + // Remark: Comment field is the only one which has new/old prefixes for the keys (newComment/ oldComment), + // all others have old/- pattern such as oldCreated/created + if (_.has(transactionChanges, 'comment')) { + originalMessage.oldComment = TransactionUtils.getDescription(oldTransaction); + originalMessage.newComment = transactionChanges.comment; + } + if (_.has(transactionChanges, 'created')) { + originalMessage.oldCreated = TransactionUtils.getCreated(oldTransaction); + originalMessage.created = transactionChanges.created; + } + + // The amount is always a combination of the currency and the number value so when one changes we need to store both + // to match how we handle the modified expense action in oldDot + if (_.has(transactionChanges, 'amount') || _.has(transactionChanges, 'currency')) { + originalMessage.oldAmount = TransactionUtils.getAmount(oldTransaction, isFromExpenseReport); + originalMessage.amount = lodashGet(transactionChanges, 'amount', originalMessage.oldAmount); + originalMessage.oldCurrency = TransactionUtils.getCurrency(oldTransaction); + originalMessage.currency = lodashGet(transactionChanges, 'currency', originalMessage.oldCurrency); + } + + return originalMessage; +} + /** * Get the title for a report. * @@ -1882,6 +1967,47 @@ function buildOptimisticReportPreview(chatReport, iouReport, comment = '') { }; } +/** + * Builds an optimistic modified expense action with a randomly generated reportActionID. + * + * @param {Object} transactionThread + * @param {Object} oldTransaction + * @param {Object} transactionChanges + * @param {Object} isFromExpenseReport + * @returns {Object} + */ +function buildOptimisticModifiedExpenseReportAction(transactionThread, oldTransaction, transactionChanges, isFromExpenseReport) { + const originalMessage = getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, isFromExpenseReport); + return { + actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatar(currentUserAccountID)), + created: DateUtils.getDBTime(), + isAttachment: false, + message: [ + { + // Currently we are composing the message from the originalMessage and message is only used in OldDot and not in the App + text: 'You', + style: 'strong', + type: CONST.REPORT.MESSAGE.TYPE.TEXT, + }, + ], + originalMessage, + person: [ + { + style: 'strong', + text: lodashGet(currentUserPersonalDetails, 'displayName', currentUserAccountID), + type: 'TEXT', + }, + ], + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + reportActionID: NumberUtils.rand64(), + reportID: transactionThread.reportID, + shouldShow: true, + }; +} + /** * Updates a report preview action that exists for an IOU report. * @@ -3089,6 +3215,7 @@ export { buildOptimisticExpenseReport, buildOptimisticIOUReportAction, buildOptimisticReportPreview, + buildOptimisticModifiedExpenseReportAction, updateReportPreview, buildOptimisticTaskReportAction, buildOptimisticAddCommentReportAction, @@ -3141,6 +3268,7 @@ export { getParentReport, getTaskParentReportActionIDInAssigneeReport, getReportPreviewMessage, + getModifiedExpenseMessage, shouldHideComposer, getOriginalReportID, canAccessReport, diff --git a/src/libs/TransactionUtils.js b/src/libs/TransactionUtils.js index f88f53467ae8..a05cd377514c 100644 --- a/src/libs/TransactionUtils.js +++ b/src/libs/TransactionUtils.js @@ -1,7 +1,24 @@ +import Onyx from 'react-native-onyx'; +import {format} from 'date-fns'; +import lodashGet from 'lodash/get'; +import _ from 'underscore'; import CONST from '../CONST'; +import ONYXKEYS from '../ONYXKEYS'; import DateUtils from './DateUtils'; import * as NumberUtils from './NumberUtils'; +let allTransactions = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (val) => { + if (!val) { + return; + } + allTransactions = val; + }, +}); + /** * Optimistically generate a transaction. * @@ -41,6 +58,103 @@ function buildOptimisticTransaction(amount, currency, reportID, comment = '', so }; } -export default { - buildOptimisticTransaction, -}; +/** + * Given the edit made to the money request, return an updated transaction object. + * + * @param {Object} transaction + * @param {Object} transactionChanges + * @param {Object} isFromExpenseReport + * @returns {Object} + */ +function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport) { + // Only changing the first level fields so no need for deep clone now + const updatedTransaction = _.clone(transaction); + + // The comment property does not have its modifiedComment counterpart + if (_.has(transactionChanges, 'comment')) { + updatedTransaction.comment = { + ...updatedTransaction.comment, + comment: transactionChanges.comment, + }; + } + if (_.has(transactionChanges, 'created')) { + updatedTransaction.modifiedCreated = transactionChanges.created; + } + if (_.has(transactionChanges, 'amount')) { + updatedTransaction.modifiedAmount = isFromExpenseReport ? -transactionChanges.amount : transactionChanges.amount; + } + if (_.has(transactionChanges, 'currency')) { + updatedTransaction.modifiedCurrency = transactionChanges.currency; + } + updatedTransaction.pendingAction = CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE; + + return updatedTransaction; +} + +/** + * Retrieve the particular transaction object given its ID. + * + * @param {String} transactionID + * @returns {Object} + */ +function getTransaction(transactionID) { + return lodashGet(allTransactions, `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {}); +} + +/** + * Return the comment field (referred to as description in the App) from the transaction. + * The comment does not have its modifiedComment counterpart. + * + * @param {Object} transaction + * @returns {String} + */ +function getDescription(transaction) { + return lodashGet(transaction, 'comment.comment', ''); +} + +/** + * Return the amount field from the transaction, return the modifiedAmount if present. + * + * @param {Object} transaction + * @param {Boolean} isFromExpenseReport + * @returns {Number} + */ +function getAmount(transaction, isFromExpenseReport) { + // In case of expense reports, the amounts are stored using an opposite sign + const multiplier = isFromExpenseReport ? -1 : 1; + const amount = lodashGet(transaction, 'modifiedAmount', 0); + if (amount) { + return multiplier * amount; + } + return multiplier * lodashGet(transaction, 'amount', 0); +} + +/** + * Return the currency field from the transaction, return the modifiedCurrency if present. + * + * @param {Object} transaction + * @returns {String} + */ +function getCurrency(transaction) { + const currency = lodashGet(transaction, 'modifiedCurrency', ''); + if (currency) { + return currency; + } + return lodashGet(transaction, 'currency', ''); +} + +/** + * Return the created field from the transaction, return the modifiedCreated if present. + * + * @param {Object} transaction + * @returns {String} + */ +function getCreated(transaction) { + const created = lodashGet(transaction, 'modifiedCreated', ''); + if (created) { + return format(new Date(created), CONST.DATE.FNS_FORMAT_STRING); + } + return format(new Date(lodashGet(transaction, 'created', '')), CONST.DATE.FNS_FORMAT_STRING); +} + +export {buildOptimisticTransaction, getUpdatedTransaction, getTransaction, getDescription, getAmount, getCurrency, getCreated}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 9d0a6279ca18..426a8957079b 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -16,7 +16,7 @@ import * as ReportActionsUtils from '../ReportActionsUtils'; import * as IOUUtils from '../IOUUtils'; import * as OptionsListUtils from '../OptionsListUtils'; import DateUtils from '../DateUtils'; -import TransactionUtils from '../TransactionUtils'; +import * as TransactionUtils from '../TransactionUtils'; import * as ErrorUtils from '../ErrorUtils'; import * as UserUtils from '../UserUtils'; import * as Report from './Report'; @@ -789,6 +789,90 @@ function splitBillAndOpenReport(participants, currentUserLogin, currentUserAccou Report.notifyNewAction(groupData.chatReportID, currentUserAccountID); } +/** + * @param {String} transactionID + * @param {Number} transactionThreadReportID + * @param {Object} transactionChanges + */ +function editMoneyRequest(transactionID, transactionThreadReportID, transactionChanges) { + // STEP 1: Get all collections we're updating + const transactionThread = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]; + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const iouReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.parentReportID}`]; + const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport); + + // STEP 2: Build new modified expense report action. + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport); + const updatedTransaction = TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport); + // STEP 3: Compute the IOU total and update the report preview message so LHN amount owed is correct + // STEP 4: Compose the optimistic data + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: updatedTransaction, + }, + ]; + + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`, + value: { + [updatedReportAction.reportActionID]: {pendingAction: null}, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {pendingAction: null}, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: transaction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.report}`, + value: iouReport, + }, + ]; + + // STEP 6: Call the API endpoint + API.write( + 'EditMoneyRequest', + { + transactionID, + reportActionID: updatedReportAction.reportActionID, + + // Using the getter methods here to ensure we pass modified field if present + created: TransactionUtils.getCreated(updatedTransaction), + amount: TransactionUtils.getAmount(updatedTransaction, isFromExpenseReport), + currency: TransactionUtils.getCurrency(updatedTransaction), + comment: TransactionUtils.getDescription(updatedTransaction), + }, + {optimisticData, successData, failureData}, + ); +} + /** * @param {String} transactionID * @param {Object} reportAction - the money request reportAction we are deleting @@ -1556,6 +1640,7 @@ function navigateToNextPage(iou, iouType, reportID, report) { } export { + editMoneyRequest, deleteMoneyRequest, splitBill, splitBillAndOpenReport, diff --git a/src/pages/EditRequestAmountPage.js b/src/pages/EditRequestAmountPage.js new file mode 100644 index 000000000000..b04de9ebfd5a --- /dev/null +++ b/src/pages/EditRequestAmountPage.js @@ -0,0 +1,70 @@ +import React, {useCallback, useRef} from 'react'; +import {InteractionManager} from 'react-native'; +import {useFocusEffect} from '@react-navigation/native'; +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 MoneyRequestAmountForm from './iou/steps/MoneyRequestAmountForm'; + +const propTypes = { + /** Transaction default amount value */ + defaultAmount: PropTypes.number.isRequired, + + /** Transaction default currency value */ + defaultCurrency: PropTypes.string.isRequired, + + /** Callback to fire when the Save button is pressed */ + onSubmit: PropTypes.func.isRequired, +}; + +function EditRequestAmountPage({defaultAmount, defaultCurrency, onSubmit}) { + const {translate} = useLocalize(); + const textInput = useRef(null); + + const focusTextInput = () => { + // Component may not be initialized due to navigation transitions + // Wait until interactions are complete before trying to focus + InteractionManager.runAfterInteractions(() => { + // Focus text input + if (!textInput.current) { + return; + } + + textInput.current.focus(); + }); + }; + + useFocusEffect( + useCallback(() => { + focusTextInput(); + }, []), + ); + + return ( + + + (textInput.current = e)} + onCurrencyButtonPress={() => null} + onSubmitButtonPress={onSubmit} + /> + + ); +} + +EditRequestAmountPage.propTypes = propTypes; +EditRequestAmountPage.displayName = 'EditRequestAmountPage'; + +export default EditRequestAmountPage; diff --git a/src/pages/EditRequestCreatedPage.js b/src/pages/EditRequestCreatedPage.js new file mode 100644 index 000000000000..9d367d3008d7 --- /dev/null +++ b/src/pages/EditRequestCreatedPage.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ScreenWrapper from '../components/ScreenWrapper'; +import HeaderWithBackButton from '../components/HeaderWithBackButton'; +import Form from '../components/Form'; +import ONYXKEYS from '../ONYXKEYS'; +import styles from '../styles/styles'; +import Navigation from '../libs/Navigation/Navigation'; +import useLocalize from '../hooks/useLocalize'; +import NewDatePicker from '../components/NewDatePicker'; + +const propTypes = { + /** Transaction defailt created value */ + defaultCreated: PropTypes.string.isRequired, + + /** Callback to fire when the Save button is pressed */ + onSubmit: PropTypes.func.isRequired, +}; + +function EditRequestCreatedPage({defaultCreated, onSubmit}) { + const {translate} = useLocalize(); + + return ( + + +
+ + +
+ ); +} + +EditRequestCreatedPage.propTypes = propTypes; +EditRequestCreatedPage.displayName = 'EditRequestCreatedPage'; + +export default EditRequestCreatedPage; diff --git a/src/pages/EditRequestDescriptionPage.js b/src/pages/EditRequestDescriptionPage.js index 34f88f29dc28..eb909e8cc9b4 100644 --- a/src/pages/EditRequestDescriptionPage.js +++ b/src/pages/EditRequestDescriptionPage.js @@ -2,7 +2,6 @@ import React, {useRef} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import TextInput from '../components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; import ScreenWrapper from '../components/ScreenWrapper'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; import Form from '../components/Form'; @@ -10,18 +9,18 @@ import ONYXKEYS from '../ONYXKEYS'; import styles from '../styles/styles'; import Navigation from '../libs/Navigation/Navigation'; import CONST from '../CONST'; +import useLocalize from '../hooks/useLocalize'; const propTypes = { - ...withLocalizePropTypes, - - /** Transaction description default value */ + /** Transaction default description value */ defaultDescription: PropTypes.string.isRequired, /** Callback to fire when the Save button is pressed */ onSubmit: PropTypes.func.isRequired, }; -function EditRequestDescriptionPage(props) { +function EditRequestDescriptionPage({defaultDescription, onSubmit}) { + const {translate} = useLocalize(); const descriptionInputRef = useRef(null); return ( descriptionInputRef.current && descriptionInputRef.current.focus()} > Navigation.goBack()} />
@@ -59,4 +59,4 @@ function EditRequestDescriptionPage(props) { EditRequestDescriptionPage.propTypes = propTypes; EditRequestDescriptionPage.displayName = 'EditRequestDescriptionPage'; -export default withLocalize(EditRequestDescriptionPage); +export default EditRequestDescriptionPage; diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index d2280ec78fd7..971ad056ae7e 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -2,19 +2,21 @@ import React from 'react'; import PropTypes from 'prop-types'; import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; +import {format} from 'date-fns'; import CONST from '../CONST'; import Navigation from '../libs/Navigation/Navigation'; -import compose from '../libs/compose'; -import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; import ONYXKEYS from '../ONYXKEYS'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; +import * as ReportUtils from '../libs/ReportUtils'; +import * as TransactionUtils from '../libs/TransactionUtils'; import EditRequestDescriptionPage from './EditRequestDescriptionPage'; +import EditRequestCreatedPage from './EditRequestCreatedPage'; +import EditRequestAmountPage from './EditRequestAmountPage'; import reportPropTypes from './reportPropTypes'; -import * as ReportUtils from '../libs/ReportUtils'; +import * as IOU from '../libs/actions/IOU'; +import * as CurrencyUtils from '../libs/CurrencyUtils'; const propTypes = { - ...withLocalizePropTypes, - /** Route from navigation */ route: PropTypes.shape({ /** Params from the route */ @@ -35,27 +37,74 @@ const defaultProps = { report: {}, }; -function EditRequestPage(props) { - const parentReportAction = ReportActionsUtils.getParentReportAction(props.report); - const moneyRequestReportAction = ReportUtils.getMoneyRequestAction(parentReportAction); - const transactionDescription = moneyRequestReportAction.comment; - const field = lodashGet(props, ['route', 'params', 'field'], ''); +function EditRequestPage({report, route}) { + const transactionID = lodashGet(ReportActionsUtils.getParentReportAction(report), 'originalMessage.IOUTransactionID', ''); + const transaction = TransactionUtils.getTransaction(transactionID); + const transactionDescription = TransactionUtils.getDescription(transaction); + const transactionAmount = TransactionUtils.getAmount(transaction, ReportUtils.isExpenseReport(ReportUtils.getParentReport(report))); + const transactionCurrency = TransactionUtils.getCurrency(transaction); - function updateTransactionWithChanges(changes) { - // Update the transaction... - // eslint-disable-next-line no-console - console.log({changes}); + // Take only the YYYY-MM-DD value + const transactionCreatedDate = new Date(TransactionUtils.getCreated(transaction)); + const transactionCreated = format(transactionCreatedDate, CONST.DATE.FNS_FORMAT_STRING); + const fieldToEdit = lodashGet(route, ['params', 'field'], ''); - // Note: The "modal" we are dismissing is the MoneyRequestAmountPage + // Update the transaction object and close the modal + function editMoneyRequest(transactionChanges) { + IOU.editMoneyRequest(transactionID, report.reportID, transactionChanges); Navigation.dismissModal(); } - if (field === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) { + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) { return ( { - updateTransactionWithChanges(changes); + onSubmit={(transactionChanges) => { + // In case the comment hasn't been changed, do not make the API request. + if (transactionChanges.comment.trim() === transactionDescription) { + Navigation.dismissModal(); + return; + } + editMoneyRequest({comment: transactionChanges.comment.trim()}); + }} + /> + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { + return ( + { + // In case the date hasn't been changed, do not make the API request. + if (transactionChanges.created === transactionCreated) { + Navigation.dismissModal(); + return; + } + editMoneyRequest(transactionChanges); + }} + /> + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { + return ( + { + const amount = CurrencyUtils.convertToSmallestUnit(transactionCurrency, Number.parseFloat(transactionChanges)); + // In case the amount hasn't been changed, do not make the API request. + if (amount === transactionAmount) { + Navigation.dismissModal(); + return; + } + // Temporarily disabling currency editing and it will be enabled as a quick follow up + editMoneyRequest({ + amount, + currency: transactionCurrency, + }); }} /> ); @@ -67,11 +116,8 @@ function EditRequestPage(props) { EditRequestPage.displayName = 'EditRequestPage'; EditRequestPage.propTypes = propTypes; EditRequestPage.defaultProps = defaultProps; -export default compose( - withLocalize, - withOnyx({ - report: { - key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`, - }, - }), -)(EditRequestPage); +export default withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.threadReportID}`, + }, +})(EditRequestPage); diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 61ca405061f5..3aa9113351c2 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -312,6 +312,8 @@ function ReportActionItem(props) { ) : null} ); + } else if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) { + children = ; } else { const message = _.last(lodashGet(props.action, 'message', [{}])); const hasBeenFlagged = !_.contains([CONST.MODERATION.MODERATOR_DECISION_APPROVED, CONST.MODERATION.MODERATOR_DECISION_PENDING], moderationDecision); @@ -455,8 +457,8 @@ function ReportActionItem(props) { }; if (props.action.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { - const parentReport = ReportActionsUtils.getParentReportAction(props.report); - if (ReportActionsUtils.isTransactionThread(parentReport)) { + const parentReportAction = ReportActionsUtils.getParentReportAction(props.report); + if (ReportActionsUtils.isTransactionThread(parentReportAction)) { return ( { onSubmitButtonPress(currentAmount); @@ -226,6 +229,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu } setSelection(e.nativeEvent.selection); }} + disabled={disableCurrency} />