aboutsummaryrefslogtreecommitdiffstats
path: root/ui/app/components/send
diff options
context:
space:
mode:
authorDan <danjm.com@gmail.com>2018-06-20 23:48:23 +0800
committerWhymarrh Whitby <whymarrh.whitby@gmail.com>2018-07-16 23:28:32 +0800
commitb3d78ed8a1fbea059344b04416fb21bdb1b73f86 (patch)
tree6a2d8f6dda690961331c9bcbf5e2c6bf7102bced /ui/app/components/send
parentf31e87dcd5cec09e81c741e311efc3793c9d9b98 (diff)
downloadtangerine-wallet-browser-b3d78ed8a1fbea059344b04416fb21bdb1b73f86.tar
tangerine-wallet-browser-b3d78ed8a1fbea059344b04416fb21bdb1b73f86.tar.gz
tangerine-wallet-browser-b3d78ed8a1fbea059344b04416fb21bdb1b73f86.tar.bz2
tangerine-wallet-browser-b3d78ed8a1fbea059344b04416fb21bdb1b73f86.tar.lz
tangerine-wallet-browser-b3d78ed8a1fbea059344b04416fb21bdb1b73f86.tar.xz
tangerine-wallet-browser-b3d78ed8a1fbea059344b04416fb21bdb1b73f86.tar.zst
tangerine-wallet-browser-b3d78ed8a1fbea059344b04416fb21bdb1b73f86.zip
Remove send_ directory, revert to just having send
Revert accidentally changed constants. Require defaults in ens-input, gas-fee-display and confirm screens.
Diffstat (limited to 'ui/app/components/send')
-rw-r--r--ui/app/components/send/README.md0
-rw-r--r--ui/app/components/send/account-list-item/account-list-item-README.md0
-rw-r--r--ui/app/components/send/account-list-item/account-list-item.component.js73
-rw-r--r--ui/app/components/send/account-list-item/account-list-item.container.js15
-rw-r--r--ui/app/components/send/account-list-item/account-list-item.scss0
-rw-r--r--ui/app/components/send/account-list-item/index.js1
-rw-r--r--ui/app/components/send/account-list-item/tests/account-list-item-component.test.js138
-rw-r--r--ui/app/components/send/account-list-item/tests/account-list-item-container.test.js32
-rw-r--r--ui/app/components/send/currency-display/currency-display.js (renamed from ui/app/components/send/currency-display.js)4
-rw-r--r--ui/app/components/send/currency-display/index.js1
-rw-r--r--ui/app/components/send/index.js1
-rw-r--r--ui/app/components/send/send-content/index.js1
-rw-r--r--ui/app/components/send/send-content/send-amount-row/README.md0
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js54
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js40
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js9
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js22
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/index.js1
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js90
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js91
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-selectors.test.js22
-rw-r--r--ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-utils.test.js27
-rw-r--r--ui/app/components/send/send-content/send-amount-row/index.js1
-rw-r--r--ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js123
-rw-r--r--ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js54
-rw-r--r--ui/app/components/send/send-content/send-amount-row/send-amount-row.scss0
-rw-r--r--ui/app/components/send/send-content/send-amount-row/send-amount-row.selectors.js9
-rw-r--r--ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-component.test.js202
-rw-r--r--ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js125
-rw-r--r--ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-selectors.test.js34
-rw-r--r--ui/app/components/send/send-content/send-content-README.md0
-rw-r--r--ui/app/components/send/send-content/send-content.component.js28
-rw-r--r--ui/app/components/send/send-content/send-content.scss0
-rw-r--r--ui/app/components/send/send-content/send-dropdown-list/index.js1
-rw-r--r--ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js52
-rw-r--r--ui/app/components/send/send-content/send-dropdown-list/tests/send-dropdown-list-component.test.js105
-rw-r--r--ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown-README.md0
-rw-r--r--ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.component.js46
-rw-r--r--ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.scss0
-rw-r--r--ui/app/components/send/send-content/send-from-row/from-dropdown/index.js1
-rw-r--r--ui/app/components/send/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js88
-rw-r--r--ui/app/components/send/send-content/send-from-row/index.js1
-rw-r--r--ui/app/components/send/send-content/send-from-row/send-from-row-README.md0
-rw-r--r--ui/app/components/send/send-content/send-from-row/send-from-row.component.js63
-rw-r--r--ui/app/components/send/send-content/send-from-row/send-from-row.container.js46
-rw-r--r--ui/app/components/send/send-content/send-from-row/send-from-row.selectors.js9
-rw-r--r--ui/app/components/send/send-content/send-from-row/tests/send-from-row-component.test.js121
-rw-r--r--ui/app/components/send/send-content/send-from-row/tests/send-from-row-container.test.js110
-rw-r--r--ui/app/components/send/send-content/send-from-row/tests/send-from-row-selectors.test.js20
-rw-r--r--ui/app/components/send/send-content/send-gas-row/README.md0
-rw-r--r--ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js61
-rw-r--r--ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js1
-rw-r--r--ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js55
-rw-r--r--ui/app/components/send/send-content/send-gas-row/index.js1
-rw-r--r--ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js48
-rw-r--r--ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js27
-rw-r--r--ui/app/components/send/send-content/send-gas-row/send-gas-row.scss0
-rw-r--r--ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js14
-rw-r--r--ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js70
-rw-r--r--ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js70
-rw-r--r--ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js49
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/index.js1
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js1
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md0
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js27
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js12
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.scss0
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-component.test.js28
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js28
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper-README.md0
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js43
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.scss0
-rw-r--r--ui/app/components/send/send-content/send-row-wrapper/tests/send-row-wrapper-component.test.js79
-rw-r--r--ui/app/components/send/send-content/send-to-row/index.js1
-rw-r--r--ui/app/components/send/send-content/send-to-row/send-to-row-README.md0
-rw-r--r--ui/app/components/send/send-content/send-to-row/send-to-row.component.js69
-rw-r--r--ui/app/components/send/send-content/send-to-row/send-to-row.container.js42
-rw-r--r--ui/app/components/send/send-content/send-to-row/send-to-row.selectors.js14
-rw-r--r--ui/app/components/send/send-content/send-to-row/send-to-row.utils.js19
-rw-r--r--ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js149
-rw-r--r--ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js113
-rw-r--r--ui/app/components/send/send-content/send-to-row/tests/send-to-row-selectors.test.js47
-rw-r--r--ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js51
-rw-r--r--ui/app/components/send/send-content/tests/send-content-component.test.js38
-rw-r--r--ui/app/components/send/send-footer/README.md0
-rw-r--r--ui/app/components/send/send-footer/index.js1
-rw-r--r--ui/app/components/send/send-footer/send-footer.component.js101
-rw-r--r--ui/app/components/send/send-footer/send-footer.container.js100
-rw-r--r--ui/app/components/send/send-footer/send-footer.scss0
-rw-r--r--ui/app/components/send/send-footer/send-footer.selectors.js11
-rw-r--r--ui/app/components/send/send-footer/send-footer.utils.js81
-rw-r--r--ui/app/components/send/send-footer/tests/send-footer-component.test.js230
-rw-r--r--ui/app/components/send/send-footer/tests/send-footer-container.test.js191
-rw-r--r--ui/app/components/send/send-footer/tests/send-footer-selectors.test.js24
-rw-r--r--ui/app/components/send/send-footer/tests/send-footer-utils.test.js210
-rw-r--r--ui/app/components/send/send-header/README.md0
-rw-r--r--ui/app/components/send/send-header/index.js1
-rw-r--r--ui/app/components/send/send-header/send-header.component.js34
-rw-r--r--ui/app/components/send/send-header/send-header.container.js19
-rw-r--r--ui/app/components/send/send-header/send-header.selectors.js37
-rw-r--r--ui/app/components/send/send-header/tests/send-header-component.test.js70
-rw-r--r--ui/app/components/send/send-header/tests/send-header-container.test.js59
-rw-r--r--ui/app/components/send/send-header/tests/send-header-selectors.test.js47
-rw-r--r--ui/app/components/send/send.component.js179
-rw-r--r--ui/app/components/send/send.constants.js57
-rw-r--r--ui/app/components/send/send.container.js93
-rw-r--r--ui/app/components/send/send.scss0
-rw-r--r--ui/app/components/send/send.selectors.js279
-rw-r--r--ui/app/components/send/send.utils.js312
-rw-r--r--ui/app/components/send/tests/send-component.test.js332
-rw-r--r--ui/app/components/send/tests/send-container.test.js169
-rw-r--r--ui/app/components/send/tests/send-selectors-test-data.js230
-rw-r--r--ui/app/components/send/tests/send-selectors.test.js685
-rw-r--r--ui/app/components/send/tests/send-utils.test.js486
-rw-r--r--ui/app/components/send/to-autocomplete/index.js1
-rw-r--r--ui/app/components/send/to-autocomplete/to-autocomplete.js120
116 files changed, 7176 insertions, 2 deletions
diff --git a/ui/app/components/send/README.md b/ui/app/components/send/README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/README.md
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..9f4a96e61
--- /dev/null
+++ b/ui/app/components/send/account-list-item/account-list-item.component.js
@@ -0,0 +1,73 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import { checksumAddress } from '../../../util'
+import Identicon from '../../identicon'
+import CurrencyDisplay from '../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,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ 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>)
+ }
+}
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..4b4519288
--- /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,
+ getCurrentCurrency,
+} from '../send.selectors.js'
+import AccountListItem from './account-list-item.component'
+
+export default connect(mapStateToProps)(AccountListItem)
+
+function mapStateToProps (state) {
+ return {
+ conversionRate: getConversionRate(state),
+ currentCurrency: getCurrentCurrency(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/account-list-item/index.js b/ui/app/components/send/account-list-item/index.js
new file mode 100644
index 000000000..907485cf7
--- /dev/null
+++ b/ui/app/components/send/account-list-item/index.js
@@ -0,0 +1 @@
+export { default } from './account-list-item.container'
diff --git a/ui/app/components/send/account-list-item/tests/account-list-item-component.test.js b/ui/app/components/send/account-list-item/tests/account-list-item-component.test.js
new file mode 100644
index 000000000..ef152d2e7
--- /dev/null
+++ b/ui/app/components/send/account-list-item/tests/account-list-item-component.test.js
@@ -0,0 +1,138 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import proxyquire from 'proxyquire'
+import Identicon from '../../../identicon'
+import CurrencyDisplay from '../../currency-display'
+
+const utilsMethodStubs = {
+ checksumAddress: sinon.stub().returns('mockCheckSumAddress'),
+}
+
+const AccountListItem = proxyquire('../account-list-item.component.js', {
+ '../../../util': utilsMethodStubs,
+}).default
+
+
+const propsMethodSpies = {
+ handleClick: sinon.spy(),
+}
+
+describe('AccountListItem Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<AccountListItem
+ account={ { address: 'mockAddress', name: 'mockName', balance: 'mockBalance' } }
+ className={'mockClassName'}
+ conversionRate={4}
+ currentCurrency={'mockCurrentyCurrency'}
+ displayAddress={false}
+ displayBalance={false}
+ handleClick={propsMethodSpies.handleClick}
+ icon={<i className="mockIcon" />}
+ />, { context: { t: str => str + '_t' } })
+ })
+
+ afterEach(() => {
+ propsMethodSpies.handleClick.resetHistory()
+ })
+
+ describe('render', () => {
+ it('should render a div with the passed className', () => {
+ assert.equal(wrapper.find('.mockClassName').length, 1)
+ assert(wrapper.find('.mockClassName').is('div'))
+ assert(wrapper.find('.mockClassName').hasClass('account-list-item'))
+ })
+
+ it('should call handleClick with the expected props when the root div is clicked', () => {
+ const { onClick } = wrapper.find('.mockClassName').props()
+ assert.equal(propsMethodSpies.handleClick.callCount, 0)
+ onClick()
+ assert.equal(propsMethodSpies.handleClick.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.handleClick.getCall(0).args,
+ [{ address: 'mockAddress', name: 'mockName', balance: 'mockBalance' }]
+ )
+ })
+
+ it('should have a top row div', () => {
+ assert.equal(wrapper.find('.mockClassName > .account-list-item__top-row').length, 1)
+ assert(wrapper.find('.mockClassName > .account-list-item__top-row').is('div'))
+ })
+
+ it('should have an identicon, name and icon in the top row', () => {
+ const topRow = wrapper.find('.mockClassName > .account-list-item__top-row')
+ assert.equal(topRow.find(Identicon).length, 1)
+ assert.equal(topRow.find('.account-list-item__account-name').length, 1)
+ assert.equal(topRow.find('.account-list-item__icon').length, 1)
+ })
+
+ it('should show the account name if it exists', () => {
+ const topRow = wrapper.find('.mockClassName > .account-list-item__top-row')
+ assert.equal(topRow.find('.account-list-item__account-name').text(), 'mockName')
+ })
+
+ it('should show the account address if there is no name', () => {
+ wrapper.setProps({ account: { address: 'addressButNoName' } })
+ const topRow = wrapper.find('.mockClassName > .account-list-item__top-row')
+ assert.equal(topRow.find('.account-list-item__account-name').text(), 'addressButNoName')
+ })
+
+ it('should render the passed icon', () => {
+ const topRow = wrapper.find('.mockClassName > .account-list-item__top-row')
+ assert(topRow.find('.account-list-item__icon').childAt(0).is('i'))
+ assert(topRow.find('.account-list-item__icon').childAt(0).hasClass('mockIcon'))
+ })
+
+ it('should not render an icon if none is passed', () => {
+ wrapper.setProps({ icon: null })
+ const topRow = wrapper.find('.mockClassName > .account-list-item__top-row')
+ assert.equal(topRow.find('.account-list-item__icon').length, 0)
+ })
+
+ it('should render the account address as a checksumAddress if displayAddress is true and name is provided', () => {
+ wrapper.setProps({ displayAddress: true })
+ assert.equal(wrapper.find('.account-list-item__account-address').length, 1)
+ assert.equal(wrapper.find('.account-list-item__account-address').text(), 'mockCheckSumAddress')
+ assert.deepEqual(
+ utilsMethodStubs.checksumAddress.getCall(0).args,
+ ['mockAddress']
+ )
+ })
+
+ it('should not render the account address as a checksumAddress if displayAddress is false', () => {
+ wrapper.setProps({ displayAddress: false })
+ assert.equal(wrapper.find('.account-list-item__account-address').length, 0)
+ })
+
+ it('should not render the account address as a checksumAddress if name is not provided', () => {
+ wrapper.setProps({ account: { address: 'someAddressButNoName' } })
+ assert.equal(wrapper.find('.account-list-item__account-address').length, 0)
+ })
+
+ it('should render a CurrencyDisplay with the correct props if displayBalance is true', () => {
+ wrapper.setProps({ displayBalance: true })
+ assert.equal(wrapper.find(CurrencyDisplay).length, 1)
+ assert.deepEqual(
+ wrapper.find(CurrencyDisplay).props(),
+ {
+ className: 'account-list-item__account-balances',
+ conversionRate: 4,
+ convertedBalanceClassName: 'account-list-item__account-secondary-balance',
+ convertedCurrency: 'mockCurrentyCurrency',
+ primaryBalanceClassName: 'account-list-item__account-primary-balance',
+ primaryCurrency: 'ETH',
+ readOnly: true,
+ value: 'mockBalance',
+ }
+ )
+ })
+
+ it('should not render a CurrencyDisplay if displayBalance is false', () => {
+ wrapper.setProps({ displayBalance: false })
+ assert.equal(wrapper.find(CurrencyDisplay).length, 0)
+ })
+ })
+})
diff --git a/ui/app/components/send/account-list-item/tests/account-list-item-container.test.js b/ui/app/components/send/account-list-item/tests/account-list-item-container.test.js
new file mode 100644
index 000000000..af0859117
--- /dev/null
+++ b/ui/app/components/send/account-list-item/tests/account-list-item-container.test.js
@@ -0,0 +1,32 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+
+let mapStateToProps
+
+proxyquire('../account-list-item.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ return () => ({})
+ },
+ },
+ '../send.selectors.js': {
+ getConversionRate: (s) => `mockConversionRate:${s}`,
+ getCurrentCurrency: (s) => `mockCurrentCurrency:${s}`,
+ },
+})
+
+describe('account-list-item container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ conversionRate: 'mockConversionRate:mockState',
+ currentCurrency: 'mockCurrentCurrency:mockState',
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/currency-display.js b/ui/app/components/send/currency-display/currency-display.js
index 1cf55ce1a..1b9f7738c 100644
--- a/ui/app/components/send/currency-display.js
+++ b/ui/app/components/send/currency-display/currency-display.js
@@ -1,8 +1,8 @@
const Component = require('react').Component
const h = require('react-hyperscript')
const inherits = require('util').inherits
-const { conversionUtil, multiplyCurrencies } = require('../../conversion-util')
-const { removeLeadingZeroes } = require('../send_/send.utils')
+const { conversionUtil, multiplyCurrencies } = require('../../../conversion-util')
+const { removeLeadingZeroes } = require('../send.utils')
const currencyFormatter = require('currency-formatter')
const currencies = require('currency-formatter/currencies')
const ethUtil = require('ethereumjs-util')
diff --git a/ui/app/components/send/currency-display/index.js b/ui/app/components/send/currency-display/index.js
new file mode 100644
index 000000000..0185a19e9
--- /dev/null
+++ b/ui/app/components/send/currency-display/index.js
@@ -0,0 +1 @@
+export { default } from './currency-display.js' \ No newline at end of file
diff --git a/ui/app/components/send/index.js b/ui/app/components/send/index.js
new file mode 100644
index 000000000..b5114babc
--- /dev/null
+++ b/ui/app/components/send/index.js
@@ -0,0 +1 @@
+export { default } from './send.container'
diff --git a/ui/app/components/send/send-content/index.js b/ui/app/components/send/send-content/index.js
new file mode 100644
index 000000000..891c17e6a
--- /dev/null
+++ b/ui/app/components/send/send-content/index.js
@@ -0,0 +1 @@
+export { default } from './send-content.component'
diff --git a/ui/app/components/send/send-content/send-amount-row/README.md b/ui/app/components/send/send-content/send-amount-row/README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/README.md
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
new file mode 100644
index 000000000..4d0d36ab4
--- /dev/null
+++ 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,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ 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>
+ )
+ }
+
+}
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
new file mode 100644
index 000000000..2d2ec42f7
--- /dev/null
+++ 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.duck'
+
+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
new file mode 100644
index 000000000..69fec1994
--- /dev/null
+++ 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
new file mode 100644
index 000000000..b490a7fd7
--- /dev/null
+++ 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/amount-max-button/index.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/index.js
new file mode 100644
index 000000000..ee8271494
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/index.js
@@ -0,0 +1 @@
+export { default } from './amount-max-button.container'
diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js
new file mode 100644
index 000000000..86a05ff21
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js
@@ -0,0 +1,90 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import AmountMaxButton from '../amount-max-button.component.js'
+
+const propsMethodSpies = {
+ setAmountToMax: sinon.spy(),
+ setMaxModeTo: sinon.spy(),
+}
+
+const MOCK_EVENT = { preventDefault: () => {} }
+
+sinon.spy(AmountMaxButton.prototype, 'setMaxAmount')
+
+describe('AmountMaxButton Component', function () {
+ let wrapper
+ let instance
+
+ beforeEach(() => {
+ wrapper = shallow(<AmountMaxButton
+ balance={'mockBalance'}
+ gasTotal={'mockGasTotal'}
+ maxModeOn={false}
+ selectedToken={ { address: 'mockTokenAddress' } }
+ setAmountToMax={propsMethodSpies.setAmountToMax}
+ setMaxModeTo={propsMethodSpies.setMaxModeTo}
+ tokenBalance={'mockTokenBalance'}
+ />, { context: { t: str => str + '_t' } })
+ instance = wrapper.instance()
+ })
+
+ afterEach(() => {
+ propsMethodSpies.setAmountToMax.resetHistory()
+ propsMethodSpies.setMaxModeTo.resetHistory()
+ AmountMaxButton.prototype.setMaxAmount.resetHistory()
+ })
+
+ describe('setMaxAmount', () => {
+
+ it('should call setAmountToMax with the correct params', () => {
+ assert.equal(propsMethodSpies.setAmountToMax.callCount, 0)
+ instance.setMaxAmount()
+ assert.equal(propsMethodSpies.setAmountToMax.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.setAmountToMax.getCall(0).args,
+ [{
+ balance: 'mockBalance',
+ gasTotal: 'mockGasTotal',
+ selectedToken: { address: 'mockTokenAddress' },
+ tokenBalance: 'mockTokenBalance',
+ }]
+ )
+ })
+
+ })
+
+ describe('render', () => {
+ it('should render a div with a send-v2__amount-max class', () => {
+ assert.equal(wrapper.find('.send-v2__amount-max').length, 1)
+ assert(wrapper.find('.send-v2__amount-max').is('div'))
+ })
+
+ it('should call setMaxModeTo and setMaxAmount when the send-v2__amount-max div is clicked', () => {
+ const {
+ onClick,
+ } = wrapper.find('.send-v2__amount-max').props()
+
+ assert.equal(AmountMaxButton.prototype.setMaxAmount.callCount, 0)
+ assert.equal(propsMethodSpies.setMaxModeTo.callCount, 0)
+ onClick(MOCK_EVENT)
+ assert.equal(AmountMaxButton.prototype.setMaxAmount.callCount, 1)
+ assert.equal(propsMethodSpies.setMaxModeTo.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.setMaxModeTo.getCall(0).args,
+ [true]
+ )
+ })
+
+ it('should not render text when maxModeOn is true', () => {
+ wrapper.setProps({ maxModeOn: true })
+ assert.equal(wrapper.find('.send-v2__amount-max').text(), '')
+ })
+
+ it('should render the expected text when maxModeOn is false', () => {
+ wrapper.setProps({ maxModeOn: false })
+ assert.equal(wrapper.find('.send-v2__amount-max').text(), 'max_t')
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js
new file mode 100644
index 000000000..2cc00d6d6
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-container.test.js
@@ -0,0 +1,91 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+const actionSpies = {
+ setMaxModeTo: sinon.spy(),
+ updateSendAmount: sinon.spy(),
+}
+const duckActionSpies = {
+ updateSendErrors: sinon.spy(),
+}
+
+proxyquire('../amount-max-button.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },
+ '../../../send.selectors.js': {
+ getGasTotal: (s) => `mockGasTotal:${s}`,
+ getSelectedToken: (s) => `mockSelectedToken:${s}`,
+ getSendFromBalance: (s) => `mockBalance:${s}`,
+ getTokenBalance: (s) => `mockTokenBalance:${s}`,
+ },
+ './amount-max-button.selectors.js': { getMaxModeOn: (s) => `mockMaxModeOn:${s}` },
+ './amount-max-button.utils.js': { calcMaxAmount: (mockObj) => mockObj.val + 1 },
+ '../../../../../actions': actionSpies,
+ '../../../../../ducks/send.duck': duckActionSpies,
+})
+
+describe('amount-max-button container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ balance: 'mockBalance:mockState',
+ gasTotal: 'mockGasTotal:mockState',
+ maxModeOn: 'mockMaxModeOn:mockState',
+ selectedToken: 'mockSelectedToken:mockState',
+ tokenBalance: 'mockTokenBalance:mockState',
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ describe('setAmountToMax()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.setAmountToMax({ val: 11, foo: 'bar' })
+ assert(dispatchSpy.calledTwice)
+ assert(duckActionSpies.updateSendErrors.calledOnce)
+ assert.deepEqual(
+ duckActionSpies.updateSendErrors.getCall(0).args[0],
+ { amount: null }
+ )
+ assert(actionSpies.updateSendAmount.calledOnce)
+ assert.equal(
+ actionSpies.updateSendAmount.getCall(0).args[0],
+ 12
+ )
+ })
+ })
+
+ describe('setMaxModeTo()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.setMaxModeTo('mockVal')
+ assert(dispatchSpy.calledOnce)
+ assert.equal(
+ actionSpies.setMaxModeTo.getCall(0).args[0],
+ 'mockVal'
+ )
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-selectors.test.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-selectors.test.js
new file mode 100644
index 000000000..655fe1969
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-selectors.test.js
@@ -0,0 +1,22 @@
+import assert from 'assert'
+import {
+ getMaxModeOn,
+} from '../amount-max-button.selectors.js'
+
+describe('amount-max-button selectors', () => {
+
+ describe('getMaxModeOn()', () => {
+ it('should', () => {
+ const state = {
+ metamask: {
+ send: {
+ maxModeOn: null,
+ },
+ },
+ }
+
+ assert.equal(getMaxModeOn(state), null)
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-utils.test.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-utils.test.js
new file mode 100644
index 000000000..816df6a12
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-utils.test.js
@@ -0,0 +1,27 @@
+import assert from 'assert'
+import {
+ calcMaxAmount,
+} from '../amount-max-button.utils.js'
+
+describe('amount-max-button utils', () => {
+
+ describe('calcMaxAmount()', () => {
+ it('should calculate the correct amount when no selectedToken defined', () => {
+ assert.deepEqual(calcMaxAmount({
+ balance: 'ffffff',
+ gasTotal: 'ff',
+ selectedToken: false,
+ }), 'ffff00')
+ })
+
+ it('should calculate the correct amount when a selectedToken is defined', () => {
+ assert.deepEqual(calcMaxAmount({
+ selectedToken: {
+ decimals: 10,
+ },
+ tokenBalance: 100,
+ }), 'e8d4a51000')
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-amount-row/index.js b/ui/app/components/send/send-content/send-amount-row/index.js
new file mode 100644
index 000000000..abc6852fe
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/index.js
@@ -0,0 +1 @@
+export { default } from './send-amount-row.container'
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
new file mode 100644
index 000000000..c548a5695
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js
@@ -0,0 +1,123 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import SendRowWrapper from '../send-row-wrapper/'
+import AmountMaxButton from './amount-max-button/'
+import CurrencyDisplay from '../../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,
+ updateGasFeeError: PropTypes.func,
+ updateSendAmount: PropTypes.func,
+ updateSendAmountError: PropTypes.func,
+ updateGas: PropTypes.func,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ validateAmount (amount) {
+ const {
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ selectedToken,
+ tokenBalance,
+ updateGasFeeError,
+ updateSendAmountError,
+ } = this.props
+
+ updateSendAmountError({
+ amount,
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ selectedToken,
+ tokenBalance,
+ })
+
+ if (selectedToken) {
+ updateGasFeeError({
+ amount,
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ selectedToken,
+ tokenBalance,
+ })
+ }
+ }
+
+ updateAmount (amount) {
+ const { updateSendAmount, setMaxModeTo } = this.props
+
+ setMaxModeTo(false)
+ updateSendAmount(amount)
+ }
+
+ updateGas (amount) {
+ const { selectedToken, updateGas } = this.props
+
+ if (selectedToken) {
+ updateGas({ amount })
+ }
+ }
+
+ render () {
+ const {
+ amount,
+ amountConversionRate,
+ convertedCurrency,
+ gasTotal,
+ inError,
+ primaryCurrency,
+ selectedToken,
+ } = this.props
+
+ return (
+ <SendRowWrapper
+ label={`${this.context.t('amount')}:`}
+ showError={inError}
+ errorType={'amount'}
+ >
+ {!inError && gasTotal && <AmountMaxButton />}
+ <CurrencyDisplay
+ conversionRate={amountConversionRate}
+ convertedCurrency={convertedCurrency}
+ onBlur={newAmount => {
+ this.updateGas(newAmount)
+ this.updateAmount(newAmount)
+ }}
+ onChange={newAmount => this.validateAmount(newAmount)}
+ inError={inError}
+ primaryCurrency={primaryCurrency || 'ETH'}
+ selectedToken={selectedToken}
+ value={amount}
+ step="any"
+ />
+ </SendRowWrapper>
+ )
+ }
+
+}
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
new file mode 100644
index 000000000..3504d1b73
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js
@@ -0,0 +1,54 @@
+import { connect } from 'react-redux'
+import {
+ getAmountConversionRate,
+ getConversionRate,
+ getCurrentCurrency,
+ getGasTotal,
+ getPrimaryCurrency,
+ getSelectedToken,
+ getSendAmount,
+ getSendFromBalance,
+ getTokenBalance,
+} from '../../send.selectors'
+import {
+ sendAmountIsInError,
+} from './send-amount-row.selectors'
+import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils'
+import {
+ setMaxModeTo,
+ updateSendAmount,
+} from '../../../../actions'
+import {
+ updateSendErrors,
+} from '../../../../ducks/send.duck'
+import SendAmountRow from './send-amount-row.component'
+
+export default connect(mapStateToProps, mapDispatchToProps)(SendAmountRow)
+
+function mapStateToProps (state) {
+ return {
+ amount: getSendAmount(state),
+ amountConversionRate: getAmountConversionRate(state),
+ balance: getSendFromBalance(state),
+ conversionRate: getConversionRate(state),
+ convertedCurrency: getCurrentCurrency(state),
+ gasTotal: getGasTotal(state),
+ inError: sendAmountIsInError(state),
+ primaryCurrency: getPrimaryCurrency(state),
+ selectedToken: getSelectedToken(state),
+ tokenBalance: getTokenBalance(state),
+ }
+}
+
+function mapDispatchToProps (dispatch) {
+ return {
+ setMaxModeTo: bool => dispatch(setMaxModeTo(bool)),
+ updateSendAmount: newAmount => dispatch(updateSendAmount(newAmount)),
+ updateGasFeeError: (amountDataObject) => {
+ dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject)))
+ },
+ updateSendAmountError: (amountDataObject) => {
+ dispatch(updateSendErrors(getAmountErrorObject(amountDataObject)))
+ },
+ }
+}
diff --git a/ui/app/components/send/send-content/send-amount-row/send-amount-row.scss b/ui/app/components/send/send-content/send-amount-row/send-amount-row.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/send-amount-row.scss
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
new file mode 100644
index 000000000..fb08c7ed7
--- /dev/null
+++ 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-amount-row/tests/send-amount-row-component.test.js b/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-component.test.js
new file mode 100644
index 000000000..8425e076e
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-component.test.js
@@ -0,0 +1,202 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import SendAmountRow from '../send-amount-row.component.js'
+
+import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component'
+import AmountMaxButton from '../amount-max-button/amount-max-button.container'
+import CurrencyDisplay from '../../../currency-display'
+
+const propsMethodSpies = {
+ setMaxModeTo: sinon.spy(),
+ updateSendAmount: sinon.spy(),
+ updateSendAmountError: sinon.spy(),
+ updateGas: sinon.spy(),
+ updateGasFeeError: sinon.spy(),
+}
+
+sinon.spy(SendAmountRow.prototype, 'updateAmount')
+sinon.spy(SendAmountRow.prototype, 'validateAmount')
+sinon.spy(SendAmountRow.prototype, 'updateGas')
+
+describe('SendAmountRow Component', function () {
+ let wrapper
+ let instance
+
+ beforeEach(() => {
+ wrapper = shallow(<SendAmountRow
+ amount={'mockAmount'}
+ amountConversionRate={'mockAmountConversionRate'}
+ balance={'mockBalance'}
+ conversionRate={7}
+ convertedCurrency={'mockConvertedCurrency'}
+ gasTotal={'mockGasTotal'}
+ inError={false}
+ primaryCurrency={'mockPrimaryCurrency'}
+ selectedToken={ { address: 'mockTokenAddress' } }
+ setMaxModeTo={propsMethodSpies.setMaxModeTo}
+ tokenBalance={'mockTokenBalance'}
+ updateGasFeeError={propsMethodSpies.updateGasFeeError}
+ updateSendAmount={propsMethodSpies.updateSendAmount}
+ updateSendAmountError={propsMethodSpies.updateSendAmountError}
+ updateGas={propsMethodSpies.updateGas}
+ />, { context: { t: str => str + '_t' } })
+ instance = wrapper.instance()
+ })
+
+ afterEach(() => {
+ propsMethodSpies.setMaxModeTo.resetHistory()
+ propsMethodSpies.updateSendAmount.resetHistory()
+ propsMethodSpies.updateSendAmountError.resetHistory()
+ propsMethodSpies.updateGasFeeError.resetHistory()
+ SendAmountRow.prototype.validateAmount.resetHistory()
+ SendAmountRow.prototype.updateAmount.resetHistory()
+ })
+
+ describe('validateAmount', () => {
+
+ it('should call updateSendAmountError with the correct params', () => {
+ assert.equal(propsMethodSpies.updateSendAmountError.callCount, 0)
+ instance.validateAmount('someAmount')
+ assert.equal(propsMethodSpies.updateSendAmountError.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendAmountError.getCall(0).args,
+ [{
+ amount: 'someAmount',
+ amountConversionRate: 'mockAmountConversionRate',
+ balance: 'mockBalance',
+ conversionRate: 7,
+ gasTotal: 'mockGasTotal',
+ primaryCurrency: 'mockPrimaryCurrency',
+ selectedToken: { address: 'mockTokenAddress' },
+ tokenBalance: 'mockTokenBalance',
+ }]
+ )
+ })
+
+ it('should call updateGasFeeError if selectedToken is truthy', () => {
+ assert.equal(propsMethodSpies.updateGasFeeError.callCount, 0)
+ instance.validateAmount('someAmount')
+ assert.equal(propsMethodSpies.updateGasFeeError.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateGasFeeError.getCall(0).args,
+ [{
+ amount: 'someAmount',
+ amountConversionRate: 'mockAmountConversionRate',
+ balance: 'mockBalance',
+ conversionRate: 7,
+ gasTotal: 'mockGasTotal',
+ primaryCurrency: 'mockPrimaryCurrency',
+ selectedToken: { address: 'mockTokenAddress' },
+ tokenBalance: 'mockTokenBalance',
+ }]
+ )
+ })
+
+ it('should call not updateGasFeeError if selectedToken is falsey', () => {
+ wrapper.setProps({ selectedToken: null })
+ assert.equal(propsMethodSpies.updateGasFeeError.callCount, 0)
+ instance.validateAmount('someAmount')
+ assert.equal(propsMethodSpies.updateGasFeeError.callCount, 0)
+ })
+
+ })
+
+ describe('updateAmount', () => {
+
+ it('should call setMaxModeTo', () => {
+ assert.equal(propsMethodSpies.setMaxModeTo.callCount, 0)
+ instance.updateAmount('someAmount')
+ assert.equal(propsMethodSpies.setMaxModeTo.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.setMaxModeTo.getCall(0).args,
+ [false]
+ )
+ })
+
+ it('should call updateSendAmount', () => {
+ assert.equal(propsMethodSpies.updateSendAmount.callCount, 0)
+ instance.updateAmount('someAmount')
+ assert.equal(propsMethodSpies.updateSendAmount.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendAmount.getCall(0).args,
+ ['someAmount']
+ )
+ })
+
+ })
+
+ describe('render', () => {
+ it('should render a SendRowWrapper component', () => {
+ assert.equal(wrapper.find(SendRowWrapper).length, 1)
+ })
+
+ it('should pass the correct props to SendRowWrapper', () => {
+ const {
+ errorType,
+ label,
+ showError,
+ } = wrapper.find(SendRowWrapper).props()
+
+ assert.equal(errorType, 'amount')
+
+ assert.equal(label, 'amount_t:')
+
+ assert.equal(showError, false)
+ })
+
+ it('should render an AmountMaxButton as the first child of the SendRowWrapper', () => {
+ assert(wrapper.find(SendRowWrapper).childAt(0).is(AmountMaxButton))
+ })
+
+ it('should render a CurrencyDisplay as the second child of the SendRowWrapper', () => {
+ assert(wrapper.find(SendRowWrapper).childAt(1).is(CurrencyDisplay))
+ })
+
+ it('should render the CurrencyDisplay with the correct props', () => {
+ const {
+ conversionRate,
+ convertedCurrency,
+ onBlur,
+ onChange,
+ inError,
+ primaryCurrency,
+ selectedToken,
+ value,
+ } = wrapper.find(SendRowWrapper).childAt(1).props()
+ assert.equal(conversionRate, 'mockAmountConversionRate')
+ assert.equal(convertedCurrency, 'mockConvertedCurrency')
+ assert.equal(inError, false)
+ assert.equal(primaryCurrency, 'mockPrimaryCurrency')
+ assert.deepEqual(selectedToken, { address: 'mockTokenAddress' })
+ assert.equal(value, 'mockAmount')
+ assert.equal(SendAmountRow.prototype.updateGas.callCount, 0)
+ assert.equal(SendAmountRow.prototype.updateAmount.callCount, 0)
+ onBlur('mockNewAmount')
+ assert.equal(SendAmountRow.prototype.updateGas.callCount, 1)
+ assert.deepEqual(
+ SendAmountRow.prototype.updateGas.getCall(0).args,
+ ['mockNewAmount']
+ )
+ assert.equal(SendAmountRow.prototype.updateAmount.callCount, 1)
+ assert.deepEqual(
+ SendAmountRow.prototype.updateAmount.getCall(0).args,
+ ['mockNewAmount']
+ )
+ assert.equal(SendAmountRow.prototype.validateAmount.callCount, 0)
+ onChange('mockNewAmount')
+ assert.equal(SendAmountRow.prototype.validateAmount.callCount, 1)
+ assert.deepEqual(
+ SendAmountRow.prototype.validateAmount.getCall(0).args,
+ ['mockNewAmount']
+ )
+ })
+
+ it('should pass the default primaryCurrency to the CurrencyDisplay if primaryCurrency is falsy', () => {
+ wrapper.setProps({ primaryCurrency: null })
+ const { primaryCurrency } = wrapper.find(SendRowWrapper).childAt(1).props()
+ assert.equal(primaryCurrency, 'ETH')
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js b/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js
new file mode 100644
index 000000000..52e351aee
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-container.test.js
@@ -0,0 +1,125 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+const actionSpies = {
+ setMaxModeTo: sinon.spy(),
+ updateSendAmount: sinon.spy(),
+}
+const duckActionSpies = {
+ updateSendErrors: sinon.spy(),
+}
+
+proxyquire('../send-amount-row.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },
+ '../../send.selectors': {
+ getAmountConversionRate: (s) => `mockAmountConversionRate:${s}`,
+ getConversionRate: (s) => `mockConversionRate:${s}`,
+ getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`,
+ getGasTotal: (s) => `mockGasTotal:${s}`,
+ getPrimaryCurrency: (s) => `mockPrimaryCurrency:${s}`,
+ getSelectedToken: (s) => `mockSelectedToken:${s}`,
+ getSendAmount: (s) => `mockAmount:${s}`,
+ getSendFromBalance: (s) => `mockBalance:${s}`,
+ getTokenBalance: (s) => `mockTokenBalance:${s}`,
+ },
+ './send-amount-row.selectors': { sendAmountIsInError: (s) => `mockInError:${s}` },
+ '../../send.utils': {
+ getAmountErrorObject: (mockDataObject) => ({ ...mockDataObject, mockChange: true }),
+ getGasFeeErrorObject: (mockDataObject) => ({ ...mockDataObject, mockGasFeeErrorChange: true }),
+ },
+ '../../../../actions': actionSpies,
+ '../../../../ducks/send.duck': duckActionSpies,
+})
+
+describe('send-amount-row container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ amount: 'mockAmount:mockState',
+ amountConversionRate: 'mockAmountConversionRate:mockState',
+ balance: 'mockBalance:mockState',
+ conversionRate: 'mockConversionRate:mockState',
+ convertedCurrency: 'mockConvertedCurrency:mockState',
+ gasTotal: 'mockGasTotal:mockState',
+ inError: 'mockInError:mockState',
+ primaryCurrency: 'mockPrimaryCurrency:mockState',
+ selectedToken: 'mockSelectedToken:mockState',
+ tokenBalance: 'mockTokenBalance:mockState',
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ duckActionSpies.updateSendErrors.resetHistory()
+ })
+
+ describe('setMaxModeTo()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.setMaxModeTo('mockBool')
+ assert(dispatchSpy.calledOnce)
+ assert(actionSpies.setMaxModeTo.calledOnce)
+ assert.equal(
+ actionSpies.setMaxModeTo.getCall(0).args[0],
+ 'mockBool'
+ )
+ })
+ })
+
+ describe('updateSendAmount()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.updateSendAmount('mockAmount')
+ assert(dispatchSpy.calledOnce)
+ assert(actionSpies.updateSendAmount.calledOnce)
+ assert.equal(
+ actionSpies.updateSendAmount.getCall(0).args[0],
+ 'mockAmount'
+ )
+ })
+ })
+
+ describe('updateGasFeeError()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.updateGasFeeError({ some: 'data' })
+ assert(dispatchSpy.calledOnce)
+ assert(duckActionSpies.updateSendErrors.calledOnce)
+ assert.deepEqual(
+ duckActionSpies.updateSendErrors.getCall(0).args[0],
+ { some: 'data', mockGasFeeErrorChange: true }
+ )
+ })
+ })
+
+ describe('updateSendAmountError()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.updateSendAmountError({ some: 'data' })
+ assert(dispatchSpy.calledOnce)
+ assert(duckActionSpies.updateSendErrors.calledOnce)
+ assert.deepEqual(
+ duckActionSpies.updateSendErrors.getCall(0).args[0],
+ { some: 'data', mockChange: true }
+ )
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-selectors.test.js b/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-selectors.test.js
new file mode 100644
index 000000000..4672cb8a7
--- /dev/null
+++ b/ui/app/components/send/send-content/send-amount-row/tests/send-amount-row-selectors.test.js
@@ -0,0 +1,34 @@
+import assert from 'assert'
+import {
+ sendAmountIsInError,
+} from '../send-amount-row.selectors.js'
+
+describe('send-amount-row selectors', () => {
+
+ describe('sendAmountIsInError()', () => {
+ it('should return true if send.errors.amount is truthy', () => {
+ const state = {
+ send: {
+ errors: {
+ amount: 'abc',
+ },
+ },
+ }
+
+ assert.equal(sendAmountIsInError(state), true)
+ })
+
+ it('should return false if send.errors.amount is falsy', () => {
+ const state = {
+ send: {
+ errors: {
+ amount: null,
+ },
+ },
+ }
+
+ assert.equal(sendAmountIsInError(state), false)
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-content-README.md b/ui/app/components/send/send-content/send-content-README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-content-README.md
diff --git a/ui/app/components/send/send-content/send-content.component.js b/ui/app/components/send/send-content/send-content.component.js
new file mode 100644
index 000000000..adc114c0e
--- /dev/null
+++ b/ui/app/components/send/send-content/send-content.component.js
@@ -0,0 +1,28 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import PageContainerContent from '../../page-container/page-container-content.component'
+import SendAmountRow from './send-amount-row/'
+import SendFromRow from './send-from-row/'
+import SendGasRow from './send-gas-row/'
+import SendToRow from './send-to-row/'
+
+export default class SendContent extends Component {
+
+ static propTypes = {
+ updateGas: PropTypes.func,
+ };
+
+ render () {
+ return (
+ <PageContainerContent>
+ <div className="send-v2__form">
+ <SendFromRow />
+ <SendToRow updateGas={(updateData) => this.props.updateGas(updateData)} />
+ <SendAmountRow updateGas={(updateData) => this.props.updateGas(updateData)} />
+ <SendGasRow />
+ </div>
+ </PageContainerContent>
+ )
+ }
+
+}
diff --git a/ui/app/components/send/send-content/send-content.scss b/ui/app/components/send/send-content/send-content.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-content.scss
diff --git a/ui/app/components/send/send-content/send-dropdown-list/index.js b/ui/app/components/send/send-content/send-dropdown-list/index.js
new file mode 100644
index 000000000..04af6536c
--- /dev/null
+++ b/ui/app/components/send/send-content/send-dropdown-list/index.js
@@ -0,0 +1 @@
+export { default } from './send-dropdown-list.component'
diff --git a/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js b/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js
new file mode 100644
index 000000000..bedac1259
--- /dev/null
+++ b/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js
@@ -0,0 +1,52 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import AccountListItem from '../../account-list-item/'
+
+export default class SendDropdownList extends Component {
+
+ static propTypes = {
+ accounts: PropTypes.array,
+ closeDropdown: PropTypes.func,
+ onSelect: PropTypes.func,
+ activeAddress: PropTypes.string,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ getListItemIcon (accountAddress, activeAddress) {
+ return accountAddress === activeAddress
+ ? <i className={`fa fa-check fa-lg`} style={ { color: '#02c9b1' } }/>
+ : null
+ }
+
+ render () {
+ const {
+ accounts,
+ closeDropdown,
+ onSelect,
+ activeAddress,
+ } = 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.address, activeAddress)}
+ key={`send-dropdown-account-#${index}`}
+ />)}
+ </div>
+ </div>)
+ }
+
+}
diff --git a/ui/app/components/send/send-content/send-dropdown-list/tests/send-dropdown-list-component.test.js b/ui/app/components/send/send-content/send-dropdown-list/tests/send-dropdown-list-component.test.js
new file mode 100644
index 000000000..b92dd4dfe
--- /dev/null
+++ b/ui/app/components/send/send-content/send-dropdown-list/tests/send-dropdown-list-component.test.js
@@ -0,0 +1,105 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import SendDropdownList from '../send-dropdown-list.component.js'
+
+import AccountListItem from '../../../account-list-item/account-list-item.container'
+
+const propsMethodSpies = {
+ closeDropdown: sinon.spy(),
+ onSelect: sinon.spy(),
+}
+
+sinon.spy(SendDropdownList.prototype, 'getListItemIcon')
+
+describe('SendDropdownList Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendDropdownList
+ accounts={[
+ { address: 'mockAccount0' },
+ { address: 'mockAccount1' },
+ { address: 'mockAccount2' },
+ ]}
+ closeDropdown={propsMethodSpies.closeDropdown}
+ onSelect={propsMethodSpies.onSelect}
+ activeAddress={'mockAddress2'}
+ />, { context: { t: str => str + '_t' } })
+ })
+
+ afterEach(() => {
+ propsMethodSpies.closeDropdown.resetHistory()
+ propsMethodSpies.onSelect.resetHistory()
+ SendDropdownList.prototype.getListItemIcon.resetHistory()
+ })
+
+ describe('getListItemIcon', () => {
+ it('should return check icon if the passed addresses are the same', () => {
+ assert.deepEqual(
+ wrapper.instance().getListItemIcon('mockAccount0', 'mockAccount0'),
+ <i className={`fa fa-check fa-lg`} style={ { color: '#02c9b1' } }/>
+ )
+ })
+
+ it('should return null if the passed addresses are different', () => {
+ assert.equal(
+ wrapper.instance().getListItemIcon('mockAccount0', 'mockAccount1'),
+ null
+ )
+ })
+ })
+
+ describe('render', () => {
+ it('should render a single div with two children', () => {
+ assert(wrapper.is('div'))
+ assert.equal(wrapper.children().length, 2)
+ })
+
+ it('should render the children with the correct classes', () => {
+ assert(wrapper.childAt(0).hasClass('send-v2__from-dropdown__close-area'))
+ assert(wrapper.childAt(1).hasClass('send-v2__from-dropdown__list'))
+ })
+
+ it('should call closeDropdown onClick of the send-v2__from-dropdown__close-area', () => {
+ assert.equal(propsMethodSpies.closeDropdown.callCount, 0)
+ wrapper.childAt(0).props().onClick()
+ assert.equal(propsMethodSpies.closeDropdown.callCount, 1)
+ })
+
+ it('should render an AccountListItem for each item in accounts', () => {
+ assert.equal(wrapper.childAt(1).children().length, 3)
+ assert(wrapper.childAt(1).children().every(AccountListItem))
+ })
+
+ it('should pass the correct props to the AccountListItem', () => {
+ wrapper.childAt(1).children().forEach((accountListItem, index) => {
+ const {
+ account,
+ className,
+ handleClick,
+ } = accountListItem.props()
+ assert.deepEqual(account, { address: 'mockAccount' + index })
+ assert.equal(className, 'account-list-item__dropdown')
+ assert.equal(propsMethodSpies.onSelect.callCount, 0)
+ handleClick()
+ assert.equal(propsMethodSpies.onSelect.callCount, 1)
+ assert.deepEqual(propsMethodSpies.onSelect.getCall(0).args[0], { address: 'mockAccount' + index })
+ propsMethodSpies.onSelect.resetHistory()
+ propsMethodSpies.closeDropdown.resetHistory()
+ assert.equal(propsMethodSpies.closeDropdown.callCount, 0)
+ handleClick()
+ assert.equal(propsMethodSpies.closeDropdown.callCount, 1)
+ propsMethodSpies.onSelect.resetHistory()
+ propsMethodSpies.closeDropdown.resetHistory()
+ })
+ })
+
+ it('should call this.getListItemIcon for each AccountListItem', () => {
+ assert.equal(SendDropdownList.prototype.getListItemIcon.callCount, 3)
+ const getListItemIconCalls = SendDropdownList.prototype.getListItemIcon.getCalls()
+ assert(getListItemIconCalls.every(({ args }, index) => args[0] === 'mockAccount' + index))
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown-README.md b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown-README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown-README.md
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
new file mode 100644
index 000000000..4f43a9d61
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.component.js
@@ -0,0 +1,46 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import AccountListItem from '../../../account-list-item/'
+import SendDropdownList from '../../send-dropdown-list/'
+
+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,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ render () {
+ const {
+ accounts,
+ closeDropdown,
+ dropdownOpen,
+ openDropdown,
+ selectedAccount,
+ onSelect,
+ } = this.props
+
+ return <div className="send-v2__from-dropdown">
+ <AccountListItem
+ account={selectedAccount}
+ handleClick={openDropdown}
+ icon={<i className={`fa fa-caret-down fa-lg`} style={ { color: '#dedede' } }/>}
+ />
+ {dropdownOpen && <SendDropdownList
+ accounts={accounts}
+ closeDropdown={closeDropdown}
+ onSelect={onSelect}
+ activeAddress={selectedAccount.address}
+ />}
+ </div>
+ }
+
+}
diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.scss b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.scss
diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js b/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js
new file mode 100644
index 000000000..2314ef4e3
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js
@@ -0,0 +1 @@
+export { default } from './from-dropdown.component'
diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js b/ui/app/components/send/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js
new file mode 100644
index 000000000..84fcb281e
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js
@@ -0,0 +1,88 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import FromDropdown from '../from-dropdown.component.js'
+
+import AccountListItem from '../../../../account-list-item/account-list-item.container'
+import SendDropdownList from '../../../send-dropdown-list/send-dropdown-list.component'
+
+const propsMethodSpies = {
+ closeDropdown: sinon.spy(),
+ openDropdown: sinon.spy(),
+ onSelect: sinon.spy(),
+}
+
+describe('FromDropdown Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<FromDropdown
+ accounts={['mockAccount']}
+ closeDropdown={propsMethodSpies.closeDropdown}
+ dropdownOpen={false}
+ onSelect={propsMethodSpies.onSelect}
+ openDropdown={propsMethodSpies.openDropdown}
+ selectedAccount={ { address: 'mockAddress' } }
+ />, { context: { t: str => str + '_t' } })
+ })
+
+ afterEach(() => {
+ propsMethodSpies.closeDropdown.resetHistory()
+ propsMethodSpies.openDropdown.resetHistory()
+ propsMethodSpies.onSelect.resetHistory()
+ })
+
+ describe('render', () => {
+ it('should render a div with a .send-v2__from-dropdown class', () => {
+ assert.equal(wrapper.find('.send-v2__from-dropdown').length, 1)
+ })
+
+ it('should render an AccountListItem as the first child of the .send-v2__from-dropdown div', () => {
+ assert(wrapper.find('.send-v2__from-dropdown').childAt(0).is(AccountListItem))
+ })
+
+ it('should pass the correct props to AccountListItem', () => {
+ const {
+ account,
+ handleClick,
+ icon,
+ } = wrapper.find('.send-v2__from-dropdown').childAt(0).props()
+ assert.deepEqual(account, { address: 'mockAddress' })
+ assert.deepEqual(
+ icon,
+ <i className={`fa fa-caret-down fa-lg`} style={ { color: '#dedede' } }/>
+ )
+ assert.equal(propsMethodSpies.openDropdown.callCount, 0)
+ handleClick()
+ assert.equal(propsMethodSpies.openDropdown.callCount, 1)
+ })
+
+ it('should not render a SendDropdownList when dropdownOpen is false', () => {
+ assert.equal(wrapper.find(SendDropdownList).length, 0)
+ })
+
+ it('should render a SendDropdownList when dropdownOpen is true', () => {
+ wrapper.setProps({ dropdownOpen: true })
+ assert(wrapper.find(SendDropdownList).length, 1)
+ })
+
+ it('should pass the correct props to the SendDropdownList]', () => {
+ wrapper.setProps({ dropdownOpen: true })
+ const {
+ accounts,
+ closeDropdown,
+ onSelect,
+ activeAddress,
+ } = wrapper.find(SendDropdownList).props()
+ assert.deepEqual(accounts, ['mockAccount'])
+ assert.equal(activeAddress, 'mockAddress')
+ assert.equal(propsMethodSpies.closeDropdown.callCount, 0)
+ closeDropdown()
+ assert.equal(propsMethodSpies.closeDropdown.callCount, 1)
+ assert.equal(propsMethodSpies.onSelect.callCount, 0)
+ onSelect()
+ assert.equal(propsMethodSpies.onSelect.callCount, 1)
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-from-row/index.js b/ui/app/components/send/send-content/send-from-row/index.js
new file mode 100644
index 000000000..0a79726b2
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/index.js
@@ -0,0 +1 @@
+export { default } from './send-from-row.container'
diff --git a/ui/app/components/send/send-content/send-from-row/send-from-row-README.md b/ui/app/components/send/send-content/send-from-row/send-from-row-README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/send-from-row-README.md
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
new file mode 100644
index 000000000..3e0e0de22
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/send-from-row.component.js
@@ -0,0 +1,63 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import SendRowWrapper from '../send-row-wrapper/'
+import FromDropdown from './from-dropdown/'
+
+export default class SendFromRow extends Component {
+
+ static propTypes = {
+ closeFromDropdown: PropTypes.func,
+ conversionRate: PropTypes.number,
+ from: PropTypes.object,
+ fromAccounts: PropTypes.array,
+ fromDropdownOpen: PropTypes.bool,
+ openFromDropdown: PropTypes.func,
+ tokenContract: PropTypes.object,
+ updateSendFrom: PropTypes.func,
+ setSendTokenBalance: PropTypes.func,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ async handleFromChange (newFrom) {
+ const {
+ updateSendFrom,
+ tokenContract,
+ setSendTokenBalance,
+ } = this.props
+
+ if (tokenContract) {
+ const usersToken = await tokenContract.balanceOf(newFrom.address)
+ setSendTokenBalance(usersToken)
+ }
+ updateSendFrom(newFrom)
+ }
+
+ render () {
+ const {
+ closeFromDropdown,
+ conversionRate,
+ from,
+ fromAccounts,
+ fromDropdownOpen,
+ openFromDropdown,
+ } = this.props
+
+ return (
+ <SendRowWrapper label={`${this.context.t('from')}:`}>
+ <FromDropdown
+ accounts={fromAccounts}
+ 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
new file mode 100644
index 000000000..33cb63b43
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/send-from-row.container.js
@@ -0,0 +1,46 @@
+import { connect } from 'react-redux'
+import {
+ accountsWithSendEtherInfoSelector,
+ getConversionRate,
+ getSelectedTokenContract,
+ getSendFromObject,
+} from '../../send.selectors.js'
+import {
+ getFromDropdownOpen,
+} from './send-from-row.selectors.js'
+import { calcTokenBalance } from '../../send.utils.js'
+import {
+ updateSendFrom,
+ setSendTokenBalance,
+} from '../../../../actions'
+import {
+ closeFromDropdown,
+ openFromDropdown,
+} from '../../../../ducks/send.duck'
+import SendFromRow from './send-from-row.component'
+
+export default connect(mapStateToProps, mapDispatchToProps)(SendFromRow)
+
+function mapStateToProps (state) {
+ return {
+ conversionRate: getConversionRate(state),
+ from: getSendFromObject(state),
+ fromAccounts: accountsWithSendEtherInfoSelector(state),
+ fromDropdownOpen: getFromDropdownOpen(state),
+ tokenContract: getSelectedTokenContract(state),
+ }
+}
+
+function mapDispatchToProps (dispatch) {
+ return {
+ closeFromDropdown: () => dispatch(closeFromDropdown()),
+ openFromDropdown: () => dispatch(openFromDropdown()),
+ updateSendFrom: newFrom => dispatch(updateSendFrom(newFrom)),
+ setSendTokenBalance: (usersToken, selectedToken) => {
+ if (!usersToken) return
+
+ const tokenBalance = calcTokenBalance({ usersToken, selectedToken })
+ dispatch(setSendTokenBalance(tokenBalance))
+ },
+ }
+}
diff --git a/ui/app/components/send/send-content/send-from-row/send-from-row.selectors.js b/ui/app/components/send/send-content/send-from-row/send-from-row.selectors.js
new file mode 100644
index 000000000..03ef4806b
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/send-from-row.selectors.js
@@ -0,0 +1,9 @@
+const selectors = {
+ getFromDropdownOpen,
+}
+
+module.exports = selectors
+
+function getFromDropdownOpen (state) {
+ return state.send.fromDropdownOpen
+}
diff --git a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-component.test.js b/ui/app/components/send/send-content/send-from-row/tests/send-from-row-component.test.js
new file mode 100644
index 000000000..9ba8d1739
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/tests/send-from-row-component.test.js
@@ -0,0 +1,121 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import SendFromRow from '../send-from-row.component.js'
+
+import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component'
+import FromDropdown from '../from-dropdown/from-dropdown.component'
+
+const propsMethodSpies = {
+ closeFromDropdown: sinon.spy(),
+ openFromDropdown: sinon.spy(),
+ updateSendFrom: sinon.spy(),
+ setSendTokenBalance: sinon.spy(),
+}
+
+sinon.spy(SendFromRow.prototype, 'handleFromChange')
+
+describe('SendFromRow Component', function () {
+ let wrapper
+ let instance
+
+ beforeEach(() => {
+ wrapper = shallow(<SendFromRow
+ closeFromDropdown={propsMethodSpies.closeFromDropdown}
+ conversionRate={15}
+ from={ { address: 'mockAddress' } }
+ fromAccounts={['mockAccount']}
+ fromDropdownOpen={false}
+ openFromDropdown={propsMethodSpies.openFromDropdown}
+ setSendTokenBalance={propsMethodSpies.setSendTokenBalance}
+ tokenContract={null}
+ updateSendFrom={propsMethodSpies.updateSendFrom}
+ />, { context: { t: str => str + '_t' } })
+ instance = wrapper.instance()
+ })
+
+ afterEach(() => {
+ propsMethodSpies.closeFromDropdown.resetHistory()
+ propsMethodSpies.openFromDropdown.resetHistory()
+ propsMethodSpies.updateSendFrom.resetHistory()
+ propsMethodSpies.setSendTokenBalance.resetHistory()
+ SendFromRow.prototype.handleFromChange.resetHistory()
+ })
+
+ describe('handleFromChange', () => {
+
+ it('should call updateSendFrom', () => {
+ assert.equal(propsMethodSpies.updateSendFrom.callCount, 0)
+ instance.handleFromChange('mockFrom')
+ assert.equal(propsMethodSpies.updateSendFrom.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendFrom.getCall(0).args,
+ ['mockFrom']
+ )
+ })
+
+ it('should call tokenContract.balanceOf and setSendTokenBalance if tokenContract is defined', async () => {
+ wrapper.setProps({
+ tokenContract: {
+ balanceOf: () => new Promise((resolve) => resolve('mockUsersToken')),
+ },
+ })
+ assert.equal(propsMethodSpies.setSendTokenBalance.callCount, 0)
+ await instance.handleFromChange('mockFrom')
+ assert.equal(propsMethodSpies.setSendTokenBalance.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.setSendTokenBalance.getCall(0).args,
+ ['mockUsersToken']
+ )
+ })
+
+ })
+
+ describe('render', () => {
+ it('should render a SendRowWrapper component', () => {
+ assert.equal(wrapper.find(SendRowWrapper).length, 1)
+ })
+
+ it('should pass the correct props to SendRowWrapper', () => {
+ const {
+ label,
+ } = wrapper.find(SendRowWrapper).props()
+
+ assert.equal(label, 'from_t:')
+ })
+
+ it('should render an FromDropdown as a child of the SendRowWrapper', () => {
+ assert(wrapper.find(SendRowWrapper).childAt(0).is(FromDropdown))
+ })
+
+ it('should render the FromDropdown with the correct props', () => {
+ const {
+ accounts,
+ closeDropdown,
+ conversionRate,
+ dropdownOpen,
+ onSelect,
+ openDropdown,
+ selectedAccount,
+ } = wrapper.find(SendRowWrapper).childAt(0).props()
+ assert.deepEqual(accounts, ['mockAccount'])
+ assert.equal(dropdownOpen, false)
+ assert.equal(conversionRate, 15)
+ assert.deepEqual(selectedAccount, { address: 'mockAddress' })
+ assert.equal(propsMethodSpies.closeFromDropdown.callCount, 0)
+ closeDropdown()
+ assert.equal(propsMethodSpies.closeFromDropdown.callCount, 1)
+ assert.equal(propsMethodSpies.openFromDropdown.callCount, 0)
+ openDropdown()
+ assert.equal(propsMethodSpies.openFromDropdown.callCount, 1)
+ assert.equal(SendFromRow.prototype.handleFromChange.callCount, 0)
+ onSelect('mockNewFrom')
+ assert.equal(SendFromRow.prototype.handleFromChange.callCount, 1)
+ assert.deepEqual(
+ SendFromRow.prototype.handleFromChange.getCall(0).args,
+ ['mockNewFrom']
+ )
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-container.test.js b/ui/app/components/send/send-content/send-from-row/tests/send-from-row-container.test.js
new file mode 100644
index 000000000..e080b2fe3
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/tests/send-from-row-container.test.js
@@ -0,0 +1,110 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+const actionSpies = {
+ updateSendFrom: sinon.spy(),
+ setSendTokenBalance: sinon.spy(),
+}
+const duckActionSpies = {
+ closeFromDropdown: sinon.spy(),
+ openFromDropdown: sinon.spy(),
+}
+
+proxyquire('../send-from-row.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },
+ '../../send.selectors.js': {
+ accountsWithSendEtherInfoSelector: (s) => `mockFromAccounts:${s}`,
+ getConversionRate: (s) => `mockConversionRate:${s}`,
+ getSelectedTokenContract: (s) => `mockTokenContract:${s}`,
+ getSendFromObject: (s) => `mockFrom:${s}`,
+ },
+ './send-from-row.selectors.js': { getFromDropdownOpen: (s) => `mockFromDropdownOpen:${s}` },
+ '../../send.utils.js': { calcTokenBalance: ({ usersToken, selectedToken }) => usersToken + selectedToken },
+ '../../../../actions': actionSpies,
+ '../../../../ducks/send.duck': duckActionSpies,
+})
+
+describe('send-from-row container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ conversionRate: 'mockConversionRate:mockState',
+ from: 'mockFrom:mockState',
+ fromAccounts: 'mockFromAccounts:mockState',
+ fromDropdownOpen: 'mockFromDropdownOpen:mockState',
+ tokenContract: 'mockTokenContract:mockState',
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ describe('closeFromDropdown()', () => {
+ it('should dispatch a closeFromDropdown action', () => {
+ mapDispatchToPropsObject.closeFromDropdown()
+ assert(dispatchSpy.calledOnce)
+ assert(duckActionSpies.closeFromDropdown.calledOnce)
+ assert.equal(
+ duckActionSpies.closeFromDropdown.getCall(0).args[0],
+ undefined
+ )
+ })
+ })
+
+ describe('openFromDropdown()', () => {
+ it('should dispatch a openFromDropdown action', () => {
+ mapDispatchToPropsObject.openFromDropdown()
+ assert(dispatchSpy.calledOnce)
+ assert(duckActionSpies.openFromDropdown.calledOnce)
+ assert.equal(
+ duckActionSpies.openFromDropdown.getCall(0).args[0],
+ undefined
+ )
+ })
+ })
+
+ describe('updateSendFrom()', () => {
+ it('should dispatch an updateSendFrom action', () => {
+ mapDispatchToPropsObject.updateSendFrom('mockFrom')
+ assert(dispatchSpy.calledOnce)
+ assert.equal(
+ actionSpies.updateSendFrom.getCall(0).args[0],
+ 'mockFrom'
+ )
+ })
+ })
+
+ describe('setSendTokenBalance()', () => {
+ it('should dispatch an setSendTokenBalance action', () => {
+ mapDispatchToPropsObject.setSendTokenBalance('mockUsersToken', 'mockSelectedToken')
+ assert(dispatchSpy.calledOnce)
+ assert.equal(
+ actionSpies.setSendTokenBalance.getCall(0).args[0],
+ 'mockUsersTokenmockSelectedToken'
+ )
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-from-row/tests/send-from-row-selectors.test.js b/ui/app/components/send/send-content/send-from-row/tests/send-from-row-selectors.test.js
new file mode 100644
index 000000000..ecb57bbc3
--- /dev/null
+++ b/ui/app/components/send/send-content/send-from-row/tests/send-from-row-selectors.test.js
@@ -0,0 +1,20 @@
+import assert from 'assert'
+import {
+ getFromDropdownOpen,
+} from '../send-from-row.selectors.js'
+
+describe('send-from-row selectors', () => {
+
+ describe('getFromDropdownOpen()', () => {
+ it('should get send.fromDropdownOpen', () => {
+ const state = {
+ send: {
+ fromDropdownOpen: null,
+ },
+ }
+
+ assert.equal(getFromDropdownOpen(state), null)
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-gas-row/README.md b/ui/app/components/send/send-content/send-gas-row/README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/README.md
diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js
new file mode 100644
index 000000000..bb9a94428
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js
@@ -0,0 +1,61 @@
+import React, {Component} from 'react'
+import PropTypes from 'prop-types'
+import CurrencyDisplay from '../../../../send/currency-display'
+
+
+export default class GasFeeDisplay extends Component {
+
+ static propTypes = {
+ conversionRate: PropTypes.number,
+ primaryCurrency: PropTypes.string,
+ convertedCurrency: PropTypes.string,
+ gasLoadingError: PropTypes.bool,
+ gasTotal: PropTypes.string,
+ onClick: PropTypes.func,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ render () {
+ const {
+ conversionRate,
+ gasTotal,
+ onClick,
+ primaryCurrency = 'ETH',
+ convertedCurrency,
+ gasLoadingError,
+ } = this.props
+
+ return (
+ <div className="send-v2__gas-fee-display">
+ {gasTotal
+ ? <CurrencyDisplay
+ primaryCurrency={primaryCurrency}
+ convertedCurrency={convertedCurrency}
+ value={gasTotal}
+ conversionRate={conversionRate}
+ gasLoadingError={gasLoadingError}
+ convertedPrefix={'$'}
+ readOnly
+ />
+ : gasLoadingError
+ ? <div className="currency-display.currency-display--message">
+ {this.context.t('setGasPrice')}
+ </div>
+ : <div className="currency-display">
+ {this.context.t('loading')}
+ </div>
+ }
+ <button
+ className="sliders-icon-container"
+ onClick={onClick}
+ disabled={!gasTotal && !gasLoadingError}
+ >
+ <i className="fa fa-sliders sliders-icon" />
+ </button>
+ </div>
+ )
+ }
+}
diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js
new file mode 100644
index 000000000..dba0edb7b
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js
@@ -0,0 +1 @@
+export { default } from './gas-fee-display.component'
diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js
new file mode 100644
index 000000000..7cbe8d0df
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js
@@ -0,0 +1,55 @@
+import React from 'react'
+import assert from 'assert'
+import {shallow} from 'enzyme'
+import GasFeeDisplay from '../gas-fee-display.component'
+import CurrencyDisplay from '../../../../../send/currency-display'
+import sinon from 'sinon'
+
+
+const propsMethodSpies = {
+ showCustomizeGasModal: sinon.spy(),
+}
+
+describe('SendGasRow Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<GasFeeDisplay
+ conversionRate={20}
+ gasTotal={'mockGasTotal'}
+ onClick={propsMethodSpies.showCustomizeGasModal}
+ primaryCurrency={'mockPrimaryCurrency'}
+ convertedCurrency={'mockConvertedCurrency'}
+ />, {context: {t: str => str + '_t'}})
+ })
+
+ afterEach(() => {
+ propsMethodSpies.showCustomizeGasModal.resetHistory()
+ })
+
+ describe('render', () => {
+ it('should render a CurrencyDisplay component', () => {
+ assert.equal(wrapper.find(CurrencyDisplay).length, 1)
+ })
+
+ it('should render the CurrencyDisplay with the correct props', () => {
+ const {
+ conversionRate,
+ convertedCurrency,
+ value,
+ } = wrapper.find(CurrencyDisplay).props()
+ assert.equal(conversionRate, 20)
+ assert.equal(convertedCurrency, 'mockConvertedCurrency')
+ assert.equal(value, 'mockGasTotal')
+ })
+
+ it('should render the Button with the correct props', () => {
+ const {
+ onClick,
+ } = wrapper.find('button').props()
+ assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 0)
+ onClick()
+ assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 1)
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-gas-row/index.js b/ui/app/components/send/send-content/send-gas-row/index.js
new file mode 100644
index 000000000..3c7ff1d5f
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/index.js
@@ -0,0 +1 @@
+export { default } from './send-gas-row.container'
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
new file mode 100644
index 000000000..91b58cfd0
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js
@@ -0,0 +1,48 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import SendRowWrapper from '../send-row-wrapper/'
+import GasFeeDisplay from './gas-fee-display/gas-fee-display.component'
+
+export default class SendGasRow extends Component {
+
+ static propTypes = {
+ conversionRate: PropTypes.number,
+ convertedCurrency: PropTypes.string,
+ gasFeeError: PropTypes.bool,
+ gasLoadingError: PropTypes.bool,
+ gasTotal: PropTypes.string,
+ showCustomizeGasModal: PropTypes.func,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ render () {
+ const {
+ conversionRate,
+ convertedCurrency,
+ gasLoadingError,
+ gasTotal,
+ gasFeeError,
+ showCustomizeGasModal,
+ } = this.props
+
+ return (
+ <SendRowWrapper
+ label={`${this.context.t('gasFee')}:`}
+ showError={gasFeeError}
+ errorType={'gasFee'}
+ >
+ <GasFeeDisplay
+ conversionRate={conversionRate}
+ convertedCurrency={convertedCurrency}
+ gasLoadingError={gasLoadingError}
+ gasTotal={gasTotal}
+ onClick={() => showCustomizeGasModal()}
+ />
+ </SendRowWrapper>
+ )
+ }
+
+}
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
new file mode 100644
index 000000000..8f8e3e4dd
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux'
+import {
+ getConversionRate,
+ getCurrentCurrency,
+ getGasTotal,
+} from '../../send.selectors.js'
+import { getGasLoadingError, gasFeeIsInError } 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: getCurrentCurrency(state),
+ gasTotal: getGasTotal(state),
+ gasFeeError: gasFeeIsInError(state),
+ gasLoadingError: getGasLoadingError(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.scss b/ui/app/components/send/send-content/send-gas-row/send-gas-row.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.scss
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
new file mode 100644
index 000000000..96f6293c2
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js
@@ -0,0 +1,14 @@
+const selectors = {
+ gasFeeIsInError,
+ getGasLoadingError,
+}
+
+module.exports = selectors
+
+function getGasLoadingError (state) {
+ return state.send.errors.gasLoading
+}
+
+function gasFeeIsInError (state) {
+ return Boolean(state.send.errors.gasFee)
+}
diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js
new file mode 100644
index 000000000..54a92bd2d
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js
@@ -0,0 +1,70 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import SendGasRow from '../send-gas-row.component.js'
+
+import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component'
+import GasFeeDisplay from '../gas-fee-display/gas-fee-display.component'
+
+const propsMethodSpies = {
+ showCustomizeGasModal: sinon.spy(),
+}
+
+describe('SendGasRow Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendGasRow
+ conversionRate={20}
+ convertedCurrency={'mockConvertedCurrency'}
+ gasFeeError={'mockGasFeeError'}
+ gasLoadingError={false}
+ gasTotal={'mockGasTotal'}
+ showCustomizeGasModal={propsMethodSpies.showCustomizeGasModal}
+ />, { context: { t: str => str + '_t' } })
+ })
+
+ afterEach(() => {
+ propsMethodSpies.showCustomizeGasModal.resetHistory()
+ })
+
+ describe('render', () => {
+ it('should render a SendRowWrapper component', () => {
+ assert.equal(wrapper.find(SendRowWrapper).length, 1)
+ })
+
+ it('should pass the correct props to SendRowWrapper', () => {
+ const {
+ label,
+ showError,
+ errorType,
+ } = wrapper.find(SendRowWrapper).props()
+
+ assert.equal(label, 'gasFee_t:')
+ assert.equal(showError, 'mockGasFeeError')
+ assert.equal(errorType, 'gasFee')
+ })
+
+ it('should render a GasFeeDisplay as a child of the SendRowWrapper', () => {
+ assert(wrapper.find(SendRowWrapper).childAt(0).is(GasFeeDisplay))
+ })
+
+ it('should render the GasFeeDisplay with the correct props', () => {
+ const {
+ conversionRate,
+ convertedCurrency,
+ gasLoadingError,
+ gasTotal,
+ onClick,
+ } = wrapper.find(SendRowWrapper).childAt(0).props()
+ assert.equal(conversionRate, 20)
+ assert.equal(convertedCurrency, 'mockConvertedCurrency')
+ assert.equal(gasLoadingError, false)
+ assert.equal(gasTotal, 'mockGasTotal')
+ assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 0)
+ onClick()
+ assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 1)
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js
new file mode 100644
index 000000000..2ce062505
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-container.test.js
@@ -0,0 +1,70 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+const actionSpies = {
+ showModal: sinon.spy(),
+}
+
+proxyquire('../send-gas-row.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },
+ '../../send.selectors.js': {
+ getConversionRate: (s) => `mockConversionRate:${s}`,
+ getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`,
+ getGasTotal: (s) => `mockGasTotal:${s}`,
+ },
+ './send-gas-row.selectors.js': {
+ getGasLoadingError: (s) => `mockGasLoadingError:${s}`,
+ gasFeeIsInError: (s) => `mockGasFeeError:${s}`,
+ },
+ '../../../../actions': actionSpies,
+})
+
+describe('send-gas-row container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ conversionRate: 'mockConversionRate:mockState',
+ convertedCurrency: 'mockConvertedCurrency:mockState',
+ gasTotal: 'mockGasTotal:mockState',
+ gasFeeError: 'mockGasFeeError:mockState',
+ gasLoadingError: 'mockGasLoadingError:mockState',
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ describe('showCustomizeGasModal()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.showCustomizeGasModal()
+ assert(dispatchSpy.calledOnce)
+ assert.deepEqual(
+ actionSpies.showModal.getCall(0).args[0],
+ { name: 'CUSTOMIZE_GAS' }
+ )
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js
new file mode 100644
index 000000000..d46dd9d8b
--- /dev/null
+++ b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js
@@ -0,0 +1,49 @@
+import assert from 'assert'
+import {
+ gasFeeIsInError,
+ getGasLoadingError,
+} from '../send-gas-row.selectors.js'
+
+describe('send-gas-row selectors', () => {
+
+ describe('getGasLoadingError()', () => {
+ it('should return send.errors.gasLoading', () => {
+ const state = {
+ send: {
+ errors: {
+ gasLoading: 'abc',
+ },
+ },
+ }
+
+ assert.equal(getGasLoadingError(state), 'abc')
+ })
+ })
+
+ describe('gasFeeIsInError()', () => {
+ it('should return true if send.errors.gasFee is truthy', () => {
+ const state = {
+ send: {
+ errors: {
+ gasFee: 'def',
+ },
+ },
+ }
+
+ assert.equal(gasFeeIsInError(state), true)
+ })
+
+ it('should return false send.errors.gasFee is falsely', () => {
+ const state = {
+ send: {
+ errors: {
+ gasFee: null,
+ },
+ },
+ }
+
+ assert.equal(gasFeeIsInError(state), false)
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-row-wrapper/index.js b/ui/app/components/send/send-content/send-row-wrapper/index.js
new file mode 100644
index 000000000..d17545dcc
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/index.js
@@ -0,0 +1 @@
+export { default } from './send-row-wrapper.component'
diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js
new file mode 100644
index 000000000..c00617f83
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js
@@ -0,0 +1 @@
+export { default } from './send-row-error-message.container'
diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md
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
new file mode 100644
index 000000000..61bc7bab7
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js
@@ -0,0 +1,27 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+
+export default class SendRowErrorMessage extends Component {
+
+ static propTypes = {
+ errors: PropTypes.object,
+ errorType: PropTypes.string,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ render () {
+ const { errors, errorType } = this.props
+
+ const errorMessage = errors[errorType]
+
+ return (
+ errorMessage
+ ? <div className="send-v2__error">{this.context.t(errorMessage)}</div>
+ : null
+ )
+ }
+
+}
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
new file mode 100644
index 000000000..59622047f
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js
@@ -0,0 +1,12 @@
+import { connect } from 'react-redux'
+import { getSendErrors } from '../../../send.selectors'
+import SendRowErrorMessage from './send-row-error-message.component'
+
+export default connect(mapStateToProps)(SendRowErrorMessage)
+
+function mapStateToProps (state, ownProps) {
+ return {
+ errors: getSendErrors(state),
+ errorType: ownProps.errorType,
+ }
+}
diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.scss b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.scss
diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-component.test.js b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-component.test.js
new file mode 100644
index 000000000..2304a43d2
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-component.test.js
@@ -0,0 +1,28 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import SendRowErrorMessage from '../send-row-error-message.component.js'
+
+describe('SendRowErrorMessage Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendRowErrorMessage
+ errors={{ error1: 'abc', error2: 'def' }}
+ errorType={'error3'}
+ />, { context: { t: str => str + '_t' } })
+ })
+
+ describe('render', () => {
+ it('should render null if the passed errors do not contain an error of errorType', () => {
+ assert.equal(wrapper.find('.send-v2__error').length, 0)
+ assert.equal(wrapper.html(), null)
+ })
+
+ it('should render an error message if the passed errors contain an error of errorType', () => {
+ wrapper.setProps({ errors: { error1: 'abc', error2: 'def', error3: 'xyz' } })
+ assert.equal(wrapper.find('.send-v2__error').length, 1)
+ assert.equal(wrapper.find('.send-v2__error').text(), 'xyz_t')
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js
new file mode 100644
index 000000000..eecff165d
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js
@@ -0,0 +1,28 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+
+let mapStateToProps
+
+proxyquire('../send-row-error-message.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ return () => ({})
+ },
+ },
+ '../../../send.selectors': { getSendErrors: (s) => `mockErrors:${s}` },
+})
+
+describe('send-row-error-message container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState', { errorType: 'someType' }), {
+ errors: 'mockErrors:mockState',
+ errorType: 'someType' })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper-README.md b/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper-README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper-README.md
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
new file mode 100644
index 000000000..b7528a15f
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js
@@ -0,0 +1,43 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import SendRowErrorMessage from './send-row-error-message/'
+
+export default class SendRowWrapper extends Component {
+
+ static propTypes = {
+ children: PropTypes.node,
+ errorType: PropTypes.string,
+ label: PropTypes.string,
+ showError: PropTypes.bool,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ render () {
+ const {
+ 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}/>}
+ {customLabelContent}
+ </div>
+ <div className="send-v2__form-field">
+ {formField}
+ </div>
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.scss b/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.scss
diff --git a/ui/app/components/send/send-content/send-row-wrapper/tests/send-row-wrapper-component.test.js b/ui/app/components/send/send-content/send-row-wrapper/tests/send-row-wrapper-component.test.js
new file mode 100644
index 000000000..30280e1d0
--- /dev/null
+++ b/ui/app/components/send/send-content/send-row-wrapper/tests/send-row-wrapper-component.test.js
@@ -0,0 +1,79 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import SendRowWrapper from '../send-row-wrapper.component.js'
+
+import SendRowErrorMessage from '../send-row-error-message/send-row-error-message.container'
+
+describe('SendContent Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendRowWrapper
+ errorType={'mockErrorType'}
+ label={'mockLabel'}
+ showError={false}
+ >
+ <span>Mock Form Field</span>
+ </SendRowWrapper>)
+ })
+
+ describe('render', () => {
+ it('should render a div with a send-v2__form-row class', () => {
+ assert.equal(wrapper.find('div.send-v2__form-row').length, 1)
+ })
+
+ it('should render two children of the root div, with send-v2_form label and field classes', () => {
+ assert.equal(wrapper.find('.send-v2__form-row > .send-v2__form-label').length, 1)
+ assert.equal(wrapper.find('.send-v2__form-row > .send-v2__form-field').length, 1)
+ })
+
+ it('should render the label as a child of the send-v2__form-label', () => {
+ assert.equal(wrapper.find('.send-v2__form-row > .send-v2__form-label').childAt(0).text(), 'mockLabel')
+ })
+
+ it('should render its first child as a child of the send-v2__form-field', () => {
+ assert.equal(wrapper.find('.send-v2__form-row > .send-v2__form-field').childAt(0).text(), 'Mock Form Field')
+ })
+
+ it('should not render a SendRowErrorMessage if showError is false', () => {
+ assert.equal(wrapper.find(SendRowErrorMessage).length, 0)
+ })
+
+ it('should render a SendRowErrorMessage with and errorType props if showError is true', () => {
+ wrapper.setProps({showError: true})
+ assert.equal(wrapper.find(SendRowErrorMessage).length, 1)
+
+ const expectedSendRowErrorMessage = wrapper.find('.send-v2__form-row > .send-v2__form-label').childAt(1)
+ assert(expectedSendRowErrorMessage.is(SendRowErrorMessage))
+ assert.deepEqual(
+ expectedSendRowErrorMessage.props(),
+ { errorType: 'mockErrorType' }
+ )
+ })
+
+ it('should render its second child as a child of the send-v2__form-field, if it has two children', () => {
+ wrapper = shallow(<SendRowWrapper
+ errorType={'mockErrorType'}
+ label={'mockLabel'}
+ showError={false}
+ >
+ <span>Mock Custom Label Content</span>
+ <span>Mock Form Field</span>
+ </SendRowWrapper>)
+ assert.equal(wrapper.find('.send-v2__form-row > .send-v2__form-field').childAt(0).text(), 'Mock Form Field')
+ })
+
+ it('should render its first child as the last child of the send-v2__form-label, if it has two children', () => {
+ wrapper = shallow(<SendRowWrapper
+ errorType={'mockErrorType'}
+ label={'mockLabel'}
+ showError={false}
+ >
+ <span>Mock Custom Label Content</span>
+ <span>Mock Form Field</span>
+ </SendRowWrapper>)
+ assert.equal(wrapper.find('.send-v2__form-row > .send-v2__form-label').childAt(1).text(), 'Mock Custom Label Content')
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-to-row/index.js b/ui/app/components/send/send-content/send-to-row/index.js
new file mode 100644
index 000000000..121f15148
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/index.js
@@ -0,0 +1 @@
+export { default } from './send-to-row.container'
diff --git a/ui/app/components/send/send-content/send-to-row/send-to-row-README.md b/ui/app/components/send/send-content/send-to-row/send-to-row-README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/send-to-row-README.md
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
new file mode 100644
index 000000000..892ad5d67
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/send-to-row.component.js
@@ -0,0 +1,69 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import SendRowWrapper from '../send-row-wrapper/'
+import EnsInput from '../../../ens-input'
+import { getToErrorObject } from './send-to-row.utils.js'
+
+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,
+ updateGas: PropTypes.func,
+ updateSendTo: PropTypes.func,
+ updateSendToError: PropTypes.func,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ handleToChange (to, nickname = '', toError) {
+ const { updateSendTo, updateSendToError, updateGas } = this.props
+ const toErrorObject = getToErrorObject(to, toError)
+ updateSendTo(to, nickname)
+ updateSendToError(toErrorObject)
+ if (toErrorObject.to === null) {
+ updateGas({ to })
+ }
+ }
+
+ render () {
+ const {
+ closeToDropdown,
+ inError,
+ network,
+ openToDropdown,
+ to,
+ toAccounts,
+ toDropdownOpen,
+ } = this.props
+
+ return (
+ <SendRowWrapper
+ errorType={'to'}
+ label={`${this.context.t('to')}`}
+ showError={inError}
+ >
+ <EnsInput
+ accounts={toAccounts}
+ closeDropdown={() => closeToDropdown()}
+ dropdownOpen={toDropdownOpen}
+ inError={inError}
+ name={'address'}
+ network={network}
+ onChange={({ toAddress, nickname, toError }) => this.handleToChange(toAddress, nickname, toError)}
+ 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
new file mode 100644
index 000000000..1c9c9d518
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/send-to-row.container.js
@@ -0,0 +1,42 @@
+import { connect } from 'react-redux'
+import {
+ getCurrentNetwork,
+ getSendTo,
+ getSendToAccounts,
+} from '../../send.selectors.js'
+import {
+ getToDropdownOpen,
+ sendToIsInError,
+} from './send-to-row.selectors.js'
+import {
+ updateSendTo,
+} from '../../../../actions'
+import {
+ updateSendErrors,
+ openToDropdown,
+ closeToDropdown,
+} from '../../../../ducks/send.duck'
+import SendToRow from './send-to-row.component'
+
+export default connect(mapStateToProps, mapDispatchToProps)(SendToRow)
+
+function mapStateToProps (state) {
+ return {
+ inError: sendToIsInError(state),
+ network: getCurrentNetwork(state),
+ to: getSendTo(state),
+ toAccounts: getSendToAccounts(state),
+ toDropdownOpen: getToDropdownOpen(state),
+ }
+}
+
+function mapDispatchToProps (dispatch) {
+ return {
+ closeToDropdown: () => dispatch(closeToDropdown()),
+ openToDropdown: () => dispatch(openToDropdown()),
+ updateSendTo: (to, nickname) => dispatch(updateSendTo(to, nickname)),
+ updateSendToError: (toErrorObject) => {
+ dispatch(updateSendErrors(toErrorObject))
+ },
+ }
+}
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
new file mode 100644
index 000000000..8919014be
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/send-to-row.selectors.js
@@ -0,0 +1,14 @@
+const selectors = {
+ getToDropdownOpen,
+ sendToIsInError,
+}
+
+module.exports = selectors
+
+function getToDropdownOpen (state) {
+ return state.send.toDropdownOpen
+}
+
+function sendToIsInError (state) {
+ 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
new file mode 100644
index 000000000..6b90a9f09
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js
@@ -0,0 +1,19 @@
+const {
+ REQUIRED_ERROR,
+ INVALID_RECIPIENT_ADDRESS_ERROR,
+} = require('../../send.constants')
+const { isValidAddress } = require('../../../../util')
+
+function getToErrorObject (to, toError = null) {
+ if (!to) {
+ toError = REQUIRED_ERROR
+ } else if (!isValidAddress(to) && !toError) {
+ toError = INVALID_RECIPIENT_ADDRESS_ERROR
+ }
+
+ return { to: toError }
+}
+
+module.exports = {
+ getToErrorObject,
+}
diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js
new file mode 100644
index 000000000..781371004
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-component.test.js
@@ -0,0 +1,149 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import proxyquire from 'proxyquire'
+
+const SendToRow = proxyquire('../send-to-row.component.js', {
+ './send-to-row.utils.js': {
+ getToErrorObject: (to, toError) => ({
+ to: to === false ? null : `mockToErrorObject:${to}${toError}`,
+ }),
+ },
+}).default
+
+import SendRowWrapper from '../../send-row-wrapper/send-row-wrapper.component'
+import EnsInput from '../../../../ens-input'
+
+const propsMethodSpies = {
+ closeToDropdown: sinon.spy(),
+ openToDropdown: sinon.spy(),
+ updateGas: sinon.spy(),
+ updateSendTo: sinon.spy(),
+ updateSendToError: sinon.spy(),
+}
+
+sinon.spy(SendToRow.prototype, 'handleToChange')
+
+describe('SendToRow Component', function () {
+ let wrapper
+ let instance
+
+ beforeEach(() => {
+ wrapper = shallow(<SendToRow
+ closeToDropdown={propsMethodSpies.closeToDropdown}
+ inError={false}
+ network={'mockNetwork'}
+ openToDropdown={propsMethodSpies.openToDropdown}
+ to={'mockTo'}
+ toAccounts={['mockAccount']}
+ toDropdownOpen={false}
+ updateGas={propsMethodSpies.updateGas}
+ updateSendTo={propsMethodSpies.updateSendTo}
+ updateSendToError={propsMethodSpies.updateSendToError}
+ />, { context: { t: str => str + '_t' } })
+ instance = wrapper.instance()
+ })
+
+ afterEach(() => {
+ propsMethodSpies.closeToDropdown.resetHistory()
+ propsMethodSpies.openToDropdown.resetHistory()
+ propsMethodSpies.updateSendTo.resetHistory()
+ propsMethodSpies.updateSendToError.resetHistory()
+ SendToRow.prototype.handleToChange.resetHistory()
+ })
+
+ describe('handleToChange', () => {
+
+ it('should call updateSendTo', () => {
+ assert.equal(propsMethodSpies.updateSendTo.callCount, 0)
+ instance.handleToChange('mockTo2', 'mockNickname')
+ assert.equal(propsMethodSpies.updateSendTo.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendTo.getCall(0).args,
+ ['mockTo2', 'mockNickname']
+ )
+ })
+
+ it('should call updateSendToError', () => {
+ assert.equal(propsMethodSpies.updateSendToError.callCount, 0)
+ instance.handleToChange('mockTo2', '', 'mockToError')
+ assert.equal(propsMethodSpies.updateSendToError.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendToError.getCall(0).args,
+ [{ to: 'mockToErrorObject:mockTo2mockToError' }]
+ )
+ })
+
+ it('should not call updateGas if there is a to error', () => {
+ assert.equal(propsMethodSpies.updateGas.callCount, 0)
+ instance.handleToChange('mockTo2')
+ assert.equal(propsMethodSpies.updateGas.callCount, 0)
+ })
+
+ it('should call updateGas if there is no to error', () => {
+ assert.equal(propsMethodSpies.updateGas.callCount, 0)
+ instance.handleToChange(false)
+ assert.equal(propsMethodSpies.updateGas.callCount, 1)
+ })
+ })
+
+ describe('render', () => {
+ it('should render a SendRowWrapper component', () => {
+ assert.equal(wrapper.find(SendRowWrapper).length, 1)
+ })
+
+ it('should pass the correct props to SendRowWrapper', () => {
+ const {
+ errorType,
+ label,
+ showError,
+ } = wrapper.find(SendRowWrapper).props()
+
+ assert.equal(errorType, 'to')
+
+ assert.equal(label, 'to_t')
+
+ assert.equal(showError, false)
+ })
+
+ it('should render an EnsInput as a child of the SendRowWrapper', () => {
+ assert(wrapper.find(SendRowWrapper).childAt(0).is(EnsInput))
+ })
+
+ it('should render the EnsInput with the correct props', () => {
+ const {
+ accounts,
+ closeDropdown,
+ dropdownOpen,
+ inError,
+ name,
+ network,
+ onChange,
+ openDropdown,
+ placeholder,
+ to,
+ } = wrapper.find(SendRowWrapper).childAt(0).props()
+ assert.deepEqual(accounts, ['mockAccount'])
+ assert.equal(dropdownOpen, false)
+ assert.equal(inError, false)
+ assert.equal(name, 'address')
+ assert.equal(network, 'mockNetwork')
+ assert.equal(placeholder, 'recipientAddress_t')
+ assert.equal(to, 'mockTo')
+ assert.equal(propsMethodSpies.closeToDropdown.callCount, 0)
+ closeDropdown()
+ assert.equal(propsMethodSpies.closeToDropdown.callCount, 1)
+ assert.equal(propsMethodSpies.openToDropdown.callCount, 0)
+ openDropdown()
+ assert.equal(propsMethodSpies.openToDropdown.callCount, 1)
+ assert.equal(SendToRow.prototype.handleToChange.callCount, 0)
+ onChange({ toAddress: 'mockNewTo', nickname: 'mockNewNickname', toError: 'mockToError' })
+ assert.equal(SendToRow.prototype.handleToChange.callCount, 1)
+ assert.deepEqual(
+ SendToRow.prototype.handleToChange.getCall(0).args,
+ ['mockNewTo', 'mockNewNickname', 'mockToError']
+ )
+ })
+ })
+})
diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js
new file mode 100644
index 000000000..92355c00a
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js
@@ -0,0 +1,113 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+const actionSpies = {
+ updateSendTo: sinon.spy(),
+}
+const duckActionSpies = {
+ closeToDropdown: sinon.spy(),
+ openToDropdown: sinon.spy(),
+ updateSendErrors: sinon.spy(),
+}
+
+proxyquire('../send-to-row.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },
+ '../../send.selectors.js': {
+ getCurrentNetwork: (s) => `mockNetwork:${s}`,
+ getSendTo: (s) => `mockTo:${s}`,
+ getSendToAccounts: (s) => `mockToAccounts:${s}`,
+ },
+ './send-to-row.selectors.js': {
+ getToDropdownOpen: (s) => `mockToDropdownOpen:${s}`,
+ sendToIsInError: (s) => `mockInError:${s}`,
+ },
+ '../../../../actions': actionSpies,
+ '../../../../ducks/send.duck': duckActionSpies,
+})
+
+describe('send-to-row container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ inError: 'mockInError:mockState',
+ network: 'mockNetwork:mockState',
+ to: 'mockTo:mockState',
+ toAccounts: 'mockToAccounts:mockState',
+ toDropdownOpen: 'mockToDropdownOpen:mockState',
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ describe('closeToDropdown()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.closeToDropdown()
+ assert(dispatchSpy.calledOnce)
+ assert(duckActionSpies.closeToDropdown.calledOnce)
+ assert.equal(
+ duckActionSpies.closeToDropdown.getCall(0).args[0],
+ undefined
+ )
+ })
+ })
+
+ describe('openToDropdown()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.openToDropdown()
+ assert(dispatchSpy.calledOnce)
+ assert(duckActionSpies.openToDropdown.calledOnce)
+ assert.equal(
+ duckActionSpies.openToDropdown.getCall(0).args[0],
+ undefined
+ )
+ })
+ })
+
+ describe('updateSendTo()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.updateSendTo('mockTo', 'mockNickname')
+ assert(dispatchSpy.calledOnce)
+ assert(actionSpies.updateSendTo.calledOnce)
+ assert.deepEqual(
+ actionSpies.updateSendTo.getCall(0).args,
+ ['mockTo', 'mockNickname']
+ )
+ })
+ })
+
+ describe('updateSendToError()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.updateSendToError('mockToErrorObject')
+ assert(dispatchSpy.calledOnce)
+ assert(duckActionSpies.updateSendErrors.calledOnce)
+ assert.equal(
+ duckActionSpies.updateSendErrors.getCall(0).args[0],
+ 'mockToErrorObject'
+ )
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-selectors.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-selectors.test.js
new file mode 100644
index 000000000..122ad3265
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-selectors.test.js
@@ -0,0 +1,47 @@
+import assert from 'assert'
+import {
+ getToDropdownOpen,
+ sendToIsInError,
+} from '../send-to-row.selectors.js'
+
+describe('send-to-row selectors', () => {
+
+ describe('getToDropdownOpen()', () => {
+ it('should return send.getToDropdownOpen', () => {
+ const state = {
+ send: {
+ toDropdownOpen: false,
+ },
+ }
+
+ assert.equal(getToDropdownOpen(state), false)
+ })
+ })
+
+ describe('sendToIsInError()', () => {
+ it('should return true if send.errors.to is truthy', () => {
+ const state = {
+ send: {
+ errors: {
+ to: 'abc',
+ },
+ },
+ }
+
+ assert.equal(sendToIsInError(state), true)
+ })
+
+ it('should return false if send.errors.to is falsy', () => {
+ const state = {
+ send: {
+ errors: {
+ to: null,
+ },
+ },
+ }
+
+ assert.equal(sendToIsInError(state), false)
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js
new file mode 100644
index 000000000..4d2447c32
--- /dev/null
+++ b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-utils.test.js
@@ -0,0 +1,51 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+import {
+ REQUIRED_ERROR,
+ INVALID_RECIPIENT_ADDRESS_ERROR,
+} from '../../../send.constants'
+
+const stubs = {
+ isValidAddress: sinon.stub().callsFake(to => Boolean(to.match(/^[0xabcdef123456798]+$/))),
+}
+
+const toRowUtils = proxyquire('../send-to-row.utils.js', {
+ '../../../../util': {
+ isValidAddress: stubs.isValidAddress,
+ },
+})
+const {
+ getToErrorObject,
+} = toRowUtils
+
+describe('send-to-row utils', () => {
+
+ describe('getToErrorObject()', () => {
+ it('should return a required error if to is falsy', () => {
+ assert.deepEqual(getToErrorObject(null), {
+ to: REQUIRED_ERROR,
+ })
+ })
+
+ it('should return an invalid recipient error if to is truthy but invalid', () => {
+ assert.deepEqual(getToErrorObject('mockInvalidTo'), {
+ to: INVALID_RECIPIENT_ADDRESS_ERROR,
+ })
+ })
+
+ it('should return null if to is truthy and valid', () => {
+ assert.deepEqual(getToErrorObject('0xabc123'), {
+ to: null,
+ })
+ })
+
+ it('should return the passed error if to is truthy but invalid if to is truthy and valid', () => {
+ assert.deepEqual(getToErrorObject('invalid #$ 345878', 'someExplicitError'), {
+ to: 'someExplicitError',
+ })
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-content/tests/send-content-component.test.js b/ui/app/components/send/send-content/tests/send-content-component.test.js
new file mode 100644
index 000000000..d5bb6693c
--- /dev/null
+++ b/ui/app/components/send/send-content/tests/send-content-component.test.js
@@ -0,0 +1,38 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import SendContent from '../send-content.component.js'
+
+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'
+
+describe('SendContent Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendContent />)
+ })
+
+ describe('render', () => {
+ it('should render a PageContainerContent component', () => {
+ assert.equal(wrapper.find(PageContainerContent).length, 1)
+ })
+
+ it('should render a div with a .send-v2__form class as a child of PageContainerContent', () => {
+ const PageContainerContentChild = wrapper.find(PageContainerContent).children()
+ PageContainerContentChild.is('div')
+ PageContainerContentChild.is('.send-v2__form')
+ })
+
+ it('should render the correct row components as grandchildren of the PageContainerContent component', () => {
+ const PageContainerContentChild = wrapper.find(PageContainerContent).children()
+ assert(PageContainerContentChild.childAt(0).is(SendFromRow))
+ assert(PageContainerContentChild.childAt(1).is(SendToRow))
+ assert(PageContainerContentChild.childAt(2).is(SendAmountRow))
+ assert(PageContainerContentChild.childAt(3).is(SendGasRow))
+ })
+ })
+})
diff --git a/ui/app/components/send/send-footer/README.md b/ui/app/components/send/send-footer/README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-footer/README.md
diff --git a/ui/app/components/send/send-footer/index.js b/ui/app/components/send/send-footer/index.js
new file mode 100644
index 000000000..58e91d622
--- /dev/null
+++ b/ui/app/components/send/send-footer/index.js
@@ -0,0 +1 @@
+export { default } from './send-footer.container'
diff --git a/ui/app/components/send/send-footer/send-footer.component.js b/ui/app/components/send/send-footer/send-footer.component.js
new file mode 100644
index 000000000..2085f1dce
--- /dev/null
+++ b/ui/app/components/send/send-footer/send-footer.component.js
@@ -0,0 +1,101 @@
+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,
+ inError: PropTypes.bool,
+ selectedToken: PropTypes.object,
+ sign: PropTypes.func,
+ to: PropTypes.string,
+ toAccounts: PropTypes.array,
+ tokenBalance: PropTypes.string,
+ unapprovedTxs: PropTypes.object,
+ update: PropTypes.func,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ onCancel () {
+ this.props.clearSend()
+ this.props.history.push(DEFAULT_ROUTE)
+ }
+
+ onSubmit (event) {
+ event.preventDefault()
+ const {
+ addToAddressBookIfNew,
+ amount,
+ editingTransactionId,
+ from: {address: from},
+ gasLimit: gas,
+ gasPrice,
+ selectedToken,
+ sign,
+ to,
+ unapprovedTxs,
+ // updateTx,
+ update,
+ toAccounts,
+ history,
+ } = this.props
+
+ // Should not be needed because submit should be disabled if there are errors.
+ // const noErrors = !amountError && toError === null
+
+ // if (!noErrors) {
+ // return
+ // }
+
+ // TODO: add nickname functionality
+ addToAddressBookIfNew(to, toAccounts)
+
+ const promise = editingTransactionId
+ ? update({
+ amount,
+ editingTransactionId,
+ from,
+ gas,
+ gasPrice,
+ selectedToken,
+ to,
+ unapprovedTxs,
+ })
+ : sign({ selectedToken, to, amount, from, gas, gasPrice })
+
+ Promise.resolve(promise)
+ .then(() => history.push(CONFIRM_TRANSACTION_ROUTE))
+ }
+
+ formShouldBeDisabled () {
+ const { inError, selectedToken, tokenBalance, gasTotal, to } = this.props
+ const missingTokenBalance = selectedToken && !tokenBalance
+ return inError || !gasTotal || missingTokenBalance || !to
+ }
+
+ render () {
+ return (
+ <PageContainerFooter
+ onCancel={() => this.onCancel()}
+ onSubmit={e => this.onSubmit(e)}
+ disabled={this.formShouldBeDisabled()}
+ />
+ )
+ }
+
+}
diff --git a/ui/app/components/send/send-footer/send-footer.container.js b/ui/app/components/send/send-footer/send-footer.container.js
new file mode 100644
index 000000000..0af6fcfa1
--- /dev/null
+++ b/ui/app/components/send/send-footer/send-footer.container.js
@@ -0,0 +1,100 @@
+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,
+} from './send-footer.utils'
+
+export default connect(mapStateToProps, mapDispatchToProps)(SendFooter)
+
+function mapStateToProps (state) {
+ return {
+ amount: getSendAmount(state),
+ editingTransactionId: getSendEditingTransactionId(state),
+ from: getSendFromObject(state),
+ gasLimit: getGasLimit(state),
+ gasPrice: getGasPrice(state),
+ gasTotal: getGasTotal(state),
+ inError: isSendFormInError(state),
+ selectedToken: getSelectedToken(state),
+ to: getSendTo(state),
+ toAccounts: getSendToAccounts(state),
+ tokenBalance: getTokenBalance(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,
+ })
+
+ return 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.scss b/ui/app/components/send/send-footer/send-footer.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-footer/send-footer.scss
diff --git a/ui/app/components/send/send-footer/send-footer.selectors.js b/ui/app/components/send/send-footer/send-footer.selectors.js
new file mode 100644
index 000000000..e20addfdc
--- /dev/null
+++ b/ui/app/components/send/send-footer/send-footer.selectors.js
@@ -0,0 +1,11 @@
+const { getSendErrors } = require('../send.selectors')
+
+const selectors = {
+ isSendFormInError,
+}
+
+module.exports = selectors
+
+function isSendFormInError (state) {
+ return Object.values(getSendErrors(state)).some(n => n)
+}
diff --git a/ui/app/components/send/send-footer/send-footer.utils.js b/ui/app/components/send/send-footer/send-footer.utils.js
new file mode 100644
index 000000000..875e7d948
--- /dev/null
+++ b/ui/app/components/send/send-footer/send-footer.utils.js
@@ -0,0 +1,81 @@
+const ethAbi = require('ethereumjs-abi')
+const ethUtil = require('ethereumjs-util')
+const { TOKEN_TRANSFER_FUNCTION_SIGNATURE } = require('../send.constants')
+
+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
+ }
+ }
+
+ return editingTx
+}
+
+function addressIsNew (toAccounts, newAddress) {
+ return !toAccounts.find(({ address }) => newAddress === address)
+}
+
+module.exports = {
+ addressIsNew,
+ constructTxParams,
+ constructUpdatedTx,
+ addHexPrefixToObjectValues,
+}
diff --git a/ui/app/components/send/send-footer/tests/send-footer-component.test.js b/ui/app/components/send/send-footer/tests/send-footer-component.test.js
new file mode 100644
index 000000000..4b2cd327d
--- /dev/null
+++ b/ui/app/components/send/send-footer/tests/send-footer-component.test.js
@@ -0,0 +1,230 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } from '../../../../routes'
+import SendFooter from '../send-footer.component.js'
+
+import PageContainerFooter from '../../../page-container/page-container-footer'
+
+const propsMethodSpies = {
+ addToAddressBookIfNew: sinon.spy(),
+ clearSend: sinon.spy(),
+ sign: sinon.spy(),
+ update: sinon.spy(),
+}
+const historySpies = {
+ push: sinon.spy(),
+}
+const MOCK_EVENT = { preventDefault: () => {} }
+
+sinon.spy(SendFooter.prototype, 'onCancel')
+sinon.spy(SendFooter.prototype, 'onSubmit')
+
+describe('SendFooter Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendFooter
+ addToAddressBookIfNew={propsMethodSpies.addToAddressBookIfNew}
+ amount={'mockAmount'}
+ clearSend={propsMethodSpies.clearSend}
+ disabled={true}
+ editingTransactionId={'mockEditingTransactionId'}
+ errors={{}}
+ from={ { address: 'mockAddress', balance: 'mockBalance' } }
+ gasLimit={'mockGasLimit'}
+ gasPrice={'mockGasPrice'}
+ gasTotal={'mockGasTotal'}
+ history={historySpies}
+ inError={false}
+ selectedToken={{ mockProp: 'mockSelectedTokenProp' }}
+ sign={propsMethodSpies.sign}
+ to={'mockTo'}
+ toAccounts={['mockAccount']}
+ tokenBalance={'mockTokenBalance'}
+ unapprovedTxs={['mockTx']}
+ update={propsMethodSpies.update}
+ />, { context: { t: str => str } })
+ })
+
+ afterEach(() => {
+ propsMethodSpies.clearSend.resetHistory()
+ propsMethodSpies.addToAddressBookIfNew.resetHistory()
+ propsMethodSpies.clearSend.resetHistory()
+ propsMethodSpies.sign.resetHistory()
+ propsMethodSpies.update.resetHistory()
+ historySpies.push.resetHistory()
+ SendFooter.prototype.onCancel.resetHistory()
+ SendFooter.prototype.onSubmit.resetHistory()
+ })
+
+ describe('onCancel', () => {
+ it('should call clearSend', () => {
+ assert.equal(propsMethodSpies.clearSend.callCount, 0)
+ wrapper.instance().onCancel()
+ assert.equal(propsMethodSpies.clearSend.callCount, 1)
+ })
+
+ it('should call history.push', () => {
+ assert.equal(historySpies.push.callCount, 0)
+ wrapper.instance().onCancel()
+ assert.equal(historySpies.push.callCount, 1)
+ assert.equal(historySpies.push.getCall(0).args[0], DEFAULT_ROUTE)
+ })
+ })
+
+
+ describe('formShouldBeDisabled()', () => {
+ const config = {
+ 'should return true if inError is truthy': {
+ inError: true,
+ expectedResult: true,
+ },
+ 'should return true if gasTotal is falsy': {
+ inError: false,
+ gasTotal: false,
+ expectedResult: true,
+ },
+ 'should return true if to is truthy': {
+ to: '0xsomevalidAddress',
+ inError: false,
+ gasTotal: false,
+ expectedResult: true,
+ },
+ 'should return true if selectedToken is truthy and tokenBalance is falsy': {
+ selectedToken: true,
+ tokenBalance: null,
+ expectedResult: true,
+ },
+ 'should return false if inError is false and all other params are truthy': {
+ inError: false,
+ gasTotal: '0x123',
+ selectedToken: true,
+ tokenBalance: 123,
+ expectedResult: false,
+ },
+ }
+ Object.entries(config).map(([description, obj]) => {
+ it(description, () => {
+ wrapper.setProps(obj)
+ assert.equal(wrapper.instance().formShouldBeDisabled(), obj.expectedResult)
+ })
+ })
+ })
+
+ describe('onSubmit', () => {
+ it('should call addToAddressBookIfNew with the correct params', () => {
+ wrapper.instance().onSubmit(MOCK_EVENT)
+ assert(propsMethodSpies.addToAddressBookIfNew.calledOnce)
+ assert.deepEqual(
+ propsMethodSpies.addToAddressBookIfNew.getCall(0).args,
+ ['mockTo', ['mockAccount']]
+ )
+ })
+
+ it('should call props.update if editingTransactionId is truthy', () => {
+ wrapper.instance().onSubmit(MOCK_EVENT)
+ assert(propsMethodSpies.update.calledOnce)
+ assert.deepEqual(
+ propsMethodSpies.update.getCall(0).args[0],
+ {
+ amount: 'mockAmount',
+ editingTransactionId: 'mockEditingTransactionId',
+ from: 'mockAddress',
+ gas: 'mockGasLimit',
+ gasPrice: 'mockGasPrice',
+ selectedToken: { mockProp: 'mockSelectedTokenProp' },
+ to: 'mockTo',
+ unapprovedTxs: ['mockTx'],
+ }
+ )
+ })
+
+ it('should not call props.sign if editingTransactionId is truthy', () => {
+ assert.equal(propsMethodSpies.sign.callCount, 0)
+ })
+
+ it('should call props.sign if editingTransactionId is falsy', () => {
+ wrapper.setProps({ editingTransactionId: null })
+ wrapper.instance().onSubmit(MOCK_EVENT)
+ assert(propsMethodSpies.sign.calledOnce)
+ assert.deepEqual(
+ propsMethodSpies.sign.getCall(0).args[0],
+ {
+ amount: 'mockAmount',
+ from: 'mockAddress',
+ gas: 'mockGasLimit',
+ gasPrice: 'mockGasPrice',
+ selectedToken: { mockProp: 'mockSelectedTokenProp' },
+ to: 'mockTo',
+ }
+ )
+ })
+
+ it('should not call props.update if editingTransactionId is falsy', () => {
+ assert.equal(propsMethodSpies.update.callCount, 0)
+ })
+
+ it('should call history.push', done => {
+ Promise.resolve(wrapper.instance().onSubmit(MOCK_EVENT))
+ .then(() => {
+ assert.equal(historySpies.push.callCount, 1)
+ assert.equal(historySpies.push.getCall(0).args[0], CONFIRM_TRANSACTION_ROUTE)
+ done()
+ })
+ })
+ })
+
+ describe('render', () => {
+ beforeEach(() => {
+ sinon.stub(SendFooter.prototype, 'formShouldBeDisabled').returns('formShouldBeDisabledReturn')
+ wrapper = shallow(<SendFooter
+ addToAddressBookIfNew={propsMethodSpies.addToAddressBookIfNew}
+ amount={'mockAmount'}
+ clearSend={propsMethodSpies.clearSend}
+ disabled={true}
+ editingTransactionId={'mockEditingTransactionId'}
+ errors={{}}
+ from={ { address: 'mockAddress', balance: 'mockBalance' } }
+ gasLimit={'mockGasLimit'}
+ gasPrice={'mockGasPrice'}
+ gasTotal={'mockGasTotal'}
+ history={historySpies}
+ inError={false}
+ selectedToken={{ mockProp: 'mockSelectedTokenProp' }}
+ sign={propsMethodSpies.sign}
+ to={'mockTo'}
+ toAccounts={['mockAccount']}
+ tokenBalance={'mockTokenBalance'}
+ unapprovedTxs={['mockTx']}
+ update={propsMethodSpies.update}
+ />, { context: { t: str => str } })
+ })
+
+ afterEach(() => {
+ SendFooter.prototype.formShouldBeDisabled.restore()
+ })
+
+ it('should render a PageContainerFooter component', () => {
+ assert.equal(wrapper.find(PageContainerFooter).length, 1)
+ })
+
+ it('should pass the correct props to PageContainerFooter', () => {
+ const {
+ onCancel,
+ onSubmit,
+ disabled,
+ } = wrapper.find(PageContainerFooter).props()
+ assert.equal(disabled, 'formShouldBeDisabledReturn')
+
+ assert.equal(SendFooter.prototype.onSubmit.callCount, 0)
+ onSubmit(MOCK_EVENT)
+ assert.equal(SendFooter.prototype.onSubmit.callCount, 1)
+
+ assert.equal(SendFooter.prototype.onCancel.callCount, 0)
+ onCancel()
+ assert.equal(SendFooter.prototype.onCancel.callCount, 1)
+ })
+ })
+})
diff --git a/ui/app/components/send/send-footer/tests/send-footer-container.test.js b/ui/app/components/send/send-footer/tests/send-footer-container.test.js
new file mode 100644
index 000000000..39d6a7686
--- /dev/null
+++ b/ui/app/components/send/send-footer/tests/send-footer-container.test.js
@@ -0,0 +1,191 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+const actionSpies = {
+ addToAddressBook: sinon.spy(),
+ clearSend: sinon.spy(),
+ signTokenTx: sinon.spy(),
+ signTx: sinon.spy(),
+ updateTransaction: sinon.spy(),
+}
+const utilsStubs = {
+ addressIsNew: sinon.stub().returns(true),
+ constructTxParams: sinon.stub().returns('mockConstructedTxParams'),
+ constructUpdatedTx: sinon.stub().returns('mockConstructedUpdatedTxParams'),
+}
+
+proxyquire('../send-footer.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },
+ '../../../actions': actionSpies,
+ '../send.selectors': {
+ getGasLimit: (s) => `mockGasLimit:${s}`,
+ getGasPrice: (s) => `mockGasPrice:${s}`,
+ getGasTotal: (s) => `mockGasTotal:${s}`,
+ getSelectedToken: (s) => `mockSelectedToken:${s}`,
+ getSendAmount: (s) => `mockAmount:${s}`,
+ getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`,
+ getSendFromObject: (s) => `mockFromObject:${s}`,
+ getSendTo: (s) => `mockTo:${s}`,
+ getSendToAccounts: (s) => `mockToAccounts:${s}`,
+ getTokenBalance: (s) => `mockTokenBalance:${s}`,
+ getUnapprovedTxs: (s) => `mockUnapprovedTxs:${s}`,
+ },
+ './send-footer.selectors': { isSendFormInError: (s) => `mockInError:${s}` },
+ './send-footer.utils': utilsStubs,
+})
+
+describe('send-footer container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ amount: 'mockAmount:mockState',
+ selectedToken: 'mockSelectedToken:mockState',
+ editingTransactionId: 'mockEditingTransactionId:mockState',
+ from: 'mockFromObject:mockState',
+ gasLimit: 'mockGasLimit:mockState',
+ gasPrice: 'mockGasPrice:mockState',
+ gasTotal: 'mockGasTotal:mockState',
+ inError: 'mockInError:mockState',
+ to: 'mockTo:mockState',
+ toAccounts: 'mockToAccounts:mockState',
+ tokenBalance: 'mockTokenBalance:mockState',
+ unapprovedTxs: 'mockUnapprovedTxs:mockState',
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ describe('clearSend()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.clearSend()
+ assert(dispatchSpy.calledOnce)
+ assert(actionSpies.clearSend.calledOnce)
+ })
+ })
+
+ describe('sign()', () => {
+ it('should dispatch a signTokenTx action if selectedToken is defined', () => {
+ mapDispatchToPropsObject.sign({
+ selectedToken: {
+ address: '0xabc',
+ },
+ to: 'mockTo',
+ amount: 'mockAmount',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ })
+ assert(dispatchSpy.calledOnce)
+ assert.deepEqual(
+ utilsStubs.constructTxParams.getCall(0).args[0],
+ {
+ selectedToken: {
+ address: '0xabc',
+ },
+ to: 'mockTo',
+ amount: 'mockAmount',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ }
+ )
+ assert.deepEqual(
+ actionSpies.signTokenTx.getCall(0).args,
+ [ '0xabc', 'mockTo', 'mockAmount', 'mockConstructedTxParams' ]
+ )
+ })
+
+ it('should dispatch a sign action if selectedToken is not defined', () => {
+ utilsStubs.constructTxParams.resetHistory()
+ mapDispatchToPropsObject.sign({
+ to: 'mockTo',
+ amount: 'mockAmount',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ })
+ assert(dispatchSpy.calledOnce)
+ assert.deepEqual(
+ utilsStubs.constructTxParams.getCall(0).args[0],
+ {
+ selectedToken: undefined,
+ to: 'mockTo',
+ amount: 'mockAmount',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ }
+ )
+ assert.deepEqual(
+ actionSpies.signTx.getCall(0).args,
+ [ 'mockConstructedTxParams' ]
+ )
+ })
+ })
+
+ describe('update()', () => {
+ it('should dispatch an updateTransaction action', () => {
+ mapDispatchToPropsObject.update({
+ to: 'mockTo',
+ amount: 'mockAmount',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ editingTransactionId: 'mockEditingTransactionId',
+ selectedToken: 'mockSelectedToken',
+ unapprovedTxs: 'mockUnapprovedTxs',
+ })
+ assert(dispatchSpy.calledOnce)
+ assert.deepEqual(
+ utilsStubs.constructUpdatedTx.getCall(0).args[0],
+ {
+ to: 'mockTo',
+ amount: 'mockAmount',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ editingTransactionId: 'mockEditingTransactionId',
+ selectedToken: 'mockSelectedToken',
+ unapprovedTxs: 'mockUnapprovedTxs',
+ }
+ )
+ assert.equal(actionSpies.updateTransaction.getCall(0).args[0], 'mockConstructedUpdatedTxParams')
+ })
+ })
+
+ describe('addToAddressBookIfNew()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.addToAddressBookIfNew('mockNewAddress', 'mockToAccounts', 'mockNickname')
+ assert(dispatchSpy.calledOnce)
+ assert.equal(utilsStubs.addressIsNew.getCall(0).args[0], 'mockToAccounts')
+ assert.deepEqual(
+ actionSpies.addToAddressBook.getCall(0).args,
+ [ '0xmockNewAddress', 'mockNickname' ]
+ )
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/send-footer/tests/send-footer-selectors.test.js b/ui/app/components/send/send-footer/tests/send-footer-selectors.test.js
new file mode 100644
index 000000000..8de032f57
--- /dev/null
+++ b/ui/app/components/send/send-footer/tests/send-footer-selectors.test.js
@@ -0,0 +1,24 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+
+const {
+ isSendFormInError,
+} = proxyquire('../send-footer.selectors', {
+ '../send.selectors': {
+ getSendErrors: (mockState) => mockState.errors,
+ },
+})
+
+describe('send-footer selectors', () => {
+
+ describe('getTitleKey()', () => {
+ it('should return true if any of the values of the object returned by getSendErrors are truthy', () => {
+ assert.equal(isSendFormInError({ errors: { a: 'abc', b: false} }), true)
+ })
+
+ it('should return false if all of the values of the object returned by getSendErrors are falsy', () => {
+ assert.equal(isSendFormInError({ errors: { a: false, b: null} }), false)
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-footer/tests/send-footer-utils.test.js b/ui/app/components/send/send-footer/tests/send-footer-utils.test.js
new file mode 100644
index 000000000..2d3135995
--- /dev/null
+++ b/ui/app/components/send/send-footer/tests/send-footer-utils.test.js
@@ -0,0 +1,210 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+const { TOKEN_TRANSFER_FUNCTION_SIGNATURE } = require('../../send.constants')
+
+const stubs = {
+ rawEncode: sinon.stub().callsFake((arr1, arr2) => {
+ return [ ...arr1, ...arr2 ]
+ }),
+}
+
+const sendUtils = proxyquire('../send-footer.utils.js', {
+ 'ethereumjs-abi': {
+ rawEncode: stubs.rawEncode,
+ },
+})
+const {
+ addressIsNew,
+ constructTxParams,
+ constructUpdatedTx,
+ addHexPrefixToObjectValues,
+} = sendUtils
+
+describe('send-footer utils', () => {
+
+ describe('addHexPrefixToObjectValues()', () => {
+ it('should return a new object with the same properties with a 0x prefix', () => {
+ assert.deepEqual(
+ addHexPrefixToObjectValues({
+ prop1: '0x123',
+ prop2: '456',
+ prop3: 'x',
+ }),
+ {
+ prop1: '0x123',
+ prop2: '0x456',
+ prop3: '0xx',
+ }
+ )
+ })
+ })
+
+ describe('addressIsNew()', () => {
+ it('should return false if the address exists in toAccounts', () => {
+ assert.equal(
+ addressIsNew([
+ { address: '0xabc' },
+ { address: '0xdef' },
+ { address: '0xghi' },
+ ], '0xdef'),
+ false
+ )
+ })
+
+ it('should return true if the address does not exists in toAccounts', () => {
+ assert.equal(
+ addressIsNew([
+ { address: '0xabc' },
+ { address: '0xdef' },
+ { address: '0xghi' },
+ ], '0xxyz'),
+ true
+ )
+ })
+ })
+
+ describe('constructTxParams()', () => {
+ it('should return a new txParams object with value and to properties if there is no selectedToken', () => {
+ assert.deepEqual(
+ constructTxParams({
+ selectedToken: false,
+ to: 'mockTo',
+ amount: 'mockAmount',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ }),
+ {
+ to: '0xmockTo',
+ value: '0xmockAmount',
+ from: '0xmockFrom',
+ gas: '0xmockGas',
+ gasPrice: '0xmockGasPrice',
+ }
+ )
+ })
+
+ it('should return a new txParams object without a to property and a 0 value if there is a selectedToken', () => {
+ assert.deepEqual(
+ constructTxParams({
+ selectedToken: true,
+ to: 'mockTo',
+ amount: 'mockAmount',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ }),
+ {
+ value: '0x0',
+ from: '0xmockFrom',
+ gas: '0xmockGas',
+ gasPrice: '0xmockGasPrice',
+ }
+ )
+ })
+ })
+
+ describe('constructUpdatedTx()', () => {
+ it('should return a new object with an updated txParams', () => {
+ const result = constructUpdatedTx({
+ amount: 'mockAmount',
+ editingTransactionId: '0x456',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ selectedToken: false,
+ to: 'mockTo',
+ unapprovedTxs: {
+ '0x123': {},
+ '0x456': {
+ unapprovedTxParam: 'someOtherParam',
+ txParams: {
+ data: 'someData',
+ },
+ },
+ },
+ })
+
+ assert.deepEqual(result, {
+ unapprovedTxParam: 'someOtherParam',
+ txParams: {
+ from: '0xmockFrom',
+ gas: '0xmockGas',
+ gasPrice: '0xmockGasPrice',
+ value: '0xmockAmount',
+ to: '0xmockTo',
+ data: '0xsomeData',
+ },
+ })
+ })
+
+ it('should not have data property if there is non in the original tx', () => {
+ const result = constructUpdatedTx({
+ amount: 'mockAmount',
+ editingTransactionId: '0x456',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ selectedToken: false,
+ to: 'mockTo',
+ unapprovedTxs: {
+ '0x123': {},
+ '0x456': {
+ unapprovedTxParam: 'someOtherParam',
+ txParams: {
+ from: 'oldFrom',
+ gas: 'oldGas',
+ gasPrice: 'oldGasPrice',
+ },
+ },
+ },
+ })
+
+ assert.deepEqual(result, {
+ unapprovedTxParam: 'someOtherParam',
+ txParams: {
+ from: '0xmockFrom',
+ gas: '0xmockGas',
+ gasPrice: '0xmockGasPrice',
+ value: '0xmockAmount',
+ to: '0xmockTo',
+ },
+ })
+ })
+
+ it('should have token property values if selectedToken is truthy', () => {
+ const result = constructUpdatedTx({
+ amount: 'mockAmount',
+ editingTransactionId: '0x456',
+ from: 'mockFrom',
+ gas: 'mockGas',
+ gasPrice: 'mockGasPrice',
+ selectedToken: {
+ address: 'mockTokenAddress',
+ },
+ to: 'mockTo',
+ unapprovedTxs: {
+ '0x123': {},
+ '0x456': {
+ unapprovedTxParam: 'someOtherParam',
+ txParams: {},
+ },
+ },
+ })
+
+ assert.deepEqual(result, {
+ unapprovedTxParam: 'someOtherParam',
+ txParams: {
+ from: '0xmockFrom',
+ gas: '0xmockGas',
+ gasPrice: '0xmockGasPrice',
+ value: '0x0',
+ to: '0xmockTokenAddress',
+ data: `${TOKEN_TRANSFER_FUNCTION_SIGNATURE}ss56Tont`,
+ },
+ })
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send-header/README.md b/ui/app/components/send/send-header/README.md
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send-header/README.md
diff --git a/ui/app/components/send/send-header/index.js b/ui/app/components/send/send-header/index.js
new file mode 100644
index 000000000..0b17f0b7d
--- /dev/null
+++ b/ui/app/components/send/send-header/index.js
@@ -0,0 +1 @@
+export { default } from './send-header.container'
diff --git a/ui/app/components/send/send-header/send-header.component.js b/ui/app/components/send/send-header/send-header.component.js
new file mode 100644
index 000000000..efc4bbf27
--- /dev/null
+++ b/ui/app/components/send/send-header/send-header.component.js
@@ -0,0 +1,34 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import PageContainerHeader from '../../page-container/page-container-header'
+import { DEFAULT_ROUTE } from '../../../routes'
+
+export default class SendHeader extends Component {
+
+ static propTypes = {
+ clearSend: PropTypes.func,
+ history: PropTypes.object,
+ titleKey: PropTypes.string,
+ subtitleParams: PropTypes.array,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ onClose () {
+ this.props.clearSend()
+ this.props.history.push(DEFAULT_ROUTE)
+ }
+
+ render () {
+ return (
+ <PageContainerHeader
+ onClose={() => this.onClose()}
+ subtitle={this.context.t(...this.props.subtitleParams)}
+ title={this.context.t(this.props.titleKey)}
+ />
+ )
+ }
+
+}
diff --git a/ui/app/components/send/send-header/send-header.container.js b/ui/app/components/send/send-header/send-header.container.js
new file mode 100644
index 000000000..4bcd0d1b6
--- /dev/null
+++ b/ui/app/components/send/send-header/send-header.container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux'
+import { clearSend } from '../../../actions'
+import SendHeader from './send-header.component'
+import { getSubtitleParams, getTitleKey } from './send-header.selectors'
+
+export default connect(mapStateToProps, mapDispatchToProps)(SendHeader)
+
+function mapStateToProps (state) {
+ return {
+ titleKey: getTitleKey(state),
+ subtitleParams: getSubtitleParams(state),
+ }
+}
+
+function mapDispatchToProps (dispatch) {
+ return {
+ clearSend: () => dispatch(clearSend()),
+ }
+}
diff --git a/ui/app/components/send/send-header/send-header.selectors.js b/ui/app/components/send/send-header/send-header.selectors.js
new file mode 100644
index 000000000..d7c9d3766
--- /dev/null
+++ b/ui/app/components/send/send-header/send-header.selectors.js
@@ -0,0 +1,37 @@
+const {
+ getSelectedToken,
+ getSendEditingTransactionId,
+} = require('../send.selectors.js')
+
+const selectors = {
+ getTitleKey,
+ getSubtitleParams,
+}
+
+module.exports = selectors
+
+function getTitleKey (state) {
+ const isEditing = Boolean(getSendEditingTransactionId(state))
+ const isToken = Boolean(getSelectedToken(state))
+
+ if (isEditing) {
+ return 'edit'
+ } else if (isToken) {
+ return 'sendTokens'
+ } else {
+ return 'sendETH'
+ }
+}
+
+function getSubtitleParams (state) {
+ const isEditing = Boolean(getSendEditingTransactionId(state))
+ const token = getSelectedToken(state)
+
+ if (isEditing) {
+ return [ 'editingTransaction' ]
+ } else if (token) {
+ return [ 'onlySendTokensToAccountAddress', [ token.symbol ] ]
+ } else {
+ return [ 'onlySendToEtherAddress' ]
+ }
+}
diff --git a/ui/app/components/send/send-header/tests/send-header-component.test.js b/ui/app/components/send/send-header/tests/send-header-component.test.js
new file mode 100644
index 000000000..930bfa387
--- /dev/null
+++ b/ui/app/components/send/send-header/tests/send-header-component.test.js
@@ -0,0 +1,70 @@
+import React from 'react'
+import assert from 'assert'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import { DEFAULT_ROUTE } from '../../../../routes'
+import SendHeader from '../send-header.component.js'
+
+import PageContainerHeader from '../../../page-container/page-container-header'
+
+const propsMethodSpies = {
+ clearSend: sinon.spy(),
+}
+const historySpies = {
+ push: sinon.spy(),
+}
+
+sinon.spy(SendHeader.prototype, 'onClose')
+
+describe('SendHeader Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendHeader
+ clearSend={propsMethodSpies.clearSend}
+ history={historySpies}
+ titleKey={'mockTitleKey'}
+ subtitleParams={[ 'mockSubtitleKey', 'mockVal']}
+ />, { context: { t: (str1, str2) => str2 ? str1 + str2 : str1 } })
+ })
+
+ afterEach(() => {
+ propsMethodSpies.clearSend.resetHistory()
+ historySpies.push.resetHistory()
+ SendHeader.prototype.onClose.resetHistory()
+ })
+
+ describe('onClose', () => {
+ it('should call clearSend', () => {
+ assert.equal(propsMethodSpies.clearSend.callCount, 0)
+ wrapper.instance().onClose()
+ assert.equal(propsMethodSpies.clearSend.callCount, 1)
+ })
+
+ it('should call history.push', () => {
+ assert.equal(historySpies.push.callCount, 0)
+ wrapper.instance().onClose()
+ assert.equal(historySpies.push.callCount, 1)
+ assert.equal(historySpies.push.getCall(0).args[0], DEFAULT_ROUTE)
+ })
+ })
+
+ describe('render', () => {
+ it('should render a PageContainerHeader compenent', () => {
+ assert.equal(wrapper.find(PageContainerHeader).length, 1)
+ })
+
+ it('should pass the correct props to PageContainerHeader', () => {
+ const {
+ onClose,
+ subtitle,
+ title,
+ } = wrapper.find(PageContainerHeader).props()
+ assert.equal(subtitle, 'mockSubtitleKeymockVal')
+ assert.equal(title, 'mockTitleKey')
+ assert.equal(SendHeader.prototype.onClose.callCount, 0)
+ onClose()
+ assert.equal(SendHeader.prototype.onClose.callCount, 1)
+ })
+ })
+})
diff --git a/ui/app/components/send/send-header/tests/send-header-container.test.js b/ui/app/components/send/send-header/tests/send-header-container.test.js
new file mode 100644
index 000000000..41a7e8a89
--- /dev/null
+++ b/ui/app/components/send/send-header/tests/send-header-container.test.js
@@ -0,0 +1,59 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+const actionSpies = {
+ clearSend: sinon.spy(),
+}
+
+proxyquire('../send-header.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },
+ '../../../actions': actionSpies,
+ './send-header.selectors': {
+ getTitleKey: (s) => `mockTitleKey:${s}`,
+ getSubtitleParams: (s) => `mockSubtitleParams:${s}`,
+ },
+})
+
+describe('send-header container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ titleKey: 'mockTitleKey:mockState',
+ subtitleParams: 'mockSubtitleParams:mockState',
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ describe('clearSend()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.clearSend()
+ assert(dispatchSpy.calledOnce)
+ assert(actionSpies.clearSend.calledOnce)
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/send-header/tests/send-header-selectors.test.js b/ui/app/components/send/send-header/tests/send-header-selectors.test.js
new file mode 100644
index 000000000..e0c6a3ab3
--- /dev/null
+++ b/ui/app/components/send/send-header/tests/send-header-selectors.test.js
@@ -0,0 +1,47 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+
+const {
+ getTitleKey,
+ getSubtitleParams,
+} = proxyquire('../send-header.selectors', {
+ '../send.selectors': {
+ getSelectedToken: (mockState) => mockState.t,
+ getSendEditingTransactionId: (mockState) => mockState.e,
+ },
+})
+
+describe('send-header selectors', () => {
+
+ describe('getTitleKey()', () => {
+ it('should return the correct key when getSendEditingTransactionId is truthy', () => {
+ assert.equal(getTitleKey({ e: 1, t: true }), 'edit')
+ })
+
+ it('should return the correct key when getSendEditingTransactionId is falsy and getSelectedToken is truthy', () => {
+ assert.equal(getTitleKey({ e: null, t: 'abc' }), 'sendTokens')
+ })
+
+ it('should return the correct key when getSendEditingTransactionId is falsy and getSelectedToken is falsy', () => {
+ assert.equal(getTitleKey({ e: null }), 'sendETH')
+ })
+ })
+
+ describe('getSubtitleParams()', () => {
+ it('should return the correct params when getSendEditingTransactionId is truthy', () => {
+ assert.deepEqual(getSubtitleParams({ e: 1, t: true }), [ 'editingTransaction' ])
+ })
+
+ it('should return the correct params when getSendEditingTransactionId is falsy and getSelectedToken is truthy', () => {
+ assert.deepEqual(
+ getSubtitleParams({ e: null, t: { symbol: 'ABC' } }),
+ [ 'onlySendTokensToAccountAddress', [ 'ABC' ] ]
+ )
+ })
+
+ it('should return the correct params when getSendEditingTransactionId is falsy and getSelectedToken is falsy', () => {
+ assert.deepEqual(getSubtitleParams({ e: null }), [ 'onlySendToEtherAddress' ])
+ })
+ })
+
+})
diff --git a/ui/app/components/send/send.component.js b/ui/app/components/send/send.component.js
new file mode 100644
index 000000000..6f1b20c55
--- /dev/null
+++ b/ui/app/components/send/send.component.js
@@ -0,0 +1,179 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import PersistentForm from '../../../lib/persistent-form'
+import {
+ getAmountErrorObject,
+ getGasFeeErrorObject,
+ getToAddressForGasUpdate,
+ doesAmountErrorRequireUpdate,
+} from './send.utils'
+
+import SendHeader from './send-header/'
+import SendContent from './send-content/'
+import SendFooter from './send-footer/'
+
+export default class SendTransactionScreen extends PersistentForm {
+
+ static propTypes = {
+ amount: PropTypes.string,
+ amountConversionRate: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ ]),
+ blockGasLimit: PropTypes.string,
+ conversionRate: PropTypes.number,
+ editingTransactionId: PropTypes.string,
+ from: PropTypes.object,
+ gasLimit: PropTypes.string,
+ gasPrice: PropTypes.string,
+ gasTotal: PropTypes.string,
+ history: PropTypes.object,
+ network: PropTypes.string,
+ primaryCurrency: PropTypes.string,
+ recentBlocks: PropTypes.array,
+ selectedAddress: PropTypes.string,
+ selectedToken: PropTypes.object,
+ tokenBalance: PropTypes.string,
+ tokenContract: PropTypes.object,
+ updateAndSetGasTotal: PropTypes.func,
+ updateSendErrors: PropTypes.func,
+ updateSendTokenBalance: PropTypes.func,
+ };
+
+ static contextTypes = {
+ t: PropTypes.func,
+ };
+
+ updateGas ({ to: updatedToAddress, amount: value } = {}) {
+ const {
+ amount,
+ blockGasLimit,
+ editingTransactionId,
+ gasLimit,
+ gasPrice,
+ recentBlocks,
+ selectedAddress,
+ selectedToken = {},
+ to: currentToAddress,
+ updateAndSetGasTotal,
+ } = this.props
+
+ updateAndSetGasTotal({
+ blockGasLimit,
+ editingTransactionId,
+ gasLimit,
+ gasPrice,
+ recentBlocks,
+ selectedAddress,
+ selectedToken,
+ to: getToAddressForGasUpdate(updatedToAddress, currentToAddress),
+ value: value || amount,
+ })
+ }
+
+ 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)
+
+ const amountErrorRequiresUpdate = doesAmountErrorRequireUpdate({
+ balance,
+ gasTotal,
+ prevBalance,
+ prevGasTotal,
+ prevTokenBalance,
+ selectedToken,
+ tokenBalance,
+ })
+
+ if (amountErrorRequiresUpdate) {
+ const amountErrorObject = getAmountErrorObject({
+ amount,
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ selectedToken,
+ tokenBalance,
+ })
+ const gasFeeErrorObject = selectedToken
+ ? getGasFeeErrorObject({
+ amount,
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ selectedToken,
+ tokenBalance,
+ })
+ : { gasFee: null }
+ updateSendErrors(Object.assign(amountErrorObject, gasFeeErrorObject))
+ }
+
+ if (!uninitialized) {
+
+ if (network !== prevNetwork && network !== 'loading') {
+ updateSendTokenBalance({
+ selectedToken,
+ tokenContract,
+ address,
+ })
+ this.updateGas()
+ }
+ }
+ }
+
+ componentWillMount () {
+ const {
+ from: { address },
+ selectedToken,
+ tokenContract,
+ updateSendTokenBalance,
+ } = this.props
+ updateSendTokenBalance({
+ selectedToken,
+ tokenContract,
+ address,
+ })
+ this.updateGas()
+ }
+
+ componentWillUnmount () {
+ this.props.resetSendState()
+ }
+
+ render () {
+ const { history } = this.props
+
+ return (
+ <div className="page-container">
+ <SendHeader history={history}/>
+ <SendContent updateGas={(updateData) => this.updateGas(updateData)}/>
+ <SendFooter history={history}/>
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/send/send.constants.js b/ui/app/components/send/send.constants.js
new file mode 100644
index 000000000..8acdf0641
--- /dev/null
+++ b/ui/app/components/send/send.constants.js
@@ -0,0 +1,57 @@
+const ethUtil = require('ethereumjs-util')
+const { conversionUtil, multiplyCurrencies } = require('../../conversion-util')
+
+const MIN_GAS_PRICE_DEC = '0'
+const MIN_GAS_PRICE_HEX = (parseInt(MIN_GAS_PRICE_DEC)).toString(16)
+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'
+
+const ONE_GWEI_IN_WEI_HEX = ethUtil.addHexPrefix(conversionUtil('0x1', {
+ fromDenomination: 'GWEI',
+ toDenomination: 'WEI',
+ fromNumericBase: 'hex',
+ toNumericBase: 'hex',
+}))
+
+const SIMPLE_GAS_COST = '0x5208' // Hex for 21000, cost of a simple send.
+const BASE_TOKEN_GAS_COST = '0x186a0' // Hex for 100000, a base estimate for token transfers.
+
+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,
+ ONE_GWEI_IN_WEI_HEX,
+ REQUIRED_ERROR,
+ SIMPLE_GAS_COST,
+ TOKEN_TRANSFER_FUNCTION_SIGNATURE,
+ BASE_TOKEN_GAS_COST,
+}
diff --git a/ui/app/components/send/send.container.js b/ui/app/components/send/send.container.js
new file mode 100644
index 000000000..44ebd2792
--- /dev/null
+++ b/ui/app/components/send/send.container.js
@@ -0,0 +1,93 @@
+import { connect } from 'react-redux'
+import SendEther from './send.component'
+import { withRouter } from 'react-router-dom'
+import { compose } from 'recompose'
+import {
+ getAmountConversionRate,
+ getBlockGasLimit,
+ getConversionRate,
+ getCurrentNetwork,
+ getGasLimit,
+ getGasPrice,
+ getGasTotal,
+ getPrimaryCurrency,
+ getRecentBlocks,
+ getSelectedAddress,
+ getSelectedToken,
+ getSelectedTokenContract,
+ getSelectedTokenToFiatRate,
+ getSendAmount,
+ getSendEditingTransactionId,
+ getSendFromObject,
+ getSendTo,
+ getTokenBalance,
+} from './send.selectors'
+import {
+ updateSendTokenBalance,
+ updateGasData,
+ setGasTotal,
+} from '../../actions'
+import {
+ resetSendState,
+ updateSendErrors,
+} from '../../ducks/send.duck'
+import {
+ calcGasTotal,
+} from './send.utils.js'
+
+module.exports = compose(
+ withRouter,
+ connect(mapStateToProps, mapDispatchToProps)
+)(SendEther)
+
+function mapStateToProps (state) {
+ return {
+ amount: getSendAmount(state),
+ amountConversionRate: getAmountConversionRate(state),
+ blockGasLimit: getBlockGasLimit(state),
+ conversionRate: getConversionRate(state),
+ editingTransactionId: getSendEditingTransactionId(state),
+ from: getSendFromObject(state),
+ gasLimit: getGasLimit(state),
+ gasPrice: getGasPrice(state),
+ gasTotal: getGasTotal(state),
+ network: getCurrentNetwork(state),
+ primaryCurrency: getPrimaryCurrency(state),
+ recentBlocks: getRecentBlocks(state),
+ selectedAddress: getSelectedAddress(state),
+ selectedToken: getSelectedToken(state),
+ to: getSendTo(state),
+ tokenBalance: getTokenBalance(state),
+ tokenContract: getSelectedTokenContract(state),
+ tokenToFiatRate: getSelectedTokenToFiatRate(state),
+ }
+}
+
+function mapDispatchToProps (dispatch) {
+ return {
+ updateAndSetGasTotal: ({
+ blockGasLimit,
+ editingTransactionId,
+ gasLimit,
+ gasPrice,
+ recentBlocks,
+ selectedAddress,
+ selectedToken,
+ to,
+ value,
+ }) => {
+ !editingTransactionId
+ ? dispatch(updateGasData({ recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value }))
+ : dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice)))
+ },
+ updateSendTokenBalance: ({ selectedToken, tokenContract, address }) => {
+ dispatch(updateSendTokenBalance({
+ selectedToken,
+ tokenContract,
+ address,
+ }))
+ },
+ updateSendErrors: newError => dispatch(updateSendErrors(newError)),
+ resetSendState: () => dispatch(resetSendState()),
+ }
+}
diff --git a/ui/app/components/send/send.scss b/ui/app/components/send/send.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send/send.scss
diff --git a/ui/app/components/send/send.selectors.js b/ui/app/components/send/send.selectors.js
new file mode 100644
index 000000000..f910f7caf
--- /dev/null
+++ b/ui/app/components/send/send.selectors.js
@@ -0,0 +1,279 @@
+const { valuesFor } = require('../../util')
+const abi = require('human-standard-token-abi')
+const {
+ multiplyCurrencies,
+} = require('../../conversion-util')
+const {
+ estimateGasPriceFromRecentBlocks,
+} = require('./send.utils')
+
+const selectors = {
+ accountsWithSendEtherInfoSelector,
+ // autoAddToBetaUI,
+ getAddressBook,
+ getAmountConversionRate,
+ getBlockGasLimit,
+ getConversionRate,
+ getCurrentAccountWithSendEtherInfo,
+ getCurrentCurrency,
+ getCurrentNetwork,
+ getCurrentViewContext,
+ getForceGasMin,
+ getGasLimit,
+ getGasPrice,
+ getGasPriceFromRecentBlocks,
+ getGasTotal,
+ getPrimaryCurrency,
+ getRecentBlocks,
+ getSelectedAccount,
+ getSelectedAddress,
+ getSelectedIdentity,
+ getSelectedToken,
+ getSelectedTokenContract,
+ getSelectedTokenExchangeRate,
+ getSelectedTokenToFiatRate,
+ getSendAmount,
+ getSendEditingTransactionId,
+ getSendErrors,
+ getSendFrom,
+ getSendFromBalance,
+ getSendFromObject,
+ getSendMaxModeState,
+ getSendTo,
+ getSendToAccounts,
+ getTokenBalance,
+ getTokenExchangeRate,
+ getUnapprovedTxs,
+ transactionsSelector,
+}
+
+module.exports = selectors
+
+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 autoAddToBetaUI (state) {
+// const autoAddTransactionThreshold = 12
+// const autoAddAccountsThreshold = 2
+// const autoAddTokensThreshold = 1
+
+// const numberOfTransactions = state.metamask.selectedAddressTxList.length
+// const numberOfAccounts = Object.keys(state.metamask.accounts).length
+// const numberOfTokensAdded = state.metamask.tokens.length
+
+// const userPassesThreshold = (numberOfTransactions > autoAddTransactionThreshold) &&
+// (numberOfAccounts > autoAddAccountsThreshold) &&
+// (numberOfTokensAdded > autoAddTokensThreshold)
+// const userIsNotInBeta = !state.metamask.featureFlags.betaUI
+
+// return userIsNotInBeta && userPassesThreshold
+// }
+
+function getAddressBook (state) {
+ return state.metamask.addressBook
+}
+
+function getAmountConversionRate (state) {
+ return getSelectedToken(state)
+ ? getSelectedTokenToFiatRate(state)
+ : getConversionRate(state)
+}
+
+function getBlockGasLimit (state) {
+ return state.metamask.currentBlockGasLimit
+}
+
+function getConversionRate (state) {
+ return state.metamask.conversionRate
+}
+
+function getCurrentAccountWithSendEtherInfo (state) {
+ const currentAddress = getSelectedAddress(state)
+ const accounts = accountsWithSendEtherInfoSelector(state)
+
+ return accounts.find(({ address }) => address === currentAddress)
+}
+
+function getCurrentCurrency (state) {
+ return state.metamask.currentCurrency
+}
+
+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 getGasLimit (state) {
+ return state.metamask.send.gasLimit
+}
+
+function getGasPrice (state) {
+ return state.metamask.send.gasPrice
+}
+
+function getGasPriceFromRecentBlocks (state) {
+ return estimateGasPriceFromRecentBlocks(state.metamask.recentBlocks)
+}
+
+function getGasTotal (state) {
+ return state.metamask.send.gasTotal
+}
+
+function getPrimaryCurrency (state) {
+ const selectedToken = getSelectedToken(state)
+ return selectedToken && selectedToken.symbol
+}
+
+function getRecentBlocks (state) {
+ return state.metamask.recentBlocks
+}
+
+function getSelectedAccount (state) {
+ const accounts = state.metamask.accounts
+ const selectedAddress = getSelectedAddress(state)
+
+ return accounts[selectedAddress]
+}
+
+function getSelectedAddress (state) {
+ const selectedAddress = state.metamask.selectedAddress || Object.keys(state.metamask.accounts)[0]
+
+ return selectedAddress
+}
+
+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 = getConversionRate(state)
+
+ const tokenToFiatRate = multiplyCurrencies(
+ conversionRate,
+ selectedTokenExchangeRate,
+ { toNumericBase: 'dec' }
+ )
+
+ return tokenToFiatRate
+}
+
+function getSendAmount (state) {
+ return state.metamask.send.amount
+}
+
+function getSendEditingTransactionId (state) {
+ return state.metamask.send.editingTransactionId
+}
+
+function getSendErrors (state) {
+ return state.send.errors
+}
+
+function getSendFrom (state) {
+ return state.metamask.send.from
+}
+
+function getSendFromBalance (state) {
+ const from = getSendFrom(state) || getSelectedAccount(state)
+ return from.balance
+}
+
+function getSendFromObject (state) {
+ return getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state)
+}
+
+function getSendMaxModeState (state) {
+ return state.metamask.send.maxModeOn
+}
+
+function getSendTo (state) {
+ return state.metamask.send.to
+}
+
+function getSendToAccounts (state) {
+ const fromAccounts = accountsWithSendEtherInfoSelector(state)
+ const addressBookAccounts = getAddressBook(state)
+ const allAccounts = [...fromAccounts, ...addressBookAccounts]
+ // TODO: figure out exactly what the below returns and put a descriptive variable name on it
+ return Object.entries(allAccounts).map(([key, account]) => account)
+}
+
+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 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
new file mode 100644
index 000000000..aa255c3d4
--- /dev/null
+++ b/ui/app/components/send/send.utils.js
@@ -0,0 +1,312 @@
+const {
+ addCurrencies,
+ conversionUtil,
+ conversionGTE,
+ multiplyCurrencies,
+ conversionGreaterThan,
+ conversionLessThan,
+} = require('../../conversion-util')
+const {
+ calcTokenAmount,
+} = require('../../token-util')
+const {
+ BASE_TOKEN_GAS_COST,
+ INSUFFICIENT_FUNDS_ERROR,
+ INSUFFICIENT_TOKENS_ERROR,
+ NEGATIVE_ETH_ERROR,
+ ONE_GWEI_IN_WEI_HEX,
+ SIMPLE_GAS_COST,
+ TOKEN_TRANSFER_FUNCTION_SIGNATURE,
+} = require('./send.constants')
+const abi = require('ethereumjs-abi')
+const ethUtil = require('ethereumjs-util')
+
+module.exports = {
+ addGasBuffer,
+ calcGasTotal,
+ calcTokenBalance,
+ doesAmountErrorRequireUpdate,
+ estimateGas,
+ estimateGasPriceFromRecentBlocks,
+ generateTokenTransferData,
+ getAmountErrorObject,
+ getGasFeeErrorObject,
+ getToAddressForGasUpdate,
+ isBalanceSufficient,
+ isTokenBalanceSufficient,
+ removeLeadingZeroes,
+}
+
+function calcGasTotal (gasLimit = '0', gasPrice = '0') {
+ return multiplyCurrencies(gasLimit, gasPrice, {
+ toNumericBase: 'hex',
+ multiplicandBase: 16,
+ multiplierBase: 16,
+ })
+}
+
+function isBalanceSufficient ({
+ amount = '0x0',
+ amountConversionRate = 1,
+ balance = '0x0',
+ conversionRate = 1,
+ 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: Number(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 && !selectedToken) {
+ insufficientFunds = !isBalanceSufficient({
+ 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 getGasFeeErrorObject ({
+ amount,
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+}) {
+ let gasFeeError = null
+
+ if (gasTotal && conversionRate) {
+ const insufficientFunds = !isBalanceSufficient({
+ amount: '0x0',
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ })
+
+ if (insufficientFunds) {
+ gasFeeError = INSUFFICIENT_FUNDS_ERROR
+ }
+ }
+
+ return { gasFee: gasFeeError }
+}
+
+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
+}
+
+async function estimateGas ({ selectedAddress, selectedToken, blockGasLimit, to, value, gasPrice, estimateGasMethod }) {
+ const paramsForGasEstimate = { from: selectedAddress, value, gasPrice }
+
+ if (selectedToken) {
+ paramsForGasEstimate.value = '0x0'
+ paramsForGasEstimate.data = generateTokenTransferData({ toAddress: to, amount: value, selectedToken })
+ }
+
+ // if recipient has no code, gas is 21k max:
+ if (!selectedToken) {
+ const code = Boolean(to) && await global.eth.getCode(to)
+ if (!code || code === '0x') {
+ return SIMPLE_GAS_COST
+ }
+ } else if (selectedToken && !to) {
+ return BASE_TOKEN_GAS_COST
+ }
+
+ paramsForGasEstimate.to = selectedToken ? selectedToken.address : to
+
+ // if not, fall back to block gasLimit
+ paramsForGasEstimate.gas = ethUtil.addHexPrefix(multiplyCurrencies(blockGasLimit, 0.95, {
+ multiplicandBase: 16,
+ multiplierBase: 10,
+ roundDown: '0',
+ toNumericBase: 'hex',
+ }))
+ // run tx
+ return new Promise((resolve, reject) => {
+ return estimateGasMethod(paramsForGasEstimate, (err, estimatedGas) => {
+ if (err) {
+ const simulationFailed = (
+ err.message.includes('Transaction execution error.') ||
+ err.message.includes('gas required exceeds allowance or always failing transaction')
+ )
+ if (simulationFailed) {
+ const estimateWithBuffer = addGasBuffer(paramsForGasEstimate.gas, blockGasLimit, 1.5)
+ return resolve(ethUtil.addHexPrefix(estimateWithBuffer))
+ } else {
+ return reject(err)
+ }
+ }
+ const estimateWithBuffer = addGasBuffer(estimatedGas.toString(16), blockGasLimit, 1.5)
+ return resolve(ethUtil.addHexPrefix(estimateWithBuffer))
+ })
+ })
+}
+
+function addGasBuffer (initialGasLimitHex, blockGasLimitHex, bufferMultiplier = 1.5) {
+ const upperGasLimit = multiplyCurrencies(blockGasLimitHex, 0.9, {
+ toNumericBase: 'hex',
+ multiplicandBase: 16,
+ multiplierBase: 10,
+ numberOfDecimals: '0',
+ })
+ const bufferedGasLimit = multiplyCurrencies(initialGasLimitHex, bufferMultiplier, {
+ toNumericBase: 'hex',
+ multiplicandBase: 16,
+ multiplierBase: 10,
+ numberOfDecimals: '0',
+ })
+
+ // if initialGasLimit is above blockGasLimit, dont modify it
+ if (conversionGreaterThan(
+ { value: initialGasLimitHex, fromNumericBase: 'hex' },
+ { value: upperGasLimit, fromNumericBase: 'hex' },
+ )) return initialGasLimitHex
+ // if bufferedGasLimit is below blockGasLimit, use bufferedGasLimit
+ if (conversionLessThan(
+ { value: bufferedGasLimit, fromNumericBase: 'hex' },
+ { value: upperGasLimit, fromNumericBase: 'hex' },
+ )) return bufferedGasLimit
+ // otherwise use blockGasLimit
+ return upperGasLimit
+}
+
+function generateTokenTransferData ({ toAddress = '0x0', amount = '0x0', selectedToken }) {
+ if (!selectedToken) return
+ return TOKEN_TRANSFER_FUNCTION_SIGNATURE + Array.prototype.map.call(
+ abi.rawEncode(['address', 'uint256'], [toAddress, ethUtil.addHexPrefix(amount)]),
+ x => ('00' + x.toString(16)).slice(-2)
+ ).join('')
+}
+
+function estimateGasPriceFromRecentBlocks (recentBlocks) {
+ // Return 1 gwei if no blocks have been observed:
+ if (!recentBlocks || recentBlocks.length === 0) {
+ return ONE_GWEI_IN_WEI_HEX
+ }
+
+ const lowestPrices = recentBlocks.map((block) => {
+ if (!block.gasPrices || block.gasPrices.length < 1) {
+ return ONE_GWEI_IN_WEI_HEX
+ }
+ return block.gasPrices.reduce((currentLowest, next) => {
+ return parseInt(next, 16) < parseInt(currentLowest, 16) ? next : currentLowest
+ })
+ })
+ .sort((a, b) => parseInt(a, 16) > parseInt(b, 16) ? 1 : -1)
+
+ return lowestPrices[Math.floor(lowestPrices.length / 2)]
+}
+
+function getToAddressForGasUpdate (...addresses) {
+ return [...addresses, ''].find(str => str !== undefined && str !== null).toLowerCase()
+}
+
+function removeLeadingZeroes (str) {
+ return str.replace(/^0*(?=\d)/, '')
+}
diff --git a/ui/app/components/send/tests/send-component.test.js b/ui/app/components/send/tests/send-component.test.js
new file mode 100644
index 000000000..6194ec508
--- /dev/null
+++ b/ui/app/components/send/tests/send-component.test.js
@@ -0,0 +1,332 @@
+import React from 'react'
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+
+import SendHeader from '../send-header/send-header.container'
+import SendContent from '../send-content/send-content.component'
+import SendFooter from '../send-footer/send-footer.container'
+
+const propsMethodSpies = {
+ updateAndSetGasTotal: sinon.spy(),
+ updateSendErrors: sinon.spy(),
+ updateSendTokenBalance: sinon.spy(),
+ resetSendState: sinon.spy(),
+}
+const utilsMethodStubs = {
+ getAmountErrorObject: sinon.stub().returns({ amount: 'mockAmountError' }),
+ getGasFeeErrorObject: sinon.stub().returns({ gasFee: 'mockGasFeeError' }),
+ doesAmountErrorRequireUpdate: sinon.stub().callsFake(obj => obj.balance !== obj.prevBalance),
+}
+
+const SendTransactionScreen = proxyquire('../send.component.js', {
+ './send.utils': utilsMethodStubs,
+}).default
+
+sinon.spy(SendTransactionScreen.prototype, 'componentDidMount')
+sinon.spy(SendTransactionScreen.prototype, 'updateGas')
+
+describe('Send Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendTransactionScreen
+ amount={'mockAmount'}
+ amountConversionRate={'mockAmountConversionRate'}
+ blockGasLimit={'mockBlockGasLimit'}
+ conversionRate={10}
+ editingTransactionId={'mockEditingTransactionId'}
+ from={ { address: 'mockAddress', balance: 'mockBalance' } }
+ gasLimit={'mockGasLimit'}
+ gasPrice={'mockGasPrice'}
+ gasTotal={'mockGasTotal'}
+ history={{ mockProp: 'history-abc'}}
+ network={'3'}
+ primaryCurrency={'mockPrimaryCurrency'}
+ recentBlocks={['mockBlock']}
+ selectedAddress={'mockSelectedAddress'}
+ selectedToken={'mockSelectedToken'}
+ tokenBalance={'mockTokenBalance'}
+ tokenContract={'mockTokenContract'}
+ updateAndSetGasTotal={propsMethodSpies.updateAndSetGasTotal}
+ updateSendErrors={propsMethodSpies.updateSendErrors}
+ updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance}
+ resetSendState={propsMethodSpies.resetSendState}
+ />)
+ })
+
+ afterEach(() => {
+ SendTransactionScreen.prototype.componentDidMount.resetHistory()
+ SendTransactionScreen.prototype.updateGas.resetHistory()
+ utilsMethodStubs.doesAmountErrorRequireUpdate.resetHistory()
+ utilsMethodStubs.getAmountErrorObject.resetHistory()
+ utilsMethodStubs.getGasFeeErrorObject.resetHistory()
+ propsMethodSpies.updateAndSetGasTotal.resetHistory()
+ propsMethodSpies.updateSendErrors.resetHistory()
+ propsMethodSpies.updateSendTokenBalance.resetHistory()
+ })
+
+ it('should call componentDidMount', () => {
+ assert(SendTransactionScreen.prototype.componentDidMount.calledOnce)
+ })
+
+ describe('componentWillMount', () => {
+ it('should call this.updateGas', () => {
+ SendTransactionScreen.prototype.updateGas.resetHistory()
+ propsMethodSpies.updateSendErrors.resetHistory()
+ assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0)
+ wrapper.instance().componentWillMount()
+ assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 1)
+ })
+ })
+
+ describe('componentWillUnmount', () => {
+ it('should call this.props.resetSendState', () => {
+ propsMethodSpies.resetSendState.resetHistory()
+ assert.equal(propsMethodSpies.resetSendState.callCount, 0)
+ wrapper.instance().componentWillUnmount()
+ assert.equal(propsMethodSpies.resetSendState.callCount, 1)
+ })
+ })
+
+ describe('componentDidUpdate', () => {
+ it('should call doesAmountErrorRequireUpdate with the expected params', () => {
+ utilsMethodStubs.getAmountErrorObject.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: '',
+ },
+ })
+ assert(utilsMethodStubs.doesAmountErrorRequireUpdate.calledOnce)
+ assert.deepEqual(
+ utilsMethodStubs.doesAmountErrorRequireUpdate.getCall(0).args[0],
+ {
+ balance: 'mockBalance',
+ gasTotal: 'mockGasTotal',
+ prevBalance: '',
+ prevGasTotal: undefined,
+ prevTokenBalance: undefined,
+ selectedToken: 'mockSelectedToken',
+ tokenBalance: 'mockTokenBalance',
+ }
+ )
+ })
+
+ it('should not call getAmountErrorObject if doesAmountErrorRequireUpdate returns false', () => {
+ utilsMethodStubs.getAmountErrorObject.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'mockBalance',
+ },
+ })
+ assert.equal(utilsMethodStubs.getAmountErrorObject.callCount, 0)
+ })
+
+ it('should call getAmountErrorObject if doesAmountErrorRequireUpdate returns true', () => {
+ utilsMethodStubs.getAmountErrorObject.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ })
+ assert.equal(utilsMethodStubs.getAmountErrorObject.callCount, 1)
+ assert.deepEqual(
+ utilsMethodStubs.getAmountErrorObject.getCall(0).args[0],
+ {
+ amount: 'mockAmount',
+ amountConversionRate: 'mockAmountConversionRate',
+ balance: 'mockBalance',
+ conversionRate: 10,
+ gasTotal: 'mockGasTotal',
+ primaryCurrency: 'mockPrimaryCurrency',
+ selectedToken: 'mockSelectedToken',
+ tokenBalance: 'mockTokenBalance',
+ }
+ )
+ })
+
+ it('should call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true and selectedToken is truthy', () => {
+ utilsMethodStubs.getGasFeeErrorObject.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ })
+ assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 1)
+ assert.deepEqual(
+ utilsMethodStubs.getGasFeeErrorObject.getCall(0).args[0],
+ {
+ amount: 'mockAmount',
+ amountConversionRate: 'mockAmountConversionRate',
+ balance: 'mockBalance',
+ conversionRate: 10,
+ gasTotal: 'mockGasTotal',
+ primaryCurrency: 'mockPrimaryCurrency',
+ selectedToken: 'mockSelectedToken',
+ tokenBalance: 'mockTokenBalance',
+ }
+ )
+ })
+
+ it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns false', () => {
+ utilsMethodStubs.getGasFeeErrorObject.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: { address: 'mockAddress', balance: 'mockBalance' },
+ })
+ assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 0)
+ })
+
+ it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true but selectedToken is falsy', () => {
+ utilsMethodStubs.getGasFeeErrorObject.resetHistory()
+ wrapper.setProps({ selectedToken: null })
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ })
+ assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 0)
+ })
+
+ it('should call updateSendErrors with the expected params if selectedToken is falsy', () => {
+ propsMethodSpies.updateSendErrors.resetHistory()
+ wrapper.setProps({ selectedToken: null })
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ })
+ assert.equal(propsMethodSpies.updateSendErrors.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendErrors.getCall(0).args[0],
+ { amount: 'mockAmountError', gasFee: null }
+ )
+ })
+
+ it('should call updateSendErrors with the expected params if selectedToken is truthy', () => {
+ propsMethodSpies.updateSendErrors.resetHistory()
+ wrapper.setProps({ selectedToken: 'someToken' })
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ })
+ assert.equal(propsMethodSpies.updateSendErrors.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendErrors.getCall(0).args[0],
+ { amount: 'mockAmountError', gasFee: 'mockGasFeeError' }
+ )
+ })
+
+ it('should not call updateSendTokenBalance or this.updateGas if network === prevNetwork', () => {
+ SendTransactionScreen.prototype.updateGas.resetHistory()
+ propsMethodSpies.updateSendTokenBalance.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ network: '3',
+ })
+ assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 0)
+ assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0)
+ })
+
+ it('should not call updateSendTokenBalance or this.updateGas if network === loading', () => {
+ wrapper.setProps({ network: 'loading' })
+ SendTransactionScreen.prototype.updateGas.resetHistory()
+ propsMethodSpies.updateSendTokenBalance.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ network: '3',
+ })
+ assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 0)
+ assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 0)
+ })
+
+ it('should call updateSendTokenBalance and this.updateGas with the correct params', () => {
+ SendTransactionScreen.prototype.updateGas.resetHistory()
+ propsMethodSpies.updateSendTokenBalance.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ network: '2',
+ })
+ assert.equal(propsMethodSpies.updateSendTokenBalance.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendTokenBalance.getCall(0).args[0],
+ {
+ selectedToken: 'mockSelectedToken',
+ tokenContract: 'mockTokenContract',
+ address: 'mockAddress',
+ }
+ )
+ assert.equal(SendTransactionScreen.prototype.updateGas.callCount, 1)
+ assert.deepEqual(
+ SendTransactionScreen.prototype.updateGas.getCall(0).args,
+ []
+ )
+ })
+ })
+
+ describe('updateGas', () => {
+ it('should call updateAndSetGasTotal with the correct params if no to prop is passed', () => {
+ propsMethodSpies.updateAndSetGasTotal.resetHistory()
+ wrapper.instance().updateGas()
+ assert.equal(propsMethodSpies.updateAndSetGasTotal.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0],
+ {
+ blockGasLimit: 'mockBlockGasLimit',
+ editingTransactionId: 'mockEditingTransactionId',
+ gasLimit: 'mockGasLimit',
+ gasPrice: 'mockGasPrice',
+ recentBlocks: ['mockBlock'],
+ selectedAddress: 'mockSelectedAddress',
+ selectedToken: 'mockSelectedToken',
+ to: '',
+ value: 'mockAmount',
+ }
+ )
+ })
+
+ it('should call updateAndSetGasTotal with the correct params if a to prop is passed', () => {
+ propsMethodSpies.updateAndSetGasTotal.resetHistory()
+ wrapper.setProps({ to: 'someAddress' })
+ wrapper.instance().updateGas()
+ assert.equal(
+ propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0].to,
+ 'someaddress',
+ )
+ })
+
+ it('should call updateAndSetGasTotal with to set to lowercase if passed', () => {
+ propsMethodSpies.updateAndSetGasTotal.resetHistory()
+ wrapper.instance().updateGas({ to: '0xABC' })
+ assert.equal(propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0].to, '0xabc')
+ })
+ })
+
+ describe('render', () => {
+ it('should render a page-container class', () => {
+ assert.equal(wrapper.find('.page-container').length, 1)
+ })
+
+ it('should render SendHeader, SendContent and SendFooter', () => {
+ assert.equal(wrapper.find(SendHeader).length, 1)
+ assert.equal(wrapper.find(SendContent).length, 1)
+ assert.equal(wrapper.find(SendFooter).length, 1)
+ })
+
+ it('should pass the history prop to SendHeader and SendFooter', () => {
+ assert.deepEqual(
+ wrapper.find(SendFooter).props(),
+ {
+ history: { mockProp: 'history-abc' },
+ }
+ )
+ })
+ })
+})
diff --git a/ui/app/components/send/tests/send-container.test.js b/ui/app/components/send/tests/send-container.test.js
new file mode 100644
index 000000000..7a9120d24
--- /dev/null
+++ b/ui/app/components/send/tests/send-container.test.js
@@ -0,0 +1,169 @@
+import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+const actionSpies = {
+ updateSendTokenBalance: sinon.spy(),
+ updateGasData: sinon.spy(),
+ setGasTotal: sinon.spy(),
+}
+const duckActionSpies = {
+ updateSendErrors: sinon.spy(),
+ resetSendState: sinon.spy(),
+}
+
+proxyquire('../send.container.js', {
+ 'react-redux': {
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },
+ 'react-router-dom': { withRouter: () => {} },
+ 'recompose': { compose: (arg1, arg2) => () => arg2() },
+ './send.selectors': {
+ getAmountConversionRate: (s) => `mockAmountConversionRate:${s}`,
+ getBlockGasLimit: (s) => `mockBlockGasLimit:${s}`,
+ getConversionRate: (s) => `mockConversionRate:${s}`,
+ getCurrentNetwork: (s) => `mockNetwork:${s}`,
+ getGasLimit: (s) => `mockGasLimit:${s}`,
+ getGasPrice: (s) => `mockGasPrice:${s}`,
+ getGasTotal: (s) => `mockGasTotal:${s}`,
+ getPrimaryCurrency: (s) => `mockPrimaryCurrency:${s}`,
+ getRecentBlocks: (s) => `mockRecentBlocks:${s}`,
+ getSelectedAddress: (s) => `mockSelectedAddress:${s}`,
+ getSelectedToken: (s) => `mockSelectedToken:${s}`,
+ getSelectedTokenContract: (s) => `mockTokenContract:${s}`,
+ getSelectedTokenToFiatRate: (s) => `mockTokenToFiatRate:${s}`,
+ getSendAmount: (s) => `mockAmount:${s}`,
+ getSendTo: (s) => `mockTo:${s}`,
+ getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`,
+ getSendFromObject: (s) => `mockFrom:${s}`,
+ getTokenBalance: (s) => `mockTokenBalance:${s}`,
+ },
+ '../../actions': actionSpies,
+ '../../ducks/send.duck': duckActionSpies,
+ './send.utils.js': {
+ calcGasTotal: (gasLimit, gasPrice) => gasLimit + gasPrice,
+ },
+})
+
+describe('send container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), {
+ amount: 'mockAmount:mockState',
+ amountConversionRate: 'mockAmountConversionRate:mockState',
+ blockGasLimit: 'mockBlockGasLimit:mockState',
+ conversionRate: 'mockConversionRate:mockState',
+ editingTransactionId: 'mockEditingTransactionId:mockState',
+ from: 'mockFrom:mockState',
+ gasLimit: 'mockGasLimit:mockState',
+ gasPrice: 'mockGasPrice:mockState',
+ gasTotal: 'mockGasTotal:mockState',
+ network: 'mockNetwork:mockState',
+ primaryCurrency: 'mockPrimaryCurrency:mockState',
+ recentBlocks: 'mockRecentBlocks:mockState',
+ selectedAddress: 'mockSelectedAddress:mockState',
+ selectedToken: 'mockSelectedToken:mockState',
+ to: 'mockTo:mockState',
+ tokenBalance: 'mockTokenBalance:mockState',
+ tokenContract: 'mockTokenContract:mockState',
+ tokenToFiatRate: 'mockTokenToFiatRate:mockState',
+ })
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ describe('updateAndSetGasTotal()', () => {
+ const mockProps = {
+ blockGasLimit: 'mockBlockGasLimit',
+ editingTransactionId: '0x2',
+ gasLimit: '0x3',
+ gasPrice: '0x4',
+ recentBlocks: ['mockBlock'],
+ selectedAddress: '0x4',
+ selectedToken: { address: '0x1' },
+ to: 'mockTo',
+ value: 'mockValue',
+ }
+
+ it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => {
+ mapDispatchToPropsObject.updateAndSetGasTotal(mockProps)
+ assert(dispatchSpy.calledOnce)
+ assert.equal(
+ actionSpies.setGasTotal.getCall(0).args[0],
+ '0x30x4'
+ )
+ })
+
+ it('should dispatch an updateGasData action when editingTransactionId is falsy', () => {
+ const { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value } = mockProps
+ mapDispatchToPropsObject.updateAndSetGasTotal(
+ Object.assign({}, mockProps, {editingTransactionId: false})
+ )
+ assert(dispatchSpy.calledOnce)
+ assert.deepEqual(
+ actionSpies.updateGasData.getCall(0).args[0],
+ { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value }
+ )
+ })
+ })
+
+ describe('updateSendTokenBalance()', () => {
+ const mockProps = {
+ address: '0x10',
+ tokenContract: '0x00a',
+ selectedToken: {address: '0x1'},
+ }
+
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.updateSendTokenBalance(Object.assign({}, mockProps))
+ assert(dispatchSpy.calledOnce)
+ assert.deepEqual(
+ actionSpies.updateSendTokenBalance.getCall(0).args[0],
+ mockProps
+ )
+ })
+ })
+
+ describe('updateSendErrors()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.updateSendErrors('mockError')
+ assert(dispatchSpy.calledOnce)
+ assert.equal(
+ duckActionSpies.updateSendErrors.getCall(0).args[0],
+ 'mockError'
+ )
+ })
+ })
+
+ describe('resetSendState()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.resetSendState()
+ assert(dispatchSpy.calledOnce)
+ assert.equal(
+ duckActionSpies.resetSendState.getCall(0).args.length,
+ 0
+ )
+ })
+ })
+
+ })
+
+})
diff --git a/ui/app/components/send/tests/send-selectors-test-data.js b/ui/app/components/send/tests/send-selectors-test-data.js
new file mode 100644
index 000000000..8f9c19314
--- /dev/null
+++ b/ui/app/components/send/tests/send-selectors-test-data.js
@@ -0,0 +1,230 @@
+module.exports = {
+ 'metamask': {
+ 'isInitialized': true,
+ 'isUnlocked': true,
+ 'featureFlags': {'betaUI': true},
+ 'rpcTarget': 'https://rawtestrpc.metamask.io/',
+ 'identities': {
+ '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': {
+ 'address': '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825',
+ 'name': 'Send Account 1',
+ },
+ '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': {
+ 'address': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
+ 'name': 'Send Account 2',
+ },
+ '0x2f8d4a878cfa04a6e60d46362f5644deab66572d': {
+ 'address': '0x2f8d4a878cfa04a6e60d46362f5644deab66572d',
+ 'name': 'Send Account 3',
+ },
+ '0xd85a4b6a394794842887b8284293d69163007bbb': {
+ 'address': '0xd85a4b6a394794842887b8284293d69163007bbb',
+ 'name': 'Send Account 4',
+ },
+ },
+ 'currentBlockGasLimit': '0x4c1878',
+ 'currentCurrency': 'USD',
+ 'conversionRate': 1200.88200327,
+ 'conversionDate': 1489013762,
+ 'noActiveNotices': true,
+ 'frequentRpcList': [],
+ 'network': '3',
+ 'accounts': {
+ '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825': {
+ 'code': '0x',
+ 'balance': '0x47c9d71831c76efe',
+ 'nonce': '0x1b',
+ 'address': '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825',
+ },
+ '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb': {
+ 'code': '0x',
+ 'balance': '0x37452b1315889f80',
+ 'nonce': '0xa',
+ 'address': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
+ },
+ '0x2f8d4a878cfa04a6e60d46362f5644deab66572d': {
+ 'code': '0x',
+ 'balance': '0x30c9d71831c76efe',
+ 'nonce': '0x1c',
+ 'address': '0x2f8d4a878cfa04a6e60d46362f5644deab66572d',
+ },
+ '0xd85a4b6a394794842887b8284293d69163007bbb': {
+ 'code': '0x',
+ 'balance': '0x0',
+ 'nonce': '0x0',
+ 'address': '0xd85a4b6a394794842887b8284293d69163007bbb',
+ },
+ },
+ 'addressBook': [
+ {
+ 'address': '0x06195827297c7a80a443b6894d3bdb8824b43896',
+ 'name': 'Address Book Account 1',
+ },
+ ],
+ 'tokens': [
+ {
+ 'address': '0x1a195821297c7a80a433b6894d3bdb8824b43896',
+ 'decimals': 18,
+ 'symbol': 'ABC',
+ },
+ {
+ 'address': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ 'decimals': 4,
+ 'symbol': 'DEF',
+ },
+ {
+ 'address': '0xa42084c8d1d9a2198631988579bb36b48433a72b',
+ 'decimals': 18,
+ 'symbol': 'GHI',
+ },
+ ],
+ 'tokenExchangeRates': {
+ 'def_eth': {
+ rate: 2.0,
+ },
+ 'ghi_eth': {
+ rate: 31.01,
+ },
+ },
+ 'transactions': {},
+ 'selectedAddressTxList': [
+ {
+ 'id': 'mockTokenTx1',
+ 'txParams': {
+ 'to': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ },
+ 'time': 1700000000000,
+ },
+ {
+ 'id': 'mockTokenTx2',
+ 'txParams': {
+ 'to': '0xafaketokenaddress',
+ },
+ 'time': 1600000000000,
+ },
+ {
+ 'id': 'mockTokenTx3',
+ 'txParams': {
+ 'to': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ },
+ 'time': 1500000000000,
+ },
+ {
+ 'id': 'mockEthTx1',
+ 'txParams': {
+ 'to': '0xd85a4b6a394794842887b8284293d69163007bbb',
+ },
+ 'time': 1400000000000,
+ },
+ ],
+ 'selectedTokenAddress': '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ 'unapprovedMsgs': {
+ '0xabc': { id: 'unapprovedMessage1', 'time': 1650000000000 },
+ '0xdef': { id: 'unapprovedMessage2', 'time': 1550000000000 },
+ '0xghi': { id: 'unapprovedMessage3', 'time': 1450000000000 },
+ },
+ 'unapprovedMsgCount': 0,
+ 'unapprovedPersonalMsgs': {},
+ 'unapprovedPersonalMsgCount': 0,
+ 'keyringTypes': [
+ 'Simple Key Pair',
+ 'HD Key Tree',
+ ],
+ 'keyrings': [
+ {
+ 'type': 'HD Key Tree',
+ 'accounts': [
+ 'fdea65c8e26263f6d9a1b5de9555d2931a33b825',
+ 'c5b8dbac4c1d3f152cdeb400e2313f309c410acb',
+ '2f8d4a878cfa04a6e60d46362f5644deab66572d',
+ ],
+ },
+ {
+ 'type': 'Simple Key Pair',
+ 'accounts': [
+ '0xd85a4b6a394794842887b8284293d69163007bbb',
+ ],
+ },
+ ],
+ 'selectedAddress': '0xd85a4b6a394794842887b8284293d69163007bbb',
+ 'provider': {
+ 'type': 'testnet',
+ },
+ 'shapeShiftTxList': [
+ { id: 'shapeShiftTx1', 'time': 1675000000000 },
+ { id: 'shapeShiftTx2', 'time': 1575000000000 },
+ { id: 'shapeShiftTx3', 'time': 1475000000000 },
+ ],
+ 'lostAccounts': [],
+ 'send': {
+ 'gasLimit': '0xFFFF',
+ 'gasPrice': '0xaa',
+ 'gasTotal': '0xb451dc41b578',
+ 'tokenBalance': 3434,
+ 'from': {
+ 'address': '0xabcdefg',
+ 'balance': '0x5f4e3d2c1',
+ },
+ 'to': '0x987fedabc',
+ 'amount': '0x080',
+ 'memo': '',
+ 'errors': {
+ 'someError': null,
+ },
+ 'maxModeOn': false,
+ 'editingTransactionId': 97531,
+ 'forceGasMin': true,
+ },
+ 'unapprovedTxs': {
+ '4768706228115573': {
+ 'id': 4768706228115573,
+ 'time': 1487363153561,
+ 'status': 'unapproved',
+ 'gasMultiplier': 1,
+ 'metamaskNetworkId': '3',
+ 'txParams': {
+ 'from': '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
+ 'to': '0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761',
+ 'value': '0xde0b6b3a7640000',
+ 'metamaskId': 4768706228115573,
+ 'metamaskNetworkId': '3',
+ 'gas': '0x5209',
+ },
+ 'gasLimitSpecified': false,
+ 'estimatedGas': '0x5209',
+ 'txFee': '17e0186e60800',
+ 'txValue': 'de0b6b3a7640000',
+ 'maxCost': 'de234b52e4a0800',
+ 'gasPrice': '4a817c800',
+ },
+ },
+ 'currentLocale': 'en',
+ recentBlocks: ['mockBlock1', 'mockBlock2', 'mockBlock3'],
+ },
+ 'appState': {
+ 'menuOpen': false,
+ 'currentView': {
+ 'name': 'accountDetail',
+ 'detailView': null,
+ 'context': '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc',
+ },
+ 'accountDetail': {
+ 'subview': 'transactions',
+ },
+ 'modal': {
+ 'modalState': {},
+ 'previousModalState': {},
+ },
+ 'transForward': true,
+ 'isLoading': false,
+ 'warning': null,
+ 'scrollToBottom': false,
+ 'forgottenPassword': null,
+ },
+ 'identities': {},
+ 'send': {
+ 'fromDropdownOpen': false,
+ 'toDropdownOpen': false,
+ 'errors': { 'someError': null },
+ },
+}
diff --git a/ui/app/components/send/tests/send-selectors.test.js b/ui/app/components/send/tests/send-selectors.test.js
new file mode 100644
index 000000000..218da656b
--- /dev/null
+++ b/ui/app/components/send/tests/send-selectors.test.js
@@ -0,0 +1,685 @@
+import assert from 'assert'
+import sinon from 'sinon'
+import selectors from '../send.selectors.js'
+const {
+ accountsWithSendEtherInfoSelector,
+ // autoAddToBetaUI,
+ getAddressBook,
+ getBlockGasLimit,
+ getAmountConversionRate,
+ getConversionRate,
+ getCurrentAccountWithSendEtherInfo,
+ getCurrentCurrency,
+ getCurrentNetwork,
+ getCurrentViewContext,
+ getForceGasMin,
+ getGasLimit,
+ getGasPrice,
+ getGasTotal,
+ getPrimaryCurrency,
+ getRecentBlocks,
+ getSelectedAccount,
+ getSelectedAddress,
+ getSelectedIdentity,
+ getSelectedToken,
+ getSelectedTokenContract,
+ getSelectedTokenExchangeRate,
+ getSelectedTokenToFiatRate,
+ getSendAmount,
+ getSendEditingTransactionId,
+ getSendErrors,
+ getSendFrom,
+ getSendFromBalance,
+ getSendFromObject,
+ getSendMaxModeState,
+ getSendTo,
+ getSendToAccounts,
+ getTokenBalance,
+ getTokenExchangeRate,
+ getUnapprovedTxs,
+ transactionsSelector,
+} = selectors
+import mockState from './send-selectors-test-data'
+
+describe('send selectors', () => {
+ const tempGlobalEth = Object.assign({}, global.eth)
+ beforeEach(() => {
+ global.eth = {
+ contract: sinon.stub().returns({
+ at: address => 'mockAt:' + address,
+ }),
+ }
+ })
+
+ afterEach(() => {
+ global.eth = tempGlobalEth
+ })
+
+ describe('accountsWithSendEtherInfoSelector()', () => {
+ it('should return an array of account objects with name info from identities', () => {
+ assert.deepEqual(
+ accountsWithSendEtherInfoSelector(mockState),
+ [
+ {
+ code: '0x',
+ balance: '0x47c9d71831c76efe',
+ nonce: '0x1b',
+ address: '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825',
+ name: 'Send Account 1',
+ },
+ {
+ code: '0x',
+ balance: '0x37452b1315889f80',
+ nonce: '0xa',
+ address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
+ name: 'Send Account 2',
+ },
+ {
+ code: '0x',
+ balance: '0x30c9d71831c76efe',
+ nonce: '0x1c',
+ address: '0x2f8d4a878cfa04a6e60d46362f5644deab66572d',
+ name: 'Send Account 3',
+ },
+ {
+ code: '0x',
+ balance: '0x0',
+ nonce: '0x0',
+ address: '0xd85a4b6a394794842887b8284293d69163007bbb',
+ name: 'Send Account 4',
+ },
+ ]
+ )
+ })
+ })
+
+ // describe('autoAddToBetaUI()', () => {
+ // it('should', () => {
+ // assert.deepEqual(
+ // autoAddToBetaUI(mockState),
+
+ // )
+ // })
+ // })
+
+ describe('getAddressBook()', () => {
+ it('should return the address book', () => {
+ assert.deepEqual(
+ getAddressBook(mockState),
+ [
+ {
+ address: '0x06195827297c7a80a443b6894d3bdb8824b43896',
+ name: 'Address Book Account 1',
+ },
+ ],
+ )
+ })
+ })
+
+ describe('getAmountConversionRate()', () => {
+ it('should return the token conversion rate if a token is selected', () => {
+ assert.equal(
+ getAmountConversionRate(mockState),
+ 2401.76400654
+ )
+ })
+
+ it('should return the eth conversion rate if no token is selected', () => {
+ const editedMockState = {
+ metamask: Object.assign({}, mockState.metamask, { selectedTokenAddress: null }),
+ }
+ assert.equal(
+ getAmountConversionRate(editedMockState),
+ 1200.88200327
+ )
+ })
+ })
+
+ describe('getBlockGasLimit', () => {
+ it('should return the current block gas limit', () => {
+ assert.deepEqual(
+ getBlockGasLimit(mockState),
+ '0x4c1878'
+ )
+ })
+ })
+
+ describe('getConversionRate()', () => {
+ it('should return the eth conversion rate', () => {
+ assert.deepEqual(
+ getConversionRate(mockState),
+ 1200.88200327
+ )
+ })
+ })
+
+ describe('getCurrentAccountWithSendEtherInfo()', () => {
+ it('should return the currently selected account with identity info', () => {
+ assert.deepEqual(
+ getCurrentAccountWithSendEtherInfo(mockState),
+ {
+ code: '0x',
+ balance: '0x0',
+ nonce: '0x0',
+ address: '0xd85a4b6a394794842887b8284293d69163007bbb',
+ name: 'Send Account 4',
+ }
+ )
+ })
+ })
+
+ describe('getCurrentCurrency()', () => {
+ it('should return the currently selected currency', () => {
+ assert.equal(
+ getCurrentCurrency(mockState),
+ 'USD'
+ )
+ })
+ })
+
+ describe('getCurrentNetwork()', () => {
+ it('should return the id of the currently selected network', () => {
+ assert.equal(
+ getCurrentNetwork(mockState),
+ '3'
+ )
+ })
+ })
+
+ describe('getCurrentViewContext()', () => {
+ it('should return the context of the current view', () => {
+ assert.equal(
+ getCurrentViewContext(mockState),
+ '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'
+ )
+ })
+ })
+
+ describe('getForceGasMin()', () => {
+ it('should get the send.forceGasMin property', () => {
+ assert.equal(
+ getForceGasMin(mockState),
+ true
+ )
+ })
+ })
+
+ describe('getGasLimit()', () => {
+ it('should return the send.gasLimit', () => {
+ assert.equal(
+ getGasLimit(mockState),
+ '0xFFFF'
+ )
+ })
+ })
+
+ describe('getGasPrice()', () => {
+ it('should return the send.gasPrice', () => {
+ assert.equal(
+ getGasPrice(mockState),
+ '0xaa'
+ )
+ })
+ })
+
+ describe('getGasTotal()', () => {
+ it('should return the send.gasTotal', () => {
+ assert.equal(
+ getGasTotal(mockState),
+ '0xb451dc41b578'
+ )
+ })
+ })
+
+ describe('getPrimaryCurrency()', () => {
+ it('should return the symbol of the selected token', () => {
+ assert.equal(
+ getPrimaryCurrency(mockState),
+ 'DEF'
+ )
+ })
+ })
+
+ describe('getRecentBlocks()', () => {
+ it('should return the recent blocks', () => {
+ assert.deepEqual(
+ getRecentBlocks(mockState),
+ ['mockBlock1', 'mockBlock2', 'mockBlock3']
+ )
+ })
+ })
+
+ describe('getSelectedAccount()', () => {
+ it('should return the currently selected account', () => {
+ assert.deepEqual(
+ getSelectedAccount(mockState),
+ {
+ code: '0x',
+ balance: '0x0',
+ nonce: '0x0',
+ address: '0xd85a4b6a394794842887b8284293d69163007bbb',
+ }
+ )
+ })
+ })
+
+ describe('getSelectedAddress()', () => {
+ it('should', () => {
+ assert.equal(
+ getSelectedAddress(mockState),
+ '0xd85a4b6a394794842887b8284293d69163007bbb'
+ )
+ })
+ })
+
+ describe('getSelectedIdentity()', () => {
+ it('should return the identity object of the currently selected address', () => {
+ assert.deepEqual(
+ getSelectedIdentity(mockState),
+ {
+ address: '0xd85a4b6a394794842887b8284293d69163007bbb',
+ name: 'Send Account 4',
+ }
+ )
+ })
+ })
+
+ describe('getSelectedToken()', () => {
+ it('should return the currently selected token if selected', () => {
+ assert.deepEqual(
+ getSelectedToken(mockState),
+ {
+ address: '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ decimals: 4,
+ symbol: 'DEF',
+ }
+ )
+ })
+
+ it('should return the send token if none is currently selected, but a send token exists', () => {
+ const mockSendToken = {
+ address: '0x123456708414189a58339873ab429b6c47ab92d3',
+ decimals: 4,
+ symbol: 'JKL',
+ }
+ const editedMockState = {
+ metamask: Object.assign({}, mockState.metamask, {
+ selectedTokenAddress: null,
+ send: {
+ token: mockSendToken,
+ },
+ }),
+ }
+ assert.deepEqual(
+ getSelectedToken(editedMockState),
+ Object.assign({}, mockSendToken)
+ )
+ })
+ })
+
+ describe('getSelectedTokenContract()', () => {
+ it('should return the contract at the selected token address', () => {
+ assert.equal(
+ getSelectedTokenContract(mockState),
+ 'mockAt:0x8d6b81208414189a58339873ab429b6c47ab92d3'
+ )
+ })
+
+ it('should return null if no token is selected', () => {
+ const modifiedMetamaskState = Object.assign({}, mockState.metamask, { selectedTokenAddress: false })
+ assert.equal(
+ getSelectedTokenContract(Object.assign({}, mockState, { metamask: modifiedMetamaskState })),
+ null
+ )
+ })
+ })
+
+ describe('getSelectedTokenExchangeRate()', () => {
+ it('should return the exchange rate for the selected token', () => {
+ assert.equal(
+ getSelectedTokenExchangeRate(mockState),
+ 2.0
+ )
+ })
+ })
+
+ describe('getSelectedTokenToFiatRate()', () => {
+ it('should return rate for converting the selected token to fiat', () => {
+ assert.equal(
+ getSelectedTokenToFiatRate(mockState),
+ 2401.76400654
+ )
+ })
+ })
+
+ describe('getSendAmount()', () => {
+ it('should return the send.amount', () => {
+ assert.equal(
+ getSendAmount(mockState),
+ '0x080'
+ )
+ })
+ })
+
+ describe('getSendEditingTransactionId()', () => {
+ it('should return the send.editingTransactionId', () => {
+ assert.equal(
+ getSendEditingTransactionId(mockState),
+ 97531
+ )
+ })
+ })
+
+ describe('getSendErrors()', () => {
+ it('should return the send.errors', () => {
+ assert.deepEqual(
+ getSendErrors(mockState),
+ { someError: null }
+ )
+ })
+ })
+
+ describe('getSendFrom()', () => {
+ it('should return the send.from', () => {
+ assert.deepEqual(
+ getSendFrom(mockState),
+ {
+ address: '0xabcdefg',
+ balance: '0x5f4e3d2c1',
+ }
+ )
+ })
+ })
+
+ describe('getSendFromBalance()', () => {
+ it('should get the send.from balance if it exists', () => {
+ assert.equal(
+ getSendFromBalance(mockState),
+ '0x5f4e3d2c1'
+ )
+ })
+
+ it('should get the selected account balance if the send.from does not exist', () => {
+ const editedMockState = {
+ metamask: Object.assign({}, mockState.metamask, {
+ send: {
+ from: null,
+ },
+ }),
+ }
+ assert.equal(
+ getSendFromBalance(editedMockState),
+ '0x0'
+ )
+ })
+ })
+
+ describe('getSendFromObject()', () => {
+ it('should return send.from if it exists', () => {
+ assert.deepEqual(
+ getSendFromObject(mockState),
+ {
+ address: '0xabcdefg',
+ balance: '0x5f4e3d2c1',
+ }
+ )
+ })
+
+ it('should return the current account with send ether info if send.from does not exist', () => {
+ const editedMockState = {
+ metamask: Object.assign({}, mockState.metamask, {
+ send: {
+ from: null,
+ },
+ }),
+ }
+ assert.deepEqual(
+ getSendFromObject(editedMockState),
+ {
+ code: '0x',
+ balance: '0x0',
+ nonce: '0x0',
+ address: '0xd85a4b6a394794842887b8284293d69163007bbb',
+ name: 'Send Account 4',
+ }
+ )
+ })
+ })
+
+ describe('getSendMaxModeState()', () => {
+ it('should return send.maxModeOn', () => {
+ assert.equal(
+ getSendMaxModeState(mockState),
+ false
+ )
+ })
+ })
+
+ describe('getSendTo()', () => {
+ it('should return send.to', () => {
+ assert.equal(
+ getSendTo(mockState),
+ '0x987fedabc'
+ )
+ })
+ })
+
+ describe('getSendToAccounts()', () => {
+ it('should return an array including all the users accounts and the address book', () => {
+ assert.deepEqual(
+ getSendToAccounts(mockState),
+ [
+ {
+ code: '0x',
+ balance: '0x47c9d71831c76efe',
+ nonce: '0x1b',
+ address: '0xfdea65c8e26263f6d9a1b5de9555d2931a33b825',
+ name: 'Send Account 1',
+ },
+ {
+ code: '0x',
+ balance: '0x37452b1315889f80',
+ nonce: '0xa',
+ address: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
+ name: 'Send Account 2',
+ },
+ {
+ code: '0x',
+ balance: '0x30c9d71831c76efe',
+ nonce: '0x1c',
+ address: '0x2f8d4a878cfa04a6e60d46362f5644deab66572d',
+ name: 'Send Account 3',
+ },
+ {
+ code: '0x',
+ balance: '0x0',
+ nonce: '0x0',
+ address: '0xd85a4b6a394794842887b8284293d69163007bbb',
+ name: 'Send Account 4',
+ },
+ {
+ address: '0x06195827297c7a80a443b6894d3bdb8824b43896',
+ name: 'Address Book Account 1',
+ },
+ ]
+ )
+ })
+ })
+
+ describe('getTokenBalance()', () => {
+ it('should', () => {
+ assert.equal(
+ getTokenBalance(mockState),
+ 3434
+ )
+ })
+ })
+
+ describe('getTokenExchangeRate()', () => {
+ it('should return the passed tokens exchange rates', () => {
+ assert.equal(
+ getTokenExchangeRate(mockState, 'GHI'),
+ 31.01
+ )
+ })
+ })
+
+ describe('getUnapprovedTxs()', () => {
+ it('should return the unapproved txs', () => {
+ assert.deepEqual(
+ getUnapprovedTxs(mockState),
+ {
+ 4768706228115573: {
+ id: 4768706228115573,
+ time: 1487363153561,
+ status: 'unapproved',
+ gasMultiplier: 1,
+ metamaskNetworkId: '3',
+ txParams: {
+ from: '0xc5b8dbac4c1d3f152cdeb400e2313f309c410acb',
+ to: '0x18a3462427bcc9133bb46e88bcbe39cd7ef0e761',
+ value: '0xde0b6b3a7640000',
+ metamaskId: 4768706228115573,
+ metamaskNetworkId: '3',
+ gas: '0x5209',
+ },
+ gasLimitSpecified: false,
+ estimatedGas: '0x5209',
+ txFee: '17e0186e60800',
+ txValue: 'de0b6b3a7640000',
+ maxCost: 'de234b52e4a0800',
+ gasPrice: '4a817c800',
+ },
+ }
+ )
+ })
+ })
+
+ describe('transactionsSelector()', () => {
+ it('should return the selected addresses selected token transactions', () => {
+ assert.deepEqual(
+ transactionsSelector(mockState),
+ [
+ {
+ id: 'mockTokenTx1',
+ txParams: {
+ to: '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ },
+ time: 1700000000000,
+ },
+ {
+ id: 'mockTokenTx3',
+ txParams: {
+ to: '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ },
+ time: 1500000000000,
+ },
+ ]
+ )
+ })
+
+ it('should return all transactions if no token is selected', () => {
+ const modifiedMetamaskState = Object.assign({}, mockState.metamask, { selectedTokenAddress: false })
+ const modifiedState = Object.assign({}, mockState, { metamask: modifiedMetamaskState })
+ assert.deepEqual(
+ transactionsSelector(modifiedState),
+ [
+ {
+ id: 'mockTokenTx1',
+ time: 1700000000000,
+ txParams: {
+ to: '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ },
+ },
+ {
+ id: 'unapprovedMessage1',
+ time: 1650000000000,
+ },
+ {
+ id: 'mockTokenTx2',
+ time: 1600000000000,
+ txParams: {
+ to: '0xafaketokenaddress',
+ },
+ },
+ {
+ id: 'unapprovedMessage2',
+ time: 1550000000000,
+ },
+ {
+ id: 'mockTokenTx3',
+ time: 1500000000000,
+ txParams: {
+ to: '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ },
+ },
+ {
+ id: 'unapprovedMessage3',
+ time: 1450000000000,
+ },
+ {
+ id: 'mockEthTx1',
+ time: 1400000000000,
+ txParams: {
+ to: '0xd85a4b6a394794842887b8284293d69163007bbb',
+ },
+ },
+ ]
+ )
+ })
+
+ it('should return shapeshift transactions if current network is 1', () => {
+ const modifiedMetamaskState = Object.assign({}, mockState.metamask, { selectedTokenAddress: false, network: '1' })
+ const modifiedState = Object.assign({}, mockState, { metamask: modifiedMetamaskState })
+ assert.deepEqual(
+ transactionsSelector(modifiedState),
+ [
+ {
+ id: 'mockTokenTx1',
+ time: 1700000000000,
+ txParams: {
+ to: '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ },
+ },
+ { id: 'shapeShiftTx1', 'time': 1675000000000 },
+ {
+ id: 'unapprovedMessage1',
+ time: 1650000000000,
+ },
+ {
+ id: 'mockTokenTx2',
+ time: 1600000000000,
+ txParams: {
+ to: '0xafaketokenaddress',
+ },
+ },
+ { id: 'shapeShiftTx2', 'time': 1575000000000 },
+ {
+ id: 'unapprovedMessage2',
+ time: 1550000000000,
+ },
+ {
+ id: 'mockTokenTx3',
+ time: 1500000000000,
+ txParams: {
+ to: '0x8d6b81208414189a58339873ab429b6c47ab92d3',
+ },
+ },
+ { id: 'shapeShiftTx3', 'time': 1475000000000 },
+ {
+ id: 'unapprovedMessage3',
+ time: 1450000000000,
+ },
+ {
+ id: 'mockEthTx1',
+ time: 1400000000000,
+ txParams: {
+ to: '0xd85a4b6a394794842887b8284293d69163007bbb',
+ },
+ },
+ ]
+ )
+ })
+ })
+
+})
diff --git a/ui/app/components/send/tests/send-utils.test.js b/ui/app/components/send/tests/send-utils.test.js
new file mode 100644
index 000000000..b8579e0e4
--- /dev/null
+++ b/ui/app/components/send/tests/send-utils.test.js
@@ -0,0 +1,486 @@
+import assert from 'assert'
+import sinon from 'sinon'
+import proxyquire from 'proxyquire'
+import {
+ BASE_TOKEN_GAS_COST,
+ ONE_GWEI_IN_WEI_HEX,
+ SIMPLE_GAS_COST,
+} from '../send.constants'
+const {
+ addCurrencies,
+ subtractCurrencies,
+} = require('../../../conversion-util')
+
+const {
+ INSUFFICIENT_FUNDS_ERROR,
+ INSUFFICIENT_TOKENS_ERROR,
+} = require('../send.constants')
+
+const stubs = {
+ addCurrencies: sinon.stub().callsFake((a, b, obj) => {
+ if (String(a).match(/^0x.+/)) a = Number(String(a).slice(2))
+ if (String(b).match(/^0x.+/)) b = Number(String(b).slice(2))
+ return a + b
+ }),
+ conversionUtil: sinon.stub().callsFake((val, obj) => parseInt(val, 16)),
+ conversionGTE: sinon.stub().callsFake((obj1, obj2) => obj1.value >= obj2.value),
+ multiplyCurrencies: sinon.stub().callsFake((a, b) => `${a}x${b}`),
+ calcTokenAmount: sinon.stub().callsFake((a, d) => 'calc:' + a + d),
+ rawEncode: sinon.stub().returns([16, 1100]),
+ conversionGreaterThan: sinon.stub().callsFake((obj1, obj2) => obj1.value > obj2.value),
+ conversionLessThan: sinon.stub().callsFake((obj1, obj2) => obj1.value < obj2.value),
+}
+
+const sendUtils = proxyquire('../send.utils.js', {
+ '../../conversion-util': {
+ addCurrencies: stubs.addCurrencies,
+ conversionUtil: stubs.conversionUtil,
+ conversionGTE: stubs.conversionGTE,
+ multiplyCurrencies: stubs.multiplyCurrencies,
+ conversionGreaterThan: stubs.conversionGreaterThan,
+ conversionLessThan: stubs.conversionLessThan,
+ },
+ '../../token-util': { calcTokenAmount: stubs.calcTokenAmount },
+ 'ethereumjs-abi': {
+ rawEncode: stubs.rawEncode,
+ },
+})
+
+const {
+ calcGasTotal,
+ estimateGas,
+ doesAmountErrorRequireUpdate,
+ estimateGasPriceFromRecentBlocks,
+ generateTokenTransferData,
+ getAmountErrorObject,
+ getGasFeeErrorObject,
+ getToAddressForGasUpdate,
+ calcTokenBalance,
+ isBalanceSufficient,
+ isTokenBalanceSufficient,
+} = sendUtils
+
+describe('send utils', () => {
+
+ describe('calcGasTotal()', () => {
+ it('should call multiplyCurrencies with the correct params and return the multiplyCurrencies return', () => {
+ const result = calcGasTotal(12, 15)
+ assert.equal(result, '12x15')
+ const call_ = stubs.multiplyCurrencies.getCall(0).args
+ assert.deepEqual(
+ call_,
+ [12, 15, {
+ toNumericBase: 'hex',
+ multiplicandBase: 16,
+ multiplierBase: 16,
+ } ]
+ )
+ })
+ })
+
+ describe('doesAmountErrorRequireUpdate()', () => {
+ const config = {
+ 'should return true if balances are different': {
+ balance: 0,
+ prevBalance: 1,
+ expectedResult: true,
+ },
+ 'should return true if gasTotals are different': {
+ gasTotal: 0,
+ prevGasTotal: 1,
+ expectedResult: true,
+ },
+ 'should return true if token balances are different': {
+ tokenBalance: 0,
+ prevTokenBalance: 1,
+ selectedToken: 'someToken',
+ expectedResult: true,
+ },
+ 'should return false if they are all the same': {
+ balance: 1,
+ prevBalance: 1,
+ gasTotal: 1,
+ prevGasTotal: 1,
+ tokenBalance: 1,
+ prevTokenBalance: 1,
+ selectedToken: 'someToken',
+ expectedResult: false,
+ },
+ }
+ Object.entries(config).map(([description, obj]) => {
+ it(description, () => {
+ assert.equal(doesAmountErrorRequireUpdate(obj), obj.expectedResult)
+ })
+ })
+
+ })
+
+ describe('generateTokenTransferData()', () => {
+ it('should return undefined if not passed a selected token', () => {
+ assert.equal(generateTokenTransferData({ toAddress: 'mockAddress', amount: '0xa', selectedToken: false}), undefined)
+ })
+
+ it('should call abi.rawEncode with the correct params', () => {
+ stubs.rawEncode.resetHistory()
+ generateTokenTransferData({ toAddress: 'mockAddress', amount: 'ab', selectedToken: true})
+ assert.deepEqual(
+ stubs.rawEncode.getCall(0).args,
+ [['address', 'uint256'], ['mockAddress', '0xab']]
+ )
+ })
+
+ it('should return encoded token transfer data', () => {
+ assert.equal(
+ generateTokenTransferData({ toAddress: 'mockAddress', amount: '0xa', selectedToken: true}),
+ '0xa9059cbb104c'
+ )
+ })
+ })
+
+ describe('getAmountErrorObject()', () => {
+ const config = {
+ 'should return insufficientFunds error if isBalanceSufficient returns false': {
+ amount: 15,
+ amountConversionRate: 2,
+ balance: 1,
+ conversionRate: 3,
+ gasTotal: 17,
+ primaryCurrency: 'ABC',
+ expectedResult: { amount: INSUFFICIENT_FUNDS_ERROR },
+ },
+ 'should not return insufficientFunds error if selectedToken is truthy': {
+ amount: '0x0',
+ amountConversionRate: 2,
+ balance: 1,
+ conversionRate: 3,
+ gasTotal: 17,
+ primaryCurrency: 'ABC',
+ selectedToken: { symbole: 'DEF', decimals: 0 },
+ decimals: 0,
+ tokenBalance: 'sometokenbalance',
+ expectedResult: { amount: null },
+ },
+ 'should return insufficientTokens error if token is selected and isTokenBalanceSufficient returns false': {
+ amount: '0x10',
+ amountConversionRate: 2,
+ balance: 100,
+ conversionRate: 3,
+ decimals: 10,
+ gasTotal: 17,
+ primaryCurrency: 'ABC',
+ selectedToken: 'someToken',
+ tokenBalance: 123,
+ expectedResult: { amount: INSUFFICIENT_TOKENS_ERROR },
+ },
+ }
+ Object.entries(config).map(([description, obj]) => {
+ it(description, () => {
+ assert.deepEqual(getAmountErrorObject(obj), obj.expectedResult)
+ })
+ })
+ })
+
+ describe('getGasFeeErrorObject()', () => {
+ const config = {
+ 'should return insufficientFunds error if isBalanceSufficient returns false': {
+ amountConversionRate: 2,
+ balance: 16,
+ conversionRate: 3,
+ gasTotal: 17,
+ primaryCurrency: 'ABC',
+ expectedResult: { gasFee: INSUFFICIENT_FUNDS_ERROR },
+ },
+ 'should return null error if isBalanceSufficient returns true': {
+ amountConversionRate: 2,
+ balance: 16,
+ conversionRate: 3,
+ gasTotal: 15,
+ primaryCurrency: 'ABC',
+ expectedResult: { gasFee: null },
+ },
+ }
+ Object.entries(config).map(([description, obj]) => {
+ it(description, () => {
+ assert.deepEqual(getGasFeeErrorObject(obj), obj.expectedResult)
+ })
+ })
+ })
+
+ describe('calcTokenBalance()', () => {
+ it('should return the calculated token blance', () => {
+ assert.equal(calcTokenBalance({
+ selectedToken: {
+ decimals: 11,
+ },
+ usersToken: {
+ balance: 20,
+ },
+ }), 'calc:2011')
+ })
+ })
+
+ describe('isBalanceSufficient()', () => {
+ it('should correctly call addCurrencies and return the result of calling conversionGTE', () => {
+ stubs.conversionGTE.resetHistory()
+ const result = isBalanceSufficient({
+ amount: 15,
+ amountConversionRate: 2,
+ balance: 100,
+ conversionRate: 3,
+ gasTotal: 17,
+ primaryCurrency: 'ABC',
+ })
+ assert.deepEqual(
+ stubs.addCurrencies.getCall(0).args,
+ [
+ 15, 17, {
+ aBase: 16,
+ bBase: 16,
+ toNumericBase: 'hex',
+ },
+ ]
+ )
+ assert.deepEqual(
+ stubs.conversionGTE.getCall(0).args,
+ [
+ {
+ value: 100,
+ fromNumericBase: 'hex',
+ fromCurrency: 'ABC',
+ conversionRate: 3,
+ },
+ {
+ value: 32,
+ fromNumericBase: 'hex',
+ conversionRate: 2,
+ fromCurrency: 'ABC',
+ },
+ ]
+ )
+
+ assert.equal(result, true)
+ })
+ })
+
+ describe('isTokenBalanceSufficient()', () => {
+ it('should correctly call conversionUtil and return the result of calling conversionGTE', () => {
+ stubs.conversionGTE.resetHistory()
+ stubs.conversionUtil.resetHistory()
+ const result = isTokenBalanceSufficient({
+ amount: '0x10',
+ tokenBalance: 123,
+ decimals: 10,
+ })
+ assert.deepEqual(
+ stubs.conversionUtil.getCall(0).args,
+ [
+ '0x10', {
+ fromNumericBase: 'hex',
+ },
+ ]
+ )
+ assert.deepEqual(
+ stubs.conversionGTE.getCall(0).args,
+ [
+ {
+ value: 123,
+ fromNumericBase: 'dec',
+ },
+ {
+ value: 'calc:1610',
+ fromNumericBase: 'dec',
+ },
+ ]
+ )
+
+ assert.equal(result, false)
+ })
+ })
+
+ describe('estimateGas', () => {
+ const baseMockParams = {
+ blockGasLimit: '0x64',
+ selectedAddress: 'mockAddress',
+ to: '0xisContract',
+ estimateGasMethod: sinon.stub().callsFake(
+ (data, cb) => cb(
+ data.to.match(/willFailBecauseOf:/) ? { message: data.to.match(/:(.+)$/)[1] } : null,
+ { toString: (n) => `0xabc${n}` }
+ )
+ ),
+ }
+ const baseExpectedCall = {
+ from: 'mockAddress',
+ gas: '0x64x0.95',
+ to: '0xisContract',
+ }
+
+ beforeEach(() => {
+ global.eth = {
+ getCode: sinon.stub().callsFake(
+ (address) => Promise.resolve(address.match(/isContract/) ? 'not-0x' : '0x')
+ ),
+ }
+ })
+
+ afterEach(() => {
+ baseMockParams.estimateGasMethod.resetHistory()
+ global.eth.getCode.resetHistory()
+ })
+
+ it('should call ethQuery.estimateGas with the expected params', async () => {
+ const result = await sendUtils.estimateGas(baseMockParams)
+ assert.equal(baseMockParams.estimateGasMethod.callCount, 1)
+ assert.deepEqual(
+ baseMockParams.estimateGasMethod.getCall(0).args[0],
+ Object.assign({ gasPrice: undefined, value: undefined }, baseExpectedCall)
+ )
+ assert.equal(result, '0xabc16')
+ })
+
+ it('should call ethQuery.estimateGas with the expected params when initialGasLimitHex is lower than the upperGasLimit', async () => {
+ const result = await estimateGas(Object.assign({}, baseMockParams, { blockGasLimit: '0xbcd' }))
+ assert.equal(baseMockParams.estimateGasMethod.callCount, 1)
+ assert.deepEqual(
+ baseMockParams.estimateGasMethod.getCall(0).args[0],
+ Object.assign({ gasPrice: undefined, value: undefined }, baseExpectedCall, { gas: '0xbcdx0.95' })
+ )
+ assert.equal(result, '0xabc16x1.5')
+ })
+
+ it('should call ethQuery.estimateGas with a value of 0x0 and the expected data and to if passed a selectedToken', async () => {
+ const result = await estimateGas(Object.assign({ data: 'mockData', selectedToken: { address: 'mockAddress' } }, baseMockParams))
+ assert.equal(baseMockParams.estimateGasMethod.callCount, 1)
+ assert.deepEqual(
+ baseMockParams.estimateGasMethod.getCall(0).args[0],
+ Object.assign({}, baseExpectedCall, {
+ gasPrice: undefined,
+ value: '0x0',
+ data: '0xa9059cbb104c',
+ to: 'mockAddress',
+ })
+ )
+ assert.equal(result, '0xabc16')
+ })
+
+ it(`should return ${SIMPLE_GAS_COST} if ethQuery.getCode does not return '0x'`, async () => {
+ assert.equal(baseMockParams.estimateGasMethod.callCount, 0)
+ const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123' }))
+ assert.equal(result, SIMPLE_GAS_COST)
+ })
+
+ it(`should return ${SIMPLE_GAS_COST} if not passed a selectedToken or truthy to address`, async () => {
+ assert.equal(baseMockParams.estimateGasMethod.callCount, 0)
+ const result = await estimateGas(Object.assign({}, baseMockParams, { to: null }))
+ assert.equal(result, SIMPLE_GAS_COST)
+ })
+
+ it(`should not return ${SIMPLE_GAS_COST} if passed a selectedToken`, async () => {
+ assert.equal(baseMockParams.estimateGasMethod.callCount, 0)
+ const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123', selectedToken: { address: '' } }))
+ assert.notEqual(result, SIMPLE_GAS_COST)
+ })
+
+ it(`should return ${BASE_TOKEN_GAS_COST} if passed a selectedToken but no to address`, async () => {
+ const result = await estimateGas(Object.assign({}, baseMockParams, { to: null, selectedToken: { address: '' } }))
+ assert.equal(result, BASE_TOKEN_GAS_COST)
+ })
+
+ it(`should return the adjusted blockGasLimit if it fails with a 'Transaction execution error.'`, async () => {
+ const result = await estimateGas(Object.assign({}, baseMockParams, {
+ to: 'isContract willFailBecauseOf:Transaction execution error.',
+ }))
+ assert.equal(result, '0x64x0.95')
+ })
+
+ it(`should return the adjusted blockGasLimit if it fails with a 'gas required exceeds allowance or always failing transaction.'`, async () => {
+ const result = await estimateGas(Object.assign({}, baseMockParams, {
+ to: 'isContract willFailBecauseOf:gas required exceeds allowance or always failing transaction.',
+ }))
+ assert.equal(result, '0x64x0.95')
+ })
+
+ it(`should reject other errors`, async () => {
+ try {
+ await estimateGas(Object.assign({}, baseMockParams, {
+ to: 'isContract willFailBecauseOf:some other error',
+ }))
+ } catch (err) {
+ assert.deepEqual(err, { message: 'some other error' })
+ }
+ })
+ })
+
+ describe('estimateGasPriceFromRecentBlocks', () => {
+ const ONE_GWEI_IN_WEI_HEX_PLUS_ONE = addCurrencies(ONE_GWEI_IN_WEI_HEX, '0x1', {
+ aBase: 16,
+ bBase: 16,
+ toNumericBase: 'hex',
+ })
+ const ONE_GWEI_IN_WEI_HEX_PLUS_TWO = addCurrencies(ONE_GWEI_IN_WEI_HEX, '0x2', {
+ aBase: 16,
+ bBase: 16,
+ toNumericBase: 'hex',
+ })
+ const ONE_GWEI_IN_WEI_HEX_MINUS_ONE = subtractCurrencies(ONE_GWEI_IN_WEI_HEX, '0x1', {
+ aBase: 16,
+ bBase: 16,
+ toNumericBase: 'hex',
+ })
+
+ it(`should return ${ONE_GWEI_IN_WEI_HEX} if recentBlocks is falsy`, () => {
+ assert.equal(estimateGasPriceFromRecentBlocks(), ONE_GWEI_IN_WEI_HEX)
+ })
+
+ it(`should return ${ONE_GWEI_IN_WEI_HEX} if recentBlocks is empty`, () => {
+ assert.equal(estimateGasPriceFromRecentBlocks([]), ONE_GWEI_IN_WEI_HEX)
+ })
+
+ it(`should estimate a block's gasPrice as ${ONE_GWEI_IN_WEI_HEX} if it has no gas prices`, () => {
+ const mockRecentBlocks = [
+ { gasPrices: null },
+ { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] },
+ { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] },
+ ]
+ assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX)
+ })
+
+ it(`should estimate a block's gasPrice as ${ONE_GWEI_IN_WEI_HEX} if it has empty gas prices`, () => {
+ const mockRecentBlocks = [
+ { gasPrices: [] },
+ { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] },
+ { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] },
+ ]
+ assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX)
+ })
+
+ it(`should return the middle value of all blocks lowest prices`, () => {
+ const mockRecentBlocks = [
+ { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_TWO ] },
+ { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] },
+ { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] },
+ ]
+ assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX_PLUS_ONE)
+ })
+
+ it(`should work if a block has multiple gas prices`, () => {
+ const mockRecentBlocks = [
+ { gasPrices: [ '0x1', '0x2', '0x3', '0x4', '0x5' ] },
+ { gasPrices: [ '0x101', '0x100', '0x103', '0x104', '0x102' ] },
+ { gasPrices: [ '0x150', '0x50', '0x100', '0x200', '0x5' ] },
+ ]
+ assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), '0x5')
+ })
+ })
+
+ describe('getToAddressForGasUpdate()', () => {
+ it('should return empty string if all params are undefined or null', () => {
+ assert.equal(getToAddressForGasUpdate(undefined, null), '')
+ })
+
+ it('should return the first string that is not defined or null in lower case', () => {
+ assert.equal(getToAddressForGasUpdate('A', null), 'a')
+ assert.equal(getToAddressForGasUpdate(undefined, 'B'), 'b')
+ })
+ })
+})
diff --git a/ui/app/components/send/to-autocomplete/index.js b/ui/app/components/send/to-autocomplete/index.js
new file mode 100644
index 000000000..afa2eb5a4
--- /dev/null
+++ b/ui/app/components/send/to-autocomplete/index.js
@@ -0,0 +1 @@
+export { default } from './to-autocomplete.js' \ No newline at end of file
diff --git a/ui/app/components/send/to-autocomplete/to-autocomplete.js b/ui/app/components/send/to-autocomplete/to-autocomplete.js
new file mode 100644
index 000000000..80cfa7a85
--- /dev/null
+++ b/ui/app/components/send/to-autocomplete/to-autocomplete.js
@@ -0,0 +1,120 @@
+const Component = require('react').Component
+const PropTypes = require('prop-types')
+const h = require('react-hyperscript')
+const inherits = require('util').inherits
+const AccountListItem = require('../account-list-item/account-list-item.component').default
+const connect = require('react-redux').connect
+
+ToAutoComplete.contextTypes = {
+ t: PropTypes.func,
+}
+
+module.exports = connect()(ToAutoComplete)
+
+
+inherits(ToAutoComplete, Component)
+function ToAutoComplete () {
+ Component.call(this)
+
+ this.state = { accountsToRender: [] }
+}
+
+ToAutoComplete.prototype.getListItemIcon = function (listItemAddress, toAddress) {
+ const listItemIcon = h(`i.fa.fa-check.fa-lg`, { style: { color: '#02c9b1' } })
+
+ return toAddress && listItemAddress === toAddress
+ ? listItemIcon
+ : null
+}
+
+ToAutoComplete.prototype.renderDropdown = function () {
+ const {
+ closeDropdown,
+ onChange,
+ to,
+ } = this.props
+ const { accountsToRender } = this.state
+
+ return accountsToRender.length && h('div', {}, [
+
+ h('div.send-v2__from-dropdown__close-area', {
+ onClick: closeDropdown,
+ }),
+
+ h('div.send-v2__from-dropdown__list', {}, [
+
+ ...accountsToRender.map(account => h(AccountListItem, {
+ account,
+ className: 'account-list-item__dropdown',
+ handleClick: () => {
+ onChange(account.address)
+ closeDropdown()
+ },
+ icon: this.getListItemIcon(account.address, to),
+ displayBalance: false,
+ displayAddress: true,
+ })),
+
+ ]),
+
+ ])
+}
+
+ToAutoComplete.prototype.handleInputEvent = function (event = {}, cb) {
+ const {
+ to,
+ accounts,
+ closeDropdown,
+ openDropdown,
+ } = this.props
+
+ const matchingAccounts = accounts.filter(({ address }) => address.match(to || ''))
+ const matches = matchingAccounts.length
+
+ if (!matches || matchingAccounts[0].address === to) {
+ this.setState({ accountsToRender: [] })
+ event.target && event.target.select()
+ closeDropdown()
+ } else {
+ this.setState({ accountsToRender: matchingAccounts })
+ openDropdown()
+ }
+ cb && cb(event.target.value)
+}
+
+ToAutoComplete.prototype.componentDidUpdate = function (nextProps, nextState) {
+ if (this.props.to !== nextProps.to) {
+ this.handleInputEvent()
+ }
+}
+
+ToAutoComplete.prototype.render = function () {
+ const {
+ to,
+ dropdownOpen,
+ onChange,
+ inError,
+ } = this.props
+
+ return h('div.send-v2__to-autocomplete', {}, [
+
+ h('input.send-v2__to-autocomplete__input', {
+ placeholder: this.context.t('recipientAddress'),
+ className: inError ? `send-v2__error-border` : '',
+ value: to,
+ onChange: event => onChange(event.target.value),
+ onFocus: event => this.handleInputEvent(event),
+ style: {
+ borderColor: inError ? 'red' : null,
+ },
+ }),
+
+ !to && h(`i.fa.fa-caret-down.fa-lg.send-v2__to-autocomplete__down-caret`, {
+ style: { color: '#dedede' },
+ onClick: () => this.handleInputEvent(),
+ }),
+
+ dropdownOpen && this.renderDropdown(),
+
+ ])
+}