From 31175625b446cb5d18b17db23018bca8b14d280c Mon Sep 17 00:00:00 2001 From: Chi Kei Chan Date: Thu, 21 Mar 2019 16:03:30 -0700 Subject: Folder restructure (#6304) * Remove ui/app/keychains/ * Remove ui/app/img/ (unused images) * Move conversion-util to helpers/utils/ * Move token-util to helpers/utils/ * Move /helpers/*.js inside /helpers/utils/ * Move util tests inside /helpers/utils/ * Renameand move confirm-transaction/util.js to helpers/utils/ * Move higher-order-components to helpers/higher-order-components/ * Move infura-conversion.json to helpers/constants/ * Move all utility functions to helpers/utils/ * Move pages directory to top-level * Move all constants to helpers/constants/ * Move metametrics inside helpers/ * Move app and root inside pages/ * Move routes inside helpers/ * Re-organize ducks/ * Move reducers to ducks/ * Move selectors inside selectors/ * Move test out of test folder * Move action, reducer, store inside store/ * Move ui components inside ui/ * Move UI components inside ui/ * Move connected components inside components/app/ * Move i18n-helper inside helpers/ * Fix unit tests * Fix unit test * Move pages components * Rename routes component * Move reducers to ducks/index * Fix bad path in unit test --- .../confirm-transaction-base.component.js | 574 +++++++++++++++++++++ .../confirm-transaction-base.container.js | 242 +++++++++ ui/app/pages/confirm-transaction-base/index.js | 1 + .../confirm-transaction-base.component.test.js | 14 + 4 files changed, 831 insertions(+) create mode 100644 ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js create mode 100644 ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js create mode 100644 ui/app/pages/confirm-transaction-base/index.js create mode 100644 ui/app/pages/confirm-transaction-base/tests/confirm-transaction-base.component.test.js (limited to 'ui/app/pages/confirm-transaction-base') diff --git a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js new file mode 100644 index 000000000..1da9c34bd --- /dev/null +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -0,0 +1,574 @@ +import ethUtil from 'ethereumjs-util' +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmPageContainer, { ConfirmDetailRow } from '../../components/app/confirm-page-container' +import { isBalanceSufficient } from '../../components/app/send/send.utils' +import { DEFAULT_ROUTE, CONFIRM_TRANSACTION_ROUTE } from '../../helpers/constants/routes' +import { + INSUFFICIENT_FUNDS_ERROR_KEY, + TRANSACTION_ERROR_KEY, +} from '../../helpers/constants/error-keys' +import { CONFIRMED_STATUS, DROPPED_STATUS } from '../../helpers/constants/transactions' +import UserPreferencedCurrencyDisplay from '../../components/app/user-preferenced-currency-display' +import { PRIMARY, SECONDARY } from '../../helpers/constants/common' +import AdvancedGasInputs from '../../components/app/gas-customization/advanced-gas-inputs' + +export default class ConfirmTransactionBase extends Component { + static contextTypes = { + t: PropTypes.func, + metricsEvent: PropTypes.func, + } + + static propTypes = { + // react-router props + match: PropTypes.object, + history: PropTypes.object, + // Redux props + balance: PropTypes.string, + cancelTransaction: PropTypes.func, + cancelAllTransactions: PropTypes.func, + clearConfirmTransaction: PropTypes.func, + clearSend: PropTypes.func, + conversionRate: PropTypes.number, + currentCurrency: PropTypes.string, + editTransaction: PropTypes.func, + ethTransactionAmount: PropTypes.string, + ethTransactionFee: PropTypes.string, + ethTransactionTotal: PropTypes.string, + fiatTransactionAmount: PropTypes.string, + fiatTransactionFee: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + fromAddress: PropTypes.string, + fromName: PropTypes.string, + hexTransactionAmount: PropTypes.string, + hexTransactionFee: PropTypes.string, + hexTransactionTotal: PropTypes.string, + isTxReprice: PropTypes.bool, + methodData: PropTypes.object, + nonce: PropTypes.string, + assetImage: PropTypes.string, + sendTransaction: PropTypes.func, + showCustomizeGasModal: PropTypes.func, + showTransactionConfirmedModal: PropTypes.func, + showRejectTransactionsConfirmationModal: PropTypes.func, + toAddress: PropTypes.string, + tokenData: PropTypes.object, + tokenProps: PropTypes.object, + toName: PropTypes.string, + transactionStatus: PropTypes.string, + txData: PropTypes.object, + unapprovedTxCount: PropTypes.number, + currentNetworkUnapprovedTxs: PropTypes.object, + updateGasAndCalculate: PropTypes.func, + customGas: PropTypes.object, + // Component props + action: PropTypes.string, + contentComponent: PropTypes.node, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + primaryTotalTextOverride: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + secondaryTotalTextOverride: PropTypes.string, + hideData: PropTypes.bool, + hideDetails: PropTypes.bool, + hideSubtitle: PropTypes.bool, + identiconAddress: PropTypes.string, + onCancel: PropTypes.func, + onEdit: PropTypes.func, + onEditGas: PropTypes.func, + onSubmit: PropTypes.func, + setMetaMetricsSendCount: PropTypes.func, + metaMetricsSendCount: PropTypes.number, + subtitle: PropTypes.string, + subtitleComponent: PropTypes.node, + summaryComponent: PropTypes.node, + title: PropTypes.string, + titleComponent: PropTypes.node, + valid: PropTypes.bool, + warning: PropTypes.string, + advancedInlineGasShown: PropTypes.bool, + insufficientBalance: PropTypes.bool, + hideFiatConversion: PropTypes.bool, + } + + state = { + submitting: false, + submitError: null, + } + + componentDidUpdate () { + const { + transactionStatus, + showTransactionConfirmedModal, + history, + clearConfirmTransaction, + } = this.props + + if (transactionStatus === DROPPED_STATUS || transactionStatus === CONFIRMED_STATUS) { + showTransactionConfirmedModal({ + onSubmit: () => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }, + }) + + return + } + } + + getErrorKey () { + const { + balance, + conversionRate, + hexTransactionFee, + txData: { + simulationFails, + txParams: { + value: amount, + } = {}, + } = {}, + } = this.props + + const insufficientBalance = balance && !isBalanceSufficient({ + amount, + gasTotal: hexTransactionFee || '0x0', + balance, + conversionRate, + }) + + if (insufficientBalance) { + return { + valid: false, + errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, + } + } + + if (simulationFails) { + return { + valid: true, + errorKey: simulationFails.errorKey ? simulationFails.errorKey : TRANSACTION_ERROR_KEY, + } + } + + return { + valid: true, + } + } + + handleEditGas () { + const { onEditGas, showCustomizeGasModal, action, txData: { origin }, methodData = {} } = this.props + + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'User clicks "Edit" on gas', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) + + if (onEditGas) { + onEditGas() + } else { + showCustomizeGasModal() + } + } + + renderDetails () { + const { + detailsComponent, + primaryTotalTextOverride, + secondaryTotalTextOverride, + hexTransactionFee, + hexTransactionTotal, + hideDetails, + advancedInlineGasShown, + customGas, + insufficientBalance, + updateGasAndCalculate, + hideFiatConversion, + } = this.props + + if (hideDetails) { + return null + } + + return ( + detailsComponent || ( +
+
+ this.handleEditGas()} + secondaryText={hideFiatConversion ? this.context.t('noConversionRateAvailable') : ''} + /> + {advancedInlineGasShown + ? updateGasAndCalculate({ ...customGas, gasPrice: newGasPrice })} + updateCustomGasLimit={newGasLimit => updateGasAndCalculate({ ...customGas, gasLimit: newGasLimit })} + customGasPrice={customGas.gasPrice} + customGasLimit={customGas.gasLimit} + insufficientBalance={insufficientBalance} + customPriceIsSafe={true} + isSpeedUp={false} + /> + : null + } +
+
+ +
+
+ ) + ) + } + + renderData () { + const { t } = this.context + const { + txData: { + txParams: { + data, + } = {}, + } = {}, + methodData: { + name, + params, + } = {}, + hideData, + dataComponent, + } = this.props + + if (hideData) { + return null + } + + return dataComponent || ( +
+
+ {`${t('functionType')}:`} + + { name || t('notFound') } + +
+ { + params && ( +
+
+ { `${t('parameters')}:` } +
+
+
{ JSON.stringify(params, null, 2) }
+
+
+ ) + } +
+ {`${t('hexData')}: ${ethUtil.toBuffer(data).length} bytes`} +
+
+ { data } +
+
+ ) + } + + handleEdit () { + const { txData, tokenData, tokenProps, onEdit, action, txData: { origin }, methodData = {} } = this.props + + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Edit Transaction', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) + + onEdit({ txData, tokenData, tokenProps }) + } + + handleCancelAll () { + const { + cancelAllTransactions, + clearConfirmTransaction, + history, + showRejectTransactionsConfirmationModal, + unapprovedTxCount, + } = this.props + + showRejectTransactionsConfirmationModal({ + unapprovedTxCount, + async onSubmit () { + await cancelAllTransactions() + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }, + }) + } + + handleCancel () { + const { metricsEvent } = this.context + const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction, action, txData: { origin }, methodData = {} } = this.props + + if (onCancel) { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Cancel', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) + onCancel(txData) + } else { + cancelTransaction(txData) + .then(() => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }) + } + } + + handleSubmit () { + const { metricsEvent } = this.context + const { txData: { origin }, sendTransaction, clearConfirmTransaction, txData, history, onSubmit, action, metaMetricsSendCount = 0, setMetaMetricsSendCount, methodData = {} } = this.props + const { submitting } = this.state + + if (submitting) { + return + } + + this.setState({ + submitting: true, + submitError: null, + }, () => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Transaction Completed', + }, + customVariables: { + recipientKnown: null, + functionType: action || getMethodName(methodData.name) || this.context.t('contractInteraction'), + origin, + }, + }) + + setMetaMetricsSendCount(metaMetricsSendCount + 1) + .then(() => { + if (onSubmit) { + Promise.resolve(onSubmit(txData)) + .then(() => { + this.setState({ + submitting: false, + }) + }) + } else { + sendTransaction(txData) + .then(() => { + clearConfirmTransaction() + this.setState({ + submitting: false, + }, () => { + history.push(DEFAULT_ROUTE) + }) + }) + .catch(error => { + this.setState({ + submitting: false, + submitError: error.message, + }) + }) + } + }) + }) + } + + renderTitleComponent () { + const { title, titleComponent, hexTransactionAmount } = this.props + + // Title string passed in by props takes priority + if (title) { + return null + } + + return titleComponent || ( + + ) + } + + renderSubtitleComponent () { + const { subtitle, subtitleComponent, hexTransactionAmount } = this.props + + // Subtitle string passed in by props takes priority + if (subtitle) { + return null + } + + return subtitleComponent || ( + + ) + } + + handleNextTx (txId) { + const { history, clearConfirmTransaction } = this.props + if (txId) { + clearConfirmTransaction() + history.push(`${CONFIRM_TRANSACTION_ROUTE}/${txId}`) + } + } + + getNavigateTxData () { + const { currentNetworkUnapprovedTxs, txData: { id } = {} } = this.props + const enumUnapprovedTxs = Object.keys(currentNetworkUnapprovedTxs).reverse() + const currentPosition = enumUnapprovedTxs.indexOf(id.toString()) + + return { + totalTx: enumUnapprovedTxs.length, + positionOfCurrentTx: currentPosition + 1, + nextTxId: enumUnapprovedTxs[currentPosition + 1], + prevTxId: enumUnapprovedTxs[currentPosition - 1], + showNavigation: enumUnapprovedTxs.length > 1, + firstTx: enumUnapprovedTxs[0], + lastTx: enumUnapprovedTxs[enumUnapprovedTxs.length - 1], + ofText: this.context.t('ofTextNofM'), + requestsWaitingText: this.context.t('requestsAwaitingAcknowledgement'), + } + } + + componentDidMount () { + const { txData: { origin } = {} } = this.props + const { metricsEvent } = this.context + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Confirm: Started', + }, + customVariables: { + origin, + }, + }) + } + + render () { + const { + isTxReprice, + fromName, + fromAddress, + toName, + toAddress, + methodData, + valid: propsValid = true, + errorMessage, + errorKey: propsErrorKey, + action, + title, + subtitle, + hideSubtitle, + identiconAddress, + summaryComponent, + contentComponent, + onEdit, + nonce, + assetImage, + warning, + unapprovedTxCount, + } = this.props + const { submitting, submitError } = this.state + + const { name } = methodData + const { valid, errorKey } = this.getErrorKey() + const { totalTx, positionOfCurrentTx, nextTxId, prevTxId, showNavigation, firstTx, lastTx, ofText, requestsWaitingText } = this.getNavigateTxData() + + return ( + this.handleNextTx(txId)} + firstTx={firstTx} + lastTx={lastTx} + ofText={ofText} + requestsWaitingText={requestsWaitingText} + disabled={!propsValid || !valid || submitting} + onEdit={() => this.handleEdit()} + onCancelAll={() => this.handleCancelAll()} + onCancel={() => this.handleCancel()} + onSubmit={() => this.handleSubmit()} + /> + ) + } +} + +export function getMethodName (camelCase) { + if (!camelCase || typeof camelCase !== 'string') { + return '' + } + + return camelCase + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z])([a-z])/g, ' $1$2') + .replace(/ +/g, ' ') +} diff --git a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js new file mode 100644 index 000000000..83543f1a4 --- /dev/null +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -0,0 +1,242 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import R from 'ramda' +import contractMap from 'eth-contract-metadata' +import ConfirmTransactionBase from './confirm-transaction-base.component' +import { + clearConfirmTransaction, + updateGasAndCalculate, +} from '../../ducks/confirm-transaction/confirm-transaction.duck' +import { clearSend, cancelTx, cancelTxs, updateAndApproveTx, showModal, setMetaMetricsSendCount } from '../../store/actions' +import { + INSUFFICIENT_FUNDS_ERROR_KEY, + GAS_LIMIT_TOO_LOW_ERROR_KEY, +} from '../../helpers/constants/error-keys' +import { getHexGasTotal } from '../../helpers/utils/confirm-tx.util' +import { isBalanceSufficient, calcGasTotal } from '../../components/app/send/send.utils' +import { conversionGreaterThan } from '../../helpers/utils/conversion-util' +import { MIN_GAS_LIMIT_DEC } from '../../components/app/send/send.constants' +import { checksumAddress, addressSlicer, valuesFor } from '../../helpers/utils/util' +import {getMetaMaskAccounts, getAdvancedInlineGasShown, preferencesSelector, getIsMainnet} from '../../selectors/selectors' + +const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { + return { + ...acc, + [base.toLowerCase()]: contractMap[base], + } +}, {}) + +const mapStateToProps = (state, props) => { + const { toAddress: propsToAddress } = props + const { showFiatInTestnets } = preferencesSelector(state) + const isMainnet = getIsMainnet(state) + const { confirmTransaction, metamask, gas } = state + const { + ethTransactionAmount, + ethTransactionFee, + ethTransactionTotal, + fiatTransactionAmount, + fiatTransactionFee, + fiatTransactionTotal, + hexTransactionAmount, + hexTransactionFee, + hexTransactionTotal, + tokenData, + methodData, + txData, + tokenProps, + nonce, + } = confirmTransaction + const { txParams = {}, lastGasPrice, id: transactionId } = txData + const { + from: fromAddress, + to: txParamsToAddress, + gasPrice, + gas: gasLimit, + value: amount, + } = txParams + const accounts = getMetaMaskAccounts(state) + const { + conversionRate, + identities, + currentCurrency, + selectedAddress, + selectedAddressTxList, + assetImages, + network, + unapprovedTxs, + metaMetricsSendCount, + } = metamask + const assetImage = assetImages[txParamsToAddress] + + const { + customGasLimit, + customGasPrice, + } = gas + + const { balance } = accounts[selectedAddress] + const { name: fromName } = identities[selectedAddress] + const toAddress = propsToAddress || txParamsToAddress + const toName = identities[toAddress] + ? identities[toAddress].name + : ( + casedContractMap[toAddress] + ? casedContractMap[toAddress].name + : addressSlicer(checksumAddress(toAddress)) + ) + + const isTxReprice = Boolean(lastGasPrice) + + const transaction = R.find(({ id }) => id === transactionId)(selectedAddressTxList) + const transactionStatus = transaction ? transaction.status : '' + + const currentNetworkUnapprovedTxs = R.filter( + ({ metamaskNetworkId }) => metamaskNetworkId === network, + unapprovedTxs, + ) + const unapprovedTxCount = valuesFor(currentNetworkUnapprovedTxs).length + + const insufficientBalance = !isBalanceSufficient({ + amount, + gasTotal: calcGasTotal(gasLimit, gasPrice), + balance, + conversionRate, + }) + + return { + balance, + fromAddress, + fromName, + toAddress, + toName, + ethTransactionAmount, + ethTransactionFee, + ethTransactionTotal, + fiatTransactionAmount, + fiatTransactionFee, + fiatTransactionTotal, + hexTransactionAmount, + hexTransactionFee, + hexTransactionTotal, + txData, + tokenData, + methodData, + tokenProps, + isTxReprice, + currentCurrency, + conversionRate, + transactionStatus, + nonce, + assetImage, + unapprovedTxs, + unapprovedTxCount, + currentNetworkUnapprovedTxs, + customGas: { + gasLimit: customGasLimit || gasLimit, + gasPrice: customGasPrice || gasPrice, + }, + advancedInlineGasShown: getAdvancedInlineGasShown(state), + insufficientBalance, + hideSubtitle: (!isMainnet && !showFiatInTestnets), + hideFiatConversion: (!isMainnet && !showFiatInTestnets), + metaMetricsSendCount, + } +} + +const mapDispatchToProps = dispatch => { + return { + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + clearSend: () => dispatch(clearSend()), + showTransactionConfirmedModal: ({ onSubmit }) => { + return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onSubmit })) + }, + showCustomizeGasModal: ({ txData, onSubmit, validate }) => { + return dispatch(showModal({ name: 'CUSTOMIZE_GAS', txData, onSubmit, validate })) + }, + updateGasAndCalculate: ({ gasLimit, gasPrice }) => { + return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) + }, + showRejectTransactionsConfirmationModal: ({ onSubmit, unapprovedTxCount }) => { + return dispatch(showModal({ name: 'REJECT_TRANSACTIONS', onSubmit, unapprovedTxCount })) + }, + cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), + cancelAllTransactions: (txList) => dispatch(cancelTxs(txList)), + sendTransaction: txData => dispatch(updateAndApproveTx(txData)), + setMetaMetricsSendCount: val => dispatch(setMetaMetricsSendCount(val)), + } +} + +const getValidateEditGas = ({ balance, conversionRate, txData }) => { + const { txParams: { value: amount } = {} } = txData + + return ({ gasLimit, gasPrice }) => { + const gasTotal = getHexGasTotal({ gasLimit, gasPrice }) + const hasSufficientBalance = isBalanceSufficient({ + amount, + gasTotal, + balance, + conversionRate, + }) + + if (!hasSufficientBalance) { + return { + valid: false, + errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, + } + } + + const gasLimitTooLow = gasLimit && conversionGreaterThan( + { + value: MIN_GAS_LIMIT_DEC, + fromNumericBase: 'dec', + conversionRate, + }, + { + value: gasLimit, + fromNumericBase: 'hex', + }, + ) + + if (gasLimitTooLow) { + return { + valid: false, + errorKey: GAS_LIMIT_TOO_LOW_ERROR_KEY, + } + } + + return { + valid: true, + } + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { balance, conversionRate, txData, unapprovedTxs } = stateProps + const { + cancelAllTransactions: dispatchCancelAllTransactions, + showCustomizeGasModal: dispatchShowCustomizeGasModal, + updateGasAndCalculate: dispatchUpdateGasAndCalculate, + ...otherDispatchProps + } = dispatchProps + + const validateEditGas = getValidateEditGas({ balance, conversionRate, txData }) + + return { + ...stateProps, + ...otherDispatchProps, + ...ownProps, + showCustomizeGasModal: () => dispatchShowCustomizeGasModal({ + txData, + onSubmit: customGas => dispatchUpdateGasAndCalculate(customGas), + validate: validateEditGas, + }), + cancelAllTransactions: () => dispatchCancelAllTransactions(valuesFor(unapprovedTxs)), + updateGasAndCalculate: dispatchUpdateGasAndCalculate, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(ConfirmTransactionBase) diff --git a/ui/app/pages/confirm-transaction-base/index.js b/ui/app/pages/confirm-transaction-base/index.js new file mode 100644 index 000000000..9996e9aeb --- /dev/null +++ b/ui/app/pages/confirm-transaction-base/index.js @@ -0,0 +1 @@ +export { default } from './confirm-transaction-base.container' diff --git a/ui/app/pages/confirm-transaction-base/tests/confirm-transaction-base.component.test.js b/ui/app/pages/confirm-transaction-base/tests/confirm-transaction-base.component.test.js new file mode 100644 index 000000000..8ca7ca4e7 --- /dev/null +++ b/ui/app/pages/confirm-transaction-base/tests/confirm-transaction-base.component.test.js @@ -0,0 +1,14 @@ +import assert from 'assert' +import { getMethodName } from '../confirm-transaction-base.component' + +describe('ConfirmTransactionBase Component', () => { + describe('getMethodName', () => { + it('should get correct method names', () => { + assert.equal(getMethodName(undefined), '') + assert.equal(getMethodName({}), '') + assert.equal(getMethodName('confirm'), 'confirm') + assert.equal(getMethodName('balanceOf'), 'balance Of') + assert.equal(getMethodName('ethToTokenSwapInput'), 'eth To Token Swap Input') + }) + }) +}) -- cgit