diff options
Merge branch 'i3725-refactor-send-component-' into i3725-refactor-send-component-2
Diffstat (limited to 'ui/app/components/send_')
37 files changed, 1582 insertions, 251 deletions
diff --git a/ui/app/components/send_/account-list-item/account-list-item-README.md b/ui/app/components/send_/account-list-item/account-list-item-README.md new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ui/app/components/send_/account-list-item/account-list-item-README.md diff --git a/ui/app/components/send_/account-list-item/account-list-item.component.js b/ui/app/components/send_/account-list-item/account-list-item.component.js new file mode 100644 index 000000000..b8407d147 --- /dev/null +++ b/ui/app/components/send_/account-list-item/account-list-item.component.js @@ -0,0 +1,74 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { checksumAddress } from '../../../util' +import Identicon from '../../identicon' +import CurrencyDisplay from '../../send/currency-display' + +export default class AccountListItem extends Component { + + static propTypes = { + account: PropTypes.object, + className: PropTypes.string, + conversionRate: PropTypes.number, + currentCurrency: PropTypes.string, + displayAddress: PropTypes.bool, + displayBalance: PropTypes.bool, + handleClick: PropTypes.func, + icon: PropTypes.node, + }; + + render () { + const { + account, + className, + conversionRate, + currentCurrency, + displayAddress = false, + displayBalance = true, + handleClick, + icon = null, + } = this.props + + const { name, address, balance } = account || {} + + return (<div + className={`account-list-item ${className}`} + onClick={() => handleClick({ name, address, balance })} + > + + <div className="account-list-item__top-row"> + <Identicon + address={address} + className="account-list-item__identicon" + diameter={18} + /> + + <div className="account-list-item__account-name">{ name || address }</div> + + {icon && <div className="account-list-item__icon">{ icon }</div>} + + </div> + + {displayAddress && name && <div className="account-list-item__account-address"> + { checksumAddress(address) } + </div>} + + {displayBalance && <CurrencyDisplay + className="account-list-item__account-balances" + conversionRate={conversionRate} + convertedBalanceClassName="account-list-item__account-secondary-balance" + convertedCurrency={currentCurrency} + primaryBalanceClassName="account-list-item__account-primary-balance" + primaryCurrency="ETH" + readOnly={true} + value={balance} + />} + + </div>) + } +} + +AccountListItem.contextTypes = { + t: PropTypes.func, +} + diff --git a/ui/app/components/send_/account-list-item/account-list-item.container.js b/ui/app/components/send_/account-list-item/account-list-item.container.js new file mode 100644 index 000000000..3151b1f1d --- /dev/null +++ b/ui/app/components/send_/account-list-item/account-list-item.container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux' +import { + getConversionRate, + getConvertedCurrency, +} from '../send.selectors.js' +import AccountListItem from './account-list-item.component' + +export default connect(mapStateToProps)(AccountListItem) + +function mapStateToProps (state) { + return { + conversionRate: getConversionRate(state), + currentCurrency: getConvertedCurrency(state), + } +} diff --git a/ui/app/components/send_/account-list-item/account-list-item.scss b/ui/app/components/send_/account-list-item/account-list-item.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ui/app/components/send_/account-list-item/account-list-item.scss diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.component.js b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.component.js index e69de29bb..bdf12b738 100644 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.component.js +++ b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.component.js @@ -0,0 +1,54 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class AmountMaxButton extends Component { + + static propTypes = { + balance: PropTypes.string, + gasTotal: PropTypes.string, + maxModeOn: PropTypes.bool, + selectedToken: PropTypes.object, + setAmountToMax: PropTypes.func, + setMaxModeTo: PropTypes.func, + tokenBalance: PropTypes.string, + }; + + setMaxAmount () { + const { + balance, + gasTotal, + selectedToken, + setAmountToMax, + tokenBalance, + } = this.props + + setAmountToMax({ + balance, + gasTotal, + selectedToken, + tokenBalance, + }) + } + + render () { + const { setMaxModeTo, maxModeOn } = this.props + + return ( + <div + className="send-v2__amount-max" + onClick={(event) => { + event.preventDefault() + setMaxModeTo(true) + this.setMaxAmount() + }} + > + {!maxModeOn ? this.context.t('max') : ''} + </div> + ) + } + +} + +AmountMaxButton.contextTypes = { + t: PropTypes.func, +} diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.container.js b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.container.js index e69de29bb..a72f41775 100644 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.container.js +++ b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.container.js @@ -0,0 +1,40 @@ +import { connect } from 'react-redux' +import { + getGasTotal, + getSelectedToken, + getSendFromBalance, + getTokenBalance, +} from '../../../send.selectors.js' +import { getMaxModeOn } from './amount-max-button.selectors.js' +import { calcMaxAmount } from './amount-max-button.utils.js' +import { + updateSendAmount, + setMaxModeTo, +} from '../../../../../actions' +import AmountMaxButton from './amount-max-button.component' +import { + updateSendErrors, +} from '../../../../../ducks/send' + +export default connect(mapStateToProps, mapDispatchToProps)(AmountMaxButton) + +function mapStateToProps (state) { + + return { + balance: getSendFromBalance(state), + gasTotal: getGasTotal(state), + maxModeOn: getMaxModeOn(state), + selectedToken: getSelectedToken(state), + tokenBalance: getTokenBalance(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + setAmountToMax: maxAmountDataObject => { + dispatch(updateSendErrors({ amount: null })) + dispatch(updateSendAmount(calcMaxAmount(maxAmountDataObject))) + }, + setMaxModeTo: bool => dispatch(setMaxModeTo(bool)), + } +} diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js index e69de29bb..69fec1994 100644 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js +++ b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js @@ -0,0 +1,9 @@ +const selectors = { + getMaxModeOn, +} + +module.exports = selectors + +function getMaxModeOn (state) { + return state.metamask.send.maxModeOn +} diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js index e69de29bb..b490a7fd7 100644 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js +++ b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js @@ -0,0 +1,22 @@ +const { + multiplyCurrencies, + subtractCurrencies, +} = require('../../../../../conversion-util') +const ethUtil = require('ethereumjs-util') + +function calcMaxAmount ({ balance, gasTotal, selectedToken, tokenBalance }) { + const { decimals } = selectedToken || {} + const multiplier = Math.pow(10, Number(decimals || 0)) + + return selectedToken + ? multiplyCurrencies(tokenBalance, multiplier, {toNumericBase: 'hex'}) + : subtractCurrencies( + ethUtil.addHexPrefix(balance), + ethUtil.addHexPrefix(gasTotal), + { toNumericBase: 'hex' } + ) +} + +module.exports = { + calcMaxAmount, +} diff --git a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.component.js b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.component.js index e69de29bb..8e201ae41 100644 --- a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.component.js +++ b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.component.js @@ -0,0 +1,96 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component' +import AmountMaxButton from './amount-max-button/amount-max-button.container' +import CurrencyDisplay from '../../../send/currency-display' + +export default class SendAmountRow extends Component { + + static propTypes = { + amount: PropTypes.string, + amountConversionRate: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + balance: PropTypes.string, + conversionRate: PropTypes.number, + convertedCurrency: PropTypes.string, + gasTotal: PropTypes.string, + inError: PropTypes.bool, + primaryCurrency: PropTypes.string, + selectedToken: PropTypes.object, + setMaxModeTo: PropTypes.func, + tokenBalance: PropTypes.string, + updateSendAmount: PropTypes.func, + updateSendAmountError: PropTypes.func, + } + + validateAmount (amount) { + const { + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + updateSendAmountError, + } = this.props + + updateSendAmountError({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + }) + } + + handleAmountChange (amount) { + const { updateSendAmount, setMaxModeTo } = this.props + + setMaxModeTo(false) + this.validateAmount(amount) + updateSendAmount(amount) + } + + render () { + const { + amount, + amountConversionRate, + convertedCurrency, + gasTotal, + inError, + primaryCurrency = 'ETH', + selectedToken, + } = this.props + + return ( + <SendRowWrapper + label={`${this.context.t('amount')}:`} + showError={inError} + errorType={'amount'} + > + {!inError && gasTotal && <AmountMaxButton />} + <CurrencyDisplay + conversionRate={amountConversionRate} + convertedCurrency={convertedCurrency} + handleChange={newAmount => this.handleAmountChange(newAmount)} + inError={inError} + primaryCurrency={primaryCurrency} + selectedToken={selectedToken} + value={amount || '0x0'} + /> + </SendRowWrapper> + ) + } + +} + +SendAmountRow.contextTypes = { + t: PropTypes.func, +} + diff --git a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js index 6ae80e7f2..13888ec53 100644 --- a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js +++ b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js @@ -1,48 +1,51 @@ +import { connect } from 'react-redux' import { - getSelectedToken, - getPrimaryCurrency, getAmountConversionRate, + getConversionRate, getConvertedCurrency, - getSendAmount, getGasTotal, - getSelectedBalance, + getPrimaryCurrency, + getSelectedToken, + getSendAmount, + getSendFromBalance, getTokenBalance, -} from '../../send.selectors.js' +} from '../../send.selectors' import { - getMaxModeOn, - getSendAmountError, -} from './send-amount-row.selectors.js' -import { getAmountErrorObject } from './send-to-row.utils.js' + sendAmountIsInError, +} from './send-amount-row.selectors' +import { getAmountErrorObject } from '../../send.utils' import { - updateSendErrors, - updateSendTo, -} from '../../../actions' + setMaxModeTo, + updateSendAmount, +} from '../../../../actions' import { - openToDropdown, - closeToDropdown, -} from '../../../ducks/send' -import SendToRow from './send-to-row.component' + updateSendErrors, +} from '../../../../ducks/send' +import SendAmountRow from './send-amount-row.component' -export default connect(mapStateToProps, mapDispatchToProps)(SendToRow) +export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow) function mapStateToProps (state) { -updateSendTo -return { - to: getSendTo(state), - toAccounts: getSendToAccounts(state), - toDropdownOpen: getToDropdownOpen(state), - inError: sendToIsInError(state), - network: getCurrentNetwork(state), -} + return { + amount: getSendAmount(state), + amountConversionRate: getAmountConversionRate(state), + balance: getSendFromBalance(state), + conversionRate: getConversionRate(state), + convertedCurrency: getConvertedCurrency(state), + gasTotal: getGasTotal(state), + inError: sendAmountIsInError(state), + primaryCurrency: getPrimaryCurrency(state), + selectedToken: getSelectedToken(state), + tokenBalance: getTokenBalance(state), + } } function mapDispatchToProps (dispatch) { -return { - updateSendToError: (to) => { - dispatch(updateSendErrors(getToErrorObject(to))) - }, - updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), - openToDropdown: () => dispatch(()), - closeToDropdown: () => dispatch(()), + return { + setMaxModeTo: bool => dispatch(setMaxModeTo(bool)), + updateSendAmount: newAmount => dispatch(updateSendAmount(newAmount)), + updateSendAmountError: (amountDataObject) => { + dispatch(updateSendErrors(getAmountErrorObject(amountDataObject))) + }, + } } -}
\ No newline at end of file diff --git a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.selectors.js b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.selectors.js index e69de29bb..fb08c7ed7 100644 --- a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.selectors.js +++ b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.selectors.js @@ -0,0 +1,9 @@ +const selectors = { + sendAmountIsInError, +} + +module.exports = selectors + +function sendAmountIsInError (state) { + return Boolean(state.send.errors.amount) +} diff --git a/ui/app/components/send_/send-content/send-content.component.js b/ui/app/components/send_/send-content/send-content.component.js index e69de29bb..e213cfcf3 100644 --- a/ui/app/components/send_/send-content/send-content.component.js +++ b/ui/app/components/send_/send-content/send-content.component.js @@ -0,0 +1,23 @@ +import React, { Component } from 'react' +import PageContainerContent from '../../page-container/page-container-content.component' +import SendAmountRow from './send-amount-row/send-amount-row.container' +import SendFromRow from './send-from-row/send-from-row.container' +import SendGasRow from './send-gas-row/send-gas-row.container' +import SendToRow from './send-to-row/send-to-row.container' + +export default class SendContent extends Component { + + render () { + return ( + <PageContainerContent> + <div className="send-v2__form"> + <SendFromRow /> + <SendToRow /> + <SendAmountRow /> + <SendGasRow /> + </div> + </PageContainerContent> + ) + } + +} diff --git a/ui/app/components/send_/send-content/send-from-row/from-dropdown/from-dropdown.component.js b/ui/app/components/send_/send-content/send-from-row/from-dropdown/from-dropdown.component.js index e69de29bb..337228122 100644 --- a/ui/app/components/send_/send-content/send-from-row/from-dropdown/from-dropdown.component.js +++ b/ui/app/components/send_/send-content/send-from-row/from-dropdown/from-dropdown.component.js @@ -0,0 +1,75 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import AccountListItem from '../../../account-list-item/account-list-item.container' + +export default class FromDropdown extends Component { + + static propTypes = { + accounts: PropTypes.array, + closeDropdown: PropTypes.func, + dropdownOpen: PropTypes.bool, + onSelect: PropTypes.func, + openDropdown: PropTypes.func, + selectedAccount: PropTypes.object, + }; + + renderListItemIcon (icon, color) { + return <i className={`fa ${icon} fa-lg`} style={ { color } }/> + } + + getListItemIcon (currentAccount, selectedAccount) { + return currentAccount.address === selectedAccount.address + ? this.renderListItemIcon('fa-check', '#02c9b1') + : null + } + + renderDropdown () { + const { + accounts, + closeDropdown, + onSelect, + selectedAccount, + } = this.props + + return (<div> + <div + className="send-v2__from-dropdown__close-area" + onClick={() => closeDropdown} + /> + <div className="send-v2__from-dropdown__list"> + {accounts.map((account, index) => <AccountListItem + account={account} + className="account-list-item__dropdown" + handleClick={() => { + onSelect(account) + closeDropdown() + }} + icon={this.getListItemIcon(account, selectedAccount.address)} + key={`from-dropdown-account-#${index}`} + />)} + </div> + </div>) + } + + render () { + const { + dropdownOpen, + openDropdown, + selectedAccount, + } = this.props + + return <div className="send-v2__from-dropdown"> + <AccountListItem + account={selectedAccount} + handleClick={openDropdown} + icon={this.renderListItemIcon('fa-caret-down', '#dedede')} + /> + {dropdownOpen && this.renderDropdown()}, + </div> + } + +} + +FromDropdown.contextTypes = { + t: PropTypes.func, +} diff --git a/ui/app/components/send_/send-content/send-from-row/send-from-row.component.js b/ui/app/components/send_/send-content/send-from-row/send-from-row.component.js index 7582cb2e6..17e7f8e46 100644 --- a/ui/app/components/send_/send-content/send-from-row/send-from-row.component.js +++ b/ui/app/components/send_/send-content/send-from-row/send-from-row.component.js @@ -1,60 +1,59 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import SendRowWrapper from '../../../send/from-dropdown' -import FromDropdown from '' +import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component' +import FromDropdown from './from-dropdown/from-dropdown.component' export default class SendFromRow extends Component { static propTypes = { closeFromDropdown: PropTypes.func, - conversionRate: PropTypes.string, - from: PropTypes.string, + conversionRate: PropTypes.number, + from: PropTypes.object, fromAccounts: PropTypes.array, fromDropdownOpen: PropTypes.bool, openFromDropdown: PropTypes.func, tokenContract: PropTypes.object, updateSendFrom: PropTypes.func, - updateSendTokenBalance: PropTypes.func, + setSendTokenBalance: PropTypes.func, }; async handleFromChange (newFrom) { const { updateSendFrom, tokenContract, - updateSendTokenBalance, + setSendTokenBalance, } = this.props if (tokenContract) { const usersToken = await tokenContract.balanceOf(newFrom.address) - updateSendTokenBalance(usersToken) + setSendTokenBalance(usersToken) } updateSendFrom(newFrom) } render () { const { + closeFromDropdown, + conversionRate, from, fromAccounts, - conversionRate, fromDropdownOpen, - tokenContract, openFromDropdown, - closeFromDropdown, } = this.props return ( <SendRowWrapper label={`${this.context.t('from')}:`}> <FromDropdown - dropdownOpen={fromDropdownOpen} accounts={fromAccounts} - selectedAccount={from} - onSelect={newFrom => this.handleFromChange(newFrom)} - openDropdown={() => openFromDropdown()} closeDropdown={() => closeFromDropdown()} conversionRate={conversionRate} + dropdownOpen={fromDropdownOpen} + onSelect={newFrom => this.handleFromChange(newFrom)} + openDropdown={() => openFromDropdown()} + selectedAccount={from} /> </SendRowWrapper> - ); + ) } } diff --git a/ui/app/components/send_/send-content/send-from-row/send-from-row.container.js b/ui/app/components/send_/send-content/send-from-row/send-from-row.container.js index 2ff3f0ccd..9e366445d 100644 --- a/ui/app/components/send_/send-content/send-from-row/send-from-row.container.js +++ b/ui/app/components/send_/send-content/send-from-row/send-from-row.container.js @@ -1,29 +1,31 @@ +import { connect } from 'react-redux' import { - getSendFrom, - conversionRateSelector, - getSelectedTokenContract, - getCurrentAccountWithSendEtherInfo, accountsWithSendEtherInfoSelector, + getConversionRate, + getSelectedTokenContract, + getSendFromObject, } from '../../send.selectors.js' -import { getFromDropdownOpen } from './send-from-row.selectors.js' +import { + getFromDropdownOpen, +} from './send-from-row.selectors.js' import { calcTokenUpdateAmount } from './send-from-row.utils.js' import { - updateSendTokenBalance, updateSendFrom, -} from '../../../actions' + setSendTokenBalance, +} from '../../../../actions' import { - openFromDropdown, closeFromDropdown, -} from '../../../ducks/send' + openFromDropdown, +} from '../../../../ducks/send' import SendFromRow from './send-from-row.component' export default connect(mapStateToProps, mapDispatchToProps)(SendFromRow) function mapStateToProps (state) { return { - from: getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state), + conversionRate: getConversionRate(state), + from: getSendFromObject(state), fromAccounts: accountsWithSendEtherInfoSelector(state), - conversionRate: conversionRateSelector(state), fromDropdownOpen: getFromDropdownOpen(state), tokenContract: getSelectedTokenContract(state), } @@ -31,14 +33,14 @@ function mapStateToProps (state) { function mapDispatchToProps (dispatch) { return { - updateSendTokenBalance: (usersToken, selectedToken) => { + closeFromDropdown: () => dispatch(closeFromDropdown()), + openFromDropdown: () => dispatch(openFromDropdown()), + updateSendFrom: newFrom => dispatch(updateSendFrom(newFrom)), + setSendTokenBalance: (usersToken, selectedToken) => { if (!usersToken) return const tokenBalance = calcTokenUpdateAmount(selectedToken, selectedToken) - dispatch(updateSendTokenBalance(tokenBalance)) + dispatch(setSendTokenBalance(tokenBalance)) }, - updateSendFrom: newFrom => dispatch(updateSendFrom(newFrom)), - openFromDropdown: () => dispatch(()), - closeFromDropdown: () => dispatch(()), } } diff --git a/ui/app/components/send_/send-content/send-from-row/send-from-row.utils.js b/ui/app/components/send_/send-content/send-from-row/send-from-row.utils.js index 2be25816f..0aaaef793 100644 --- a/ui/app/components/send_/send-content/send-from-row/send-from-row.utils.js +++ b/ui/app/components/send_/send-content/send-from-row/send-from-row.utils.js @@ -1,6 +1,6 @@ const { calcTokenAmount, -} = require('../../token-util') +} = require('../../../../token-util') function calcTokenUpdateAmount (usersToken, selectedToken) { const { decimals } = selectedToken || {} @@ -8,5 +8,5 @@ function calcTokenUpdateAmount (usersToken, selectedToken) { } module.exports = { - calcTokenUpdateAmount + calcTokenUpdateAmount, } diff --git a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.component.js b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.component.js index e69de29bb..c62c110e0 100644 --- a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.component.js +++ b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.component.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component' +import GasFeeDisplay from '../../../send/gas-fee-display-v2' + +export default class SendGasRow extends Component { + + static propTypes = { + closeFromDropdown: PropTypes.func, + conversionRate: PropTypes.number, + convertedCurrency: PropTypes.string, + from: PropTypes.string, + fromAccounts: PropTypes.array, + fromDropdownOpen: PropTypes.bool, + gasLoadingError: PropTypes.bool, + gasTotal: PropTypes.string, + openFromDropdown: PropTypes.func, + showCustomizeGasModal: PropTypes.func, + tokenContract: PropTypes.object, + updateSendFrom: PropTypes.func, + }; + + render () { + const { + conversionRate, + convertedCurrency, + gasLoadingError, + gasTotal, + showCustomizeGasModal, + } = this.props + + return ( + <SendRowWrapper label={`${this.context.t('gasFee')}:`}> + <GasFeeDisplay + conversionRate={conversionRate} + convertedCurrency={convertedCurrency} + gasLoadingError={gasLoadingError} + gasTotal={gasTotal} + onClick={() => showCustomizeGasModal()} + /> + </SendRowWrapper> + ) + } + +} + +SendGasRow.contextTypes = { + t: PropTypes.func, +} diff --git a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js index e69de29bb..20d3daa59 100644 --- a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js +++ b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux' +import { + getConversionRate, + getConvertedCurrency, + getGasTotal, +} from '../../send.selectors.js' +import { sendGasIsInError } from './send-gas-row.selectors.js' +import { showModal } from '../../../../actions' +import SendGasRow from './send-gas-row.component' + +export default connect(mapStateToProps, mapDispatchToProps)(SendGasRow) + +function mapStateToProps (state) { + return { + conversionRate: getConversionRate(state), + convertedCurrency: getConvertedCurrency(state), + gasTotal: getGasTotal(state), + gasLoadingError: sendGasIsInError(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS' })), + } +} diff --git a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.selectors.js b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.selectors.js index e69de29bb..ad4ef4877 100644 --- a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.selectors.js +++ b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.selectors.js @@ -0,0 +1,9 @@ +const selectors = { + sendGasIsInError, +} + +module.exports = selectors + +function sendGasIsInError (state) { + return state.metamask.send.errors.gasLoading +} diff --git a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js b/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js index 08f830cc5..0d314208b 100644 --- a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js +++ b/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js @@ -1,3 +1,6 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + export default class SendRowErrorMessage extends Component { static propTypes = { @@ -7,17 +10,18 @@ export default class SendRowErrorMessage extends Component { render () { const { errors, errorType } = this.props + const errorMessage = errors[errorType] return ( errorMessage - ? <div className='send-v2__error'>{errorMessage}</div> + ? <div className="send-v2__error">{this.context.t(errorMessage)}</div> : null - ); + ) } } SendRowErrorMessage.contextTypes = { t: PropTypes.func, -}
\ No newline at end of file +} diff --git a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js b/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js index 2278dbe63..59622047f 100644 --- a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js +++ b/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js @@ -1,3 +1,4 @@ +import { connect } from 'react-redux' import { getSendErrors } from '../../../send.selectors' import SendRowErrorMessage from './send-row-error-message.component' @@ -8,4 +9,4 @@ function mapStateToProps (state, ownProps) { errors: getSendErrors(state), errorType: ownProps.errorType, } -}
\ No newline at end of file +} diff --git a/ui/app/components/send_/send-content/send-row-wrapper/send-row-wrapper.component.js b/ui/app/components/send_/send-content/send-row-wrapper/send-row-wrapper.component.js index a1ac591b9..707b3ae80 100644 --- a/ui/app/components/send_/send-content/send-row-wrapper/send-row-wrapper.component.js +++ b/ui/app/components/send_/send-content/send-row-wrapper/send-row-wrapper.component.js @@ -5,31 +5,35 @@ import SendRowErrorMessage from './send-row-error-message/send-row-error-message export default class SendRowWrapper extends Component { static propTypes = { - label: PropTypes.string, - showError: PropTypes.bool, children: PropTypes.node, errorType: PropTypes.string, + label: PropTypes.string, + showError: PropTypes.bool, }; render () { const { - label, - errorType = '', - showError = false, - children, + children, + errorType = '', + label, + showError = false, } = this.props + const formField = Array.isArray(children) ? children[1] || children[0] : children + const customLabelContent = children.length > 1 ? children[0] : null + return ( <div className="send-v2__form-row"> <div className="send-v2__form-label"> {label} - (showError && <SendRowErrorMessage errorType={errorType}/>) + {showError && <SendRowErrorMessage errorType={errorType}/>} + {customLabelContent} </div> <div className="send-v2__form-field"> - {children} + {formField} </div> </div> - ); + ) } } diff --git a/ui/app/components/send_/send-content/send-to-row/send-to-row.component.js b/ui/app/components/send_/send-content/send-to-row/send-to-row.component.js index abcb54efc..a6e4c1624 100644 --- a/ui/app/components/send_/send-content/send-to-row/send-to-row.component.js +++ b/ui/app/components/send_/send-content/send-to-row/send-to-row.component.js @@ -1,57 +1,59 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import SendRowWrapper from '../../../send/from-dropdown' -import ToDropdown from '../../../ens-input' +import SendRowWrapper from '../send-row-wrapper/send-row-wrapper.component' +import EnsInput from '../../../ens-input' export default class SendToRow extends Component { static propTypes = { + closeToDropdown: PropTypes.func, + inError: PropTypes.bool, + network: PropTypes.string, + openToDropdown: PropTypes.func, to: PropTypes.string, toAccounts: PropTypes.array, toDropdownOpen: PropTypes.bool, - inError: PropTypes.bool, updateSendTo: PropTypes.func, updateSendToError: PropTypes.func, - openToDropdown: PropTypes.func, - closeToDropdown: PropTypes.func, - network: PropTypes.number, }; handleToChange (to, nickname = '') { const { updateSendTo, updateSendToError } = this.props updateSendTo(to, nickname) - updateSendErrors(to) + updateSendToError(to) } render () { const { - from, - fromAccounts, - conversionRate, - fromDropdownOpen, - tokenContract, - openToDropdown, closeToDropdown, - network, inError, + network, + openToDropdown, + to, + toAccounts, + toDropdownOpen, } = this.props return ( - <SendRowWrapper label={`${this.context.t('to')}:`}> + <SendRowWrapper + errorType={'to'} + label={`${this.context.t('to')}:`} + showError={inError} + > <EnsInput - name={'address'} - placeholder={this.context.t('recipient Address')} - network={network}, - to={to}, accounts={toAccounts} - dropdownOpen={toDropdownOpen} - openDropdown={() => openToDropdown()} closeDropdown={() => closeToDropdown()} - onChange={this.handleToChange} + dropdownOpen={toDropdownOpen} inError={inError} + name={'address'} + network={network} + onChange={(newTo, newNickname) => this.handleToChange(newTo, newNickname)} + openDropdown={() => openToDropdown()} + placeholder={this.context.t('recipientAddress')} + to={to} /> </SendRowWrapper> - ); + ) } } diff --git a/ui/app/components/send_/send-content/send-to-row/send-to-row.container.js b/ui/app/components/send_/send-content/send-to-row/send-to-row.container.js index 1c446c168..bffdda49c 100644 --- a/ui/app/components/send_/send-content/send-to-row/send-to-row.container.js +++ b/ui/app/components/send_/send-content/send-to-row/send-to-row.container.js @@ -1,7 +1,8 @@ +import { connect } from 'react-redux' import { - getSendTo, - getToAccounts, getCurrentNetwork, + getSendTo, + getSendToAccounts, } from '../../send.selectors.js' import { getToDropdownOpen, @@ -9,35 +10,34 @@ import { } from './send-to-row.selectors.js' import { getToErrorObject } from './send-to-row.utils.js' import { - updateSendErrors, updateSendTo, -} from '../../../actions' +} from '../../../../actions' import { - openToDropdown, - closeToDropdown, -} from '../../../ducks/send' + updateSendErrors, + openToDropdown, + closeToDropdown, +} from '../../../../ducks/send' import SendToRow from './send-to-row.component' export default connect(mapStateToProps, mapDispatchToProps)(SendToRow) function mapStateToProps (state) { - updateSendTo return { + inError: sendToIsInError(state), + network: getCurrentNetwork(state), to: getSendTo(state), toAccounts: getSendToAccounts(state), toDropdownOpen: getToDropdownOpen(state), - inError: sendToIsInError(state), - network: getCurrentNetwork(state), } } function mapDispatchToProps (dispatch) { return { + closeToDropdown: () => dispatch(closeToDropdown()), + openToDropdown: () => dispatch(openToDropdown()), + updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), updateSendToError: (to) => { dispatch(updateSendErrors(getToErrorObject(to))) }, - updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)), - openToDropdown: () => dispatch(()), - closeToDropdown: () => dispatch(()), } -}
\ No newline at end of file +} diff --git a/ui/app/components/send_/send-content/send-to-row/send-to-row.selectors.js b/ui/app/components/send_/send-content/send-to-row/send-to-row.selectors.js index 05bb65fa3..8919014be 100644 --- a/ui/app/components/send_/send-content/send-to-row/send-to-row.selectors.js +++ b/ui/app/components/send_/send-content/send-to-row/send-to-row.selectors.js @@ -10,5 +10,5 @@ function getToDropdownOpen (state) { } function sendToIsInError (state) { - return Boolean(state.metamask.send.to) + return Boolean(state.send.errors.to) } diff --git a/ui/app/components/send_/send-content/send-to-row/send-to-row.utils.js b/ui/app/components/send_/send-content/send-to-row/send-to-row.utils.js index 52bfde009..22e2e1f34 100644 --- a/ui/app/components/send_/send-content/send-to-row/send-to-row.utils.js +++ b/ui/app/components/send_/send-content/send-to-row/send-to-row.utils.js @@ -1,17 +1,21 @@ +const { + REQUIRED_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, +} = require('../../send.constants') const { isValidAddress } = require('../../../../util') function getToErrorObject (to) { let toError = null if (!to) { - toError = 'required' + toError = REQUIRED_ERROR } else if (!isValidAddress(to)) { - toError = 'invalidAddressRecipient' + toError = INVALID_RECIPIENT_ADDRESS_ERROR } - + return { to: toError } } module.exports = { - getToErrorObject + getToErrorObject, } diff --git a/ui/app/components/send_/send-footer/send-footer.component.js b/ui/app/components/send_/send-footer/send-footer.component.js index e69de29bb..fc7a78a94 100644 --- a/ui/app/components/send_/send-footer/send-footer.component.js +++ b/ui/app/components/send_/send-footer/send-footer.component.js @@ -0,0 +1,93 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import PageContainerFooter from '../../page-container/page-container-footer' +import { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } from '../../../routes' + +export default class SendFooter extends Component { + + static propTypes = { + addToAddressBookIfNew: PropTypes.func, + amount: PropTypes.string, + clearSend: PropTypes.func, + disabled: PropTypes.bool, + editingTransactionId: PropTypes.string, + errors: PropTypes.object, + from: PropTypes.object, + gasLimit: PropTypes.string, + gasPrice: PropTypes.string, + gasTotal: PropTypes.string, + history: PropTypes.object, + selectedToken: PropTypes.object, + sign: PropTypes.func, + to: PropTypes.string, + toAccounts: PropTypes.array, + tokenBalance: PropTypes.string, + unapprovedTxs: PropTypes.object, + update: PropTypes.func, + }; + + onSubmit (event) { + event.preventDefault() + const { + addToAddressBookIfNew, + amount, + editingTransactionId, + from: {address: from}, + gasLimit: gas, + gasPrice, + selectedToken, + sign, + to, + unapprovedTxs, + // updateTx, + update, + toAccounts, + } = this.props + + // Should not be needed because submit should be disabled if there are no errors. + // const noErrors = !amountError && toError === null + + // if (!noErrors) { + // return + // } + + // TODO: add nickname functionality + addToAddressBookIfNew(to, toAccounts) + + editingTransactionId + ? update({ + amount, + editingTransactionId, + from, + gas, + gasPrice, + selectedToken, + to, + unapprovedTxs, + }) + : sign({ selectedToken, to, amount, from, gas, gasPrice }) + + this.props.history.push(CONFIRM_TRANSACTION_ROUTE) + } + + + render () { + const { clearSend, disabled, history } = this.props + + return ( + <PageContainerFooter + onCancel={() => { + clearSend() + history.push(DEFAULT_ROUTE) + }} + onSubmit={e => this.onSubmit(e)} + disabled={disabled} + /> + ) + } + +} + +SendFooter.contextTypes = { + t: PropTypes.func, +} diff --git a/ui/app/components/send_/send-footer/send-footer.container.js b/ui/app/components/send_/send-footer/send-footer.container.js index e69de29bb..022b2db08 100644 --- a/ui/app/components/send_/send-footer/send-footer.container.js +++ b/ui/app/components/send_/send-footer/send-footer.container.js @@ -0,0 +1,106 @@ +import { connect } from 'react-redux' +import ethUtil from 'ethereumjs-util' +import { + addToAddressBook, + clearSend, + signTokenTx, + signTx, + updateTransaction, +} from '../../../actions' +import SendFooter from './send-footer.component' +import { + getGasLimit, + getGasPrice, + getGasTotal, + getSelectedToken, + getSendAmount, + getSendEditingTransactionId, + getSendFromObject, + getSendTo, + getSendToAccounts, + getTokenBalance, + getUnapprovedTxs, +} from '../send.selectors' +import { + isSendFormInError, +} from './send-footer.selectors' +import { + addressIsNew, + constructTxParams, + constructUpdatedTx, + formShouldBeDisabled, +} from './send-footer.utils' + +export default connect(mapStateToProps, mapDispatchToProps)(SendFooter) + +function mapStateToProps (state) { + return { + amount: getSendAmount(state), + disabled: formShouldBeDisabled({ + inError: isSendFormInError(state), + selectedToken: getSelectedToken(state), + tokenBalance: getTokenBalance(state), + gasTotal: getGasTotal(state), + }), + editingTransactionId: getSendEditingTransactionId(state), + from: getSendFromObject(state), + gasLimit: getGasLimit(state), + gasPrice: getGasPrice(state), + inError: isSendFormInError(state), + isToken: Boolean(getSelectedToken(state)), + selectedToken: getSelectedToken(state), + to: getSendTo(state), + toAccounts: getSendToAccounts(state), + unapprovedTxs: getUnapprovedTxs(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + clearSend: () => dispatch(clearSend()), + sign: ({ selectedToken, to, amount, from, gas, gasPrice }) => { + const txParams = constructTxParams({ + amount, + from, + gas, + gasPrice, + selectedToken, + to, + }) + + selectedToken + ? dispatch(signTokenTx(selectedToken.address, to, amount, txParams)) + : dispatch(signTx(txParams)) + }, + update: ({ + amount, + editingTransactionId, + from, + gas, + gasPrice, + selectedToken, + to, + unapprovedTxs, + }) => { + const editingTx = constructUpdatedTx({ + amount, + editingTransactionId, + from, + gas, + gasPrice, + selectedToken, + to, + unapprovedTxs, + }) + + dispatch(updateTransaction(editingTx)) + }, + addToAddressBookIfNew: (newAddress, toAccounts, nickname = '') => { + const hexPrefixedAddress = ethUtil.addHexPrefix(newAddress) + if (addressIsNew(toAccounts)) { + // TODO: nickname, i.e. addToAddressBook(recipient, nickname) + dispatch(addToAddressBook(hexPrefixedAddress, nickname)) + } + }, + } +} diff --git a/ui/app/components/send_/send-footer/send-footer.selectors.js b/ui/app/components/send_/send-footer/send-footer.selectors.js index e69de29bb..e8fef6be6 100644 --- a/ui/app/components/send_/send-footer/send-footer.selectors.js +++ b/ui/app/components/send_/send-footer/send-footer.selectors.js @@ -0,0 +1,12 @@ +import { getSendErrors } from '../send.selectors' + +const selectors = { + isSendFormInError, +} + +module.exports = selectors + +function isSendFormInError (state) { + const { amount, to } = getSendErrors(state) + return Boolean(amount || to !== null) +} diff --git a/ui/app/components/send_/send-footer/send-footer.utils.js b/ui/app/components/send_/send-footer/send-footer.utils.js index e69de29bb..353c0e347 100644 --- a/ui/app/components/send_/send-footer/send-footer.utils.js +++ b/ui/app/components/send_/send-footer/send-footer.utils.js @@ -0,0 +1,84 @@ +import ethAbi from 'ethereumjs-abi' +import ethUtil from 'ethereumjs-util' +import { TOKEN_TRANSFER_FUNCTION_SIGNATURE } from '../send.constants' + +function formShouldBeDisabled ({ inError, selectedToken, tokenBalance, gasTotal }) { + const missingTokenBalance = selectedToken && !tokenBalance + return inError || !gasTotal || missingTokenBalance +} + +function addHexPrefixToObjectValues (obj) { + return Object.keys(obj).reduce((newObj, key) => { + return { ...newObj, [key]: ethUtil.addHexPrefix(obj[key]) } + }, {}) +} + +function constructTxParams ({ selectedToken, to, amount, from, gas, gasPrice }) { + const txParams = { + from, + value: '0', + gas, + gasPrice, + } + + if (!selectedToken) { + txParams.value = amount + txParams.to = to + } + + const hexPrefixedTxParams = addHexPrefixToObjectValues(txParams) + + return hexPrefixedTxParams +} + +function constructUpdatedTx ({ + amount, + editingTransactionId, + from, + gas, + gasPrice, + selectedToken, + to, + unapprovedTxs, +}) { + const editingTx = { + ...unapprovedTxs[editingTransactionId], + txParams: addHexPrefixToObjectValues({ from, gas, gasPrice }), + } + + if (selectedToken) { + const data = TOKEN_TRANSFER_FUNCTION_SIGNATURE + Array.prototype.map.call( + ethAbi.rawEncode(['address', 'uint256'], [to, ethUtil.addHexPrefix(amount)]), + x => ('00' + x.toString(16)).slice(-2) + ).join('') + + Object.assign(editingTx.txParams, addHexPrefixToObjectValues({ + value: '0', + to: selectedToken.address, + data, + })) + } else { + const { data } = unapprovedTxs[editingTransactionId].txParams + + Object.assign(editingTx.txParams, addHexPrefixToObjectValues({ + value: amount, + to, + data, + })) + + if (typeof editingTx.txParams.data === 'undefined') { + delete editingTx.txParams.data + } + } +} + +function addressIsNew (toAccounts, newAddress) { + return !toAccounts.find(({ address }) => newAddress === address) +} + +module.exports = { + addressIsNew, + formShouldBeDisabled, + constructTxParams, + constructUpdatedTx, +} diff --git a/ui/app/components/send_/send-header/send-header.component.js b/ui/app/components/send_/send-header/send-header.component.js index 99adfc7e8..dc4190b93 100644 --- a/ui/app/components/send_/send-header/send-header.component.js +++ b/ui/app/components/send_/send-header/send-header.component.js @@ -1,28 +1,29 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' -import PageContainerHeader from '../../page-container/page-container-header.component' +import PageContainerHeader from '../../page-container/page-container-header' +import { DEFAULT_ROUTE } from '../../../routes' export default class SendHeader extends Component { static propTypes = { - isToken: PropTypes.bool, clearSend: PropTypes.func, - goHome: PropTypes.func, + history: PropTypes.object, + isToken: PropTypes.bool, }; render () { - const { isToken, clearSend, goHome } = this.props + const { isToken, clearSend, history } = this.props return ( <PageContainerHeader - title={isToken ? this.context.t('sendTokens') : this.context.t('sendETH')} - subtitle={this.context.t('onlySendToEtherAddress')} onClose={() => { clearSend() - goHome() + history.push(DEFAULT_ROUTE) }} + subtitle={this.context.t('onlySendToEtherAddress')} + title={isToken ? this.context.t('sendTokens') : this.context.t('sendETH')} /> - ); + ) } } diff --git a/ui/app/components/send_/send-header/send-header.container.js b/ui/app/components/send_/send-header/send-header.container.js index a4d3ac54f..0c92da3a6 100644 --- a/ui/app/components/send_/send-header/send-header.container.js +++ b/ui/app/components/send_/send-header/send-header.container.js @@ -1,5 +1,5 @@ import { connect } from 'react-redux' -import { goHome, clearSend } from '../../../actions' +import { clearSend } from '../../../actions' import SendHeader from './send-header.component' import { getSelectedToken } from '../../../selectors' @@ -7,13 +7,12 @@ export default connect(mapStateToProps, mapDispatchToProps)(SendHeader) function mapStateToProps (state) { return { - isToken: Boolean(getSelectedToken(state)) + isToken: Boolean(getSelectedToken(state)), } } function mapDispatchToProps (dispatch) { return { - goHome: () => dispatch(goHome()), clearSend: () => dispatch(clearSend()), } } diff --git a/ui/app/components/send_/send.component.js b/ui/app/components/send_/send.component.js index e69de29bb..e14a97537 100644 --- a/ui/app/components/send_/send.component.js +++ b/ui/app/components/send_/send.component.js @@ -0,0 +1,142 @@ +import React from 'react' +import PropTypes from 'prop-types' +import PersistentForm from '../../../lib/persistent-form' +import { + getAmountErrorObject, + doesAmountErrorRequireUpdate, +} from './send.utils' + +import SendHeader from './send-header/send-header.container' +import SendContent from './send-content/send-content.component' +import SendFooter from './send-footer/send-footer.container' + +export default class SendTransactionScreen extends PersistentForm { + + static propTypes = { + amount: PropTypes.string, + amountConversionRate: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + conversionRate: PropTypes.number, + data: PropTypes.string, + editingTransactionId: PropTypes.string, + from: PropTypes.object, + gasLimit: PropTypes.string, + gasPrice: PropTypes.string, + gasTotal: PropTypes.string, + history: PropTypes.object, + network: PropTypes.string, + primaryCurrency: PropTypes.string, + selectedAddress: PropTypes.string, + selectedToken: PropTypes.object, + tokenBalance: PropTypes.string, + tokenContract: PropTypes.object, + updateAndSetGasTotal: PropTypes.func, + updateSendErrors: PropTypes.func, + updateSendTokenBalance: PropTypes.func, + }; + + updateGas () { + const { + data, + editingTransactionId, + gasLimit, + gasPrice, + selectedAddress, + selectedToken = {}, + updateAndSetGasTotal, + } = this.props + + updateAndSetGasTotal({ + data, + editingTransactionId, + gasLimit, + gasPrice, + selectedAddress, + selectedToken, + }) + } + + componentDidUpdate (prevProps) { + const { + amount, + amountConversionRate, + conversionRate, + from: { address, balance }, + gasTotal, + network, + primaryCurrency, + selectedToken, + tokenBalance, + updateSendErrors, + updateSendTokenBalance, + tokenContract, + } = this.props + + const { + from: { balance: prevBalance }, + gasTotal: prevGasTotal, + tokenBalance: prevTokenBalance, + network: prevNetwork, + } = prevProps + + const uninitialized = [prevBalance, prevGasTotal].every(n => n === null) + + if (!uninitialized) { + const amountErrorRequiresUpdate = doesAmountErrorRequireUpdate({ + balance, + gasTotal, + prevBalance, + prevGasTotal, + prevTokenBalance, + selectedToken, + tokenBalance, + }) + + if (amountErrorRequiresUpdate) { + const amountErrorObject = getAmountErrorObject({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + }) + updateSendErrors(amountErrorObject) + } + + if (network !== prevNetwork && network !== 'loading') { + updateSendTokenBalance({ + selectedToken, + tokenContract, + address, + }) + this.updateGas() + } + } + } + + componentWillMount () { + this.updateGas() + } + + render () { + const { history } = this.props + + return ( + <div className="page-container"> + <SendHeader history={history}/> + <SendContent/> + <SendFooter history={history}/> + </div> + ) + } + +} + +SendTransactionScreen.contextTypes = { + t: PropTypes.func, +} diff --git a/ui/app/components/send_/send.constants.js b/ui/app/components/send_/send.constants.js new file mode 100644 index 000000000..d047ed704 --- /dev/null +++ b/ui/app/components/send_/send.constants.js @@ -0,0 +1,44 @@ +const ethUtil = require('ethereumjs-util') +const { conversionUtil, multiplyCurrencies } = require('../../conversion-util') + +const MIN_GAS_PRICE_HEX = (100000000).toString(16) +const MIN_GAS_PRICE_DEC = '100000000' +const MIN_GAS_LIMIT_DEC = '21000' +const MIN_GAS_LIMIT_HEX = (parseInt(MIN_GAS_LIMIT_DEC)).toString(16) + +const MIN_GAS_PRICE_GWEI = ethUtil.addHexPrefix(conversionUtil(MIN_GAS_PRICE_HEX, { + fromDenomination: 'WEI', + toDenomination: 'GWEI', + fromNumericBase: 'hex', + toNumericBase: 'hex', + numberOfDecimals: 1, +})) + +const MIN_GAS_TOTAL = multiplyCurrencies(MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_HEX, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, +}) + +const TOKEN_TRANSFER_FUNCTION_SIGNATURE = '0xa9059cbb' + +const INSUFFICIENT_FUNDS_ERROR = 'insufficientFunds' +const INSUFFICIENT_TOKENS_ERROR = 'insufficientTokens' +const NEGATIVE_ETH_ERROR = 'negativeETH' +const INVALID_RECIPIENT_ADDRESS_ERROR = 'invalidAddressRecipient' +const REQUIRED_ERROR = 'required' + +module.exports = { + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + INVALID_RECIPIENT_ADDRESS_ERROR, + MIN_GAS_LIMIT_DEC, + MIN_GAS_LIMIT_HEX, + MIN_GAS_PRICE_DEC, + MIN_GAS_PRICE_GWEI, + MIN_GAS_PRICE_HEX, + MIN_GAS_TOTAL, + NEGATIVE_ETH_ERROR, + REQUIRED_ERROR, + TOKEN_TRANSFER_FUNCTION_SIGNATURE, +} diff --git a/ui/app/components/send_/send.container.js b/ui/app/components/send_/send.container.js index e69de29bb..df605469a 100644 --- a/ui/app/components/send_/send.container.js +++ b/ui/app/components/send_/send.container.js @@ -0,0 +1,88 @@ +import { connect } from 'react-redux' +import abi from 'ethereumjs-abi' +import SendEther from './send.component' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import { + getAmountConversionRate, + getConversionRate, + getCurrentNetwork, + getGasLimit, + getGasPrice, + getGasTotal, + getPrimaryCurrency, + getSelectedAddress, + getSelectedToken, + getSelectedTokenContract, + getSelectedTokenToFiatRate, + getSendAmount, + getSendEditingTransactionId, + getSendFromObject, + getTokenBalance, +} from './send.selectors' +import { + updateSendTokenBalance, + updateGasTotal, + setGasTotal, +} from '../../actions' +import { + updateSendErrors, +} from '../../ducks/send' +import { + calcGasTotal, + generateTokenTransferData, +} from './send.utils.js' + +module.exports = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(SendEther) + +function mapStateToProps (state) { + const selectedAddress = getSelectedAddress(state) + const selectedToken = getSelectedToken(state) + + return { + amount: getSendAmount(state), + amountConversionRate: getAmountConversionRate(state), + conversionRate: getConversionRate(state), + data: generateTokenTransferData(abi, selectedAddress, selectedToken), + editingTransactionId: getSendEditingTransactionId(state), + from: getSendFromObject(state), + gasLimit: getGasLimit(state), + gasPrice: getGasPrice(state), + gasTotal: getGasTotal(state), + network: getCurrentNetwork(state), + primaryCurrency: getPrimaryCurrency(state), + selectedAddress: getSelectedAddress(state), + selectedToken: getSelectedToken(state), + tokenBalance: getTokenBalance(state), + tokenContract: getSelectedTokenContract(state), + tokenToFiatRate: getSelectedTokenToFiatRate(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + updateAndSetGasTotal: ({ + data, + editingTransactionId, + gasLimit, + gasPrice, + selectedAddress, + selectedToken, + }) => { + !editingTransactionId + ? dispatch(updateGasTotal({ selectedAddress, selectedToken, data })) + : dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))) + }, + updateSendTokenBalance: ({ selectedToken, tokenContract, address }) => { + dispatch(updateSendTokenBalance({ + selectedToken, + tokenContract, + address, + })) + }, + updateSendErrors: newError => dispatch(updateSendErrors(newError)), + } +} diff --git a/ui/app/components/send_/send.selectors.js b/ui/app/components/send_/send.selectors.js index 8c088098e..761b15182 100644 --- a/ui/app/components/send_/send.selectors.js +++ b/ui/app/components/send_/send.selectors.js @@ -2,105 +2,95 @@ import { valuesFor } from '../../util' import abi from 'human-standard-token-abi' import { multiplyCurrencies, -} from './conversion-util' +} from '../../conversion-util' const selectors = { + accountsWithSendEtherInfoSelector, + autoAddToBetaUI, + getAddressBook, + getAmountConversionRate, + getConversionRate, + getConvertedCurrency, + getCurrentAccountWithSendEtherInfo, + getCurrentCurrency, + getCurrentNetwork, + getCurrentViewContext, + getForceGasMin, + getGasLimit, + getGasPrice, + getGasTotal, + getPrimaryCurrency, + getSelectedAccount, getSelectedAddress, getSelectedIdentity, - getSelectedAccount, getSelectedToken, + getSelectedTokenContract, getSelectedTokenExchangeRate, - getTokenExchangeRate, - conversionRateSelector, - transactionsSelector, - accountsWithSendEtherInfoSelector, - getCurrentAccountWithSendEtherInfo, - getGasPrice, - getGasLimit, - getForceGasMin, - getAddressBook, - getSendFrom, - getCurrentCurrency, - getSendAmount, getSelectedTokenToFiatRate, - getSelectedTokenContract, - autoAddToBetaUI, - getSendMaxModeState, - getCurrentViewContext, + getSendAmount, + getSendEditingTransactionId, getSendErrors, + getSendFrom, + getSendFromBalance, + getSendFromObject, + getSendMaxModeState, getSendTo, - getCurrentNetwork, + getSendToAccounts, + getTokenBalance, + getTokenExchangeRate, + getUnapprovedTxs, + isSendFormInError, + transactionsSelector, } module.exports = selectors -function getSelectedAddress (state) { - const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0] - - return selectedAddress -} +function accountsWithSendEtherInfoSelector (state) { + const { + accounts, + identities, + } = state.metamask -function getSelectedIdentity (state) { - const selectedAddress = getSelectedAddress(state) - const identities = state.metamask.identities + const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { + return Object.assign({}, account, identities[key]) + }) - return identities[selectedAddress] + return accountsWithSendEtherInfo } -function getSelectedAccount (state) { - const accounts = state.metamask.accounts - const selectedAddress = getSelectedAddress(state) +function autoAddToBetaUI (state) { + const autoAddTransactionThreshold = 12 + const autoAddAccountsThreshold = 2 + const autoAddTokensThreshold = 1 - return accounts[selectedAddress] -} + const numberOfTransactions = state.metamask.selectedAddressTxList.length + const numberOfAccounts = Object.keys(state.metamask.accounts).length + const numberOfTokensAdded = state.metamask.tokens.length -function getSelectedToken (state) { - const tokens = state.metamask.tokens || [] - const selectedTokenAddress = state.metamask.selectedTokenAddress - const selectedToken = tokens.filter(({ address }) => address === selectedTokenAddress)[0] - const sendToken = state.metamask.send.token + const userPassesThreshold = (numberOfTransactions > autoAddTransactionThreshold) && + (numberOfAccounts > autoAddAccountsThreshold) && + (numberOfTokensAdded > autoAddTokensThreshold) + const userIsNotInBeta = !state.metamask.featureFlags.betaUI - return selectedToken || sendToken || null + return userIsNotInBeta && userPassesThreshold } -function getSelectedTokenExchangeRate (state) { - const tokenExchangeRates = state.metamask.tokenExchangeRates - const selectedToken = getSelectedToken(state) || {} - const { symbol = '' } = selectedToken - - const pair = `${symbol.toLowerCase()}_eth` - const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} - - return tokenExchangeRate +function getAddressBook (state) { + return state.metamask.addressBook } -function getTokenExchangeRate (state, tokenSymbol) { - const pair = `${tokenSymbol.toLowerCase()}_eth` - const tokenExchangeRates = state.metamask.tokenExchangeRates - const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} - - return tokenExchangeRate +function getAmountConversionRate (state) { + return getSelectedToken(state) + ? getSelectedTokenToFiatRate(state) + : getConversionRate(state) } -function conversionRateSelector (state) { +function getConversionRate (state) { return state.metamask.conversionRate } -function getAddressBook (state) { - return state.metamask.addressBook -} - -function accountsWithSendEtherInfoSelector (state) { - const { - accounts, - identities, - } = state.metamask - - const accountsWithSendEtherInfo = Object.entries(accounts).map(([key, account]) => { - return Object.assign({}, account, identities[key]) - }) - - return accountsWithSendEtherInfo +function getConvertedCurrency (state) { + return state.metamask.currentCurrency } function getCurrentAccountWithSendEtherInfo (state) { @@ -110,20 +100,21 @@ function getCurrentAccountWithSendEtherInfo (state) { return accounts.find(({ address }) => address === currentAddress) } -function transactionsSelector (state) { - const { network, selectedTokenAddress } = state.metamask - const unapprovedMsgs = valuesFor(state.metamask.unapprovedMsgs) - const shapeShiftTxList = (network === '1') ? state.metamask.shapeShiftTxList : undefined - const transactions = state.metamask.selectedAddressTxList || [] - const txsToRender = !shapeShiftTxList ? transactions.concat(unapprovedMsgs) : transactions.concat(unapprovedMsgs, shapeShiftTxList) +function getCurrentCurrency (state) { + return state.metamask.currentCurrency +} - // console.log({txsToRender, selectedTokenAddress}) - return selectedTokenAddress - ? txsToRender - .filter(({ txParams }) => txParams && txParams.to === selectedTokenAddress) - .sort((a, b) => b.time - a.time) - : txsToRender - .sort((a, b) => b.time - a.time) +function getCurrentNetwork (state) { + return state.metamask.network +} + +function getCurrentViewContext (state) { + const { currentView = {} } = state.appState + return currentView.context +} + +function getForceGasMin (state) { + return state.metamask.send.forceGasMin } function getGasPrice (state) { @@ -134,29 +125,64 @@ function getGasLimit (state) { return state.metamask.send.gasLimit } -function getForceGasMin (state) { - return state.metamask.send.forceGasMin +function getGasTotal (state) { + return state.metamask.send.gasTotal } -function getSendFrom (state) { - return state.metamask.send.from +function getPrimaryCurrency (state) { + const selectedToken = getSelectedToken(state) + return selectedToken && selectedToken.symbol } -function getSendAmount (state) { - return state.metamask.send.amount +function getSelectedAccount (state) { + const accounts = state.metamask.accounts + const selectedAddress = getSelectedAddress(state) + + return accounts[selectedAddress] } -function getSendMaxModeState (state) { - return state.metamask.send.maxModeOn +function getSelectedAddress (state) { + const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0] + + return selectedAddress } -function getCurrentCurrency (state) { - return state.metamask.currentCurrency +function getSelectedIdentity (state) { + const selectedAddress = getSelectedAddress(state) + const identities = state.metamask.identities + + return identities[selectedAddress] +} + +function getSelectedToken (state) { + const tokens = state.metamask.tokens || [] + const selectedTokenAddress = state.metamask.selectedTokenAddress + const selectedToken = tokens.filter(({ address }) => address === selectedTokenAddress)[0] + const sendToken = state.metamask.send.token + + return selectedToken || sendToken || null +} + +function getSelectedTokenContract (state) { + const selectedToken = getSelectedToken(state) + return selectedToken + ? global.eth.contract(abi).at(selectedToken.address) + : null +} + +function getSelectedTokenExchangeRate (state) { + const tokenExchangeRates = state.metamask.tokenExchangeRates + const selectedToken = getSelectedToken(state) || {} + const { symbol = '' } = selectedToken + const pair = `${symbol.toLowerCase()}_eth` + const { rate: tokenExchangeRate = 0 } = tokenExchangeRates && tokenExchangeRates[pair] || {} + + return tokenExchangeRate } function getSelectedTokenToFiatRate (state) { const selectedTokenExchangeRate = getSelectedTokenExchangeRate(state) - const conversionRate = conversionRateSelector(state) + const conversionRate = getConversionRate(state) const tokenToFiatRate = multiplyCurrencies( conversionRate, @@ -167,37 +193,33 @@ function getSelectedTokenToFiatRate (state) { return tokenToFiatRate } -function getSelectedTokenContract (state) { - const selectedToken = getSelectedToken(state) - return selectedToken - ? global.eth.contract(abi).at(selectedToken.address) - : null +function getSendAmount (state) { + return state.metamask.send.amount } -function autoAddToBetaUI (state) { - const autoAddTransactionThreshold = 12 - const autoAddAccountsThreshold = 2 - const autoAddTokensThreshold = 1 +function getSendEditingTransactionId (state) { + return state.metamask.send.editingTransactionId +} - const numberOfTransactions = state.metamask.selectedAddressTxList.length - const numberOfAccounts = Object.keys(state.metamask.accounts).length - const numberOfTokensAdded = state.metamask.tokens.length +function getSendErrors (state) { + return state.send.errors +} - const userPassesThreshold = (numberOfTransactions > autoAddTransactionThreshold) && - (numberOfAccounts > autoAddAccountsThreshold) && - (numberOfTokensAdded > autoAddTokensThreshold) - const userIsNotInBeta = !state.metamask.featureFlags.betaUI +function getSendFrom (state) { + return state.metamask.send.from +} - return userIsNotInBeta && userPassesThreshold +function getSendFromBalance (state) { + const from = getSendFrom(state) || getSelectedAccount(state) + return from.balance } -function getCurrentViewContext (state) { - const { currentView = {} } = state.appState - return currentView.context +function getSendFromObject (state) { + return getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state) } -function getSendErrors (state) { - return state.metamask.send.errors +function getSendMaxModeState (state) { + return state.metamask.send.maxModeOn } function getSendTo (state) { @@ -212,6 +234,38 @@ function getSendToAccounts (state) { return Object.entries(allAccounts).map(([key, account]) => account) } -function getCurrentNetwork (state) { - return state.metamask.network -}
\ No newline at end of file +function getTokenBalance (state) { + return state.metamask.send.tokenBalance +} + +function getTokenExchangeRate (state, tokenSymbol) { + const pair = `${tokenSymbol.toLowerCase()}_eth` + const tokenExchangeRates = state.metamask.tokenExchangeRates + const { rate: tokenExchangeRate = 0 } = tokenExchangeRates[pair] || {} + + return tokenExchangeRate +} + +function getUnapprovedTxs (state) { + return state.metamask.unapprovedTxs +} + +function isSendFormInError (state) { + const { amount, to } = getSendErrors(state) + return Boolean(amount || to !== null) +} + +function transactionsSelector (state) { + const { network, selectedTokenAddress } = state.metamask + const unapprovedMsgs = valuesFor(state.metamask.unapprovedMsgs) + const shapeShiftTxList = (network === '1') ? state.metamask.shapeShiftTxList : undefined + const transactions = state.metamask.selectedAddressTxList || [] + const txsToRender = !shapeShiftTxList ? transactions.concat(unapprovedMsgs) : transactions.concat(unapprovedMsgs, shapeShiftTxList) + + return selectedTokenAddress + ? txsToRender + .filter(({ txParams }) => txParams && txParams.to === selectedTokenAddress) + .sort((a, b) => b.time - a.time) + : txsToRender + .sort((a, b) => b.time - a.time) +} diff --git a/ui/app/components/send_/send.utils.js b/ui/app/components/send_/send.utils.js index e69de29bb..e537d5624 100644 --- a/ui/app/components/send_/send.utils.js +++ b/ui/app/components/send_/send.utils.js @@ -0,0 +1,188 @@ +const { + addCurrencies, + conversionUtil, + conversionGTE, + multiplyCurrencies, +} = require('../../conversion-util') +const { + calcTokenAmount, +} = require('../../token-util') +const { + conversionGreaterThan, +} = require('../../conversion-util') +const { + INSUFFICIENT_FUNDS_ERROR, + INSUFFICIENT_TOKENS_ERROR, + NEGATIVE_ETH_ERROR, +} = require('./send.constants') + +module.exports = { + calcGasTotal, + doesAmountErrorRequireUpdate, + generateTokenTransferData, + getAmountErrorObject, + getParamsForGasEstimate, + calcTokenBalance, + isBalanceSufficient, + isTokenBalanceSufficient, +} + +function calcGasTotal (gasLimit, gasPrice) { + return multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + }) +} + +function isBalanceSufficient ({ + amount = '0x0', + amountConversionRate, + balance, + conversionRate, + gasTotal = '0x0', + primaryCurrency, +}) { + const totalAmount = addCurrencies(amount, gasTotal, { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + + const balanceIsSufficient = conversionGTE( + { + value: balance, + fromNumericBase: 'hex', + fromCurrency: primaryCurrency, + conversionRate, + }, + { + value: totalAmount, + fromNumericBase: 'hex', + conversionRate: amountConversionRate || conversionRate, + fromCurrency: primaryCurrency, + }, + ) + + return balanceIsSufficient +} + +function isTokenBalanceSufficient ({ + amount = '0x0', + tokenBalance, + decimals, +}) { + const amountInDec = conversionUtil(amount, { + fromNumericBase: 'hex', + }) + + const tokenBalanceIsSufficient = conversionGTE( + { + value: tokenBalance, + fromNumericBase: 'dec', + }, + { + value: calcTokenAmount(amountInDec, decimals), + fromNumericBase: 'dec', + }, + ) + + return tokenBalanceIsSufficient +} + +function getAmountErrorObject ({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, +}) { + let insufficientFunds = false + if (gasTotal && conversionRate) { + insufficientFunds = !isBalanceSufficient({ + amount: selectedToken ? '0x0' : amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + }) + } + + let inSufficientTokens = false + if (selectedToken && tokenBalance !== null) { + const { decimals } = selectedToken + inSufficientTokens = !isTokenBalanceSufficient({ + tokenBalance, + amount, + decimals, + }) + } + + const amountLessThanZero = conversionGreaterThan( + { value: 0, fromNumericBase: 'dec' }, + { value: amount, fromNumericBase: 'hex' }, + ) + + let amountError = null + + if (insufficientFunds) { + amountError = INSUFFICIENT_FUNDS_ERROR + } else if (inSufficientTokens) { + amountError = INSUFFICIENT_TOKENS_ERROR + } else if (amountLessThanZero) { + amountError = NEGATIVE_ETH_ERROR + } + + return { amount: amountError } +} + +function getParamsForGasEstimate (selectedAddress, symbol, data) { + const estimatedGasParams = { + from: selectedAddress, + gas: '746a528800', + } + + if (symbol) { + Object.assign(estimatedGasParams, { value: '0x0' }) + } + + if (data) { + Object.assign(estimatedGasParams, { data }) + } + + return estimatedGasParams +} + +function calcTokenBalance ({ selectedToken, usersToken }) { + const { decimals } = selectedToken || {} + return calcTokenAmount(usersToken.balance.toString(), decimals) +} + +function doesAmountErrorRequireUpdate ({ + balance, + gasTotal, + prevBalance, + prevGasTotal, + prevTokenBalance, + selectedToken, + tokenBalance, +}) { + const balanceHasChanged = balance !== prevBalance + const gasTotalHasChange = gasTotal !== prevGasTotal + const tokenBalanceHasChanged = selectedToken && tokenBalance !== prevTokenBalance + const amountErrorRequiresUpdate = balanceHasChanged || gasTotalHasChange || tokenBalanceHasChanged + + return amountErrorRequiresUpdate +} + +function generateTokenTransferData (abi, selectedAddress, selectedToken) { + if (!selectedToken) return + return Array.prototype.map.call( + abi.rawEncode(['address', 'uint256'], [selectedAddress, '0x0']), + x => ('00' + x.toString(16)).slice(-2) + ).join('') +} |