diff --git a/src/components/IOUConfirmationList.js b/src/components/IOUConfirmationList.js new file mode 100644 index 000000000000..d9507ced11e4 --- /dev/null +++ b/src/components/IOUConfirmationList.js @@ -0,0 +1,254 @@ +import React, {Component} from 'react'; +import {View} from 'react-native'; +import PropTypes from 'prop-types'; +import {TextInput} from 'react-native-gesture-handler'; +import {withOnyx} from 'react-native-onyx'; +import styles from '../styles/styles'; +import Text from './Text'; +import themeColors from '../styles/themes/default'; +import { + getIOUConfirmationOptionsFromMyPersonalDetail, + getIOUConfirmationOptionsFromParticipants, +} from '../libs/OptionsListUtils'; +import OptionsList from './OptionsList'; +import ButtonWithLoader from './ButtonWithLoader'; +import ONYXKEYS from '../ONYXKEYS'; + +const propTypes = { + // Callback to inform parent modal of success + onConfirm: PropTypes.func.isRequired, + + // Callback to update comment from IOUModal + onUpdateComment: PropTypes.func, + + // Comment value from IOUModal + comment: PropTypes.string, + + // Should we request a single or multiple participant selection from user + hasMultipleParticipants: PropTypes.bool.isRequired, + + // IOU amount + iouAmount: PropTypes.string.isRequired, + + // Selected currency from the user + // Remove eslint disable after currency symbol is available + // eslint-disable-next-line react/no-unused-prop-types + selectedCurrency: PropTypes.string.isRequired, + + // Selected participants from IOUMOdal with login + participants: PropTypes.arrayOf(PropTypes.shape({ + login: PropTypes.string.isRequired, + alternateText: PropTypes.string, + hasDraftComment: PropTypes.bool, + icons: PropTypes.arrayOf(PropTypes.string), + searchText: PropTypes.string, + text: PropTypes.string, + keyForList: PropTypes.string, + isPinned: PropTypes.bool, + isUnread: PropTypes.bool, + reportID: PropTypes.number, + participantsList: PropTypes.arrayOf(PropTypes.object), + })).isRequired, + + /* Onyx Props */ + + // The personal details of the person who is logged in + myPersonalDetails: PropTypes.shape({ + + // Display name of the current user from their personal details + displayName: PropTypes.string, + + // Avatar URL of the current user from their personal details + avatar: PropTypes.string, + + // Primary login of the user + login: PropTypes.string, + }).isRequired, + + // Holds data related to IOU view state, rather than the underlying IOU data. + iou: PropTypes.shape({ + + // Whether or not the IOU step is loading (creating the IOU Report) + loading: PropTypes.bool, + }), +}; + +const defaultProps = { + iou: {}, + onUpdateComment: null, + comment: '', +}; + +class IOUConfirmationList extends Component { + /** + * Returns the sections needed for the OptionsSelector + * + * @param {Boolean} maxParticipantsReached + * @returns {Array} + */ + getSections() { + const sections = []; + + if (this.props.hasMultipleParticipants) { + const formattedMyPersonalDetails = getIOUConfirmationOptionsFromMyPersonalDetail( + this.props.myPersonalDetails, + + // Convert from cent to bigger form + // USD is temporary and there must be support for other currencies in the future + `$${this.calculateAmount(true) / 100}`, + ); + + // Cents is temporary and there must be support for other currencies in the future + const formattedParticipants = getIOUConfirmationOptionsFromParticipants(this.props.participants, + `$${this.calculateAmount() / 100}`); + + sections.push({ + title: 'WHO PAID?', + data: formattedMyPersonalDetails, + shouldShow: true, + indexOffset: 0, + }); + sections.push({ + title: 'WHO WAS THERE?', + data: formattedParticipants, + shouldShow: true, + indexOffset: 0, + }); + } else { + // $ Should be replaced by currency symbol once available + const formattedParticipants = getIOUConfirmationOptionsFromParticipants(this.props.participants, + `$${this.props.iouAmount}`); + + sections.push({ + title: 'TO', + data: formattedParticipants, + shouldShow: true, + indexOffset: 0, + }); + } + return sections; + } + + /** + * Gets splits for the transaction + * + * @returns {Array} + */ + getSplits() { + const splits = this.props.participants.map(participant => ({ + email: participant.login, + + // We should send in cents to API + // Cents is temporary and there must be support for other currencies in the future + amount: this.calculateAmount(), + })); + + splits.push({ + email: this.props.myPersonalDetails.login, + + // The user is default and we should send in cents to API + // USD is temporary and there must be support for other currencies in the future + amount: this.calculateAmount(true), + }); + return splits; + } + + /** + * Gets participants list for a report + * + * @returns {Array} + */ + getParticipants() { + const participants = this.props.participants.map(participant => participant.login); + participants.push(this.props.myPersonalDetails.login); + return participants; + } + + /** + * Returns selected options with all participant logins -- there is checkmark for every row in List for split flow + * @returns {Array} + */ + getAllOptionsAsSelected() { + return [...this.props.participants, + getIOUConfirmationOptionsFromMyPersonalDetail(this.props.myPersonalDetails)]; + } + + /** + * Calculates the amount per user + * @param {Boolean} isDefaultUser + * @returns {Number} + */ + calculateAmount(isDefaultUser = false) { + // Convert to cents before working with iouAmount to avoid + // javascript subtraction with decimal problem -- when dealing with decimals, + // because they are encoded as IEEE 754 floating point numbers, some of the decimal + // numbers cannot be represented with perfect accuracy. + // Cents is temporary and there must be support for other currencies in the future + const iouAmount = Math.round(parseFloat(this.props.iouAmount * 100)); + const totalParticipants = this.props.participants.length + 1; + const amountPerPerson = Math.round(iouAmount / totalParticipants); + + if (!isDefaultUser) { return amountPerPerson; } + + const sumAmount = amountPerPerson * totalParticipants; + const difference = iouAmount - sumAmount; + + return iouAmount !== sumAmount ? (amountPerPerson + difference) : amountPerPerson; + } + + render() { + return ( + + + + + + WHAT'S IT FOR? + + + + + + + + { + if (this.props.hasMultipleParticipants) { + this.props.onConfirm({splits: this.getSplits()}); + } else { + this.props.onConfirm({}); + } + }} + /> + + + ); + } +} + +IOUConfirmationList.displayName = 'IOUConfirmPage'; +IOUConfirmationList.propTypes = propTypes; +IOUConfirmationList.defaultProps = defaultProps; + +export default withOnyx({ + iou: {key: ONYXKEYS.IOU}, + myPersonalDetails: { + key: ONYXKEYS.MY_PERSONAL_DETAILS, + }, +})(IOUConfirmationList); diff --git a/src/components/OptionsList.js b/src/components/OptionsList.js index 091b7d6f1b2c..d6f81138c538 100644 --- a/src/components/OptionsList.js +++ b/src/components/OptionsList.js @@ -196,7 +196,7 @@ class OptionsList extends Component { render() { return ( - + {this.props.headerMessage ? ( diff --git a/src/libs/API.js b/src/libs/API.js index bd97b92a3f40..633c6420b24e 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -656,6 +656,39 @@ function ValidateEmail(parameters) { return Network.post(commandName, parameters); } +/** + * Create a new IOUTransaction + * + * @param {Object} parameters + * @param {String} parameters.comment + * @param {Array} parameters.debtorEmail + * @param {String} parameters.currency + * @param {String} parameters.amount + * @returns {Promise} + */ +function CreateIOUTransaction(parameters) { + const commandName = 'CreateIOUTransaction'; + requireParameters(['comment', 'debtorEmail', 'currency', 'amount'], parameters, commandName); + return Network.post(commandName, parameters); +} + +/** + * Create a new IOU Split + * + * @param {Object} parameters + * @param {String} parameters.splits + * @param {String} parameters.currency + * @param {String} parameters.reportID + * @param {String} parameters.amount + * @param {String} parameters.comment + * @returns {Promise} + */ +function CreateIOUSplit(parameters) { + const commandName = 'CreateIOUSplit'; + requireParameters(['splits', 'currency', 'amount', 'reportID'], parameters, commandName); + return Network.post(commandName, parameters); +} + export { getAuthToken, Authenticate, @@ -687,5 +720,7 @@ export { User_SecondaryLogin_Send, User_UploadAvatar, reauthenticate, + CreateIOUTransaction, + CreateIOUSplit, ValidateEmail, }; diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 977a7ed37c73..c970e7666aa5 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -352,6 +352,40 @@ function getNewChatOptions( }); } +/** + * Build the IOUConfirmation options for showing MyPersonalDetail + * + * @param {Object} myPersonalDetail + * @param {String} amountText + * @returns {Array} + */ +function getIOUConfirmationOptionsFromMyPersonalDetail( + myPersonalDetail, + amountText, +) { + return [{ + text: myPersonalDetail.displayName, + alternateText: myPersonalDetail.login, + icons: [myPersonalDetail.avatar], + descriptiveText: amountText, + }]; +} + +/** + * Build the IOUConfirmationOptions for showing participants + * + * @param {Array} participants + * @param {String} amountText + * @returns {Array} + */ +function getIOUConfirmationOptionsFromParticipants( + participants, amountText, +) { + return participants.map(participant => ({ + ...participant, descriptiveText: amountText, + })); +} + /** * Build the options for the New Group view * @@ -439,4 +473,6 @@ export { getSidebarOptions, getHeaderMessage, getPersonalDetailsForLogins, + getIOUConfirmationOptionsFromMyPersonalDetail, + getIOUConfirmationOptionsFromParticipants, }; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 9226c90479ff..f56871642da9 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -1,5 +1,8 @@ import Onyx from 'react-native-onyx'; +import _ from 'underscore'; import ONYXKEYS from '../../ONYXKEYS'; +import * as API from '../API'; +import {getSimplifiedIOUReport} from './Report'; /** * Retrieve the users preferred currency @@ -13,7 +16,99 @@ function getPreferredCurrency() { }, 1600); } -// Re-enable the prefer-default-export lint when additional functions are added +/** + * @param {Array} reportIds + * @returns {Promise} + * Gets the IOU Reports for new transaction + */ +function getIOUReportsForNewTransaction(reportIds) { + return API.Get({ + returnValueList: 'reportStuff', + reportIDList: reportIds, + shouldLoadOptionalKeys: true, + includePinnedReports: true, + }) + .then(({reports}) => _.map(reports, getSimplifiedIOUReport)) + .then((iouReportObjects) => { + const reportIOUData = {}; + + if (iouReportObjects.length === 1) { + const iouReportKey = `${ONYXKEYS.COLLECTION.REPORT_IOUS}${iouReportObjects[0].reportID}`; + return Onyx.merge(iouReportKey, + getSimplifiedIOUReport(iouReportObjects[0])); + } + + _.each(iouReportObjects, (iouReportObject) => { + if (!iouReportObject) { + return; + } + const iouReportKey = `${ONYXKEYS.COLLECTION.REPORT_IOUS}${iouReportObject.reportID}`; + reportIOUData[iouReportKey] = iouReportObject; + }); + return Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT_IOUS, {...reportIOUData}); + }) + .catch(() => Onyx.merge(ONYXKEYS.IOU, {loading: false, creatingIOUTransaction: false, error: true})) + .finally(() => Onyx.merge(ONYXKEYS.IOU, {loading: false, creatingIOUTransaction: false})); +} + +/** + * Creates IOUSplit Transaction + * @param {Object} parameters + * @param {String} parameters.amount + * @param {String} parameters.comment + * @param {String} parameters.currency + * @param {String} parameters.debtorEmail + */ +function createIOUTransaction({ + comment, amount, currency, debtorEmail, +}) { + Onyx.merge(ONYXKEYS.IOU, {loading: true, creatingIOUTransaction: true, error: false}); + API.CreateIOUTransaction({ + comment, + amount, + currency, + debtorEmail, + }) + .then(data => data.reportID) + .then(reportID => getIOUReportsForNewTransaction([reportID])); +} + +/** + * Creates IOUSplit Transaction + * @param {Object} parameters + * @param {Array} parameters.splits + * @param {String} parameters.comment + * @param {String} parameters.amount + * @param {String} parameters.currency + */ +function createIOUSplit({ + comment, + amount, + currency, + splits, +}) { + Onyx.merge(ONYXKEYS.IOU, {loading: true, creatingIOUTransaction: true, error: false}); + + API.CreateChatReport({ + emailList: splits.map(participant => participant.email).join(','), + }) + .then((data) => { + console.debug(data); + return data.reportID; + }) + .then(reportID => API.CreateIOUSplit({ + splits: JSON.stringify(splits), + currency, + amount, + comment, + reportID, + })) + .then(data => data.reportIDList) + .then(reportIDList => getIOUReportsForNewTransaction(reportIDList)); +} + export { - getPreferredCurrency, // eslint-disable-line import/prefer-default-export + getPreferredCurrency, + createIOUTransaction, + createIOUSplit, }; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index f06689613080..3d91b3551643 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -909,4 +909,6 @@ export { broadcastUserIsTyping, togglePinnedState, updateCurrentlyViewedReportID, + getSimplifiedIOUReport, + getSimplifiedReportObject, }; diff --git a/src/pages/home/sidebar/OptionRow.js b/src/pages/home/sidebar/OptionRow.js index 5212a2584476..88fd1edcf73a 100644 --- a/src/pages/home/sidebar/OptionRow.js +++ b/src/pages/home/sidebar/OptionRow.js @@ -33,7 +33,7 @@ const propTypes = { optionIsFocused: PropTypes.bool.isRequired, // A function that is called when an option is selected. Selected option is passed as a param - onSelectRow: PropTypes.func.isRequired, + onSelectRow: PropTypes.func, // A flag to indicate whether to show additional optional states, such as pin and draft icons hideAdditionalOptionStates: PropTypes.bool, @@ -63,6 +63,7 @@ const defaultProps = { forceTextUnreadStyle: false, showTitleTooltip: false, mode: 'default', + onSelectRow: null, }; const OptionRow = ({ @@ -173,6 +174,13 @@ const OptionRow = ({ ) : null} + {option.descriptiveText ? ( + + + {option.descriptiveText} + + + ) : null} {showSelectedState && ( {isSelected && ( @@ -231,6 +239,10 @@ export default memo(OptionRow, (prevProps, nextProps) => { return false; } + if (prevProps.option.descriptiveText !== nextProps.option.descriptiveText) { + return false; + } + if (prevProps.option.hasDraftComment !== nextProps.option.hasDraftComment) { return false; } diff --git a/src/pages/home/sidebar/optionPropTypes.js b/src/pages/home/sidebar/optionPropTypes.js index ec2881f790b5..b2c9ee97aa73 100644 --- a/src/pages/home/sidebar/optionPropTypes.js +++ b/src/pages/home/sidebar/optionPropTypes.js @@ -19,11 +19,14 @@ const optionPropTypes = PropTypes.shape({ alternateText: PropTypes.string.isRequired, // List of participants of the report - participantsList: PropTypes.arrayOf(participantPropTypes).isRequired, + participantsList: PropTypes.arrayOf(participantPropTypes), // The array URLs of the person's avatar icon: PropTypes.arrayOf(PropTypes.string), + // Descriptive text to be displayed besides selection element + descriptiveText: PropTypes.string, + // The type of option we have e.g. user or report type: PropTypes.string, diff --git a/src/pages/iou/IOUModal.js b/src/pages/iou/IOUModal.js index 1e2375be27cd..25f8b82e2a9c 100644 --- a/src/pages/iou/IOUModal.js +++ b/src/pages/iou/IOUModal.js @@ -1,15 +1,17 @@ import React, {Component} from 'react'; import {View, TouchableOpacity} from 'react-native'; import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; import IOUAmountPage from './steps/IOUAmountPage'; import IOUParticipantsPage from './steps/IOUParticipantsPage'; import IOUConfirmPage from './steps/IOUConfirmPage'; import Header from '../../components/Header'; import styles from '../../styles/styles'; import Icon from '../../components/Icon'; -import {getPreferredCurrency} from '../../libs/actions/IOU'; +import {createIOUSplit, createIOUTransaction, getPreferredCurrency} from '../../libs/actions/IOU'; import {Close, BackArrow} from '../../components/Icon/Expensicons'; import Navigation from '../../libs/Navigation/Navigation'; +import ONYXKEYS from '../../ONYXKEYS'; /** * IOU modal for requesting money and splitting bills. @@ -17,6 +19,17 @@ import Navigation from '../../libs/Navigation/Navigation'; const propTypes = { // Is this new IOU for a single request or group bill split? hasMultipleParticipants: PropTypes.bool, + + // Holds data related to IOU view state, rather than the underlying IOU data. + iou: PropTypes.shape({ + // Whether or not transaction creation has started + creatingIOUTransaction: PropTypes.bool, + + // Whether or not transaction creation has resulted to error + error: PropTypes.bool, + }).isRequired, + + }; const defaultProps = { @@ -40,13 +53,18 @@ class IOUModal extends Component { this.navigateToPreviousStep = this.navigateToPreviousStep.bind(this); this.navigateToNextStep = this.navigateToNextStep.bind(this); this.currencySelected = this.currencySelected.bind(this); + this.createTransaction = this.createTransaction.bind(this); + this.updateComment = this.updateComment.bind(this); this.addParticipants = this.addParticipants.bind(this); this.state = { currentStepIndex: 0, participants: [], + + // amount is currency in decimal format amount: '', selectedCurrency: 'USD', + comment: '', }; } @@ -54,6 +72,13 @@ class IOUModal extends Component { getPreferredCurrency(); } + componentDidUpdate(prevProps) { + // Successfully close the modal if transaction creation has ended and there is no error + if (prevProps.iou.creatingIOUTransaction && !this.props.iou.creatingIOUTransaction && !this.props.iou.error) { + Navigation.dismissModal(); + } + } + /** * Retrieve title for current step, based upon current step and type of IOU * @@ -61,13 +86,20 @@ class IOUModal extends Component { */ getTitleForStep() { - if (this.state.currentStepIndex === 1) { + const currentStepIndex = this.state.currentStepIndex; + if (currentStepIndex === 1 || currentStepIndex === 2) { return `${this.props.hasMultipleParticipants ? 'Split' : 'Request'} $${this.state.amount}`; } - if (steps[this.state.currentStepIndex] === Steps.IOUAmount) { + if (currentStepIndex === 0) { return this.props.hasMultipleParticipants ? 'Split Bill' : 'Request Money'; } - return steps[this.state.currentStepIndex] || ''; + return steps[currentStepIndex] || ''; + } + + addParticipants(participants) { + this.setState({ + participants, + }); } /** @@ -94,9 +126,14 @@ class IOUModal extends Component { })); } - addParticipants(participants) { + /** + * Update comment whenever user enters any new text + * + * @param {String} comment + */ + updateComment(comment) { this.setState({ - participants, + comment, }); } @@ -109,6 +146,37 @@ class IOUModal extends Component { this.setState({selectedCurrency}); } + createTransaction({splits}) { + if (splits) { + return createIOUSplit({ + comment: this.state.comment, + + // should send in cents to API + amount: this.state.amount * 100, + currency: this.state.selectedCurrency, + splits, + }); + } + + console.debug({ + comment: this.state.comment, + + // should send in cents to API + amount: this.state.amount * 100, + currency: this.state.selectedCurrency, + debtorEmail: this.state.participants[0].login, + }); + + return createIOUTransaction({ + comment: this.state.comment, + + // should send in cents to API + amount: this.state.amount * 100, + currency: this.state.selectedCurrency, + debtorEmail: this.state.participants[0].login, + }); + } + render() { const currentStep = steps[this.state.currentStepIndex]; return ( @@ -163,9 +231,13 @@ class IOUModal extends Component { )} {currentStep === Steps.IOUConfirm && ( console.debug('create IOU report')} + onConfirm={this.createTransaction} + hasMultipleParticipants={this.props.hasMultipleParticipants} participants={this.state.participants} iouAmount={this.state.amount} + comment={this.state.comment} + selectedCurrency={this.state.selectedCurrency} + onUpdateComment={this.updateComment} /> )} @@ -177,4 +249,9 @@ IOUModal.propTypes = propTypes; IOUModal.defaultProps = defaultProps; IOUModal.displayName = 'IOUModal'; -export default IOUModal; +export default withOnyx({ + iousReport: { + key: ONYXKEYS.COLLECTION.REPORT_IOUS, + }, + iou: {key: ONYXKEYS.IOU}, +})(IOUModal); diff --git a/src/pages/iou/steps/IOUConfirmPage.js b/src/pages/iou/steps/IOUConfirmPage.js index 9f34f863b5cf..650c9e160992 100644 --- a/src/pages/iou/steps/IOUConfirmPage.js +++ b/src/pages/iou/steps/IOUConfirmPage.js @@ -1,47 +1,65 @@ import React from 'react'; -import {View} from 'react-native'; import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import ONYXKEYS from '../../../ONYXKEYS'; -import styles from '../../../styles/styles'; -import ButtonWithLoader from '../../../components/ButtonWithLoader'; +import IOUConfirmationList from '../../../components/IOUConfirmationList'; const propTypes = { // Callback to inform parent modal of success onConfirm: PropTypes.func.isRequired, + // callback to update comment from IOUModal + onUpdateComment: PropTypes.func, + + // comment value from IOUModal + comment: PropTypes.string, + + // Should we request a single or multiple participant selection from user + hasMultipleParticipants: PropTypes.bool.isRequired, + // IOU amount - iouAmount: PropTypes.number.isRequired, + iouAmount: PropTypes.string.isRequired, - /* Onyx Props */ + // Selected currency from the user + // remove eslint disable after currency symbol is available + // eslint-disable-next-line react/no-unused-prop-types + selectedCurrency: PropTypes.string.isRequired, - // Holds data related to IOU view state, rather than the underlying IOU data. - iou: PropTypes.shape({ + // Selected participants from IOUMOdal with login + participants: PropTypes.arrayOf(PropTypes.shape({ + login: PropTypes.string.isRequired, + alternateText: PropTypes.string, + hasDraftComment: PropTypes.bool, + icons: PropTypes.arrayOf(PropTypes.string), + searchText: PropTypes.string, + text: PropTypes.string, + keyForList: PropTypes.string, + isPinned: PropTypes.bool, + isUnread: PropTypes.bool, + reportID: PropTypes.number, + participantsList: PropTypes.arrayOf(PropTypes.object), + })).isRequired, - // Whether or not the IOU step is loading (creating the IOU Report) - loading: PropTypes.bool, - }), }; const defaultProps = { - iou: {}, + onUpdateComment: null, + comment: '', }; const IOUConfirmPage = props => ( - - - + ); + IOUConfirmPage.displayName = 'IOUConfirmPage'; IOUConfirmPage.propTypes = propTypes; IOUConfirmPage.defaultProps = defaultProps; -export default withOnyx({ - iou: {key: ONYXKEYS.IOU}, -})(IOUConfirmPage); +export default IOUConfirmPage; diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js index 823c5468a1f0..c99742557e01 100644 --- a/src/styles/utilities/spacing.js +++ b/src/styles/utilities/spacing.js @@ -193,6 +193,10 @@ export default { paddingBottom: 8, }, + pb3: { + paddingBottom: 12, + }, + pb5: { paddingBottom: 20, },