diff options
author | kumavis <aaron@kumavis.me> | 2018-06-08 01:07:36 +0800 |
---|---|---|
committer | kumavis <aaron@kumavis.me> | 2018-06-08 01:07:36 +0800 |
commit | 66b703b1a4ba25ddaf8278f020967b4d20351027 (patch) | |
tree | 41520dbc3ad35b3c9968cfc390ecb99db5456a33 | |
parent | 2198276bd533bec767cad954ef8b21c47c0f8c09 (diff) | |
parent | 988283778a2be52640b27a359ef3fb1130e95711 (diff) | |
download | tangerine-wallet-browser-66b703b1a4ba25ddaf8278f020967b4d20351027.tar.gz tangerine-wallet-browser-66b703b1a4ba25ddaf8278f020967b4d20351027.tar.zst tangerine-wallet-browser-66b703b1a4ba25ddaf8278f020967b4d20351027.zip |
Merge branch 'develop' of github.com:MetaMask/metamask-extension into network-remove-provider-engine
168 files changed, 7741 insertions, 1486 deletions
diff --git a/.circleci/config.yml b/.circleci/config.yml index 4d9d01ee6..bc40c9a7c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -30,6 +30,15 @@ workflows: - prep-deps-npm - prep-deps-firefox - prep-build + - test-e2e-beta-chrome: + requires: + - prep-deps-npm + - prep-build + - test-e2e-beta-firefox: + requires: + - prep-deps-npm + - prep-deps-firefox + - prep-build - test-unit: requires: - prep-deps-npm @@ -57,6 +66,8 @@ workflows: - test-unit - test-e2e-chrome - test-e2e-firefox + - test-e2e-beta-chrome + - test-e2e-beta-firefox - test-integration-mascara-chrome - test-integration-mascara-firefox - test-integration-flat-chrome @@ -110,9 +121,7 @@ jobs: - checkout - run: name: Download Firefox - command: > - wget https://ftp.mozilla.org/pub/firefox/releases/58.0/linux-x86_64/en-US/firefox-58.0.tar.bz2 - && tar xjf firefox-58.0.tar.bz2 + command: ./.circleci/scripts/firefox-download.sh - save_cache: key: dependency-cache-firefox-{{ .Revision }} paths: @@ -203,15 +212,13 @@ jobs: - restore_cache: key: build-cache-{{ .Revision }} - run: - name: Test + name: test:e2e:chrome command: npm run test:e2e:chrome - store_artifacts: path: test-artifacts destination: test-artifacts test-e2e-firefox: - environment: - browsers: '["Firefox"]' docker: - image: circleci/node:8-browsers steps: @@ -220,11 +227,7 @@ jobs: key: dependency-cache-firefox-{{ .Revision }} - run: name: Install firefox - command: > - sudo rm -r /opt/firefox - && sudo mv firefox /opt/firefox58 - && sudo mv /usr/bin/firefox /usr/bin/firefox-old - && sudo ln -s /opt/firefox58/firefox /usr/bin/firefox + command: ./.circleci/scripts/firefox-install.sh - restore_cache: key: dependency-cache-{{ .Revision }} - restore_cache: @@ -236,6 +239,43 @@ jobs: path: test-artifacts destination: test-artifacts + test-e2e-beta-chrome: + docker: + - image: circleci/node:8-browsers + steps: + - checkout + - restore_cache: + key: dependency-cache-{{ .Revision }} + - restore_cache: + key: build-cache-{{ .Revision }} + - run: + name: test:e2e:chrome:beta + command: npm run test:e2e:chrome:beta + - store_artifacts: + path: test-artifacts + destination: test-artifacts + + test-e2e-beta-firefox: + docker: + - image: circleci/node:8-browsers + steps: + - checkout + - restore_cache: + key: dependency-cache-firefox-{{ .Revision }} + - run: + name: Install firefox + command: ./.circleci/scripts/firefox-install.sh + - restore_cache: + key: dependency-cache-{{ .Revision }} + - restore_cache: + key: build-cache-{{ .Revision }} + - run: + name: test:e2e:firefox:beta + command: npm run test:e2e:firefox:beta + - store_artifacts: + path: test-artifacts + destination: test-artifacts + job-screens: docker: - image: circleci/node:8-browsers @@ -325,11 +365,7 @@ jobs: key: dependency-cache-firefox-{{ .Revision }} - run: name: Install firefox - command: > - sudo rm -r /opt/firefox - && sudo mv firefox /opt/firefox58 - && sudo mv /usr/bin/firefox /usr/bin/firefox-old - && sudo ln -s /opt/firefox58/firefox /usr/bin/firefox + command: ./.circleci/scripts/firefox-install.sh - restore_cache: key: dependency-cache-{{ .Revision }} - run: @@ -372,11 +408,7 @@ jobs: key: dependency-cache-firefox-{{ .Revision }} - run: name: Install firefox - command: > - sudo rm -r /opt/firefox - && sudo mv firefox /opt/firefox58 - && sudo mv /usr/bin/firefox /usr/bin/firefox-old - && sudo ln -s /opt/firefox58/firefox /usr/bin/firefox + command: ./.circleci/scripts/firefox-install.sh - restore_cache: key: dependency-cache-{{ .Revision }} - run: @@ -415,4 +447,3 @@ jobs: - run: name: All Tests Passed command: echo 'weew - everything passed!' - diff --git a/.circleci/scripts/firefox-download.sh b/.circleci/scripts/firefox-download.sh new file mode 100755 index 000000000..c63e8c3df --- /dev/null +++ b/.circleci/scripts/firefox-download.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +echo "Downloading firefox..." +wget https://ftp.mozilla.org/pub/firefox/releases/58.0/linux-x86_64/en-US/firefox-58.0.tar.bz2 \ +&& tar xjf firefox-58.0.tar.bz2 +echo "firefox download complete" diff --git a/.circleci/scripts/firefox-install.sh b/.circleci/scripts/firefox-install.sh new file mode 100755 index 000000000..589bcbbb5 --- /dev/null +++ b/.circleci/scripts/firefox-install.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +echo "Installing firefox..." +sudo rm -r /opt/firefox +sudo mv firefox /opt/firefox58 +sudo mv /usr/bin/firefox /usr/bin/firefox-old +sudo ln -s /opt/firefox58/firefox /usr/bin/firefox +echo "Firefox installed." @@ -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/contentscript.js b/app/scripts/contentscript.js index 555902ddf..75e0a95b3 100644 --- a/app/scripts/contentscript.js +++ b/app/scripts/contentscript.js @@ -176,6 +176,7 @@ function blacklistedDomainCheck () { 'webbyawards.com', 'cdn.shopify.com/s/javascripts/tricorder/xtld-read-only-frame.html', 'adyen.com', + 'gravityforms.com', ] var currentUrl = window.location.href var currentRegex diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index b1d0217fd..c00d31949 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -388,6 +388,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), @@ -927,6 +929,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 0d4c27116..b05acbf3b 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 787f8023e..b457749ee 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 ee787aaee..f22fd0a56 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 d8e6f0845..a32b86e4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9635,6 +9635,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", @@ -18821,6 +18831,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", @@ -24605,6 +24621,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", @@ -26578,6 +26616,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 981c5dc55..707a90ffe 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", @@ -277,6 +277,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/e2e/beta/from-import-beta-ui.spec.js b/test/e2e/beta/from-import-beta-ui.spec.js index e07d4a99e..efae948f9 100644 --- a/test/e2e/beta/from-import-beta-ui.spec.js +++ b/test/e2e/beta/from-import-beta-ui.spec.js @@ -1,7 +1,7 @@ const path = require('path') const assert = require('assert') const webdriver = require('selenium-webdriver') -const { By, Key } = webdriver +const { By, Key, until } = webdriver const { delay, buildChromeWebDriver, @@ -14,8 +14,11 @@ const { checkBrowserForConsoleErrors, loadExtension, verboseReportOnFailure, + findElement, + findElements, } = require('./helpers') + describe('Using MetaMask with an existing account', function () { let extensionId let driver @@ -79,30 +82,33 @@ describe('Using MetaMask with an existing account', function () { }) it('use the local network', async function () { - const [networkSelector] = await driver.findElements(By.css('#network_component')) + const networkSelector = await findElement(driver, By.css('#network_component')) await networkSelector.click() await delay(regularDelayMs) - const [localhost] = await driver.findElements(By.xpath(`//li[contains(text(), 'Localhost')]`)) + const [localhost] = await findElements(driver, By.xpath(`//li[contains(text(), 'Localhost')]`)) await localhost.click() await delay(regularDelayMs) }) it('selects the new UI option', async () => { - const button = await driver.findElement(By.xpath("//p[contains(text(), 'Try Beta Version')]")) + const button = await findElement(driver, By.xpath("//p[contains(text(), 'Try Beta Version')]")) await button.click() await delay(regularDelayMs) // Close all other tabs - const [oldUi, infoPage, newUi] = await driver.getAllWindowHandles() + let [oldUi, infoPage, newUi] = await driver.getAllWindowHandles() + newUi = newUi || infoPage await driver.switchTo().window(oldUi) await driver.close() - await driver.switchTo().window(infoPage) - await driver.close() + if (infoPage !== newUi) { + await driver.switchTo().window(infoPage) + await driver.close() + } await driver.switchTo().window(newUi) await delay(regularDelayMs) - const [continueBtn] = await driver.findElements(By.css('.welcome-screen__button')) + const continueBtn = await findElement(driver, By.css('.welcome-screen__button')) await continueBtn.click() await delay(regularDelayMs) }) @@ -110,36 +116,36 @@ describe('Using MetaMask with an existing account', function () { describe('First time flow starting from an existing seed phrase', () => { it('imports a seed phrase', async () => { - const [seedPhrase] = await driver.findElements(By.xpath(`//a[contains(text(), 'Import with seed phrase')]`)) + const [seedPhrase] = await findElements(driver, By.xpath(`//a[contains(text(), 'Import with seed phrase')]`)) await seedPhrase.click() await delay(regularDelayMs) - const [seedTextArea] = await driver.findElements(By.css('textarea.import-account__secret-phrase')) + const [seedTextArea] = await findElements(driver, By.css('textarea.import-account__secret-phrase')) await seedTextArea.sendKeys(testSeedPhrase) await delay(regularDelayMs) - const [password] = await driver.findElements(By.id('password')) + const [password] = await findElements(driver, By.id('password')) await password.sendKeys('correct horse battery staple') - const [confirmPassword] = await driver.findElements(By.id('confirm-password')) + const [confirmPassword] = await findElements(driver, By.id('confirm-password')) confirmPassword.sendKeys('correct horse battery staple') - const [importButton] = await driver.findElements(By.xpath(`//button[contains(text(), 'Import')]`)) + const [importButton] = await findElements(driver, By.xpath(`//button[contains(text(), 'Import')]`)) await importButton.click() await delay(regularDelayMs) }) it('clicks through the privacy notice', async () => { - const [nextScreen] = await driver.findElements(By.css('.tou button')) + const [nextScreen] = await findElements(driver, By.css('.tou button')) await nextScreen.click() await delay(regularDelayMs) const canClickThrough = await driver.findElement(By.css('.tou button')).isEnabled() assert.equal(canClickThrough, false, 'disabled continue button') - const element = await driver.findElement(By.linkText('Attributions')) + const element = await findElement(driver, By.linkText('Attributions')) await driver.executeScript('arguments[0].scrollIntoView(true)', element) await delay(regularDelayMs) - const [acceptTos] = await driver.findElements(By.css('.tou button')) + const acceptTos = await findElement(driver, By.xpath(`//button[contains(text(), 'Accept')]`)) await acceptTos.click() await delay(regularDelayMs) }) @@ -147,11 +153,12 @@ describe('Using MetaMask with an existing account', function () { describe('Show account information', () => { it('shows the correct account address', async () => { - await driver.findElement(By.css('.wallet-view__details-button')).click() + const detailsButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Details')]`)) + detailsButton.click() await driver.findElement(By.css('.qr-wrapper')).isDisplayed() await delay(regularDelayMs) - const [address] = await driver.findElements(By.css('input.qr-ellip-address')) + const [address] = await findElements(driver, By.css('input.qr-ellip-address')) assert.equal(await address.getAttribute('value'), testAddress) await driver.executeScript("document.querySelector('.account-modal-close').click()") @@ -161,19 +168,22 @@ describe('Using MetaMask with an existing account', function () { it('shows a QR code for the account', async () => { await driver.findElement(By.css('.wallet-view__details-button')).click() await driver.findElement(By.css('.qr-wrapper')).isDisplayed() + const detailModal = await driver.findElement(By.css('span .modal')) await delay(regularDelayMs) await driver.executeScript("document.querySelector('.account-modal-close').click()") + await driver.wait(until.stalenessOf(detailModal)) await delay(regularDelayMs) }) }) describe('Log out and log back in', () => { it('logs out of the account', async () => { - await driver.findElement(By.css('.account-menu__icon')).click() + const accountIdenticon = driver.findElement(By.css('.account-menu__icon .identicon')) + accountIdenticon.click() await delay(regularDelayMs) - const [logoutButton] = await driver.findElements(By.css('.account-menu__logout-button')) + const [logoutButton] = await findElements(driver, By.css('.account-menu__logout-button')) assert.equal(await logoutButton.getText(), 'Log out') await logoutButton.click() await delay(regularDelayMs) @@ -191,23 +201,23 @@ describe('Using MetaMask with an existing account', function () { await driver.findElement(By.css('.account-menu__icon')).click() await delay(regularDelayMs) - const [createAccount] = await driver.findElements(By.xpath(`//div[contains(text(), 'Create Account')]`)) + const [createAccount] = await findElements(driver, By.xpath(`//div[contains(text(), 'Create Account')]`)) await createAccount.click() await delay(regularDelayMs) }) it('set account name', async () => { - const [accountName] = await driver.findElements(By.css('.new-account-create-form input')) + const [accountName] = await findElements(driver, By.css('.new-account-create-form input')) await accountName.sendKeys('2nd account') await delay(regularDelayMs) - const [createButton] = await driver.findElements(By.xpath(`//button[contains(text(), 'Create')]`)) + const [createButton] = await findElements(driver, By.xpath(`//button[contains(text(), 'Create')]`)) await createButton.click() await delay(regularDelayMs) }) it('should show the correct account name', async () => { - const [accountName] = await driver.findElements(By.css('.account-name')) + const [accountName] = await findElements(driver, By.css('.account-name')) assert.equal(await accountName.getText(), '2nd account') await delay(regularDelayMs) }) @@ -218,7 +228,7 @@ describe('Using MetaMask with an existing account', function () { await driver.findElement(By.css('.account-menu__icon')).click() await delay(regularDelayMs) - const [originalAccountMenuItem] = await driver.findElements(By.css('.account-menu__name')) + const [originalAccountMenuItem] = await findElements(driver, By.css('.account-menu__name')) await originalAccountMenuItem.click() await delay(regularDelayMs) }) @@ -226,41 +236,41 @@ describe('Using MetaMask with an existing account', function () { describe('Send ETH from inside MetaMask', () => { it('starts to send a transaction', async function () { - const [sendButton] = await driver.findElements(By.xpath(`//button[contains(text(), 'Send')]`)) + const sendButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Send')]`)) await sendButton.click() await delay(regularDelayMs) - const [inputAddress] = await driver.findElements(By.css('input[placeholder="Recipient Address"]')) - const [inputAmount] = await driver.findElements(By.css('.currency-display__input')) + const inputAddress = await findElement(driver, By.css('input[placeholder="Recipient Address"]')) + const inputAmount = await findElement(driver, By.css('.currency-display__input')) await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970') await inputAmount.sendKeys('1') // Set the gas limit - const [configureGas] = await driver.findElements(By.css('.send-v2__gas-fee-display button')) + const configureGas = await findElement(driver, By.css('.send-v2__gas-fee-display button')) await configureGas.click() await delay(regularDelayMs) - const [save] = await driver.findElements(By.xpath(`//button[contains(text(), 'Save')]`)) + const save = await findElement(driver, By.xpath(`//button[contains(text(), 'Save')]`)) await save.click() await delay(regularDelayMs) // Continue to next screen - const [nextScreen] = await driver.findElements(By.xpath(`//button[contains(text(), 'Next')]`)) + const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`)) await nextScreen.click() await delay(regularDelayMs) }) it('confirms the transaction', async function () { - const [confirmButton] = await driver.findElements(By.xpath(`//button[contains(text(), 'Confirm')]`)) + const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) await confirmButton.click() await delay(regularDelayMs) }) it('finds the transaction in the transactions list', async function () { - const transactions = await driver.findElements(By.css('.tx-list-item')) + const transactions = await findElements(driver, By.css('.tx-list-item')) assert.equal(transactions.length, 1) - const txValues = await driver.findElements(By.css('.tx-list-value')) + const txValues = await findElements(driver, By.css('.tx-list-value')) assert.equal(txValues.length, 1) assert.equal(await txValues[0].getText(), '1 ETH') }) @@ -275,7 +285,7 @@ describe('Using MetaMask with an existing account', function () { await driver.switchTo().window(faucet) await delay(regularDelayMs) - const [send1eth] = await driver.findElements(By.xpath(`//button[contains(text(), '10 ether')]`)) + const send1eth = await findElement(driver, By.xpath(`//button[contains(text(), '10 ether')]`), 14000) await send1eth.click() await delay(regularDelayMs) @@ -283,7 +293,7 @@ describe('Using MetaMask with an existing account', function () { await loadExtension(driver, extensionId) await delay(regularDelayMs) - const [confirmButton] = await driver.findElements(By.xpath(`//button[contains(text(),'Confirm')]`)) + const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`), 14000) await confirmButton.click() await delay(regularDelayMs) @@ -300,31 +310,31 @@ describe('Using MetaMask with an existing account', function () { describe('Add existing token using search', () => { it('clicks on the Add Token button', async () => { - const [addToken] = await driver.findElements(By.xpath(`//button[contains(text(), 'Add Token')]`)) + const addToken = await findElement(driver, By.xpath(`//button[contains(text(), 'Add Token')]`)) await addToken.click() await delay(regularDelayMs) }) it('picks an existing token', async () => { - const [tokenSearch] = await driver.findElements(By.css('input.add-token__input')) + const tokenSearch = await findElement(driver, By.css('#search-tokens')) await tokenSearch.sendKeys('BAT') await delay(regularDelayMs) - const [token] = await driver.findElements(By.xpath("//div[contains(text(), 'BAT')]")) + const token = await findElement(driver, By.xpath("//span[contains(text(), 'BAT')]")) await token.click() await delay(regularDelayMs) - const [nextScreen] = await driver.findElements(By.xpath(`//button[contains(text(), 'Next')]`)) + const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`)) await nextScreen.click() await delay(regularDelayMs) - const [addTokens] = await driver.findElements(By.xpath(`//button[contains(text(), 'Add Tokens')]`)) + const addTokens = await findElement(driver, By.xpath(`//button[contains(text(), 'Add Tokens')]`)) await addTokens.click() await delay(largeDelayMs) }) it('renders the balance for the new token', async () => { - const balance = await driver.findElement(By.css('.tx-view .balance-display .token-amount')) + const balance = await findElement(driver, By.css('.tx-view .balance-display .token-amount')) const tokenAmount = await balance.getText() assert.equal(tokenAmount, '0BAT') await delay(regularDelayMs) @@ -343,14 +353,14 @@ describe('Using MetaMask with an existing account', function () { tokenName, tokenDecimal, tokenSymbol, - ] = await driver.findElements(By.css('input')) + ] = await findElements(driver, By.css('.form-control')) await totalSupply.sendKeys('100') await tokenName.sendKeys('Test') await tokenDecimal.sendKeys('0') await tokenSymbol.sendKeys('TST') - const [createToken] = await driver.findElements(By.xpath(`//button[contains(text(), 'Create Token')]`)) + const createToken = await findElement(driver, By.xpath(`//button[contains(text(), 'Create Token')]`)) await createToken.click() await delay(regularDelayMs) @@ -358,7 +368,7 @@ describe('Using MetaMask with an existing account', function () { await loadExtension(driver, extensionId) await delay(regularDelayMs) - const [confirmButton] = await driver.findElements(By.xpath(`//button[contains(text(),'Confirm')]`)) + const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) await confirmButton.click() await delay(regularDelayMs) @@ -373,31 +383,32 @@ describe('Using MetaMask with an existing account', function () { }) it('clicks on the Add Token button', async () => { - const [addToken] = await driver.findElements(By.xpath(`//button[contains(text(), 'Add Token')]`)) + const addToken = await findElement(driver, By.xpath(`//button[contains(text(), 'Add Token')]`)) await addToken.click() await delay(regularDelayMs) }) it('picks the new Test token', async () => { - const [addCustomToken] = await driver.findElements(By.xpath("//div[contains(text(), 'Custom Token')]")) + const addCustomToken = await findElement(driver, By.xpath("//div[contains(text(), 'Custom Token')]")) await addCustomToken.click() await delay(regularDelayMs) - const [newTokenAddress] = await driver.findElements(By.css('.add-token__add-custom-form input')) + const newTokenAddress = await findElement(driver, By.css('#custom-address')) await newTokenAddress.sendKeys(tokenAddress) await delay(regularDelayMs) - const [nextScreen] = await driver.findElements(By.xpath(`//button[contains(text(), 'Next')]`)) + const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`)) await nextScreen.click() await delay(regularDelayMs) - const [addTokens] = await driver.findElements(By.xpath(`//button[contains(text(), 'Add Tokens')]`)) + const addTokens = await findElement(driver, By.xpath(`//button[contains(text(), 'Add Tokens')]`)) await addTokens.click() await delay(regularDelayMs) }) it('renders the balance for the new token', async () => { - const [balance] = await driver.findElements(By.css('.tx-view .balance-display .token-amount')) + const balance = await findElement(driver, By.css('.tx-view .balance-display .token-amount')) + await driver.wait(until.elementTextIs(balance, '100TST')) const tokenAmount = await balance.getText() assert.equal(tokenAmount, '100TST') await delay(regularDelayMs) diff --git a/test/e2e/beta/helpers.js b/test/e2e/beta/helpers.js index 8307fdc50..31c41d8b7 100644 --- a/test/e2e/beta/helpers.js +++ b/test/e2e/beta/helpers.js @@ -1,11 +1,14 @@ const fs = require('fs') const mkdirp = require('mkdirp') const pify = require('pify') +const {until} = require('selenium-webdriver') module.exports = { checkBrowserForConsoleErrors, loadExtension, verboseReportOnFailure, + findElement, + findElements, } async function loadExtension (driver, extensionId) { @@ -53,3 +56,11 @@ async function verboseReportOnFailure (driver, test) { const htmlSource = await driver.getPageSource() await pify(fs.writeFile)(`${filepathBase}-dom.html`, htmlSource) } + +async function findElement (driver, by, timeout = 10000) { + return driver.wait(until.elementLocated(by), timeout) +} + +async function findElements (driver, by, timeout = 10000) { + return driver.wait(until.elementsLocated(by), timeout) +} diff --git a/test/e2e/beta/metamask-beta-ui.spec.js b/test/e2e/beta/metamask-beta-ui.spec.js index 00863e3b3..ceeea31a5 100644 --- a/test/e2e/beta/metamask-beta-ui.spec.js +++ b/test/e2e/beta/metamask-beta-ui.spec.js @@ -1,7 +1,7 @@ const path = require('path') const assert = require('assert') const webdriver = require('selenium-webdriver') -const { By, Key } = webdriver +const { By, Key, until } = webdriver const { delay, buildChromeWebDriver, @@ -11,6 +11,8 @@ const { getExtensionIdFirefox, } = require('../func') const { + findElement, + findElements, checkBrowserForConsoleErrors, loadExtension, verboseReportOnFailure, @@ -22,10 +24,10 @@ describe('MetaMask', function () { let tokenAddress const testSeedPhrase = 'phrase upgrade clock rough situate wedding elder clever doctor stamp excess tent' - const tinyDelayMs = 500 + const tinyDelayMs = 1000 const regularDelayMs = tinyDelayMs * 2 const largeDelayMs = regularDelayMs * 2 - const waitingNewPageDelayMs = regularDelayMs * 10 + const waitingNewPageDelayMs = regularDelayMs * 30 this.timeout(0) this.bail(true) @@ -76,30 +78,33 @@ describe('MetaMask', function () { }) it('use the local network', async function () { - const [networkSelector] = await driver.findElements(By.css('#network_component')) + const networkSelector = await findElement(driver, By.css('#network_component')) await networkSelector.click() await delay(regularDelayMs) - const [localhost] = await driver.findElements(By.xpath(`//li[contains(text(), 'Localhost')]`)) + const localhost = await findElement(driver, By.xpath(`//li[contains(text(), 'Localhost')]`)) await localhost.click() await delay(regularDelayMs) }) it('selects the new UI option', async () => { - const button = await driver.findElement(By.xpath("//p[contains(text(), 'Try Beta Version')]")) + const button = await findElement(driver, By.xpath("//p[contains(text(), 'Try Beta Version')]")) await button.click() await delay(regularDelayMs) // Close all other tabs - const [oldUi, infoPage, newUi] = await driver.getAllWindowHandles() + let [oldUi, infoPage, newUi] = await driver.getAllWindowHandles() + newUi = newUi || infoPage await driver.switchTo().window(oldUi) await driver.close() - await driver.switchTo().window(infoPage) - await driver.close() + if (infoPage !== newUi) { + await driver.switchTo().window(infoPage) + await driver.close() + } await driver.switchTo().window(newUi) await delay(regularDelayMs) - const [continueBtn] = await driver.findElements(By.css('.welcome-screen__button')) + const continueBtn = await findElement(driver, By.css('.welcome-screen__button')) await continueBtn.click() await delay(regularDelayMs) }) @@ -107,9 +112,9 @@ describe('MetaMask', function () { describe('Going through the first time flow', () => { it('accepts a secure password', async () => { - const [passwordBox] = await driver.findElements(By.css('.create-password #create-password')) - const [passwordBoxConfirm] = await driver.findElements(By.css('.create-password #confirm-password')) - const [button] = await driver.findElements(By.css('.create-password button')) + const passwordBox = await findElement(driver, By.css('.create-password #create-password')) + const passwordBoxConfirm = await findElement(driver, By.css('.create-password #confirm-password')) + const button = await findElement(driver, By.css('.create-password button')) await passwordBox.sendKeys('correct horse battery staple') await passwordBoxConfirm.sendKeys('correct horse battery staple') @@ -118,23 +123,23 @@ describe('MetaMask', function () { }) it('clicks through the unique image screen', async () => { - const [nextScreen] = await driver.findElements(By.css('.unique-image button')) + const nextScreen = await findElement(driver, By.css('.unique-image button')) await nextScreen.click() await delay(regularDelayMs) }) it('clicks through the privacy notice', async () => { - const [nextScreen] = await driver.findElements(By.css('.tou button')) + const nextScreen = await findElement(driver, By.css('.tou button')) await nextScreen.click() await delay(regularDelayMs) const canClickThrough = await driver.findElement(By.css('.tou button')).isEnabled() assert.equal(canClickThrough, false, 'disabled continue button') - const [bottomOfTos] = await driver.findElements(By.linkText('Attributions')) + const bottomOfTos = await findElement(driver, By.linkText('Attributions')) await driver.executeScript('arguments[0].scrollIntoView(true)', bottomOfTos) await delay(regularDelayMs) - const [acceptTos] = await driver.findElements(By.css('.tou button')) + const acceptTos = await findElement(driver, By.css('.tou button')) await acceptTos.click() await delay(regularDelayMs) }) @@ -142,7 +147,7 @@ describe('MetaMask', function () { let seedPhrase it('reveals the seed phrase', async () => { - const [revealSeedPhrase] = await driver.findElements(By.css('.backup-phrase__secret-blocker')) + const revealSeedPhrase = await findElement(driver, By.css('.backup-phrase__secret-blocker')) await revealSeedPhrase.click() await delay(regularDelayMs) @@ -150,7 +155,7 @@ describe('MetaMask', function () { assert.equal(seedPhrase.split(' ').length, 12) await delay(regularDelayMs) - const [nextScreen] = await driver.findElements(By.css('.backup-phrase button')) + const nextScreen = await findElement(driver, By.css('.backup-phrase button')) await nextScreen.click() await delay(regularDelayMs) }) @@ -158,62 +163,64 @@ describe('MetaMask', function () { it('can retype the seed phrase', async () => { const words = seedPhrase.split(' ') - const [word0] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[0]}')]`)) + const word0 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[0]}')]`)) await word0.click() await delay(tinyDelayMs) - const [word1] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[1]}')]`)) + const word1 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[1]}')]`)) await word1.click() await delay(tinyDelayMs) - const [word2] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[2]}')]`)) + const word2 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[2]}')]`)) await word2.click() await delay(tinyDelayMs) - const [word3] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[3]}')]`)) + const word3 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[3]}')]`)) await word3.click() await delay(tinyDelayMs) - const [word4] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[4]}')]`)) + const word4 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[4]}')]`)) await word4.click() await delay(tinyDelayMs) - const [word5] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[5]}')]`)) + const word5 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[5]}')]`)) await word5.click() await delay(tinyDelayMs) - const [word6] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[6]}')]`)) + const word6 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[6]}')]`)) await word6.click() await delay(tinyDelayMs) - const [word7] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[7]}')]`)) + const word7 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[7]}')]`)) await word7.click() await delay(tinyDelayMs) - const [word8] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[8]}')]`)) + const word8 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[8]}')]`)) await word8.click() await delay(tinyDelayMs) - const [word9] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[9]}')]`)) + const word9 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[9]}')]`)) await word9.click() await delay(tinyDelayMs) - const [word10] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[10]}')]`)) + const word10 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[10]}')]`)) await word10.click() await delay(tinyDelayMs) - const [word11] = await driver.findElements(By.xpath(`//button[contains(text(), '${words[11]}')]`)) + const word11 = await findElement(driver, By.xpath(`//button[contains(text(), '${words[11]}')]`)) await word11.click() await delay(tinyDelayMs) - const [confirm] = await driver.findElements(By.xpath(`//button[contains(text(), 'Confirm')]`)) + const confirm = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) await confirm.click() await delay(regularDelayMs) }) it('clicks through the deposit modal', async () => { - const [closeModal] = await driver.findElements(By.css('.page-container__header-close')) + const buyModal = await driver.findElement(By.css('span .modal')) + const closeModal = await findElement(driver, By.css('.page-container__header-close')) await closeModal.click() + await driver.wait(until.stalenessOf(buyModal)) await delay(regularDelayMs) }) }) @@ -234,7 +241,7 @@ describe('MetaMask', function () { await driver.findElement(By.css('.account-menu__icon')).click() await delay(regularDelayMs) - const [logoutButton] = await driver.findElements(By.css('.account-menu__logout-button')) + const logoutButton = await findElement(driver, By.css('.account-menu__logout-button')) assert.equal(await logoutButton.getText(), 'Log out') await logoutButton.click() await delay(regularDelayMs) @@ -252,23 +259,23 @@ describe('MetaMask', function () { await driver.findElement(By.css('.account-menu__icon')).click() await delay(regularDelayMs) - const [createAccount] = await driver.findElements(By.xpath(`//div[contains(text(), 'Create Account')]`)) + const createAccount = await findElement(driver, By.xpath(`//div[contains(text(), 'Create Account')]`)) await createAccount.click() await delay(regularDelayMs) }) it('set account name', async () => { - const [accountName] = await driver.findElements(By.css('.new-account-create-form input')) + const accountName = await findElement(driver, By.css('.new-account-create-form input')) await accountName.sendKeys('2nd account') await delay(regularDelayMs) - const [create] = await driver.findElements(By.xpath(`//button[contains(text(), 'Create')]`)) + const create = await findElement(driver, By.xpath(`//button[contains(text(), 'Create')]`)) await create.click() await delay(regularDelayMs) }) it('should correct account name', async () => { - const [accountName] = await driver.findElements(By.css('.account-name')) + const accountName = await findElement(driver, By.css('.account-name')) assert.equal(await accountName.getText(), '2nd account') await delay(regularDelayMs) }) @@ -279,19 +286,19 @@ describe('MetaMask', function () { await driver.findElement(By.css('.account-menu__icon')).click() await delay(regularDelayMs) - const [logoutButton] = await driver.findElements(By.css('.account-menu__logout-button')) + const logoutButton = await findElement(driver, By.css('.account-menu__logout-button')) assert.equal(await logoutButton.getText(), 'Log out') await logoutButton.click() await delay(regularDelayMs) }) it('imports seed phrase', async () => { - const [restoreSeedLink] = await driver.findElements(By.css('.unlock-page__link--import')) + const restoreSeedLink = await findElement(driver, By.css('.unlock-page__link--import')) assert.equal(await restoreSeedLink.getText(), 'Import using account seed phrase') await restoreSeedLink.click() await delay(regularDelayMs) - const [seedTextArea] = await driver.findElements(By.css('textarea')) + const seedTextArea = await findElement(driver, By.css('textarea')) await seedTextArea.sendKeys(testSeedPhrase) await delay(regularDelayMs) @@ -302,7 +309,7 @@ describe('MetaMask', function () { }) it('balance renders', async () => { - const balance = await driver.findElement(By.css('.balance-display .token-amount')) + const balance = await findElement(driver, By.css('.balance-display .token-amount')) const tokenAmount = await balance.getText() assert.equal(tokenAmount, '100.000 ETH') await delay(regularDelayMs) @@ -311,41 +318,41 @@ describe('MetaMask', function () { describe('Send ETH from inside MetaMask', () => { it('starts to send a transaction', async function () { - const [sendButton] = await driver.findElements(By.xpath(`//button[contains(text(), 'Send')]`)) + const sendButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Send')]`)) await sendButton.click() await delay(regularDelayMs) - const [inputAddress] = await driver.findElements(By.css('input[placeholder="Recipient Address"]')) - const [inputAmount] = await driver.findElements(By.css('.currency-display__input')) + const inputAddress = await findElement(driver, By.css('input[placeholder="Recipient Address"]')) + const inputAmount = await findElement(driver, By.css('.currency-display__input')) await inputAddress.sendKeys('0x2f318C334780961FB129D2a6c30D0763d9a5C970') await inputAmount.sendKeys('1') // Set the gas limit - const [configureGas] = await driver.findElements(By.css('.send-v2__gas-fee-display button')) + const configureGas = await findElement(driver, By.css('.send-v2__gas-fee-display button')) await configureGas.click() await delay(regularDelayMs) - const [save] = await driver.findElements(By.xpath(`//button[contains(text(), 'Save')]`)) + const save = await findElement(driver, By.xpath(`//button[contains(text(), 'Save')]`)) await save.click() await delay(regularDelayMs) // Continue to next screen - const [nextScreen] = await driver.findElements(By.xpath(`//button[contains(text(), 'Next')]`)) + const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`)) await nextScreen.click() await delay(regularDelayMs) }) it('confirms the transaction', async function () { - const [confirmButton] = await driver.findElements(By.xpath(`//button[contains(text(), 'Confirm')]`)) + const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) await confirmButton.click() await delay(regularDelayMs) }) it('finds the transaction in the transactions list', async function () { - const transactions = await driver.findElements(By.css('.tx-list-item')) + const transactions = await findElements(driver, By.css('.tx-list-item')) assert.equal(transactions.length, 1) - const txValues = await driver.findElements(By.css('.tx-list-value')) + const txValues = await findElements(driver, By.css('.tx-list-value')) assert.equal(txValues.length, 1) assert.equal(await txValues[0].getText(), '1 ETH') }) @@ -360,7 +367,7 @@ describe('MetaMask', function () { await driver.switchTo().window(faucet) await delay(regularDelayMs) - const [send1eth] = await driver.findElements(By.xpath(`//button[contains(text(), '10 ether')]`)) + const send1eth = await findElement(driver, By.xpath(`//button[contains(text(), '10 ether')]`), 14000) await send1eth.click() await delay(regularDelayMs) @@ -368,7 +375,7 @@ describe('MetaMask', function () { await loadExtension(driver, extensionId) await delay(regularDelayMs) - const [confirmButton] = await driver.findElements(By.xpath(`//button[contains(text(), 'Confirm')]`)) + const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`), 14000) await confirmButton.click() await delay(regularDelayMs) @@ -385,31 +392,32 @@ describe('MetaMask', function () { describe('Add existing token using search', () => { it('clicks on the Add Token button', async () => { - const [addToken] = await driver.findElements(By.xpath(`//button[contains(text(), 'Add Token')]`)) + const addToken = await findElement(driver, By.xpath(`//button[contains(text(), 'Add Token')]`)) await addToken.click() await delay(regularDelayMs) }) it('can pick a token from the existing options', async () => { - const [tokenSearch] = await driver.findElements(By.css('input.add-token__input')) + const tokenSearch = await findElement(driver, By.css('#search-tokens')) await tokenSearch.sendKeys('BAT') await delay(regularDelayMs) - const [token] = await driver.findElements(By.xpath("//div[contains(text(), 'BAT')]")) + const token = await findElement(driver, By.xpath("//span[contains(text(), 'BAT')]")) await token.click() await delay(regularDelayMs) - const [nextScreen] = await driver.findElements(By.xpath(`//button[contains(text(), 'Next')]`)) + const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`)) await nextScreen.click() await delay(regularDelayMs) - const [addTokens] = await driver.findElements(By.xpath(`//button[contains(text(), 'Add Tokens')]`)) + const addTokens = await findElement(driver, By.xpath(`//button[contains(text(), 'Add Tokens')]`)) await addTokens.click() await delay(largeDelayMs) }) it('renders the balance for the chosen token', async () => { - const balance = await driver.findElement(By.css('.tx-view .balance-display .token-amount')) + const balance = await findElement(driver, By.css('.tx-view .balance-display .token-amount')) + await driver.wait(until.elementTextIs(balance, '0BAT')) const tokenAmount = await balance.getText() assert.equal(tokenAmount, '0BAT') await delay(regularDelayMs) @@ -428,14 +436,14 @@ describe('MetaMask', function () { tokenName, tokenDecimal, tokenSymbol, - ] = await driver.findElements(By.css('input')) + ] = await findElements(driver, By.css('.form-control')) await totalSupply.sendKeys('100') await tokenName.sendKeys('Test') await tokenDecimal.sendKeys('0') await tokenSymbol.sendKeys('TST') - const [createToken] = await driver.findElements(By.xpath(`//button[contains(text(), 'Create Token')]`)) + const createToken = await findElement(driver, By.xpath(`//button[contains(text(), 'Create Token')]`)) await createToken.click() await delay(regularDelayMs) @@ -443,7 +451,7 @@ describe('MetaMask', function () { await loadExtension(driver, extensionId) await delay(regularDelayMs) - const [confirmButton] = await driver.findElements(By.xpath(`//button[contains(text(), 'Confirm')]`)) + const confirmButton = await findElement(driver, By.xpath(`//button[contains(text(), 'Confirm')]`)) await confirmButton.click() await delay(regularDelayMs) @@ -458,31 +466,32 @@ describe('MetaMask', function () { }) it('clicks on the Add Token button', async () => { - const [addToken] = await driver.findElements(By.xpath(`//button[contains(text(), 'Add Token')]`)) + const addToken = await findElement(driver, By.xpath(`//button[contains(text(), 'Add Token')]`)) await addToken.click() await delay(regularDelayMs) }) it('picks the newly created Test token', async () => { - const [addCustomToken] = await driver.findElements(By.xpath("//div[contains(text(), 'Custom Token')]")) + const addCustomToken = await findElement(driver, By.xpath("//div[contains(text(), 'Custom Token')]")) await addCustomToken.click() await delay(regularDelayMs) - const [newTokenAddress] = await driver.findElements(By.css('.add-token__add-custom-form input')) + const newTokenAddress = await findElement(driver, By.css('#custom-address')) await newTokenAddress.sendKeys(tokenAddress) await delay(regularDelayMs) - const [nextScreen] = await driver.findElements(By.xpath(`//button[contains(text(), 'Next')]`)) + const nextScreen = await findElement(driver, By.xpath(`//button[contains(text(), 'Next')]`)) await nextScreen.click() await delay(regularDelayMs) - const [addTokens] = await driver.findElements(By.xpath(`//button[contains(text(), 'Add Tokens')]`)) + const addTokens = await findElement(driver, By.xpath(`//button[contains(text(), 'Add Tokens')]`)) await addTokens.click() await delay(regularDelayMs) }) it('renders the balance for the new token', async () => { - const [balance] = await driver.findElements(By.css('.tx-view .balance-display .token-amount')) + const balance = await findElement(driver, By.css('.tx-view .balance-display .token-amount')) + await driver.wait(until.elementTextIs(balance, '100TST')) const tokenAmount = await balance.getText() assert.equal(tokenAmount, '100TST') await delay(regularDelayMs) 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 07d7bd5d1..1edf692b6 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 @@ -703,60 +711,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, @@ -791,13 +835,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 7005adb7f..ec2329463 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 e3a3077d9..0b158a778 100644 --- a/ui/app/reducers.js +++ b/ui/app/reducers.js @@ -7,6 +7,7 @@ const copyToClipboard = require('copy-to-clipboard') 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 @@ -38,6 +39,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 612f256df..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', this.context.t('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', [ - this.context.t('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 */ |