aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.eslintrc1
-rw-r--r--app/_locales/en/messages.json7
-rw-r--r--app/scripts/metamask-controller.js14
-rw-r--r--development/states/confirm-new-ui.json7
-rw-r--r--development/states/send-edit.json7
-rw-r--r--development/states/send-new-ui.json7
-rw-r--r--development/states/tx-list-items.json7
-rw-r--r--gentests.js234
-rw-r--r--package-lock.json50
-rw-r--r--package.json3
-rw-r--r--test/integration/lib/send-new-ui.js8
-rw-r--r--ui/app/actions.js133
-rw-r--r--ui/app/app.js4
-rw-r--r--ui/app/components/currency-input.js113
-rw-r--r--ui/app/components/customize-gas-modal/index.js28
-rw-r--r--ui/app/components/dropdowns/account-dropdown-mini.js2
-rw-r--r--ui/app/components/input-number.js8
-rw-r--r--ui/app/components/page-container/index.js1
-rw-r--r--ui/app/components/page-container/page-container-content.component.js18
-rw-r--r--ui/app/components/page-container/page-container-footer/index.js1
-rw-r--r--ui/app/components/page-container/page-container-footer/page-container-footer.component.js54
-rw-r--r--ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js0
-rw-r--r--ui/app/components/page-container/page-container-header.component.js35
-rw-r--r--ui/app/components/page-container/page-container-header/index.js1
-rw-r--r--ui/app/components/page-container/page-container-header/page-container-header.component.js57
-rw-r--r--ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js0
-rw-r--r--ui/app/components/page-container/page-container.component.js72
-rw-r--r--ui/app/components/page-container/tests/page-container.component.test.js0
-rw-r--r--ui/app/components/pending-tx/confirm-deploy-contract.js2
-rw-r--r--ui/app/components/pending-tx/confirm-send-ether.js22
-rw-r--r--ui/app/components/pending-tx/confirm-send-token.js20
-rw-r--r--ui/app/components/send/account-list-item.js74
-rw-r--r--ui/app/components/send/currency-display.js65
-rw-r--r--ui/app/components/send/from-dropdown.js72
-rw-r--r--ui/app/components/send/gas-tooltip.js106
-rw-r--r--ui/app/components/send/memo-textarea.js33
-rw-r--r--ui/app/components/send/send-utils.js78
-rw-r--r--ui/app/components/send/send-v2-container.js89
-rw-r--r--ui/app/components/send/to-autocomplete.js2
-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.js74
-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_/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.js96
-rw-r--r--ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js51
-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.js164
-rw-r--r--ui/app/components/send_/send-content/send-amount-row/tests/send-amount-row-container.test.js109
-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/index.js1
-rw-r--r--ui/app/components/send_/send-content/send-gas-row/send-gas-row.component.js42
-rw-r--r--ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js26
-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.js9
-rw-r--r--ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-component.test.js65
-rw-r--r--ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-container.test.js66
-rw-r--r--ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-selectors.test.js22
-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.js70
-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.js21
-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.js45
-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.js99
-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.js227
-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.js160
-rw-r--r--ui/app/components/send_/send.constants.js (renamed from ui/app/components/send/send-constants.js)28
-rw-r--r--ui/app/components/send_/send.container.js89
-rw-r--r--ui/app/components/send_/send.scss0
-rw-r--r--ui/app/components/send_/send.selectors.js284
-rw-r--r--ui/app/components/send_/send.utils.js239
-rw-r--r--ui/app/components/send_/tests/send-component.test.js251
-rw-r--r--ui/app/components/send_/tests/send-container.test.js155
-rw-r--r--ui/app/components/send_/tests/send-selectors-test-data.js230
-rw-r--r--ui/app/components/send_/tests/send-selectors.test.js695
-rw-r--r--ui/app/components/send_/tests/send-utils.test.js404
-rw-r--r--ui/app/components/tx-list-item.js4
-rw-r--r--ui/app/conversion-util.js5
-rw-r--r--ui/app/css/itcss/components/currency-display.scss22
-rw-r--r--ui/app/ducks/send.duck.js72
-rw-r--r--ui/app/ducks/tests/send-duck.test.js145
-rw-r--r--ui/app/i18n-provider.js4
-rw-r--r--ui/app/main-container.js2
-rw-r--r--ui/app/metamask-connect.js2
-rw-r--r--ui/app/reducers.js7
-rw-r--r--ui/app/reducers/metamask.js11
-rw-r--r--ui/app/selectors.js2
-rw-r--r--ui/app/send-v2.js646
-rw-r--r--ui/app/util.js2
161 files changed, 7523 insertions, 1345 deletions
diff --git a/.eslintrc b/.eslintrc
index 511d6f216..b49a7a28a 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -142,6 +142,7 @@
"operator-linebreak": [1, "after", { "overrides": { "?": "ignore", ":": "ignore" } }],
"padded-blocks": "off",
"quotes": [2, "single", {"avoidEscape": true, "allowTemplateLiterals": true}],
+ "react/no-deprecated": 0,
"semi": [2, "never"],
"semi-spacing": [2, { "before": false, "after": true }],
"space-before-blocks": [1, "always"],
diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 40d362f51..457c3c3b1 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -253,6 +253,9 @@
"editAccountName": {
"message": "Edit Account Name"
},
+ "editingTransaction": {
+ "message": "Make changes to your transaction"
+ },
"emailUs": {
"message": "Email us!"
},
@@ -771,6 +774,10 @@
"onlySendToEtherAddress": {
"message": "Only send ETH to an Ethereum address."
},
+ "onlySendTokensToAccountAddress": {
+ "message": "Only send $1 to an Ethereum account address.",
+ "description": "displays token symbol"
+ },
"searchTokens": {
"message": "Search Tokens"
},
diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js
index c753fc06f..80eaafd92 100644
--- a/app/scripts/metamask-controller.js
+++ b/app/scripts/metamask-controller.js
@@ -387,6 +387,8 @@ module.exports = class MetamaskController extends EventEmitter {
updateAndApproveTransaction: nodeify(txController.updateAndApproveTransaction, txController),
retryTransaction: nodeify(this.retryTransaction, this),
getFilteredTxList: nodeify(txController.getFilteredTxList, txController),
+ isNonceTaken: nodeify(txController.isNonceTaken, txController),
+ estimateGas: nodeify(this.estimateGas, this),
// messageManager
signMessage: nodeify(this.signMessage, this),
@@ -945,6 +947,18 @@ module.exports = class MetamaskController extends EventEmitter {
return state
}
+ estimateGas (estimateGasParams) {
+ return new Promise((resolve, reject) => {
+ return this.txController.txGasUtil.query.estimateGas(estimateGasParams, (err, res) => {
+ if (err) {
+ return reject(err)
+ }
+
+ return resolve(res)
+ })
+ })
+ }
+
//=============================================================================
// PASSWORD MANAGEMENT
//=============================================================================
diff --git a/development/states/confirm-new-ui.json b/development/states/confirm-new-ui.json
index ae3098ecb..71ccbd96c 100644
--- a/development/states/confirm-new-ui.json
+++ b/development/states/confirm-new-ui.json
@@ -151,5 +151,10 @@
"scrollToBottom": false,
"forgottenPassword": null
},
- "identities": {}
+ "identities": {},
+ "send": {
+ "fromDropdownOpen": false,
+ "toDropdownOpen": false,
+ "errors": {}
+ }
}
diff --git a/development/states/send-edit.json b/development/states/send-edit.json
index ae3098ecb..71ccbd96c 100644
--- a/development/states/send-edit.json
+++ b/development/states/send-edit.json
@@ -151,5 +151,10 @@
"scrollToBottom": false,
"forgottenPassword": null
},
- "identities": {}
+ "identities": {},
+ "send": {
+ "fromDropdownOpen": false,
+ "toDropdownOpen": false,
+ "errors": {}
+ }
}
diff --git a/development/states/send-new-ui.json b/development/states/send-new-ui.json
index 1297a9139..4b2d20730 100644
--- a/development/states/send-new-ui.json
+++ b/development/states/send-new-ui.json
@@ -130,5 +130,10 @@
"scrollToBottom": false,
"forgottenPassword": null
},
- "identities": {}
+ "identities": {},
+ "send": {
+ "fromDropdownOpen": false,
+ "toDropdownOpen": false,
+ "errors": {}
+ }
}
diff --git a/development/states/tx-list-items.json b/development/states/tx-list-items.json
index d567e3fed..43e6f4b90 100644
--- a/development/states/tx-list-items.json
+++ b/development/states/tx-list-items.json
@@ -124,5 +124,10 @@
"scrollToBottom": false,
"forgottenPassword": null
},
- "identities": {}
+ "identities": {},
+ "send": {
+ "fromDropdownOpen": false,
+ "toDropdownOpen": false,
+ "errors": {}
+ }
}
diff --git a/gentests.js b/gentests.js
new file mode 100644
index 000000000..08232e10f
--- /dev/null
+++ b/gentests.js
@@ -0,0 +1,234 @@
+const fs = require('fs')
+const async = require('async')
+const path = require('path')
+const promisify = require('pify')
+
+// start(/\.selectors.js/, generateSelectorTest).catch(console.error)
+// start(/\.utils.js/, generateUtilTest).catch(console.error)
+startContainer(/\.container.js/, generateContainerTest).catch(console.error)
+
+async function getAllFileNames (dirName) {
+ const rootPath = path.join(__dirname, dirName)
+ const allNames = (await promisify(fs.readdir)(dirName))
+ const fileNames = allNames.filter(name => name.match(/^.+\./))
+ const dirNames = allNames.filter(name => name.match(/^[^.]+$/))
+
+ const fullPathDirNames = dirNames.map(d => `${dirName}/${d}`)
+ const subNameArrays = await promisify(async.map)(fullPathDirNames, getAllFileNames)
+ let subNames = []
+ subNameArrays.forEach(subNameArray => subNames = [...subNames, ...subNameArray])
+
+ return [
+ ...fileNames.map(name => dirName + '/' + name),
+ ...subNames,
+ ]
+}
+
+async function start (fileRegEx, testGenerator) {
+ const fileNames = await getAllFileNames('./ui/app')
+ const sFiles = fileNames.filter(name => name.match(fileRegEx))
+
+ let sFileMethodNames
+ let testFilePath
+ async.each(sFiles, async (sFile, cb) => {
+ let [, sRootPath, sPath] = sFile.match(/^(.+\/)([^/]+)$/)
+ sFileMethodNames = Object.keys(require(__dirname + '/' + sFile))
+
+ testFilePath = sPath.replace('.', '-').replace('.', '.test.')
+
+ await promisify(fs.writeFile)(
+ `${__dirname}/${sRootPath}tests/${testFilePath}`,
+ testGenerator(sPath, sFileMethodNames),
+ 'utf8'
+ )
+ }, (err) => {
+ console.log(err)
+ })
+
+}
+
+async function startContainer (fileRegEx, testGenerator) {
+ const fileNames = await getAllFileNames('./ui/app')
+ const sFiles = fileNames.filter(name => name.match(fileRegEx))
+
+ let sFileMethodNames
+ async.each(sFiles, async (sFile, cb) => {
+ console.log(`sFile`, sFile);
+ let [, sRootPath, sPath] = sFile.match(/^(.+\/)([^/]+)$/)
+
+ let testFilePath = sPath.replace('.', '-').replace('.', '.test.')
+
+ await promisify(fs.readFile)(
+ __dirname + '/' + sFile,
+ 'utf8',
+ async (err, result) => {
+ console.log(`result`, result.length);
+ const returnObjectStrings = result
+ .match(/return\s(\{[\s\S]+?})\n}/g)
+ .map(str => {
+ return str
+ .slice(0, str.length - 1)
+ .slice(7)
+ .replace(/\n/g, '')
+ .replace(/\s\s+/g, ' ')
+
+ })
+ const mapStateToPropsAssertionObject = returnObjectStrings[0]
+ .replace(/\w+:\s\w+\([\w,\s]+\),/g, str => {
+ const strKey = str.match(/^\w+/)[0]
+ return strKey + ': \'mock' + str.match(/^\w+/)[0].replace(/^./, c => c.toUpperCase()) + ':mockState\',\n'
+ })
+ .replace(/{\s\w.+/, firstLinePair => `{\n ${firstLinePair.slice(2)}`)
+ .replace(/\w+:.+,/g, s => ` ${s}`)
+ .replace(/}/g, s => ` ${s}`)
+ let mapDispatchToPropsMethodNames
+ if (returnObjectStrings[1]) {
+ mapDispatchToPropsMethodNames = returnObjectStrings[1].match(/\s\w+:\s/g).map(str => str.match(/\w+/)[0])
+ }
+ const proxyquireObject = ('{\n ' + result
+ .match(/import\s{[\s\S]+?}\sfrom\s.+/g)
+ .map(s => s.replace(/\n/g, ''))
+ .map((s, i) => {
+ const proxyKeys = s.match(/{.+}/)[0].match(/\w+/g)
+ return '\'' + s.match(/'(.+)'/)[1] + '\': { ' + (proxyKeys.length > 1
+ ? '\n ' + proxyKeys.join(': () => {},\n ') + ': () => {},\n '
+ : proxyKeys[0] + ': () => {},') + ' }'
+ })
+ .join(',\n ') + '\n}')
+ .replace('{ connect: () => {}, },', `{
+ connect: (ms, md) => {
+ mapStateToProps = ms
+ mapDispatchToProps = md
+ return () => ({})
+ },
+ },`)
+ // console.log(`proxyquireObject`, proxyquireObject);
+ // console.log(`mapStateToPropsAssertionObject`, mapStateToPropsAssertionObject);
+ // console.log(`mapDispatchToPropsMethodNames`, mapDispatchToPropsMethodNames);
+
+ const containerTest = generateContainerTest(sPath, {
+ mapStateToPropsAssertionObject,
+ mapDispatchToPropsMethodNames,
+ proxyquireObject,
+ })
+ // console.log(`containerTest`, `${__dirname}/${sRootPath}tests/${testFilePath}`, containerTest);
+ console.log('----')
+ console.log(`sRootPath`, sRootPath);
+ console.log(`testFilePath`, testFilePath);
+ await promisify(fs.writeFile)(
+ `${__dirname}/${sRootPath}tests/${testFilePath}`,
+ containerTest,
+ 'utf8'
+ )
+ }
+ )
+ }, (err) => {
+ console.log('123', err)
+ })
+
+}
+
+function generateMethodList (methodArray) {
+ return methodArray.map(n => ' ' + n).join(',\n') + ','
+}
+
+function generateMethodDescribeBlock (methodName, index) {
+ const describeBlock =
+ `${index ? ' ' : ''}describe('${methodName}()', () => {
+ it('should', () => {
+ const state = {}
+
+ assert.equal(${methodName}(state), )
+ })
+ })`
+ return describeBlock
+}
+
+function generateDispatchMethodDescribeBlock (methodName, index) {
+ const describeBlock =
+ `${index ? ' ' : ''}describe('${methodName}()', () => {
+ it('should dispatch an action', () => {
+ mapDispatchToPropsObject.${methodName}()
+ assert(dispatchSpy.calledOnce)
+ })
+ })`
+ return describeBlock
+}
+
+function generateMethodDescribeBlocks (methodArray) {
+ return methodArray
+ .map((methodName, index) => generateMethodDescribeBlock(methodName, index))
+ .join('\n\n')
+}
+
+function generateDispatchMethodDescribeBlocks (methodArray) {
+ return methodArray
+ .map((methodName, index) => generateDispatchMethodDescribeBlock(methodName, index))
+ .join('\n\n')
+}
+
+function generateSelectorTest (name, methodArray) {
+return `import assert from 'assert'
+import {
+${generateMethodList(methodArray)}
+} from '../${name}'
+
+describe('${name.match(/^[^.]+/)} selectors', () => {
+
+ ${generateMethodDescribeBlocks(methodArray)}
+
+})`
+}
+
+function generateUtilTest (name, methodArray) {
+return `import assert from 'assert'
+import {
+${generateMethodList(methodArray)}
+} from '../${name}'
+
+describe('${name.match(/^[^.]+/)} utils', () => {
+
+ ${generateMethodDescribeBlocks(methodArray)}
+
+})`
+}
+
+function generateContainerTest (sPath, {
+ mapStateToPropsAssertionObject,
+ mapDispatchToPropsMethodNames,
+ proxyquireObject,
+}) {
+return `import assert from 'assert'
+import proxyquire from 'proxyquire'
+import sinon from 'sinon'
+
+let mapStateToProps
+let mapDispatchToProps
+
+proxyquire('../${sPath}', ${proxyquireObject})
+
+describe('${sPath.match(/^[^.]+/)} container', () => {
+
+ describe('mapStateToProps()', () => {
+
+ it('should map the correct properties to props', () => {
+ assert.deepEqual(mapStateToProps('mockState'), ${mapStateToPropsAssertionObject})
+ })
+
+ })
+
+ describe('mapDispatchToProps()', () => {
+ let dispatchSpy
+ let mapDispatchToPropsObject
+
+ beforeEach(() => {
+ dispatchSpy = sinon.spy()
+ mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy)
+ })
+
+ ${mapDispatchToPropsMethodNames ? generateDispatchMethodDescribeBlocks(mapDispatchToPropsMethodNames) : 'delete'}
+
+ })
+
+})`
+} \ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index dd046f089..62ada9459 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9597,6 +9597,16 @@
"integrity": "sha512-ZH7loueKBoDb7yG9esn1U+fgq7BzlzW6NRi5/rMdxIZ05dj7GFD/Xc5rq2CDt5Yq86CyfSYVyx4242QQNZbx1g==",
"dev": true
},
+ "fill-keys": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz",
+ "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=",
+ "dev": true,
+ "requires": {
+ "is-object": "1.0.1",
+ "merge-descriptors": "1.0.1"
+ }
+ },
"fill-range": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.3.tgz",
@@ -18712,6 +18722,12 @@
}
}
},
+ "module-not-found-error": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz",
+ "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=",
+ "dev": true
+ },
"moment": {
"version": "2.22.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.22.1.tgz",
@@ -24501,6 +24517,28 @@
"integrity": "sha1-M8UDmPcOp+uW0h97gXYwpVeRx+4=",
"dev": true
},
+ "proxyquire": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.0.1.tgz",
+ "integrity": "sha512-fQr3VQrbdzHrdaDn3XuisVoJlJNDJizHAvUXw9IuXRR8BpV2x0N7LsCxrpJkeKfPbNjiNU/V5vc008cI0TmzzQ==",
+ "dev": true,
+ "requires": {
+ "fill-keys": "1.0.2",
+ "module-not-found-error": "1.0.1",
+ "resolve": "1.5.0"
+ },
+ "dependencies": {
+ "resolve": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.5.0.tgz",
+ "integrity": "sha512-hgoSGrc3pjzAPHNBg+KnFcK2HwlHTs/YrAGUr6qgTVUZmXv1UEXXl0bZNBKMA9fud6lRYFdPGz0xXxycPzmmiw==",
+ "dev": true,
+ "requires": {
+ "path-parse": "1.0.5"
+ }
+ }
+ }
+ },
"prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
@@ -26474,6 +26512,18 @@
"object-assign": "4.1.1"
}
},
+ "react": {
+ "version": "15.6.2",
+ "resolved": "https://registry.npmjs.org/react/-/react-15.6.2.tgz",
+ "integrity": "sha1-26BDSrQ5z+gvEI8PURZjkIF5qnI=",
+ "requires": {
+ "create-react-class": "15.6.2",
+ "fbjs": "0.8.16",
+ "loose-envify": "1.3.1",
+ "object-assign": "4.1.1",
+ "prop-types": "15.6.1"
+ }
+ },
"react-hyperscript": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/react-hyperscript/-/react-hyperscript-2.4.2.tgz",
diff --git a/package.json b/package.json
index ba764f45b..c08ae455b 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"dist": "gulp dist",
"doc": "jsdoc -c development/tools/.jsdoc.json",
"test": "npm run test:unit && npm run test:integration && npm run lint",
- "test:unit": "cross-env METAMASK_ENV=test mocha --exit --require test/setup.js --recursive \"test/unit/**/*.js\" && dot-only-hunter",
+ "test:unit": "cross-env METAMASK_ENV=test mocha --exit --require test/setup.js --recursive \"test/unit/**/*.js\" \"ui/app/**/*.test.js\" && dot-only-hunter",
"test:single": "cross-env METAMASK_ENV=test mocha --require test/helper.js",
"test:integration": "npm run test:integration:build && npm run test:flat && npm run test:mascara",
"test:integration:build": "gulp build:scss",
@@ -276,6 +276,7 @@
"path": "^0.12.7",
"png-file-stream": "^1.0.0",
"prompt": "^1.0.0",
+ "proxyquire": "2.0.1",
"qs": "^6.2.0",
"qunitjs": "^2.4.1",
"radgrad-jsdoc-template": "^1.1.3",
diff --git a/test/integration/lib/send-new-ui.js b/test/integration/lib/send-new-ui.js
index 176907926..4d2ea2ea4 100644
--- a/test/integration/lib/send-new-ui.js
+++ b/test/integration/lib/send-new-ui.js
@@ -101,7 +101,7 @@ async function runSendFlowTest(assert, done) {
const sendAmountField = await queryAsync($, '.send-v2__form-row:eq(2)')
sendAmountField.find('.currency-display')[0].click()
- const sendAmountFieldInput = await findAsync(sendAmountField, 'input:text')
+ const sendAmountFieldInput = await findAsync(sendAmountField, '.currency-display__input')
sendAmountFieldInput.val('5.1')
reactTriggerChange(sendAmountField.find('input')[0])
@@ -117,7 +117,7 @@ async function runSendFlowTest(assert, done) {
const sendGasField = await queryAsync($, '.send-v2__gas-fee-display')
assert.equal(
sendGasField.find('.currency-display__input-wrapper > input').val(),
- '0.000198',
+ '0.000198264',
'send gas field should show estimated gas total'
)
assert.equal(
@@ -127,7 +127,7 @@ async function runSendFlowTest(assert, done) {
)
await customizeGas(assert, 0, 21000, '0', '$0.00 USD')
- await customizeGas(assert, 500, 60000, '0.003', '$3.60 USD')
+ await customizeGas(assert, 500, 60000, '0.03', '$36.03 USD')
const sendButton = await queryAsync($, 'button.btn-primary.btn--large.page-container__footer-button')
assert.equal(sendButton[0].textContent, 'Next', 'next button rendered')
@@ -165,7 +165,7 @@ async function runSendFlowTest(assert, done) {
const sendAmountFieldInEdit = await queryAsync($, '.send-v2__form-row:eq(2)')
sendAmountFieldInEdit.find('.currency-display')[0].click()
- const sendAmountFieldInputInEdit = sendAmountFieldInEdit.find('input:text')
+ const sendAmountFieldInputInEdit = sendAmountFieldInEdit.find('.currency-display__input')
sendAmountFieldInputInEdit.val('1.0')
reactTriggerChange(sendAmountFieldInputInEdit[0])
diff --git a/ui/app/actions.js b/ui/app/actions.js
index a9372d6f3..465b3d5fe 100644
--- a/ui/app/actions.js
+++ b/ui/app/actions.js
@@ -2,6 +2,12 @@ const abi = require('human-standard-token-abi')
const pify = require('pify')
const getBuyEthUrl = require('../../app/scripts/lib/buy-eth-url')
const { getTokenAddressFromTokenObject } = require('./util')
+const {
+ calcGasTotal,
+ calcTokenBalance,
+ estimateGas,
+ estimateGasPriceFromRecentBlocks,
+} = require('./components/send_/send.utils')
const ethUtil = require('ethereumjs-util')
const { fetchLocale } = require('../i18n-helper')
const log = require('loglevel')
@@ -155,8 +161,6 @@ var actions = {
updateTransactionParams,
UPDATE_TRANSACTION_PARAMS: 'UPDATE_TRANSACTION_PARAMS',
// send screen
- estimateGas,
- getGasPrice,
UPDATE_GAS_LIMIT: 'UPDATE_GAS_LIMIT',
UPDATE_GAS_PRICE: 'UPDATE_GAS_PRICE',
UPDATE_GAS_TOTAL: 'UPDATE_GAS_TOTAL',
@@ -169,17 +173,21 @@ var actions = {
UPDATE_MAX_MODE: 'UPDATE_MAX_MODE',
UPDATE_SEND: 'UPDATE_SEND',
CLEAR_SEND: 'CLEAR_SEND',
- updateGasLimit,
- updateGasPrice,
- updateGasTotal,
+ OPEN_FROM_DROPDOWN: 'OPEN_FROM_DROPDOWN',
+ CLOSE_FROM_DROPDOWN: 'CLOSE_FROM_DROPDOWN',
+ setGasLimit,
+ setGasPrice,
+ updateGasData,
+ setGasTotal,
+ setSendTokenBalance,
updateSendTokenBalance,
updateSendFrom,
updateSendTo,
updateSendAmount,
updateSendMemo,
- updateSendErrors,
setMaxModeTo,
updateSend,
+ updateSendErrors,
clearSend,
setSelectedAddress,
// app messages
@@ -701,60 +709,96 @@ function signTx (txData) {
}
}
-function estimateGas (params = {}) {
- return (dispatch) => {
- return new Promise((resolve, reject) => {
- global.ethQuery.estimateGas(params, (err, data) => {
- if (err) {
- dispatch(actions.displayWarning(err.message))
- return reject(err)
- }
- dispatch(actions.hideWarning())
- dispatch(actions.updateGasLimit(data))
- return resolve(data)
- })
- })
+function setGasLimit (gasLimit) {
+ return {
+ type: actions.UPDATE_GAS_LIMIT,
+ value: gasLimit,
}
}
-function updateGasLimit (gasLimit) {
+function setGasPrice (gasPrice) {
return {
- type: actions.UPDATE_GAS_LIMIT,
- value: gasLimit,
+ type: actions.UPDATE_GAS_PRICE,
+ value: gasPrice,
+ }
+}
+
+function setGasTotal (gasTotal) {
+ return {
+ type: actions.UPDATE_GAS_TOTAL,
+ value: gasTotal,
}
}
-function getGasPrice () {
+function updateGasData ({
+ blockGasLimit,
+ recentBlocks,
+ selectedAddress,
+ selectedToken,
+ to,
+ value,
+}) {
+ const estimatedGasPrice = estimateGasPriceFromRecentBlocks(recentBlocks)
return (dispatch) => {
- return new Promise((resolve, reject) => {
- global.ethQuery.gasPrice((err, data) => {
- if (err) {
- dispatch(actions.displayWarning(err.message))
- return reject(err)
- }
- dispatch(actions.hideWarning())
- dispatch(actions.updateGasPrice(data))
- return resolve(data)
- })
+ return Promise.all([
+ Promise.resolve(estimatedGasPrice),
+ estimateGas({
+ estimateGasMethod: background.estimateGas,
+ blockGasLimit,
+ selectedAddress,
+ selectedToken,
+ to,
+ value,
+ gasPrice: estimatedGasPrice,
+ }),
+ ])
+ .then(([gasPrice, gas]) => {
+ dispatch(actions.setGasPrice(gasPrice))
+ dispatch(actions.setGasLimit(gas))
+ return calcGasTotal(gas, gasPrice)
+ })
+ .then((gasEstimate) => {
+ dispatch(actions.setGasTotal(gasEstimate))
+ dispatch(updateSendErrors({ gasLoadingError: null }))
+ })
+ .catch(err => {
+ log.error(err)
+ dispatch(updateSendErrors({ gasLoadingError: 'gasLoadingError' }))
})
}
}
-function updateGasPrice (gasPrice) {
- return {
- type: actions.UPDATE_GAS_PRICE,
- value: gasPrice,
+function updateSendTokenBalance ({
+ selectedToken,
+ tokenContract,
+ address,
+}) {
+ return (dispatch) => {
+ const tokenBalancePromise = tokenContract
+ ? tokenContract.balanceOf(address)
+ : Promise.resolve()
+ return tokenBalancePromise
+ .then(usersToken => {
+ if (usersToken) {
+ const newTokenBalance = calcTokenBalance({ selectedToken, usersToken })
+ dispatch(setSendTokenBalance(newTokenBalance.toString(10)))
+ }
+ })
+ .catch(err => {
+ log.error(err)
+ updateSendErrors({ tokenBalance: 'tokenBalanceError' })
+ })
}
}
-function updateGasTotal (gasTotal) {
+function updateSendErrors (errorObject) {
return {
- type: actions.UPDATE_GAS_TOTAL,
- value: gasTotal,
+ type: actions.UPDATE_SEND_ERRORS,
+ value: errorObject,
}
}
-function updateSendTokenBalance (tokenBalance) {
+function setSendTokenBalance (tokenBalance) {
return {
type: actions.UPDATE_SEND_TOKEN_BALANCE,
value: tokenBalance,
@@ -789,13 +833,6 @@ function updateSendMemo (memo) {
}
}
-function updateSendErrors (error) {
- return {
- type: actions.UPDATE_SEND_ERRORS,
- value: error,
- }
-}
-
function setMaxModeTo (bool) {
return {
type: actions.UPDATE_MAX_MODE,
diff --git a/ui/app/app.js b/ui/app/app.js
index 0e8b907df..09f7fdef4 100644
--- a/ui/app/app.js
+++ b/ui/app/app.js
@@ -11,7 +11,7 @@ const log = require('loglevel')
// init
const InitializeScreen = require('../../mascara/src/app/first-time').default
// accounts
-const SendTransactionScreen2 = require('./components/send/send-v2-container')
+const SendTransactionScreen = require('./components/send_/send.container')
const ConfirmTxScreen = require('./conf-tx')
// slideout menu
@@ -77,7 +77,7 @@ class App extends Component {
h(Authenticated, { path: SETTINGS_ROUTE, component: Settings }),
h(Authenticated, { path: NOTICE_ROUTE, exact, component: NoticeScreen }),
h(Authenticated, { path: `${CONFIRM_TRANSACTION_ROUTE}/:id?`, component: ConfirmTxScreen }),
- h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen2 }),
+ h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen }),
h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }),
h(Authenticated, { path: CONFIRM_ADD_TOKEN_ROUTE, exact, component: ConfirmAddTokenPage }),
h(Authenticated, { path: NEW_ACCOUNT_ROUTE, component: CreateAccountPage }),
diff --git a/ui/app/components/currency-input.js b/ui/app/components/currency-input.js
deleted file mode 100644
index ece3eb43d..000000000
--- a/ui/app/components/currency-input.js
+++ /dev/null
@@ -1,113 +0,0 @@
-const Component = require('react').Component
-const h = require('react-hyperscript')
-const inherits = require('util').inherits
-
-module.exports = CurrencyInput
-
-inherits(CurrencyInput, Component)
-function CurrencyInput (props) {
- Component.call(this)
-
- const sanitizedValue = sanitizeValue(props.value)
-
- this.state = {
- value: sanitizedValue,
- emptyState: false,
- focused: false,
- }
-}
-
-function removeNonDigits (str) {
- return str.match(/\d|$/g).join('')
-}
-
-// Removes characters that are not digits, then removes leading zeros
-function sanitizeInteger (val) {
- return String(parseInt(removeNonDigits(val) || '0', 10))
-}
-
-function sanitizeDecimal (val) {
- return removeNonDigits(val)
-}
-
-// Take a single string param and returns a non-negative integer or float as a string.
-// Breaks the input into three parts: the integer, the decimal point, and the decimal/fractional part.
-// Removes leading zeros from the integer, and non-digits from the integer and decimal
-// The integer is returned as '0' in cases where it would be empty. A decimal point is
-// included in the returned string if one is included in the param
-// Examples:
-// sanitizeValue('0') -> '0'
-// sanitizeValue('a') -> '0'
-// sanitizeValue('010.') -> '10.'
-// sanitizeValue('0.005') -> '0.005'
-// sanitizeValue('22.200') -> '22.200'
-// sanitizeValue('.200') -> '0.200'
-// sanitizeValue('a.b.1.c,89.123') -> '0.189123'
-function sanitizeValue (value) {
- let [ , integer, point, decimal] = (/([^.]*)([.]?)([^.]*)/).exec(value)
-
- integer = sanitizeInteger(integer) || '0'
- decimal = sanitizeDecimal(decimal)
-
- return `${integer}${point}${decimal}`
-}
-
-CurrencyInput.prototype.handleChange = function (newValue) {
- const { onInputChange } = this.props
- const { value } = this.state
-
- let parsedValue = newValue
- const newValueLastIndex = newValue.length - 1
-
- if (value === '0' && newValue[newValueLastIndex] === '0') {
- parsedValue = parsedValue.slice(0, newValueLastIndex)
- }
- const sanitizedValue = sanitizeValue(parsedValue)
- this.setState({
- value: sanitizedValue,
- emptyState: newValue === '' && sanitizedValue === '0',
- })
- onInputChange(sanitizedValue)
-}
-
-// If state.value === props.value plus a decimal point, or at least one
-// zero or a decimal point and at least one zero, then this returns state.value
-// after it is sanitized with getValueParts
-CurrencyInput.prototype.getValueToRender = function () {
- const { value } = this.props
- const { value: stateValue } = this.state
-
- const trailingStateString = (new RegExp(`^${value}(.+)`)).exec(stateValue)
- const trailingDecimalAndZeroes = trailingStateString && (/^[.0]0*/).test(trailingStateString[1])
-
- return sanitizeValue(trailingDecimalAndZeroes
- ? stateValue
- : value)
-}
-
-CurrencyInput.prototype.render = function () {
- const {
- className,
- placeholder,
- readOnly,
- inputRef,
- type,
- } = this.props
- const { emptyState, focused } = this.state
-
- const inputSizeMultiplier = readOnly ? 1 : 1.2
-
- const valueToRender = this.getValueToRender()
- return h('input', {
- className,
- type,
- value: emptyState ? '' : valueToRender,
- placeholder: focused ? '' : placeholder,
- size: valueToRender.length * inputSizeMultiplier,
- readOnly,
- onFocus: () => this.setState({ focused: true, emptyState: valueToRender === '0' }),
- onBlur: () => this.setState({ focused: false, emptyState: false }),
- onChange: e => this.handleChange(e.target.value),
- ref: inputRef,
- })
-}
diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js
index e3529041b..c8522a3c7 100644
--- a/ui/app/components/customize-gas-modal/index.js
+++ b/ui/app/components/customize-gas-modal/index.js
@@ -8,15 +8,19 @@ const GasModalCard = require('./gas-modal-card')
const ethUtil = require('ethereumjs-util')
+import {
+ updateSendErrors,
+} from '../../ducks/send.duck'
+
const {
MIN_GAS_PRICE_DEC,
MIN_GAS_LIMIT_DEC,
MIN_GAS_PRICE_GWEI,
-} = require('../send/send-constants')
+} = require('../send_/send.constants')
const {
isBalanceSufficient,
-} = require('../send/send-utils')
+} = require('../send_/send.utils')
const {
conversionUtil,
@@ -61,11 +65,11 @@ function mapStateToProps (state) {
function mapDispatchToProps (dispatch) {
return {
hideModal: () => dispatch(actions.hideModal()),
- updateGasPrice: newGasPrice => dispatch(actions.updateGasPrice(newGasPrice)),
- updateGasLimit: newGasLimit => dispatch(actions.updateGasLimit(newGasLimit)),
- updateGasTotal: newGasTotal => dispatch(actions.updateGasTotal(newGasTotal)),
+ setGasPrice: newGasPrice => dispatch(actions.setGasPrice(newGasPrice)),
+ setGasLimit: newGasLimit => dispatch(actions.setGasLimit(newGasLimit)),
+ setGasTotal: newGasTotal => dispatch(actions.setGasTotal(newGasTotal)),
updateSendAmount: newAmount => dispatch(actions.updateSendAmount(newAmount)),
- updateSendErrors: error => dispatch(actions.updateSendErrors(error)),
+ updateSendErrors: error => dispatch(updateSendErrors(error)),
}
}
@@ -105,10 +109,10 @@ module.exports = connect(mapStateToProps, mapDispatchToProps)(CustomizeGasModal)
CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) {
const {
- updateGasPrice,
- updateGasLimit,
+ setGasPrice,
+ setGasLimit,
hideModal,
- updateGasTotal,
+ setGasTotal,
maxModeOn,
selectedToken,
balance,
@@ -125,9 +129,9 @@ CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) {
updateSendAmount(maxAmount)
}
- updateGasPrice(ethUtil.addHexPrefix(gasPrice))
- updateGasLimit(ethUtil.addHexPrefix(gasLimit))
- updateGasTotal(ethUtil.addHexPrefix(gasTotal))
+ setGasPrice(ethUtil.addHexPrefix(gasPrice))
+ setGasLimit(ethUtil.addHexPrefix(gasLimit))
+ setGasTotal(ethUtil.addHexPrefix(gasTotal))
updateSendErrors({ insufficientFunds: false })
hideModal()
}
diff --git a/ui/app/components/dropdowns/account-dropdown-mini.js b/ui/app/components/dropdowns/account-dropdown-mini.js
index a3d41af90..a7a908d3b 100644
--- a/ui/app/components/dropdowns/account-dropdown-mini.js
+++ b/ui/app/components/dropdowns/account-dropdown-mini.js
@@ -1,7 +1,7 @@
const Component = require('react').Component
const h = require('react-hyperscript')
const inherits = require('util').inherits
-const AccountListItem = require('../send/account-list-item')
+const AccountListItem = require('../send_/account-list-item/account-list-item.component').default
module.exports = AccountDropdownMini
diff --git a/ui/app/components/input-number.js b/ui/app/components/input-number.js
index 5600e35ee..de5fcca54 100644
--- a/ui/app/components/input-number.js
+++ b/ui/app/components/input-number.js
@@ -1,7 +1,6 @@
const Component = require('react').Component
const h = require('react-hyperscript')
const inherits = require('util').inherits
-const CurrencyInput = require('./currency-input')
const {
addCurrencies,
conversionGTE,
@@ -51,14 +50,15 @@ InputNumber.prototype.render = function () {
const { unitLabel, step = 1, placeholder, value = 0 } = this.props
return h('div.customize-gas-input-wrapper', {}, [
- h(CurrencyInput, {
+ h('input', {
className: 'customize-gas-input',
value,
placeholder,
type: 'number',
- onInputChange: newValue => {
- this.setValue(newValue)
+ onChange: e => {
+ this.setValue(e.target.value)
},
+ min: 0,
}),
h('span.gas-tooltip-input-detail', {}, [unitLabel]),
h('div.gas-tooltip-input-arrows', {}, [
diff --git a/ui/app/components/page-container/index.js b/ui/app/components/page-container/index.js
new file mode 100644
index 000000000..415870b37
--- /dev/null
+++ b/ui/app/components/page-container/index.js
@@ -0,0 +1 @@
+export { default } from './page-container.component'
diff --git a/ui/app/components/page-container/page-container-content.component.js b/ui/app/components/page-container/page-container-content.component.js
new file mode 100644
index 000000000..a1d6988cc
--- /dev/null
+++ b/ui/app/components/page-container/page-container-content.component.js
@@ -0,0 +1,18 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+
+export default class PageContainerContent extends Component {
+
+ static propTypes = {
+ children: PropTypes.node.isRequired,
+ };
+
+ render () {
+ return (
+ <div className="page-container__content">
+ {this.props.children}
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/page-container/page-container-footer/index.js b/ui/app/components/page-container/page-container-footer/index.js
new file mode 100644
index 000000000..7825c4520
--- /dev/null
+++ b/ui/app/components/page-container/page-container-footer/index.js
@@ -0,0 +1 @@
+export { default } from './page-container-footer.component'
diff --git a/ui/app/components/page-container/page-container-footer/page-container-footer.component.js b/ui/app/components/page-container/page-container-footer/page-container-footer.component.js
new file mode 100644
index 000000000..0458ae78a
--- /dev/null
+++ b/ui/app/components/page-container/page-container-footer/page-container-footer.component.js
@@ -0,0 +1,54 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import Button from '../../button'
+
+export default class PageContainerFooter extends Component {
+
+ static propTypes = {
+ onCancel: PropTypes.func,
+ cancelText: PropTypes.string,
+ onSubmit: PropTypes.func,
+ submitText: PropTypes.string,
+ disabled: PropTypes.bool,
+ }
+
+ static contextTypes = {
+ t: PropTypes.func,
+ }
+
+ render () {
+ const {
+ onCancel,
+ cancelText,
+ onSubmit,
+ submitText,
+ disabled,
+ } = this.props
+
+ return (
+ <div className="page-container__footer">
+
+ <Button
+ type="default"
+ large={true}
+ className="page-container__footer-button"
+ onClick={() => onCancel()}
+ >
+ { cancelText || this.context.t('cancel') }
+ </Button>
+
+ <Button
+ type="primary"
+ large={true}
+ className="page-container__footer-button"
+ disabled={disabled}
+ onClick={e => onSubmit(e)}
+ >
+ { submitText || this.context.t('next') }
+ </Button>
+
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js b/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/page-container/page-container-footer/tests/page-container-footer.component.test.js
diff --git a/ui/app/components/page-container/page-container-header.component.js b/ui/app/components/page-container/page-container-header.component.js
new file mode 100644
index 000000000..5c9d63221
--- /dev/null
+++ b/ui/app/components/page-container/page-container-header.component.js
@@ -0,0 +1,35 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+
+export default class PageContainerHeader extends Component {
+
+ static propTypes = {
+ title: PropTypes.string,
+ subtitle: PropTypes.string,
+ onClose: PropTypes.func,
+ };
+
+ render () {
+ const { title, subtitle, onClose } = this.props
+
+ return (
+ <div className="page-container__header">
+
+ <div className="page-container__title">
+ {title}
+ </div>
+
+ <div className="page-container__subtitle">
+ {subtitle}
+ </div>
+
+ <div
+ className="page-container__header-close"
+ onClick={() => onClose()}
+ />
+
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/page-container/page-container-header/index.js b/ui/app/components/page-container/page-container-header/index.js
new file mode 100644
index 000000000..b194af057
--- /dev/null
+++ b/ui/app/components/page-container/page-container-header/index.js
@@ -0,0 +1 @@
+export { default } from './page-container-header.component'
diff --git a/ui/app/components/page-container/page-container-header/page-container-header.component.js b/ui/app/components/page-container/page-container-header/page-container-header.component.js
new file mode 100644
index 000000000..28882edce
--- /dev/null
+++ b/ui/app/components/page-container/page-container-header/page-container-header.component.js
@@ -0,0 +1,57 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+
+export default class PageContainerHeader extends Component {
+
+ static propTypes = {
+ title: PropTypes.string.isRequired,
+ subtitle: PropTypes.string,
+ onClose: PropTypes.func,
+ showBackButton: PropTypes.bool,
+ onBackButtonClick: PropTypes.func,
+ backButtonStyles: PropTypes.object,
+ backButtonString: PropTypes.string,
+ };
+
+ renderHeaderRow () {
+ const { showBackButton, onBackButtonClick, backButtonStyles, backButtonString } = this.props
+
+ return showBackButton && (
+ <div className="page-container__header-row">
+ <span
+ className="page-container__back-button"
+ onClick={onBackButtonClick}
+ style={backButtonStyles}
+ >
+ { backButtonString || 'Back' }
+ </span>
+ </div>
+ )
+ }
+
+ render () {
+ const { title, subtitle, onClose } = this.props
+
+ return (
+ <div className="page-container__header">
+
+ { this.renderHeaderRow() }
+
+ <div className="page-container__title">
+ {title}
+ </div>
+
+ <div className="page-container__subtitle">
+ {subtitle}
+ </div>
+
+ <div
+ className="page-container__header-close"
+ onClick={() => onClose()}
+ />
+
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js b/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/page-container/page-container-header/tests/page-container-header.component.test.js
diff --git a/ui/app/components/page-container/page-container.component.js b/ui/app/components/page-container/page-container.component.js
new file mode 100644
index 000000000..9bfb99ade
--- /dev/null
+++ b/ui/app/components/page-container/page-container.component.js
@@ -0,0 +1,72 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+
+import PageContainerHeader from './page-container-header'
+import PageContainerFooter from './page-container-footer'
+
+export default class PageContainer extends Component {
+
+ static propTypes = {
+ // PageContainerHeader props
+ title: PropTypes.string.isRequired,
+ subtitle: PropTypes.string,
+ onClose: PropTypes.func,
+ showBackButton: PropTypes.bool,
+ onBackButtonClick: PropTypes.func,
+ backButtonStyles: PropTypes.object,
+ backButtonString: PropTypes.string,
+ // Content props
+ ContentComponent: PropTypes.func,
+ contentComponentProps: PropTypes.object,
+ // PageContainerFooter props
+ onCancel: PropTypes.func,
+ cancelText: PropTypes.string,
+ onSubmit: PropTypes.func,
+ submitText: PropTypes.string,
+ disabled: PropTypes.bool,
+ };
+
+ render () {
+ const {
+ title,
+ subtitle,
+ onClose,
+ showBackButton,
+ onBackButtonClick,
+ backButtonStyles,
+ backButtonString,
+ ContentComponent,
+ contentComponentProps,
+ onCancel,
+ cancelText,
+ onSubmit,
+ submitText,
+ disabled,
+ } = this.props
+
+ return (
+ <div className="page-container">
+ <PageContainerHeader
+ title={title}
+ subtitle={subtitle}
+ onClose={onClose}
+ showBackButton={showBackButton}
+ onBackButtonClick={onBackButtonClick}
+ backButtonStyles={backButtonStyles}
+ backButtonString={backButtonString}
+ />
+ <div className="page-container__content">
+ <ContentComponent { ...contentComponentProps } />
+ </div>
+ <PageContainerFooter
+ onCancel={onCancel}
+ cancelText={cancelText}
+ onSubmit={onSubmit}
+ submitText={submitText}
+ disabled={disabled}
+ />
+ </div>
+ )
+ }
+
+}
diff --git a/ui/app/components/page-container/tests/page-container.component.test.js b/ui/app/components/page-container/tests/page-container.component.test.js
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/page-container/tests/page-container.component.test.js
diff --git a/ui/app/components/pending-tx/confirm-deploy-contract.js b/ui/app/components/pending-tx/confirm-deploy-contract.js
index aa68a9eb0..af3a14f57 100644
--- a/ui/app/components/pending-tx/confirm-deploy-contract.js
+++ b/ui/app/components/pending-tx/confirm-deploy-contract.js
@@ -11,7 +11,7 @@ const { conversionUtil } = require('../../conversion-util')
const SenderToRecipient = require('../sender-to-recipient')
const NetworkDisplay = require('../network-display')
-const { MIN_GAS_PRICE_HEX } = require('../send/send-constants')
+const { MIN_GAS_PRICE_HEX } = require('../send_/send.constants')
class ConfirmDeployContract extends Component {
constructor (props) {
diff --git a/ui/app/components/pending-tx/confirm-send-ether.js b/ui/app/components/pending-tx/confirm-send-ether.js
index 5ad35c269..97d0318ea 100644
--- a/ui/app/components/pending-tx/confirm-send-ether.js
+++ b/ui/app/components/pending-tx/confirm-send-ether.js
@@ -17,22 +17,26 @@ const {
multiplyCurrencies,
} = require('../../conversion-util')
const {
- getGasTotal,
+ calcGasTotal,
isBalanceSufficient,
-} = require('../send/send-utils')
+} = require('../send_/send.utils')
const GasFeeDisplay = require('../send/gas-fee-display-v2')
const SenderToRecipient = require('../sender-to-recipient')
const NetworkDisplay = require('../network-display')
const currencyFormatter = require('currency-formatter')
const currencies = require('currency-formatter/currencies')
-const { MIN_GAS_PRICE_HEX } = require('../send/send-constants')
+const { MIN_GAS_PRICE_HEX } = require('../send_/send.constants')
const { SEND_ROUTE, DEFAULT_ROUTE } = require('../../routes')
const {
ENVIRONMENT_TYPE_POPUP,
ENVIRONMENT_TYPE_NOTIFICATION,
} = require('../../../../app/scripts/lib/enums')
+import {
+ updateSendErrors,
+} from '../../ducks/send.duck'
+
ConfirmSendEther.contextTypes = {
t: PropTypes.func,
}
@@ -109,7 +113,7 @@ function mapDispatchToProps (dispatch) {
}))
dispatch(actions.showModal({ name: 'CUSTOMIZE_GAS' }))
},
- updateSendErrors: error => dispatch(actions.updateSendErrors(error)),
+ updateSendErrors: error => dispatch(updateSendErrors(error)),
}
}
@@ -145,7 +149,7 @@ ConfirmSendEther.prototype.updateComponentSendErrors = function (prevProps) {
if (shouldUpdateBalanceSendErrors) {
const balanceIsSufficient = this.isBalanceSufficient(txMeta)
updateSendErrors({
- insufficientFunds: balanceIsSufficient ? false : this.context.t('insufficientFunds'),
+ insufficientFunds: balanceIsSufficient ? false : 'insufficientFunds',
})
}
@@ -153,7 +157,7 @@ ConfirmSendEther.prototype.updateComponentSendErrors = function (prevProps) {
if (shouldUpdateSimulationSendError) {
updateSendErrors({
- simulationFails: !txMeta.simulationFails ? false : this.context.t('transactionError'),
+ simulationFails: !txMeta.simulationFails ? false : 'transactionError',
})
}
}
@@ -585,9 +589,9 @@ ConfirmSendEther.prototype.onSubmit = function (event) {
if (valid && this.verifyGasParams() && balanceIsSufficient) {
this.props.sendTransaction(txMeta, event)
} else if (!balanceIsSufficient) {
- updateSendErrors({ insufficientFunds: this.context.t('insufficientFunds') })
+ updateSendErrors({ insufficientFunds: 'insufficientFunds' })
} else {
- updateSendErrors({ invalidGasParams: this.context.t('invalidGasParams') })
+ updateSendErrors({ invalidGasParams: 'invalidGasParams' })
this.setState({ submitting: false })
}
}
@@ -612,7 +616,7 @@ ConfirmSendEther.prototype.isBalanceSufficient = function (txMeta) {
value: amount,
},
} = txMeta
- const gasTotal = getGasTotal(gas, gasPrice)
+ const gasTotal = calcGasTotal(gas, gasPrice)
return isBalanceSufficient({
amount,
diff --git a/ui/app/components/pending-tx/confirm-send-token.js b/ui/app/components/pending-tx/confirm-send-token.js
index ddaa13d22..1802d3143 100644
--- a/ui/app/components/pending-tx/confirm-send-token.js
+++ b/ui/app/components/pending-tx/confirm-send-token.js
@@ -21,9 +21,9 @@ const {
addCurrencies,
} = require('../../conversion-util')
const {
- getGasTotal,
+ calcGasTotal,
isBalanceSufficient,
-} = require('../send/send-utils')
+} = require('../send_/send.utils')
const {
calcTokenAmount,
} = require('../../token-util')
@@ -31,7 +31,7 @@ const classnames = require('classnames')
const currencyFormatter = require('currency-formatter')
const currencies = require('currency-formatter/currencies')
-const { MIN_GAS_PRICE_HEX } = require('../send/send-constants')
+const { MIN_GAS_PRICE_HEX } = require('../send_/send.constants')
const {
getTokenExchangeRate,
@@ -40,6 +40,10 @@ const {
} = require('../../selectors')
const { SEND_ROUTE, DEFAULT_ROUTE } = require('../../routes')
+import {
+ updateSendErrors,
+} from '../../ducks/send.duck'
+
const {
ENVIRONMENT_TYPE_POPUP,
ENVIRONMENT_TYPE_NOTIFICATION,
@@ -109,7 +113,7 @@ function mapDispatchToProps (dispatch, ownProps) {
to,
amount: tokenAmountInHex,
errors: { to: null, amount: null },
- editingTransactionId: id,
+ editingTransactionId: id && id.toString(),
token: ownProps.token,
}))
dispatch(actions.showSendTokenPage())
@@ -147,7 +151,7 @@ function mapDispatchToProps (dispatch, ownProps) {
}))
dispatch(actions.showModal({ name: 'CUSTOMIZE_GAS' }))
},
- updateSendErrors: error => dispatch(actions.updateSendErrors(error)),
+ updateSendErrors: error => dispatch(updateSendErrors(error)),
}
}
@@ -589,9 +593,9 @@ ConfirmSendToken.prototype.onSubmit = function (event) {
if (valid && this.verifyGasParams() && balanceIsSufficient) {
this.props.sendTransaction(txMeta, event)
} else if (!balanceIsSufficient) {
- updateSendErrors({ insufficientFunds: this.context.t('insufficientFunds') })
+ updateSendErrors({ insufficientFunds: 'insufficientFunds' })
} else {
- updateSendErrors({ invalidGasParams: this.context.t('invalidGasParams') })
+ updateSendErrors({ invalidGasParams: 'invalidGasParams' })
this.setState({ submitting: false })
}
}
@@ -607,7 +611,7 @@ ConfirmSendToken.prototype.isBalanceSufficient = function (txMeta) {
gasPrice,
},
} = txMeta
- const gasTotal = getGasTotal(gas, gasPrice)
+ const gasTotal = calcGasTotal(gas, gasPrice)
return isBalanceSufficient({
amount: '0',
diff --git a/ui/app/components/send/account-list-item.js b/ui/app/components/send/account-list-item.js
deleted file mode 100644
index b5e604a6e..000000000
--- a/ui/app/components/send/account-list-item.js
+++ /dev/null
@@ -1,74 +0,0 @@
-const Component = require('react').Component
-const h = require('react-hyperscript')
-const inherits = require('util').inherits
-const connect = require('react-redux').connect
-const { checksumAddress } = require('../../util')
-const Identicon = require('../identicon')
-const CurrencyDisplay = require('./currency-display')
-const { conversionRateSelector, getCurrentCurrency } = require('../../selectors')
-
-inherits(AccountListItem, Component)
-function AccountListItem () {
- Component.call(this)
-}
-
-function mapStateToProps (state) {
- return {
- conversionRate: conversionRateSelector(state),
- currentCurrency: getCurrentCurrency(state),
- }
-}
-
-module.exports = connect(mapStateToProps)(AccountListItem)
-
-AccountListItem.prototype.render = function () {
- const {
- className,
- account,
- handleClick,
- icon = null,
- conversionRate,
- currentCurrency,
- displayBalance = true,
- displayAddress = false,
- } = this.props
-
- const { name, address, balance } = account || {}
-
- return h('div.account-list-item', {
- className,
- onClick: () => handleClick({ name, address, balance }),
- }, [
-
- h('div.account-list-item__top-row', {}, [
-
- h(
- Identicon,
- {
- address,
- diameter: 18,
- className: 'account-list-item__identicon',
- },
- ),
-
- h('div.account-list-item__account-name', {}, name || address),
-
- icon && h('div.account-list-item__icon', [icon]),
-
- ]),
-
- displayAddress && name && h('div.account-list-item__account-address', checksumAddress(address)),
-
- displayBalance && h(CurrencyDisplay, {
- primaryCurrency: 'ETH',
- convertedCurrency: currentCurrency,
- value: balance,
- conversionRate,
- readOnly: true,
- className: 'account-list-item__account-balances',
- primaryBalanceClassName: 'account-list-item__account-primary-balance',
- convertedBalanceClassName: 'account-list-item__account-secondary-balance',
- }, name),
-
- ])
-}
diff --git a/ui/app/components/send/currency-display.js b/ui/app/components/send/currency-display.js
index 90fb2b66c..3bc9ad226 100644
--- a/ui/app/components/send/currency-display.js
+++ b/ui/app/components/send/currency-display.js
@@ -1,10 +1,10 @@
const Component = require('react').Component
const h = require('react-hyperscript')
const inherits = require('util').inherits
-const CurrencyInput = require('../currency-input')
const { conversionUtil, multiplyCurrencies } = require('../../conversion-util')
const currencyFormatter = require('currency-formatter')
const currencies = require('currency-formatter/currencies')
+const ethUtil = require('ethereumjs-util')
module.exports = CurrencyDisplay
@@ -21,36 +21,51 @@ function toHexWei (value) {
})
}
+CurrencyDisplay.prototype.componentWillMount = function () {
+ this.setState({
+ valueToRender: this.getValueToRender(this.props),
+ })
+}
+
+CurrencyDisplay.prototype.componentWillReceiveProps = function (nextProps) {
+ const currentValueToRender = this.getValueToRender(this.props)
+ const newValueToRender = this.getValueToRender(nextProps)
+ if (currentValueToRender !== newValueToRender) {
+ this.setState({
+ valueToRender: newValueToRender,
+ })
+ }
+}
+
CurrencyDisplay.prototype.getAmount = function (value) {
const { selectedToken } = this.props
const { decimals } = selectedToken || {}
const multiplier = Math.pow(10, Number(decimals || 0))
- const sendAmount = multiplyCurrencies(value, multiplier, {toNumericBase: 'hex'})
+ const sendAmount = multiplyCurrencies(value || '0', multiplier, {toNumericBase: 'hex'})
return selectedToken
? sendAmount
: toHexWei(value)
}
-CurrencyDisplay.prototype.getValueToRender = function () {
- const { selectedToken, conversionRate, value } = this.props
-
+CurrencyDisplay.prototype.getValueToRender = function ({ selectedToken, conversionRate, value, readOnly }) {
+ if (value === '0x0') return readOnly ? '0' : ''
const { decimals, symbol } = selectedToken || {}
const multiplier = Math.pow(10, Number(decimals || 0))
return selectedToken
- ? conversionUtil(value, {
+ ? conversionUtil(ethUtil.addHexPrefix(value), {
fromNumericBase: 'hex',
toCurrency: symbol,
conversionRate: multiplier,
invertConversionRate: true,
})
- : conversionUtil(value, {
+ : conversionUtil(ethUtil.addHexPrefix(value), {
fromNumericBase: 'hex',
toNumericBase: 'dec',
fromDenomination: 'WEI',
- numberOfDecimals: 6,
+ numberOfDecimals: 9,
conversionRate,
})
}
@@ -76,6 +91,18 @@ CurrencyDisplay.prototype.getConvertedValueToRender = function (nonFormattedValu
: convertedValue
}
+CurrencyDisplay.prototype.handleChange = function (newVal) {
+ this.setState({ valueToRender: newVal })
+ this.props.onChange(this.getAmount(newVal))
+}
+
+CurrencyDisplay.prototype.getInputWidth = function (valueToRender, readOnly) {
+ const valueString = String(valueToRender)
+ const valueLength = valueString.length || 1
+ const decimalPointDeficit = valueString.match(/\./) ? -0.5 : 0
+ return (valueLength + decimalPointDeficit + 0.75) + 'ch'
+}
+
CurrencyDisplay.prototype.render = function () {
const {
className = 'currency-display',
@@ -85,10 +112,10 @@ CurrencyDisplay.prototype.render = function () {
convertedCurrency,
readOnly = false,
inError = false,
- handleChange,
+ onBlur,
} = this.props
+ const { valueToRender } = this.state
- const valueToRender = this.getValueToRender()
const convertedValueToRender = this.getConvertedValueToRender(valueToRender)
return h('div', {
@@ -96,24 +123,30 @@ CurrencyDisplay.prototype.render = function () {
style: {
borderColor: inError ? 'red' : null,
},
- onClick: () => this.currencyInput && this.currencyInput.focus(),
+ onClick: () => {
+ this.currencyInput && this.currencyInput.focus()
+ },
}, [
h('div.currency-display__primary-row', [
h('div.currency-display__input-wrapper', [
- h(readOnly ? 'input' : CurrencyInput, {
+ h('input', {
className: primaryBalanceClassName,
value: `${valueToRender}`,
placeholder: '0',
+ type: 'number',
readOnly,
...(!readOnly ? {
- onInputChange: newValue => {
- handleChange(this.getAmount(newValue))
- },
- inputRef: input => { this.currencyInput = input },
+ onChange: e => this.handleChange(e.target.value),
+ onBlur: () => onBlur(this.getAmount(valueToRender)),
} : {}),
+ ref: input => { this.currencyInput = input },
+ style: {
+ width: this.getInputWidth(valueToRender, readOnly),
+ },
+ min: 0,
}),
h('span.currency-display__currency-symbol', primaryCurrency),
diff --git a/ui/app/components/send/from-dropdown.js b/ui/app/components/send/from-dropdown.js
deleted file mode 100644
index 0686fbe73..000000000
--- a/ui/app/components/send/from-dropdown.js
+++ /dev/null
@@ -1,72 +0,0 @@
-const Component = require('react').Component
-const h = require('react-hyperscript')
-const inherits = require('util').inherits
-const AccountListItem = require('./account-list-item')
-
-module.exports = FromDropdown
-
-inherits(FromDropdown, Component)
-function FromDropdown () {
- Component.call(this)
-}
-
-FromDropdown.prototype.getListItemIcon = function (currentAccount, selectedAccount) {
- const listItemIcon = h(`i.fa.fa-check.fa-lg`, { style: { color: '#02c9b1' } })
-
- return currentAccount.address === selectedAccount.address
- ? listItemIcon
- : null
-}
-
-FromDropdown.prototype.renderDropdown = function () {
- const {
- accounts,
- selectedAccount,
- closeDropdown,
- onSelect,
- } = this.props
-
- return h('div', {}, [
-
- h('div.send-v2__from-dropdown__close-area', {
- onClick: closeDropdown,
- }),
-
- h('div.send-v2__from-dropdown__list', {}, [
-
- ...accounts.map(account => h(AccountListItem, {
- className: 'account-list-item__dropdown',
- account,
- handleClick: () => {
- onSelect(account)
- closeDropdown()
- },
- icon: this.getListItemIcon(account, selectedAccount),
- })),
-
- ]),
-
- ])
-}
-
-FromDropdown.prototype.render = function () {
- const {
- selectedAccount,
- openDropdown,
- dropdownOpen,
- } = this.props
-
- return h('div.send-v2__from-dropdown', {}, [
-
- h(AccountListItem, {
- account: selectedAccount,
- handleClick: openDropdown,
- icon: h(`i.fa.fa-caret-down.fa-lg`, { style: { color: '#dedede' } }),
- }),
-
- dropdownOpen && this.renderDropdown(),
-
- ])
-
-}
-
diff --git a/ui/app/components/send/gas-tooltip.js b/ui/app/components/send/gas-tooltip.js
deleted file mode 100644
index 62cdc1cad..000000000
--- a/ui/app/components/send/gas-tooltip.js
+++ /dev/null
@@ -1,106 +0,0 @@
-const Component = require('react').Component
-const PropTypes = require('prop-types')
-const h = require('react-hyperscript')
-const inherits = require('util').inherits
-const InputNumber = require('../input-number.js')
-const connect = require('react-redux').connect
-
-GasTooltip.contextTypes = {
- t: PropTypes.func,
-}
-
-module.exports = connect()(GasTooltip)
-
-
-inherits(GasTooltip, Component)
-function GasTooltip () {
- Component.call(this)
- this.state = {
- gasLimit: 0,
- gasPrice: 0,
- }
-
- this.updateGasPrice = this.updateGasPrice.bind(this)
- this.updateGasLimit = this.updateGasLimit.bind(this)
- this.onClose = this.onClose.bind(this)
-}
-
-GasTooltip.prototype.componentWillMount = function () {
- const { gasPrice = 0, gasLimit = 0} = this.props
-
- this.setState({
- gasPrice: parseInt(gasPrice, 16) / 1000000000,
- gasLimit: parseInt(gasLimit, 16),
- })
-}
-
-GasTooltip.prototype.updateGasPrice = function (newPrice) {
- const { onFeeChange } = this.props
- const { gasLimit } = this.state
-
- this.setState({ gasPrice: newPrice })
- onFeeChange({
- gasLimit: gasLimit.toString(16),
- gasPrice: (newPrice * 1000000000).toString(16),
- })
-}
-
-GasTooltip.prototype.updateGasLimit = function (newLimit) {
- const { onFeeChange } = this.props
- const { gasPrice } = this.state
-
- this.setState({ gasLimit: newLimit })
- onFeeChange({
- gasLimit: newLimit.toString(16),
- gasPrice: (gasPrice * 1000000000).toString(16),
- })
-}
-
-GasTooltip.prototype.onClose = function (e) {
- e.stopPropagation()
- this.props.onClose()
-}
-
-GasTooltip.prototype.render = function () {
- const { gasPrice, gasLimit } = this.state
-
- return h('div.gas-tooltip', {}, [
- h('div.gas-tooltip-close-area', {
- onClick: this.onClose,
- }),
- h('div.customize-gas-tooltip-container', {}, [
- h('div.customize-gas-tooltip', {}, [
- h('div.gas-tooltip-header.gas-tooltip-label', {}, ['Customize Gas']),
- h('div.gas-tooltip-input-label', {}, [
- h('span.gas-tooltip-label', {}, ['Gas Price']),
- h('i.fa.fa-info-circle'),
- ]),
- h(InputNumber, {
- unitLabel: 'GWEI',
- step: 1,
- min: 0,
- placeholder: '0',
- value: gasPrice,
- onChange: (newPrice) => this.updateGasPrice(newPrice),
- }),
- h('div.gas-tooltip-input-label', {
- style: {
- 'marginTop': '81px',
- },
- }, [
- h('span.gas-tooltip-label', {}, [this.context.t('gasLimit')]),
- h('i.fa.fa-info-circle'),
- ]),
- h(InputNumber, {
- unitLabel: 'UNITS',
- step: 1,
- min: 0,
- placeholder: '0',
- value: gasLimit,
- onChange: (newLimit) => this.updateGasLimit(newLimit),
- }),
- ]),
- h('div.gas-tooltip-arrow', {}),
- ]),
- ])
-}
diff --git a/ui/app/components/send/memo-textarea.js b/ui/app/components/send/memo-textarea.js
deleted file mode 100644
index f4bb24bf8..000000000
--- a/ui/app/components/send/memo-textarea.js
+++ /dev/null
@@ -1,33 +0,0 @@
-// const Component = require('react').Component
-// const h = require('react-hyperscript')
-// const inherits = require('util').inherits
-// const Identicon = require('../identicon')
-
-// module.exports = MemoTextArea
-
-// inherits(MemoTextArea, Component)
-// function MemoTextArea () {
-// Component.call(this)
-// }
-
-// MemoTextArea.prototype.render = function () {
-// const { memo, identities, onChange } = this.props
-
-// return h('div.send-v2__memo-text-area', [
-
-// h('textarea.send-v2__memo-text-area__input', {
-// placeholder: 'Optional',
-// value: memo,
-// onChange,
-// // onBlur: () => {
-// // this.setErrorsFor('memo')
-// // },
-// onFocus: event => {
-// // this.clearErrorsFor('memo')
-// },
-// }),
-
-// ])
-
-// }
-
diff --git a/ui/app/components/send/send-utils.js b/ui/app/components/send/send-utils.js
deleted file mode 100644
index 71bfb2668..000000000
--- a/ui/app/components/send/send-utils.js
+++ /dev/null
@@ -1,78 +0,0 @@
-const {
- addCurrencies,
- conversionUtil,
- conversionGTE,
- multiplyCurrencies,
-} = require('../../conversion-util')
-const {
- calcTokenAmount,
-} = require('../../token-util')
-
-function isBalanceSufficient ({
- amount = '0x0',
- gasTotal = '0x0',
- balance,
- primaryCurrency,
- amountConversionRate,
- conversionRate,
-}) {
- const totalAmount = addCurrencies(amount, gasTotal, {
- aBase: 16,
- bBase: 16,
- toNumericBase: 'hex',
- })
-
- const balanceIsSufficient = conversionGTE(
- {
- value: balance,
- fromNumericBase: 'hex',
- fromCurrency: primaryCurrency,
- conversionRate,
- },
- {
- value: totalAmount,
- fromNumericBase: 'hex',
- conversionRate: amountConversionRate || conversionRate,
- fromCurrency: primaryCurrency,
- },
- )
-
- return balanceIsSufficient
-}
-
-function isTokenBalanceSufficient ({
- amount = '0x0',
- tokenBalance,
- decimals,
-}) {
- const amountInDec = conversionUtil(amount, {
- fromNumericBase: 'hex',
- })
-
- const tokenBalanceIsSufficient = conversionGTE(
- {
- value: tokenBalance,
- fromNumericBase: 'dec',
- },
- {
- value: calcTokenAmount(amountInDec, decimals),
- fromNumericBase: 'dec',
- },
- )
-
- return tokenBalanceIsSufficient
-}
-
-function getGasTotal (gasLimit, gasPrice) {
- return multiplyCurrencies(gasLimit, gasPrice, {
- toNumericBase: 'hex',
- multiplicandBase: 16,
- multiplierBase: 16,
- })
-}
-
-module.exports = {
- getGasTotal,
- isBalanceSufficient,
- isTokenBalanceSufficient,
-}
diff --git a/ui/app/components/send/send-v2-container.js b/ui/app/components/send/send-v2-container.js
deleted file mode 100644
index adfc91240..000000000
--- a/ui/app/components/send/send-v2-container.js
+++ /dev/null
@@ -1,89 +0,0 @@
-const connect = require('react-redux').connect
-const actions = require('../../actions')
-const abi = require('ethereumjs-abi')
-const SendEther = require('../../send-v2')
-const { withRouter } = require('react-router-dom')
-const { compose } = require('recompose')
-
-const {
- accountsWithSendEtherInfoSelector,
- getCurrentAccountWithSendEtherInfo,
- conversionRateSelector,
- getSelectedToken,
- getSelectedAddress,
- getAddressBook,
- getSendFrom,
- getCurrentCurrency,
- getSelectedTokenToFiatRate,
- getSelectedTokenContract,
-} = require('../../selectors')
-
-module.exports = compose(
- withRouter,
- connect(mapStateToProps, mapDispatchToProps)
-)(SendEther)
-
-function mapStateToProps (state) {
- const fromAccounts = accountsWithSendEtherInfoSelector(state)
- const selectedAddress = getSelectedAddress(state)
- const selectedToken = getSelectedToken(state)
- const conversionRate = conversionRateSelector(state)
-
- let data
- let primaryCurrency
- let tokenToFiatRate
- if (selectedToken) {
- data = Array.prototype.map.call(
- abi.rawEncode(['address', 'uint256'], [selectedAddress, '0x0']),
- x => ('00' + x.toString(16)).slice(-2)
- ).join('')
-
- primaryCurrency = selectedToken.symbol
-
- tokenToFiatRate = getSelectedTokenToFiatRate(state)
- }
-
- return {
- ...state.metamask.send,
- from: getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state),
- fromAccounts,
- toAccounts: [...fromAccounts, ...getAddressBook(state)],
- conversionRate,
- selectedToken,
- primaryCurrency,
- convertedCurrency: getCurrentCurrency(state),
- data,
- selectedAddress,
- amountConversionRate: selectedToken ? tokenToFiatRate : conversionRate,
- tokenContract: getSelectedTokenContract(state),
- unapprovedTxs: state.metamask.unapprovedTxs,
- network: state.metamask.network,
- }
-}
-
-function mapDispatchToProps (dispatch) {
- return {
- showCustomizeGasModal: () => dispatch(actions.showModal({ name: 'CUSTOMIZE_GAS' })),
- estimateGas: params => dispatch(actions.estimateGas(params)),
- getGasPrice: () => dispatch(actions.getGasPrice()),
- signTokenTx: (tokenAddress, toAddress, amount, txData) => (
- dispatch(actions.signTokenTx(tokenAddress, toAddress, amount, txData))
- ),
- signTx: txParams => dispatch(actions.signTx(txParams)),
- updateAndApproveTx: txParams => dispatch(actions.updateAndApproveTx(txParams)),
- updateTx: txData => dispatch(actions.updateTransaction(txData)),
- setSelectedAddress: address => dispatch(actions.setSelectedAddress(address)),
- addToAddressBook: (address, nickname) => dispatch(actions.addToAddressBook(address, nickname)),
- updateGasTotal: newTotal => dispatch(actions.updateGasTotal(newTotal)),
- updateGasPrice: newGasPrice => dispatch(actions.updateGasPrice(newGasPrice)),
- updateGasLimit: newGasLimit => dispatch(actions.updateGasLimit(newGasLimit)),
- updateSendTokenBalance: tokenBalance => dispatch(actions.updateSendTokenBalance(tokenBalance)),
- updateSendFrom: newFrom => dispatch(actions.updateSendFrom(newFrom)),
- updateSendTo: (newTo, nickname) => dispatch(actions.updateSendTo(newTo, nickname)),
- updateSendAmount: newAmount => dispatch(actions.updateSendAmount(newAmount)),
- updateSendMemo: newMemo => dispatch(actions.updateSendMemo(newMemo)),
- updateSendErrors: newError => dispatch(actions.updateSendErrors(newError)),
- clearSend: () => dispatch(actions.clearSend()),
- setMaxModeTo: bool => dispatch(actions.setMaxModeTo(bool)),
- }
-}
diff --git a/ui/app/components/send/to-autocomplete.js b/ui/app/components/send/to-autocomplete.js
index 5ea17f9a2..df74ef194 100644
--- a/ui/app/components/send/to-autocomplete.js
+++ b/ui/app/components/send/to-autocomplete.js
@@ -2,7 +2,7 @@ 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')
+const AccountListItem = require('../send_/account-list-item/account-list-item.component').default
const connect = require('react-redux').connect
ToAutoComplete.contextTypes = {
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..b8407d147
--- /dev/null
+++ b/ui/app/components/send_/account-list-item/account-list-item.component.js
@@ -0,0 +1,74 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import { checksumAddress } from '../../../util'
+import Identicon from '../../identicon'
+import CurrencyDisplay from '../../send/currency-display'
+
+export default class AccountListItem extends Component {
+
+ static propTypes = {
+ account: PropTypes.object,
+ className: PropTypes.string,
+ conversionRate: PropTypes.number,
+ currentCurrency: PropTypes.string,
+ displayAddress: PropTypes.bool,
+ displayBalance: PropTypes.bool,
+ handleClick: PropTypes.func,
+ icon: PropTypes.node,
+ };
+
+ render () {
+ const {
+ account,
+ className,
+ conversionRate,
+ currentCurrency,
+ displayAddress = false,
+ displayBalance = true,
+ handleClick,
+ icon = null,
+ } = this.props
+
+ const { name, address, balance } = account || {}
+
+ return (<div
+ className={`account-list-item ${className}`}
+ onClick={() => handleClick({ name, address, balance })}
+ >
+
+ <div className="account-list-item__top-row">
+ <Identicon
+ address={address}
+ className="account-list-item__identicon"
+ diameter={18}
+ />
+
+ <div className="account-list-item__account-name">{ name || address }</div>
+
+ {icon && <div className="account-list-item__icon">{ icon }</div>}
+
+ </div>
+
+ {displayAddress && name && <div className="account-list-item__account-address">
+ { checksumAddress(address) }
+ </div>}
+
+ {displayBalance && <CurrencyDisplay
+ className="account-list-item__account-balances"
+ conversionRate={conversionRate}
+ convertedBalanceClassName="account-list-item__account-secondary-balance"
+ convertedCurrency={currentCurrency}
+ primaryBalanceClassName="account-list-item__account-primary-balance"
+ primaryCurrency="ETH"
+ readOnly={true}
+ value={balance}
+ />}
+
+ </div>)
+ }
+}
+
+AccountListItem.contextTypes = {
+ t: PropTypes.func,
+}
+
diff --git a/ui/app/components/send_/account-list-item/account-list-item.container.js b/ui/app/components/send_/account-list-item/account-list-item.container.js
new file mode 100644
index 000000000..3151b1f1d
--- /dev/null
+++ b/ui/app/components/send_/account-list-item/account-list-item.container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux'
+import {
+ getConversionRate,
+ getConvertedCurrency,
+} from '../send.selectors.js'
+import AccountListItem from './account-list-item.component'
+
+export default connect(mapStateToProps)(AccountListItem)
+
+function mapStateToProps (state) {
+ return {
+ conversionRate: getConversionRate(state),
+ currentCurrency: getConvertedCurrency(state),
+ }
+}
diff --git a/ui/app/components/send_/account-list-item/account-list-item.scss b/ui/app/components/send_/account-list-item/account-list-item.scss
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/ui/app/components/send_/account-list-item/account-list-item.scss
diff --git a/ui/app/components/send_/account-list-item/index.js b/ui/app/components/send_/account-list-item/index.js
new file mode 100644
index 000000000..1fca540be
--- /dev/null
+++ b/ui/app/components/send_/account-list-item/index.js
@@ -0,0 +1 @@
+export { default } from './account-list-item.container' \ No newline at end of file
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..bb7f3776c
--- /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 '../../../send/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..49da920e6
--- /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}`,
+ getConvertedCurrency: (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_/index.js b/ui/app/components/send_/index.js
new file mode 100644
index 000000000..9a4dd5727
--- /dev/null
+++ b/ui/app/components/send_/index.js
@@ -0,0 +1 @@
+export { default } from './send.container' \ No newline at end of file
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..10b3c850e
--- /dev/null
+++ b/ui/app/components/send_/send-content/index.js
@@ -0,0 +1 @@
+export { default } from './send-content.component' \ No newline at end of file
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..bdf12b738
--- /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,
+ };
+
+ setMaxAmount () {
+ const {
+ balance,
+ gasTotal,
+ selectedToken,
+ setAmountToMax,
+ tokenBalance,
+ } = this.props
+
+ setAmountToMax({
+ balance,
+ gasTotal,
+ selectedToken,
+ tokenBalance,
+ })
+ }
+
+ render () {
+ const { setMaxModeTo, maxModeOn } = this.props
+
+ return (
+ <div
+ className="send-v2__amount-max"
+ onClick={(event) => {
+ event.preventDefault()
+ setMaxModeTo(true)
+ this.setMaxAmount()
+ }}
+ >
+ {!maxModeOn ? this.context.t('max') : ''}
+ </div>
+ )
+ }
+
+}
+
+AmountMaxButton.contextTypes = {
+ t: PropTypes.func,
+}
diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.container.js b/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.container.js
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..548b51f33
--- /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' \ No newline at end of file
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..94a7da56f
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-amount-row/index.js
@@ -0,0 +1 @@
+export { default } from './send-amount-row.container' \ No newline at end of file
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..8aefeed4a
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.component.js
@@ -0,0 +1,96 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import SendRowWrapper from '../send-row-wrapper/'
+import AmountMaxButton from './amount-max-button/'
+import CurrencyDisplay from '../../../send/currency-display'
+
+export default class SendAmountRow extends Component {
+
+ static propTypes = {
+ amount: PropTypes.string,
+ amountConversionRate: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number,
+ ]),
+ balance: PropTypes.string,
+ conversionRate: PropTypes.number,
+ convertedCurrency: PropTypes.string,
+ gasTotal: PropTypes.string,
+ inError: PropTypes.bool,
+ primaryCurrency: PropTypes.string,
+ selectedToken: PropTypes.object,
+ setMaxModeTo: PropTypes.func,
+ tokenBalance: PropTypes.string,
+ updateSendAmount: PropTypes.func,
+ updateSendAmountError: PropTypes.func,
+ }
+
+ validateAmount (amount) {
+ const {
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ selectedToken,
+ tokenBalance,
+ updateSendAmountError,
+ } = this.props
+
+ updateSendAmountError({
+ amount,
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ selectedToken,
+ tokenBalance,
+ })
+ }
+
+ updateAmount (amount) {
+ const { updateSendAmount, setMaxModeTo } = this.props
+
+ setMaxModeTo(false)
+ updateSendAmount(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.updateAmount(newAmount)}
+ onChange={newAmount => this.validateAmount(newAmount)}
+ inError={inError}
+ primaryCurrency={primaryCurrency || 'ETH'}
+ selectedToken={selectedToken}
+ value={amount || '0x0'}
+ />
+ </SendRowWrapper>
+ )
+ }
+
+}
+
+SendAmountRow.contextTypes = {
+ t: PropTypes.func,
+}
+
diff --git a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js
new file mode 100644
index 000000000..bbbf56971
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js
@@ -0,0 +1,51 @@
+import { connect } from 'react-redux'
+import {
+ getAmountConversionRate,
+ getConversionRate,
+ getConvertedCurrency,
+ getGasTotal,
+ getPrimaryCurrency,
+ getSelectedToken,
+ getSendAmount,
+ getSendFromBalance,
+ getTokenBalance,
+} from '../../send.selectors'
+import {
+ sendAmountIsInError,
+} from './send-amount-row.selectors'
+import { getAmountErrorObject } 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: getConvertedCurrency(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)),
+ 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..2205579ca
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-amount-row/tests/send-amount-row-component.test.js
@@ -0,0 +1,164 @@
+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 '../../../../send/currency-display'
+
+const propsMethodSpies = {
+ setMaxModeTo: sinon.spy(),
+ updateSendAmount: sinon.spy(),
+ updateSendAmountError: sinon.spy(),
+}
+
+sinon.spy(SendAmountRow.prototype, 'updateAmount')
+sinon.spy(SendAmountRow.prototype, 'validateAmount')
+
+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'}
+ updateSendAmount={propsMethodSpies.updateSendAmount}
+ updateSendAmountError={propsMethodSpies.updateSendAmountError}
+ />, { context: { t: str => str + '_t' } })
+ instance = wrapper.instance()
+ })
+
+ afterEach(() => {
+ propsMethodSpies.setMaxModeTo.resetHistory()
+ propsMethodSpies.updateSendAmount.resetHistory()
+ propsMethodSpies.updateSendAmountError.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',
+ }]
+ )
+ })
+
+ })
+
+ 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.updateAmount.callCount, 0)
+ onBlur('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..e4c913c69
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-amount-row/tests/send-amount-row-container.test.js
@@ -0,0 +1,109 @@
+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}`,
+ getConvertedCurrency: (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 }) },
+ '../../../../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)
+ })
+
+ 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('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..3a14054eb
--- /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 />
+ <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..ee7736376
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-dropdown-list/index.js
@@ -0,0 +1 @@
+export { default } from './send-dropdown-list.component' \ No newline at end of file
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..5c7174ecf
--- /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,
+ };
+
+ 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>)
+ }
+
+}
+
+SendDropdownList.contextTypes = {
+ t: PropTypes.func,
+}
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..418766cd9
--- /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,
+ };
+
+ 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>
+ }
+
+}
+
+FromDropdown.contextTypes = {
+ t: PropTypes.func,
+}
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..6ab9a157a
--- /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' \ No newline at end of file
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..4a0916dba
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-from-row/index.js
@@ -0,0 +1 @@
+export { default } from './send-from-row.container' \ No newline at end of file
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..a580aef96
--- /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,
+ };
+
+ 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>
+ )
+ }
+
+}
+
+SendFromRow.contextTypes = {
+ t: PropTypes.func,
+}
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/index.js b/ui/app/components/send_/send-content/send-gas-row/index.js
new file mode 100644
index 000000000..060ed7fd3
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-gas-row/index.js
@@ -0,0 +1 @@
+export { default } from './send-gas-row.container' \ No newline at end of file
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..c80d8c0bb
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.component.js
@@ -0,0 +1,42 @@
+import React, { Component } from 'react'
+import PropTypes from 'prop-types'
+import SendRowWrapper from '../send-row-wrapper/'
+import GasFeeDisplay from '../../../send/gas-fee-display-v2'
+
+export default class SendGasRow extends Component {
+
+ static propTypes = {
+ conversionRate: PropTypes.number,
+ convertedCurrency: PropTypes.string,
+ gasLoadingError: PropTypes.bool,
+ gasTotal: PropTypes.string,
+ showCustomizeGasModal: PropTypes.func,
+ };
+
+ render () {
+ const {
+ conversionRate,
+ convertedCurrency,
+ gasLoadingError,
+ gasTotal,
+ showCustomizeGasModal,
+ } = this.props
+
+ return (
+ <SendRowWrapper label={`${this.context.t('gasFee')}:`}>
+ <GasFeeDisplay
+ conversionRate={conversionRate}
+ convertedCurrency={convertedCurrency}
+ gasLoadingError={gasLoadingError}
+ gasTotal={gasTotal}
+ onClick={() => showCustomizeGasModal()}
+ />
+ </SendRowWrapper>
+ )
+ }
+
+}
+
+SendGasRow.contextTypes = {
+ t: PropTypes.func,
+}
diff --git a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js
new file mode 100644
index 000000000..20d3daa59
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux'
+import {
+ getConversionRate,
+ getConvertedCurrency,
+ getGasTotal,
+} from '../../send.selectors.js'
+import { sendGasIsInError } from './send-gas-row.selectors.js'
+import { showModal } from '../../../../actions'
+import SendGasRow from './send-gas-row.component'
+
+export default connect(mapStateToProps, mapDispatchToProps)(SendGasRow)
+
+function mapStateToProps (state) {
+ return {
+ conversionRate: getConversionRate(state),
+ convertedCurrency: getConvertedCurrency(state),
+ gasTotal: getGasTotal(state),
+ gasLoadingError: sendGasIsInError(state),
+ }
+}
+
+function mapDispatchToProps (dispatch) {
+ return {
+ showCustomizeGasModal: () => dispatch(showModal({ name: 'CUSTOMIZE_GAS' })),
+ }
+}
diff --git a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.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..d069ae8c6
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.selectors.js
@@ -0,0 +1,9 @@
+const selectors = {
+ sendGasIsInError,
+}
+
+module.exports = selectors
+
+function sendGasIsInError (state) {
+ return state.send.errors.gasLoading
+}
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..e4f05d708
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-component.test.js
@@ -0,0 +1,65 @@
+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 '../../../../send/gas-fee-display-v2'
+
+const propsMethodSpies = {
+ showCustomizeGasModal: sinon.spy(),
+}
+
+describe('SendGasRow Component', function () {
+ let wrapper
+
+ beforeEach(() => {
+ wrapper = shallow(<SendGasRow
+ conversionRate={20}
+ convertedCurrency={'mockConvertedCurrency'}
+ 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,
+ } = wrapper.find(SendRowWrapper).props()
+
+ assert.equal(label, 'gasFee_t:')
+ })
+
+ 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..9135524d1
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-container.test.js
@@ -0,0 +1,66 @@
+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}`,
+ getConvertedCurrency: (s) => `mockConvertedCurrency:${s}`,
+ getGasTotal: (s) => `mockGasTotal:${s}`,
+ },
+ './send-gas-row.selectors.js': { sendGasIsInError: (s) => `mockGasLoadingError:${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',
+ 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..a5196334e
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-selectors.test.js
@@ -0,0 +1,22 @@
+import assert from 'assert'
+import {
+ sendGasIsInError,
+} from '../send-gas-row.selectors.js'
+
+describe('send-gas-row selectors', () => {
+
+ describe('sendGasIsInError()', () => {
+ it('should return send.errors.gasLoading', () => {
+ const state = {
+ send: {
+ errors: {
+ gasLoading: 'abc',
+ },
+ },
+ }
+
+ assert.equal(sendGasIsInError(state), 'abc')
+ })
+ })
+
+})
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..5715f55c6
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-row-wrapper/index.js
@@ -0,0 +1 @@
+export { default } from './send-row-wrapper.component' \ No newline at end of file
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..bf49c55bd
--- /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' \ No newline at end of file
diff --git a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-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..0d314208b
--- /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,
+ };
+
+ render () {
+ const { errors, errorType } = this.props
+
+ const errorMessage = errors[errorType]
+
+ return (
+ errorMessage
+ ? <div className="send-v2__error">{this.context.t(errorMessage)}</div>
+ : null
+ )
+ }
+
+}
+
+SendRowErrorMessage.contextTypes = {
+ t: PropTypes.func,
+}
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..f484bd8d9
--- /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,
+ };
+
+ 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>
+ )
+ }
+
+}
+
+SendRowWrapper.contextTypes = {
+ t: PropTypes.func,
+}
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..4e7aa9747
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-to-row/index.js
@@ -0,0 +1 @@
+export { default } from './send-to-row.container' \ No newline at end of file
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..0a83186a5
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-to-row/send-to-row.component.js
@@ -0,0 +1,70 @@
+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,
+ };
+
+ handleToChange (to, nickname = '') {
+ const { updateSendTo, updateSendToError, updateGas } = this.props
+ const toErrorObject = getToErrorObject(to)
+ 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={(newTo, newNickname) => this.handleToChange(newTo, newNickname)}
+ openDropdown={() => openToDropdown()}
+ placeholder={this.context.t('recipientAddress')}
+ to={to}
+ />
+ </SendRowWrapper>
+ )
+ }
+
+}
+
+SendToRow.contextTypes = {
+ t: PropTypes.func,
+}
+
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..cea51ee20
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-to-row/send-to-row.utils.js
@@ -0,0 +1,21 @@
+const {
+ REQUIRED_ERROR,
+ INVALID_RECIPIENT_ADDRESS_ERROR,
+} = require('../../send.constants')
+const { isValidAddress } = require('../../../../util')
+
+function getToErrorObject (to) {
+ let toError = null
+
+ if (!to) {
+ toError = REQUIRED_ERROR
+ } else if (!isValidAddress(to)) {
+ 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..58fe51dcf
--- /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) => ({
+ to: to === false ? null : `mockToErrorObject:${to}`,
+ }),
+ },
+}).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')
+ assert.equal(propsMethodSpies.updateSendToError.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendToError.getCall(0).args,
+ [{ to: 'mockToErrorObject:mockTo2' }]
+ )
+ })
+
+ 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('mockNewTo', 'mockNewNickname')
+ assert.equal(SendToRow.prototype.handleToChange.callCount, 1)
+ assert.deepEqual(
+ SendToRow.prototype.handleToChange.getCall(0).args,
+ ['mockNewTo', 'mockNewNickname']
+ )
+ })
+ })
+})
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..615c9581b
--- /dev/null
+++ b/ui/app/components/send_/send-content/send-to-row/tests/send-to-row-utils.test.js
@@ -0,0 +1,45 @@
+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,
+ })
+ })
+ })
+
+})
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..cd1727330
--- /dev/null
+++ b/ui/app/components/send_/send-footer/index.js
@@ -0,0 +1 @@
+export { default } from './send-footer.container' \ No newline at end of file
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..6471ae1a3
--- /dev/null
+++ b/ui/app/components/send_/send-footer/send-footer.component.js
@@ -0,0 +1,99 @@
+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,
+ };
+
+ 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,
+ } = 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)
+
+ editingTransactionId
+ ? update({
+ amount,
+ editingTransactionId,
+ from,
+ gas,
+ gasPrice,
+ selectedToken,
+ to,
+ unapprovedTxs,
+ })
+ : sign({ selectedToken, to, amount, from, gas, gasPrice })
+
+ this.props.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()}
+ />
+ )
+ }
+
+}
+
+SendFooter.contextTypes = {
+ t: PropTypes.func,
+}
diff --git a/ui/app/components/send_/send-footer/send-footer.container.js b/ui/app/components/send_/send-footer/send-footer.container.js
new file mode 100644
index 000000000..260ff40bc
--- /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,
+ })
+
+ 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..e071fe54f
--- /dev/null
+++ b/ui/app/components/send_/send-footer/tests/send-footer-component.test.js
@@ -0,0 +1,227 @@
+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', () => {
+ wrapper.instance().onSubmit(MOCK_EVENT)
+ assert.equal(historySpies.push.callCount, 1)
+ assert.equal(historySpies.push.getCall(0).args[0], CONFIRM_TRANSACTION_ROUTE)
+ })
+ })
+
+ 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..b808eabbf
--- /dev/null
+++ b/ui/app/components/send_/send-header/index.js
@@ -0,0 +1 @@
+export { default } from './send-header.container' \ No newline at end of file
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..5f6617fce
--- /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,
+ };
+
+ 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)}
+ />
+ )
+ }
+
+}
+
+SendHeader.contextTypes = {
+ t: PropTypes.func,
+}
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..516251e22
--- /dev/null
+++ b/ui/app/components/send_/send.component.js
@@ -0,0 +1,160 @@
+import React from 'react'
+import PropTypes from 'prop-types'
+import PersistentForm from '../../../lib/persistent-form'
+import {
+ getAmountErrorObject,
+ doesAmountErrorRequireUpdate,
+} from './send.utils'
+
+import SendHeader from './send-header/'
+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,
+ };
+
+ updateGas ({ to } = {}) {
+ const {
+ amount,
+ blockGasLimit,
+ editingTransactionId,
+ gasLimit,
+ gasPrice,
+ recentBlocks,
+ selectedAddress,
+ selectedToken = {},
+ updateAndSetGasTotal,
+ } = this.props
+
+ updateAndSetGasTotal({
+ blockGasLimit,
+ editingTransactionId,
+ gasLimit,
+ gasPrice,
+ recentBlocks,
+ selectedAddress,
+ selectedToken,
+ to: to && to.toLowerCase(),
+ 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,
+ })
+ updateSendErrors(amountErrorObject)
+ }
+
+ 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()
+ }
+
+ render () {
+ const { history } = this.props
+
+ return (
+ <div className="page-container">
+ <SendHeader history={history}/>
+ <SendContent updateGas={(updateData) => this.updateGas(updateData)}/>
+ <SendFooter history={history}/>
+ </div>
+ )
+ }
+
+}
+
+SendTransactionScreen.contextTypes = {
+ t: PropTypes.func,
+}
diff --git a/ui/app/components/send/send-constants.js b/ui/app/components/send_/send.constants.js
index 5d89c74aa..df5dee371 100644
--- a/ui/app/components/send/send-constants.js
+++ b/ui/app/components/send_/send.constants.js
@@ -22,12 +22,34 @@ const MIN_GAS_TOTAL = multiplyCurrencies(MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_HEX, {
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.
+
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_PRICE_DEC,
- MIN_GAS_LIMIT_HEX,
- MIN_GAS_LIMIT_DEC,
MIN_GAS_TOTAL,
+ NEGATIVE_ETH_ERROR,
+ ONE_GWEI_IN_WEI_HEX,
+ REQUIRED_ERROR,
+ SIMPLE_GAS_COST,
TOKEN_TRANSFER_FUNCTION_SIGNATURE,
}
diff --git a/ui/app/components/send_/send.container.js b/ui/app/components/send_/send.container.js
new file mode 100644
index 000000000..1fd96d61f
--- /dev/null
+++ b/ui/app/components/send_/send.container.js
@@ -0,0 +1,89 @@
+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,
+ getTokenBalance,
+} from './send.selectors'
+import {
+ updateSendTokenBalance,
+ updateGasData,
+ setGasTotal,
+} from '../../actions'
+import {
+ 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),
+ 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)),
+ }
+}
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..7e7cfe2e9
--- /dev/null
+++ b/ui/app/components/send_/send.selectors.js
@@ -0,0 +1,284 @@
+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,
+ getConvertedCurrency,
+ 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 getConvertedCurrency (state) {
+ return state.metamask.currentCurrency
+}
+
+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..67699be77
--- /dev/null
+++ b/ui/app/components/send_/send.utils.js
@@ -0,0 +1,239 @@
+const {
+ addCurrencies,
+ conversionUtil,
+ conversionGTE,
+ multiplyCurrencies,
+ conversionGreaterThan,
+} = require('../../conversion-util')
+const {
+ calcTokenAmount,
+} = require('../../token-util')
+const {
+ 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 = {
+ calcGasTotal,
+ calcTokenBalance,
+ doesAmountErrorRequireUpdate,
+ estimateGas,
+ estimateGasPriceFromRecentBlocks,
+ generateTokenTransferData,
+ getAmountErrorObject,
+ isBalanceSufficient,
+ isTokenBalanceSufficient,
+}
+
+function calcGasTotal (gasLimit, gasPrice) {
+ return multiplyCurrencies(gasLimit, gasPrice, {
+ toNumericBase: 'hex',
+ multiplicandBase: 16,
+ multiplierBase: 16,
+ })
+}
+
+function isBalanceSufficient ({
+ amount = '0x0',
+ amountConversionRate = 0,
+ balance,
+ conversionRate,
+ gasTotal = '0x0',
+ primaryCurrency,
+}) {
+ const totalAmount = addCurrencies(amount, gasTotal, {
+ aBase: 16,
+ bBase: 16,
+ toNumericBase: 'hex',
+ })
+
+ const balanceIsSufficient = conversionGTE(
+ {
+ value: balance,
+ fromNumericBase: 'hex',
+ fromCurrency: primaryCurrency,
+ conversionRate,
+ },
+ {
+ value: totalAmount,
+ fromNumericBase: 'hex',
+ conversionRate: 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) {
+ insufficientFunds = !isBalanceSufficient({
+ amount: selectedToken ? '0x0' : amount,
+ amountConversionRate,
+ balance,
+ conversionRate,
+ gasTotal,
+ primaryCurrency,
+ })
+ }
+
+ let inSufficientTokens = false
+ if (selectedToken && tokenBalance !== null) {
+ const { decimals } = selectedToken
+ inSufficientTokens = !isTokenBalanceSufficient({
+ tokenBalance,
+ amount,
+ decimals,
+ })
+ }
+
+ const amountLessThanZero = conversionGreaterThan(
+ { value: 0, fromNumericBase: 'dec' },
+ { value: amount, fromNumericBase: 'hex' },
+ )
+
+ let amountError = null
+
+ if (insufficientFunds) {
+ amountError = INSUFFICIENT_FUNDS_ERROR
+ } else if (inSufficientTokens) {
+ amountError = INSUFFICIENT_TOKENS_ERROR
+ } else if (amountLessThanZero) {
+ amountError = NEGATIVE_ETH_ERROR
+ }
+
+ return { amount: amountError }
+}
+
+function 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:
+ const hasRecipient = Boolean(to)
+ if (hasRecipient && !selectedToken) {
+ const code = await global.eth.getCode(to)
+ if (!code || code === '0x') {
+ return SIMPLE_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) {
+ return resolve(paramsForGasEstimate.gas)
+ } else {
+ return reject(err)
+ }
+ }
+ return resolve(estimatedGas.toString(16))
+ })
+ })
+}
+
+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)]
+}
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..4e33d8f63
--- /dev/null
+++ b/ui/app/components/send_/tests/send-component.test.js
@@ -0,0 +1,251 @@
+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(),
+}
+const utilsMethodStubs = {
+ getAmountErrorObject: sinon.stub().returns({ amount: 'mockAmountError' }),
+ 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}
+ />)
+ })
+
+ afterEach(() => {
+ SendTransactionScreen.prototype.componentDidMount.resetHistory()
+ SendTransactionScreen.prototype.updateGas.resetHistory()
+ utilsMethodStubs.doesAmountErrorRequireUpdate.resetHistory()
+ utilsMethodStubs.getAmountErrorObject.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('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 updateSendErrors with the expected params', () => {
+ propsMethodSpies.updateSendErrors.resetHistory()
+ wrapper.instance().componentDidUpdate({
+ from: {
+ balance: 'balanceChanged',
+ },
+ })
+ assert.equal(propsMethodSpies.updateSendErrors.callCount, 1)
+ assert.deepEqual(
+ propsMethodSpies.updateSendErrors.getCall(0).args[0],
+ { amount: 'mockAmountError'}
+ )
+ })
+
+ 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', () => {
+ 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: undefined,
+ value: 'mockAmount',
+ }
+ )
+ })
+
+ 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..056aad148
--- /dev/null
+++ b/ui/app/components/send_/tests/send-container.test.js
@@ -0,0 +1,155 @@
+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(),
+}
+
+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}`,
+ 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',
+ 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'
+ )
+ })
+ })
+
+ })
+
+})
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..152af8059
--- /dev/null
+++ b/ui/app/components/send_/tests/send-selectors.test.js
@@ -0,0 +1,695 @@
+import assert from 'assert'
+import sinon from 'sinon'
+import selectors from '../send.selectors.js'
+const {
+ accountsWithSendEtherInfoSelector,
+ // autoAddToBetaUI,
+ getAddressBook,
+ getBlockGasLimit,
+ getAmountConversionRate,
+ getConversionRate,
+ getConvertedCurrency,
+ 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('getConvertedCurrency()', () => {
+ it('should return the currently selected currency', () => {
+ assert.equal(
+ getConvertedCurrency(mockState),
+ 'USD'
+ )
+ })
+ })
+
+ 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..b3f6372ef
--- /dev/null
+++ b/ui/app/components/send_/tests/send-utils.test.js
@@ -0,0 +1,404 @@
+import assert from 'assert'
+import sinon from 'sinon'
+import proxyquire from 'proxyquire'
+import {
+ 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) => 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]),
+}
+
+const sendUtils = proxyquire('../send.utils.js', {
+ '../../conversion-util': {
+ addCurrencies: stubs.addCurrencies,
+ conversionUtil: stubs.conversionUtil,
+ conversionGTE: stubs.conversionGTE,
+ multiplyCurrencies: stubs.multiplyCurrencies,
+ },
+ '../../token-util': { calcTokenAmount: stubs.calcTokenAmount },
+ 'ethereumjs-abi': {
+ rawEncode: stubs.rawEncode,
+ },
+})
+
+const {
+ calcGasTotal,
+ estimateGas,
+ doesAmountErrorRequireUpdate,
+ estimateGasPriceFromRecentBlocks,
+ generateTokenTransferData,
+ getAmountErrorObject,
+ 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 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('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()
+ 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) => `mockToString:${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 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, 'mockToString:16')
+ })
+
+ 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, 'mockToString:16')
+ })
+
+ 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 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 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')
+ })
+ })
+})
diff --git a/ui/app/components/tx-list-item.js b/ui/app/components/tx-list-item.js
index ef441ff73..9a2fb5311 100644
--- a/ui/app/components/tx-list-item.js
+++ b/ui/app/components/tx-list-item.js
@@ -208,8 +208,8 @@ TxListItem.prototype.showRetryButton = function () {
const currentNonceTxs = selectedAddressTxList.filter(tx => tx.txParams.nonce === currentNonce)
const currentNonceSubmittedTxs = currentNonceTxs.filter(tx => tx.status === 'submitted')
const lastSubmittedTxWithCurrentNonce = currentNonceSubmittedTxs[currentNonceSubmittedTxs.length - 1]
- const currentTxIsLatestWithNonce = lastSubmittedTxWithCurrentNonce
- && lastSubmittedTxWithCurrentNonce.id === transactionId
+ const currentTxIsLatestWithNonce = lastSubmittedTxWithCurrentNonce &&
+ lastSubmittedTxWithCurrentNonce.id === transactionId
return currentTxIsLatestWithNonce && Date.now() - transactionSubmittedTime > 30000
}
diff --git a/ui/app/conversion-util.js b/ui/app/conversion-util.js
index d484ed16d..100402d95 100644
--- a/ui/app/conversion-util.js
+++ b/ui/app/conversion-util.js
@@ -11,7 +11,8 @@
* @param {string} [options.fromNumericBase = 'hex' | 'dec' | 'BN'] The numeric basic of the passed value.
* @param {string} [options.toNumericBase = 'hex' | 'dec' | 'BN'] The desired numeric basic of the result.
* @param {string} [options.fromDenomination = 'WEI'] The denomination of the passed value
-* @param {number} [options.numberOfDecimals] The desired number of in the result
+* @param {string} [options.numberOfDecimals] The desired number of decimals in the result
+* @param {string} [options.roundDown] The desired number of decimals to round down to
* @param {number} [options.conversionRate] The rate to use to make the fromCurrency -> toCurrency conversion
* @returns {(number | string | BN)}
*
@@ -38,6 +39,7 @@ const BIG_NUMBER_GWEI_MULTIPLIER = new BigNumber('1000000000')
// Individual Setters
const convert = R.invoker(1, 'times')
const round = R.invoker(2, 'round')(R.__, BigNumber.ROUND_HALF_DOWN)
+const roundDown = R.invoker(2, 'round')(R.__, BigNumber.ROUND_DOWN)
const invertConversionRate = conversionRate => () => new BigNumber(1.0).div(conversionRate)
const decToBigNumberViaString = n => R.pipe(String, toBigNumber['dec'])
@@ -104,6 +106,7 @@ const converter = R.pipe(
whenPredSetWithPropAndSetter(fromAndToCurrencyPropsNotEqual, 'conversionRate', convert),
whenPropApplySetterMap('toDenomination', toSpecifiedDenomination),
whenPredSetWithPropAndSetter(R.prop('numberOfDecimals'), 'numberOfDecimals', round),
+ whenPredSetWithPropAndSetter(R.prop('roundDown'), 'roundDown', roundDown),
whenPropApplySetterMap('toNumericBase', baseChange),
R.view(R.lensProp('value'))
)
diff --git a/ui/app/css/itcss/components/currency-display.scss b/ui/app/css/itcss/components/currency-display.scss
index 36d843c79..3560b0b0c 100644
--- a/ui/app/css/itcss/components/currency-display.scss
+++ b/ui/app/css/itcss/components/currency-display.scss
@@ -47,10 +47,32 @@
&__input-wrapper {
position: relative;
display: flex;
+
+ input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ display: none;
+ }
+
+ input[type="number"]:hover::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ display: none;
+ }
}
&__currency-symbol {
margin-top: 1px;
color: $scorpion;
}
+
+ .react-numeric-input {
+ input[type="number"]::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ display: none;
+ }
+
+ input[type="number"]:hover::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ display: none;
+ }
+ }
} \ No newline at end of file
diff --git a/ui/app/ducks/send.duck.js b/ui/app/ducks/send.duck.js
new file mode 100644
index 000000000..055cc05c1
--- /dev/null
+++ b/ui/app/ducks/send.duck.js
@@ -0,0 +1,72 @@
+import extend from 'xtend'
+
+// Actions
+const OPEN_FROM_DROPDOWN = 'metamask/send/OPEN_FROM_DROPDOWN'
+const CLOSE_FROM_DROPDOWN = 'metamask/send/CLOSE_FROM_DROPDOWN'
+const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'
+const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'
+const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'
+
+// TODO: determine if this approach to initState is consistent with conventional ducks pattern
+const initState = {
+ fromDropdownOpen: false,
+ toDropdownOpen: false,
+ errors: {},
+}
+
+// Reducer
+export default function reducer ({ send: sendState = initState }, action = {}) {
+ const newState = extend({}, sendState)
+
+ switch (action.type) {
+ case OPEN_FROM_DROPDOWN:
+ return extend(newState, {
+ fromDropdownOpen: true,
+ })
+ case CLOSE_FROM_DROPDOWN:
+ return extend(newState, {
+ fromDropdownOpen: false,
+ })
+ case OPEN_TO_DROPDOWN:
+ return extend(newState, {
+ toDropdownOpen: true,
+ })
+ case CLOSE_TO_DROPDOWN:
+ return extend(newState, {
+ toDropdownOpen: false,
+ })
+ case UPDATE_SEND_ERRORS:
+ return extend(newState, {
+ errors: {
+ ...newState.errors,
+ ...action.value,
+ },
+ })
+ default:
+ return newState
+ }
+}
+
+// Action Creators
+export function openFromDropdown () {
+ return { type: OPEN_FROM_DROPDOWN }
+}
+
+export function closeFromDropdown () {
+ return { type: CLOSE_FROM_DROPDOWN }
+}
+
+export function openToDropdown () {
+ return { type: OPEN_TO_DROPDOWN }
+}
+
+export function closeToDropdown () {
+ return { type: CLOSE_TO_DROPDOWN }
+}
+
+export function updateSendErrors (errorObject) {
+ return {
+ type: UPDATE_SEND_ERRORS,
+ value: errorObject,
+ }
+}
diff --git a/ui/app/ducks/tests/send-duck.test.js b/ui/app/ducks/tests/send-duck.test.js
new file mode 100644
index 000000000..c06cf55d2
--- /dev/null
+++ b/ui/app/ducks/tests/send-duck.test.js
@@ -0,0 +1,145 @@
+import assert from 'assert'
+
+import SendReducer, {
+ openFromDropdown,
+ closeFromDropdown,
+ openToDropdown,
+ closeToDropdown,
+ updateSendErrors,
+} from '../send.duck.js'
+
+describe('Send Duck', () => {
+ const mockState = {
+ send: {
+ mockProp: 123,
+ },
+ }
+ const initState = {
+ fromDropdownOpen: false,
+ toDropdownOpen: false,
+ errors: {},
+ }
+ const OPEN_FROM_DROPDOWN = 'metamask/send/OPEN_FROM_DROPDOWN'
+ const CLOSE_FROM_DROPDOWN = 'metamask/send/CLOSE_FROM_DROPDOWN'
+ const OPEN_TO_DROPDOWN = 'metamask/send/OPEN_TO_DROPDOWN'
+ const CLOSE_TO_DROPDOWN = 'metamask/send/CLOSE_TO_DROPDOWN'
+ const UPDATE_SEND_ERRORS = 'metamask/send/UPDATE_SEND_ERRORS'
+
+ describe('SendReducer()', () => {
+ it('should initialize state', () => {
+ assert.deepEqual(
+ SendReducer({}),
+ initState
+ )
+ })
+
+ it('should return state unchanged if it does not match a dispatched actions type', () => {
+ assert.deepEqual(
+ SendReducer(mockState, {
+ type: 'someOtherAction',
+ value: 'someValue',
+ }),
+ Object.assign({}, mockState.send)
+ )
+ })
+
+ it('should set fromDropdownOpen to true when receiving a OPEN_FROM_DROPDOWN action', () => {
+ assert.deepEqual(
+ SendReducer(mockState, {
+ type: OPEN_FROM_DROPDOWN,
+ }),
+ Object.assign({fromDropdownOpen: true}, mockState.send)
+ )
+ })
+
+ it('should return a new object (and not just modify the existing state object)', () => {
+ assert.deepEqual(SendReducer(mockState), mockState.send)
+ assert.notEqual(SendReducer(mockState), mockState.send)
+ })
+
+ it('should set fromDropdownOpen to false when receiving a CLOSE_FROM_DROPDOWN action', () => {
+ assert.deepEqual(
+ SendReducer(mockState, {
+ type: CLOSE_FROM_DROPDOWN,
+ }),
+ Object.assign({fromDropdownOpen: false}, mockState.send)
+ )
+ })
+
+ it('should set toDropdownOpen to true when receiving a OPEN_TO_DROPDOWN action', () => {
+ assert.deepEqual(
+ SendReducer(mockState, {
+ type: OPEN_TO_DROPDOWN,
+ }),
+ Object.assign({toDropdownOpen: true}, mockState.send)
+ )
+ })
+
+ it('should set toDropdownOpen to false when receiving a CLOSE_TO_DROPDOWN action', () => {
+ assert.deepEqual(
+ SendReducer(mockState, {
+ type: CLOSE_TO_DROPDOWN,
+ }),
+ Object.assign({toDropdownOpen: false}, mockState.send)
+ )
+ })
+
+ it('should extend send.errors with the value of a UPDATE_SEND_ERRORS action', () => {
+ const modifiedMockState = Object.assign({}, mockState, {
+ send: {
+ errors: {
+ someError: false,
+ },
+ },
+ })
+ assert.deepEqual(
+ SendReducer(modifiedMockState, {
+ type: UPDATE_SEND_ERRORS,
+ value: { someOtherError: true },
+ }),
+ Object.assign({}, modifiedMockState.send, {
+ errors: {
+ someError: false,
+ someOtherError: true,
+ },
+ })
+ )
+ })
+ })
+
+ describe('openFromDropdown', () => {
+ assert.deepEqual(
+ openFromDropdown(),
+ { type: OPEN_FROM_DROPDOWN }
+ )
+ })
+
+ describe('closeFromDropdown', () => {
+ assert.deepEqual(
+ closeFromDropdown(),
+ { type: CLOSE_FROM_DROPDOWN }
+ )
+ })
+
+ describe('openToDropdown', () => {
+ assert.deepEqual(
+ openToDropdown(),
+ { type: OPEN_TO_DROPDOWN }
+ )
+ })
+
+ describe('closeToDropdown', () => {
+ assert.deepEqual(
+ closeToDropdown(),
+ { type: CLOSE_TO_DROPDOWN }
+ )
+ })
+
+ describe('updateSendErrors', () => {
+ assert.deepEqual(
+ updateSendErrors('mockErrorObject'),
+ { type: UPDATE_SEND_ERRORS, value: 'mockErrorObject' }
+ )
+ })
+
+})
diff --git a/ui/app/i18n-provider.js b/ui/app/i18n-provider.js
index 4ef618018..2856e0ed6 100644
--- a/ui/app/i18n-provider.js
+++ b/ui/app/i18n-provider.js
@@ -6,14 +6,14 @@ const { compose } = require('recompose')
const t = require('../i18n-helper').getMessage
class I18nProvider extends Component {
- getChildContext() {
+ getChildContext () {
const { localeMessages } = this.props
return {
t: t.bind(null, localeMessages),
}
}
- render() {
+ render () {
return this.props.children
}
}
diff --git a/ui/app/main-container.js b/ui/app/main-container.js
index c9b05db3b..b49a52363 100644
--- a/ui/app/main-container.js
+++ b/ui/app/main-container.js
@@ -20,7 +20,7 @@ MainContainer.prototype.render = function () {
// - pass resulting h() to MainContainer
// - error checking in separate func
// - router in separate func
- let contents = {
+ const contents = {
component: AccountAndTransactionDetails,
key: 'account-detail',
style: {},
diff --git a/ui/app/metamask-connect.js b/ui/app/metamask-connect.js
index 8da594635..81fa7e403 100644
--- a/ui/app/metamask-connect.js
+++ b/ui/app/metamask-connect.js
@@ -24,4 +24,4 @@ const _higherOrderMapStateToProps = (mapStateToProps) => {
}
}
-module.exports = metamaskConnect \ No newline at end of file
+module.exports = metamaskConnect
diff --git a/ui/app/reducers.js b/ui/app/reducers.js
index f155b2bf3..d0d364da9 100644
--- a/ui/app/reducers.js
+++ b/ui/app/reducers.js
@@ -8,6 +8,7 @@ const reduceIdentities = require('./reducers/identities')
const reduceMetamask = require('./reducers/metamask')
const reduceApp = require('./reducers/app')
const reduceLocale = require('./reducers/locale')
+const reduceSend = require('./ducks/send.duck').default
window.METAMASK_CACHED_LOG_STATE = null
@@ -45,6 +46,12 @@ function rootReducer (state, action) {
state.localeMessages = reduceLocale(state, action)
+ //
+ // Send
+ //
+
+ state.send = reduceSend(state, action)
+
window.METAMASK_CACHED_LOG_STATE = state
return state
}
diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js
index 732fa6dea..a4d1aece5 100644
--- a/ui/app/reducers/metamask.js
+++ b/ui/app/reducers/metamask.js
@@ -255,17 +255,6 @@ function reduceMetamask (state, action) {
},
})
- case actions.UPDATE_SEND_ERRORS:
- return extend(metamaskState, {
- send: {
- ...metamaskState.send,
- errors: {
- ...metamaskState.send.errors,
- ...action.value,
- },
- },
- })
-
case actions.UPDATE_MAX_MODE:
return extend(metamaskState, {
send: {
diff --git a/ui/app/selectors.js b/ui/app/selectors.js
index 60cc264da..a29294b86 100644
--- a/ui/app/selectors.js
+++ b/ui/app/selectors.js
@@ -185,4 +185,4 @@ function autoAddToBetaUI (state) {
function getCurrentViewContext (state) {
const { currentView = {} } = state.appState
return currentView.context
-} \ No newline at end of file
+}
diff --git a/ui/app/send-v2.js b/ui/app/send-v2.js
deleted file mode 100644
index 4fbe8ff11..000000000
--- a/ui/app/send-v2.js
+++ /dev/null
@@ -1,646 +0,0 @@
-const { inherits } = require('util')
-const PropTypes = require('prop-types')
-const PersistentForm = require('../lib/persistent-form')
-const h = require('react-hyperscript')
-
-const ethAbi = require('ethereumjs-abi')
-const ethUtil = require('ethereumjs-util')
-
-const FromDropdown = require('./components/send/from-dropdown')
-const EnsInput = require('./components/ens-input')
-const CurrencyDisplay = require('./components/send/currency-display')
-const MemoTextArea = require('./components/send/memo-textarea')
-const GasFeeDisplay = require('./components/send/gas-fee-display-v2')
-
-const {
- TOKEN_TRANSFER_FUNCTION_SIGNATURE,
-} = require('./components/send/send-constants')
-
-const {
- multiplyCurrencies,
- conversionGreaterThan,
- subtractCurrencies,
-} = require('./conversion-util')
-const {
- calcTokenAmount,
-} = require('./token-util')
-const {
- isBalanceSufficient,
- isTokenBalanceSufficient,
- getGasTotal,
-} = require('./components/send/send-utils')
-const { isValidAddress } = require('./util')
-const { CONFIRM_TRANSACTION_ROUTE, DEFAULT_ROUTE } = require('./routes')
-const Button = require('./components/button')
-
-SendTransactionScreen.contextTypes = {
- t: PropTypes.func,
-}
-
-module.exports = SendTransactionScreen
-
-inherits(SendTransactionScreen, PersistentForm)
-function SendTransactionScreen () {
- PersistentForm.call(this)
-
- this.state = {
- fromDropdownOpen: false,
- toDropdownOpen: false,
- errors: {
- to: null,
- amount: null,
- },
- gasLoadingError: false,
- }
-
- this.handleToChange = this.handleToChange.bind(this)
- this.handleAmountChange = this.handleAmountChange.bind(this)
- this.validateAmount = this.validateAmount.bind(this)
-}
-
-const getParamsForGasEstimate = function (selectedAddress, symbol, data) {
- const estimatedGasParams = {
- from: selectedAddress,
- gas: '746a528800',
- }
-
- if (symbol) {
- Object.assign(estimatedGasParams, { value: '0x0' })
- }
-
- if (data) {
- Object.assign(estimatedGasParams, { data })
- }
-
- return estimatedGasParams
-}
-
-SendTransactionScreen.prototype.updateSendTokenBalance = function (usersToken) {
- if (!usersToken) return
-
- const {
- selectedToken = {},
- updateSendTokenBalance,
- } = this.props
- const { decimals } = selectedToken || {}
- const tokenBalance = calcTokenAmount(usersToken.balance.toString(), decimals)
-
- updateSendTokenBalance(tokenBalance)
-}
-
-SendTransactionScreen.prototype.componentWillMount = function () {
- this.updateGas()
-}
-
-SendTransactionScreen.prototype.updateGas = function () {
- const {
- selectedToken = {},
- getGasPrice,
- estimateGas,
- selectedAddress,
- data,
- updateGasTotal,
- from,
- tokenContract,
- editingTransactionId,
- gasPrice,
- gasLimit,
- } = this.props
-
- const { symbol } = selectedToken || {}
-
- const tokenBalancePromise = tokenContract
- ? tokenContract.balanceOf(from.address)
- : Promise.resolve()
- tokenBalancePromise
- .then(usersToken => this.updateSendTokenBalance(usersToken))
-
- if (!editingTransactionId) {
- const estimateGasParams = getParamsForGasEstimate(selectedAddress, symbol, data)
-
- Promise
- .all([
- getGasPrice(),
- estimateGas(estimateGasParams),
- ])
- .then(([gasPrice, gas]) => {
- const newGasTotal = getGasTotal(gas, gasPrice)
- updateGasTotal(newGasTotal)
- this.setState({ gasLoadingError: false })
- })
- .catch(err => {
- this.setState({ gasLoadingError: true })
- })
- } else {
- const newGasTotal = getGasTotal(gasLimit, gasPrice)
- updateGasTotal(newGasTotal)
- }
-}
-
-SendTransactionScreen.prototype.componentDidUpdate = function (prevProps) {
- const {
- from: { balance },
- gasTotal,
- tokenBalance,
- amount,
- selectedToken,
- network,
- } = this.props
-
- const {
- from: { balance: prevBalance },
- gasTotal: prevGasTotal,
- tokenBalance: prevTokenBalance,
- network: prevNetwork,
- } = prevProps
-
- const uninitialized = [prevBalance, prevGasTotal].every(n => n === null)
-
- const balanceHasChanged = balance !== prevBalance
- const gasTotalHasChange = gasTotal !== prevGasTotal
- const tokenBalanceHasChanged = selectedToken && tokenBalance !== prevTokenBalance
- const amountValidationChange = balanceHasChanged || gasTotalHasChange || tokenBalanceHasChanged
-
- if (!uninitialized) {
- if (amountValidationChange) {
- this.validateAmount(amount)
- }
-
- if (network !== prevNetwork && network !== 'loading') {
- this.updateGas()
- }
- }
-}
-
-SendTransactionScreen.prototype.renderHeader = function () {
- const { selectedToken, clearSend, history } = this.props
-
- return h('div.page-container__header', [
-
- h('div.page-container__title', selectedToken ? this.context.t('sendTokens') : this.context.t('sendETH')),
-
- h('div.page-container__subtitle', this.context.t('onlySendToEtherAddress')),
-
- h('div.page-container__header-close', {
- onClick: () => {
- clearSend()
- history.push(DEFAULT_ROUTE)
- },
- }),
-
- ])
-}
-
-SendTransactionScreen.prototype.renderErrorMessage = function (errorType) {
- const { errors } = this.props
- const errorMessage = errors[errorType]
-
- return errorMessage
- ? h('div.send-v2__error', [ errorMessage ])
- : null
-}
-
-SendTransactionScreen.prototype.handleFromChange = async function (newFrom) {
- const {
- updateSendFrom,
- tokenContract,
- } = this.props
-
- if (tokenContract) {
- const usersToken = await tokenContract.balanceOf(newFrom.address)
- this.updateSendTokenBalance(usersToken)
- }
- updateSendFrom(newFrom)
-}
-
-SendTransactionScreen.prototype.renderFromRow = function () {
- const {
- from,
- fromAccounts,
- conversionRate,
- } = this.props
-
- const { fromDropdownOpen } = this.state
-
- return h('div.send-v2__form-row', [
-
- h('div.send-v2__form-label', 'From:'),
-
- h('div.send-v2__form-field', [
- h(FromDropdown, {
- dropdownOpen: fromDropdownOpen,
- accounts: fromAccounts,
- selectedAccount: from,
- onSelect: newFrom => this.handleFromChange(newFrom),
- openDropdown: () => this.setState({ fromDropdownOpen: true }),
- closeDropdown: () => this.setState({ fromDropdownOpen: false }),
- conversionRate,
- }),
- ]),
-
- ])
-}
-
-SendTransactionScreen.prototype.handleToChange = function (to, nickname = '') {
- const {
- updateSendTo,
- updateSendErrors,
- } = this.props
- let toError = null
-
- if (!to) {
- toError = this.context.t('required')
- } else if (!isValidAddress(to)) {
- toError = this.context.t('invalidAddressRecipient')
- }
-
- updateSendTo(to, nickname)
- updateSendErrors({ to: toError })
-}
-
-SendTransactionScreen.prototype.renderToRow = function () {
- const { toAccounts, errors, to, network } = this.props
-
- const { toDropdownOpen } = this.state
-
- return h('div.send-v2__form-row', [
-
- h('div.send-v2__form-label', [
-
- this.context.t('to'),
-
- this.renderErrorMessage(this.context.t('to')),
-
- ]),
-
- h('div.send-v2__form-field', [
- h(EnsInput, {
- name: 'address',
- placeholder: 'Recipient Address',
- network,
- to,
- accounts: Object.entries(toAccounts).map(([key, account]) => account),
- dropdownOpen: toDropdownOpen,
- openDropdown: () => this.setState({ toDropdownOpen: true }),
- closeDropdown: () => this.setState({ toDropdownOpen: false }),
- onChange: this.handleToChange,
- inError: Boolean(errors.to),
- }),
- ]),
-
- ])
-}
-
-SendTransactionScreen.prototype.handleAmountChange = function (value) {
- const amount = value
- const { updateSendAmount, setMaxModeTo } = this.props
-
- setMaxModeTo(false)
- this.validateAmount(amount)
- updateSendAmount(amount)
-}
-
-SendTransactionScreen.prototype.setAmountToMax = function () {
- const {
- from: { balance },
- updateSendAmount,
- updateSendErrors,
- tokenBalance,
- selectedToken,
- gasTotal,
- } = this.props
- const { decimals } = selectedToken || {}
- const multiplier = Math.pow(10, Number(decimals || 0))
-
- const maxAmount = selectedToken
- ? multiplyCurrencies(tokenBalance, multiplier, {toNumericBase: 'hex'})
- : subtractCurrencies(
- ethUtil.addHexPrefix(balance),
- ethUtil.addHexPrefix(gasTotal),
- { toNumericBase: 'hex' }
- )
-
- updateSendErrors({ amount: null })
-
- updateSendAmount(maxAmount)
-}
-
-SendTransactionScreen.prototype.validateAmount = function (value) {
- const {
- from: { balance },
- updateSendErrors,
- amountConversionRate,
- conversionRate,
- primaryCurrency,
- selectedToken,
- gasTotal,
- tokenBalance,
- } = this.props
- const { decimals } = selectedToken || {}
- const amount = value
-
- let amountError = null
-
- let sufficientBalance = true
-
- if (gasTotal) {
- sufficientBalance = isBalanceSufficient({
- amount: selectedToken ? '0x0' : amount,
- gasTotal,
- balance,
- primaryCurrency,
- amountConversionRate,
- conversionRate,
- })
- }
-
- const verifyTokenBalance = selectedToken && tokenBalance !== null
- let sufficientTokens
- if (verifyTokenBalance) {
- sufficientTokens = isTokenBalanceSufficient({
- tokenBalance,
- amount,
- decimals,
- })
- }
-
- const amountLessThanZero = conversionGreaterThan(
- { value: 0, fromNumericBase: 'dec' },
- { value: amount, fromNumericBase: 'hex' },
- )
-
- if (conversionRate && !sufficientBalance) {
- amountError = this.context.t('insufficientFunds')
- } else if (verifyTokenBalance && !sufficientTokens) {
- amountError = this.context.t('insufficientTokens')
- } else if (amountLessThanZero) {
- amountError = this.context.t('negativeETH')
- }
-
- updateSendErrors({ amount: amountError })
-}
-
-SendTransactionScreen.prototype.renderAmountRow = function () {
- const {
- selectedToken,
- primaryCurrency = 'ETH',
- convertedCurrency,
- amountConversionRate,
- errors,
- amount,
- setMaxModeTo,
- maxModeOn,
- gasTotal,
- } = this.props
-
- return h('div.send-v2__form-row', [
-
- h('div.send-v2__form-label', [
- 'Amount:',
- this.renderErrorMessage('amount'),
- !errors.amount && gasTotal && h('div.send-v2__amount-max', {
- onClick: (event) => {
- event.preventDefault()
- setMaxModeTo(true)
- this.setAmountToMax()
- },
- }, [ !maxModeOn ? this.context.t('max') : '' ]),
- ]),
-
- h('div.send-v2__form-field', [
- h(CurrencyDisplay, {
- inError: Boolean(errors.amount),
- primaryCurrency,
- convertedCurrency,
- selectedToken,
- value: amount || '0x0',
- conversionRate: amountConversionRate,
- handleChange: this.handleAmountChange,
- }),
- ]),
-
- ])
-}
-
-SendTransactionScreen.prototype.renderGasRow = function () {
- const {
- conversionRate,
- convertedCurrency,
- showCustomizeGasModal,
- gasTotal,
- } = this.props
- const { gasLoadingError } = this.state
-
- return h('div.send-v2__form-row', [
-
- h('div.send-v2__form-label', this.context.t('gasFee')),
-
- h('div.send-v2__form-field', [
-
- h(GasFeeDisplay, {
- gasTotal,
- conversionRate,
- convertedCurrency,
- onClick: showCustomizeGasModal,
- gasLoadingError,
- }),
-
- ]),
-
- ])
-}
-
-SendTransactionScreen.prototype.renderMemoRow = function () {
- const { updateSendMemo, memo } = this.props
-
- return h('div.send-v2__form-row', [
-
- h('div.send-v2__form-label', 'Transaction Memo:'),
-
- h('div.send-v2__form-field', [
- h(MemoTextArea, {
- memo,
- onChange: (event) => updateSendMemo(event.target.value),
- }),
- ]),
-
- ])
-}
-
-SendTransactionScreen.prototype.renderForm = function () {
- return h('.page-container__content', {}, [
- h('.send-v2__form', [
- this.renderFromRow(),
-
- this.renderToRow(),
-
- this.renderAmountRow(),
-
- this.renderGasRow(),
-
- // this.renderMemoRow(),
-
- ]),
- ])
-}
-
-SendTransactionScreen.prototype.renderFooter = function () {
- const {
- clearSend,
- gasTotal,
- tokenBalance,
- selectedToken,
- errors: { amount: amountError, to: toError },
- history,
- } = this.props
-
- const missingTokenBalance = selectedToken && (tokenBalance === null || tokenBalance === undefined)
- const noErrors = !amountError && toError === null
-
- return h('div.page-container__footer', [
- h(Button, {
- type: 'default',
- large: true,
- className: 'page-container__footer-button',
- onClick: () => {
- clearSend()
- history.push(DEFAULT_ROUTE)
- },
- }, this.context.t('cancel')),
- h(Button, {
- type: 'primary',
- large: true,
- className: 'page-container__footer-button',
- disabled: !noErrors || !gasTotal || missingTokenBalance,
- onClick: event => this.onSubmit(event),
- }, this.context.t('next')),
- ])
-}
-
-SendTransactionScreen.prototype.render = function () {
- return (
-
- h('div.page-container', [
-
- this.renderHeader(),
-
- this.renderForm(),
-
- this.renderFooter(),
- ])
-
- )
-}
-
-SendTransactionScreen.prototype.addToAddressBookIfNew = function (newAddress, nickname = '') {
- const { toAccounts, addToAddressBook } = this.props
- if (!toAccounts.find(({ address }) => newAddress === address)) {
- // TODO: nickname, i.e. addToAddressBook(recipient, nickname)
- addToAddressBook(newAddress, nickname)
- }
-}
-
-SendTransactionScreen.prototype.getEditedTx = function () {
- const {
- from: {address: from},
- to,
- amount,
- gasLimit: gas,
- gasPrice,
- selectedToken,
- editingTransactionId,
- unapprovedTxs,
- } = this.props
-
- const editingTx = {
- ...unapprovedTxs[editingTransactionId],
- txParams: {
- from: ethUtil.addHexPrefix(from),
- gas: ethUtil.addHexPrefix(gas),
- gasPrice: ethUtil.addHexPrefix(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, {
- value: ethUtil.addHexPrefix('0'),
- to: ethUtil.addHexPrefix(selectedToken.address),
- data,
- })
- } else {
- const { data } = unapprovedTxs[editingTransactionId].txParams
-
- Object.assign(editingTx.txParams, {
- value: ethUtil.addHexPrefix(amount),
- to: ethUtil.addHexPrefix(to),
- data,
- })
-
- if (typeof editingTx.txParams.data === 'undefined') {
- delete editingTx.txParams.data
- }
- }
-
- return editingTx
-}
-
-SendTransactionScreen.prototype.onSubmit = function (event) {
- event.preventDefault()
- const {
- from: {address: from},
- to: _to,
- amount,
- gasLimit: gas,
- gasPrice,
- signTokenTx,
- signTx,
- updateTx,
- selectedToken,
- editingTransactionId,
- toNickname,
- errors: { amount: amountError, to: toError },
- } = this.props
-
- const noErrors = !amountError && toError === null
-
- if (!noErrors) {
- return
- }
-
- const to = ethUtil.addHexPrefix(_to)
-
- this.addToAddressBookIfNew(to, toNickname)
-
- if (editingTransactionId) {
- const editedTx = this.getEditedTx()
- updateTx(editedTx)
- } else {
-
- const txParams = {
- from,
- value: '0',
- gas,
- gasPrice,
- }
-
- if (!selectedToken) {
- txParams.value = amount
- txParams.to = to
- }
-
- Object.keys(txParams).forEach(key => {
- txParams[key] = ethUtil.addHexPrefix(txParams[key])
- })
-
- selectedToken
- ? signTokenTx(selectedToken.address, to, amount, txParams)
- : signTx(txParams)
- }
-
- this.props.history.push(CONFIRM_TRANSACTION_ROUTE)
-}
diff --git a/ui/app/util.js b/ui/app/util.js
index 8e9390dfb..1ccd17ba7 100644
--- a/ui/app/util.js
+++ b/ui/app/util.js
@@ -291,7 +291,7 @@ function getTokenAddressFromTokenObject (token) {
/**
* Safely checksumms a potentially-null address
- *
+ *
* @param {String} [address] - address to checksum
* @returns {String} - checksummed address
*/