diff options
author | kumavis <kumavis@users.noreply.github.com> | 2018-06-19 06:11:16 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-06-19 06:11:16 +0800 |
commit | 4598554fea7b9435e5cbecc4735c479ffbadf37e (patch) | |
tree | 338aaee17d433383c9a57b385489cbc8df3f82d0 /ui/app/components | |
parent | 39a7702c8f0aa2c2e7fdf2bcf913e76bb8b827f4 (diff) | |
parent | 0e3ecbbc4f7ab0579a33bf315af4dfafeb43f339 (diff) | |
download | tangerine-wallet-browser-4598554fea7b9435e5cbecc4735c479ffbadf37e.tar tangerine-wallet-browser-4598554fea7b9435e5cbecc4735c479ffbadf37e.tar.gz tangerine-wallet-browser-4598554fea7b9435e5cbecc4735c479ffbadf37e.tar.bz2 tangerine-wallet-browser-4598554fea7b9435e5cbecc4735c479ffbadf37e.tar.lz tangerine-wallet-browser-4598554fea7b9435e5cbecc4735c479ffbadf37e.tar.xz tangerine-wallet-browser-4598554fea7b9435e5cbecc4735c479ffbadf37e.tar.zst tangerine-wallet-browser-4598554fea7b9435e5cbecc4735c479ffbadf37e.zip |
Merge pull request #4603 from MetaMask/v4.8.0
V4.8.0
Diffstat (limited to 'ui/app/components')
147 files changed, 7073 insertions, 677 deletions
diff --git a/ui/app/components/account-menu/index.js b/ui/app/components/account-menu/index.js index 7638995ea..f34631ca8 100644 --- a/ui/app/components/account-menu/index.js +++ b/ui/app/components/account-menu/index.js @@ -135,11 +135,12 @@ AccountMenu.prototype.renderAccounts = function () { showAccountDetail, } = this.props - return Object.keys(identities).map((key, index) => { - const identity = identities[key] + const accountOrder = keyrings.reduce((list, keyring) => list.concat(keyring.accounts), []) + return accountOrder.map((address) => { + const identity = identities[address] const isSelected = identity.address === selectedAddress - const balanceValue = accounts[key] ? accounts[key].balance : '' + const balanceValue = accounts[address] ? accounts[address].balance : '' const formattedBalance = balanceValue ? formatBalance(balanceValue, 6) : '...' const simpleAddress = identity.address.substring(2).toLowerCase() 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/dropdowns/token-menu-dropdown.js b/ui/app/components/dropdowns/token-menu-dropdown.js index b70d0b893..fac7c451b 100644 --- a/ui/app/components/dropdowns/token-menu-dropdown.js +++ b/ui/app/components/dropdowns/token-menu-dropdown.js @@ -4,14 +4,21 @@ const h = require('react-hyperscript') const inherits = require('util').inherits const connect = require('react-redux').connect const actions = require('../../actions') - +const genAccountLink = require('etherscan-link').createAccountLink +const copyToClipboard = require('copy-to-clipboard') +const { Menu, Item, CloseArea } = require('./components/menu') TokenMenuDropdown.contextTypes = { t: PropTypes.func, } -module.exports = connect(null, mapDispatchToProps)(TokenMenuDropdown) +module.exports = connect(mapStateToProps, mapDispatchToProps)(TokenMenuDropdown) +function mapStateToProps (state) { + return { + network: state.metamask.network, + } +} function mapDispatchToProps (dispatch) { return { @@ -37,22 +44,34 @@ TokenMenuDropdown.prototype.onClose = function (e) { TokenMenuDropdown.prototype.render = function () { const { showHideTokenConfirmationModal } = this.props - return h('div.token-menu-dropdown', {}, [ - h('div.token-menu-dropdown__close-area', { + return h(Menu, { className: 'token-menu-dropdown', isShowing: true }, [ + h(CloseArea, { onClick: this.onClose, }), - h('div.token-menu-dropdown__container', {}, [ - h('div.token-menu-dropdown__options', {}, [ - - h('div.token-menu-dropdown__option', { - onClick: (e) => { - e.stopPropagation() - showHideTokenConfirmationModal(this.props.token) - this.props.onClose() - }, - }, this.context.t('hideToken')), - - ]), - ]), + h(Item, { + onClick: (e) => { + e.stopPropagation() + showHideTokenConfirmationModal(this.props.token) + this.props.onClose() + }, + text: this.context.t('hideToken'), + }), + h(Item, { + onClick: (e) => { + e.stopPropagation() + copyToClipboard(this.props.token.address) + this.props.onClose() + }, + text: this.context.t('copyContractAddress'), + }), + h(Item, { + onClick: (e) => { + e.stopPropagation() + const url = genAccountLink(this.props.token.address, this.props.network) + global.platform.openWindow({ url }) + this.props.onClose() + }, + text: this.context.t('viewOnEtherscan'), + }), ]) } 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/pages/add-token/add-token.component.js b/ui/app/components/pages/add-token/add-token.component.js index 1f4b37b53..bcb93d401 100644 --- a/ui/app/components/pages/add-token/add-token.component.js +++ b/ui/app/components/pages/add-token/add-token.component.js @@ -231,7 +231,7 @@ class AddToken extends Component { <div className="add-token__custom-token-form"> <TextField id="custom-address" - label="Token Address" + label={this.context.t('tokenAddress')} type="text" value={customAddress} onChange={e => this.handleCustomAddressChange(e.target.value)} @@ -241,7 +241,7 @@ class AddToken extends Component { /> <TextField id="custom-symbol" - label="Token Symbol" + label={this.context.t('tokenSymbol')} type="text" value={customSymbol} onChange={e => this.handleCustomSymbolChange(e.target.value)} @@ -252,7 +252,7 @@ class AddToken extends Component { /> <TextField id="custom-decimals" - label="Decimals of Precision" + label={this.context.t('decimal')} type="number" value={customDecimals} onChange={e => this.handleCustomDecimalsChange(e.target.value)} diff --git a/ui/app/components/pages/create-account/import-account/json.js b/ui/app/components/pages/create-account/import-account/json.js index 0164417ec..1dc2ba534 100644 --- a/ui/app/components/pages/create-account/import-account/json.js +++ b/ui/app/components/pages/create-account/import-account/json.js @@ -82,18 +82,19 @@ class JsonImportSubview extends Component { } createNewKeychain () { + const { firstAddress, displayWarning, importNewJsonAccount, setSelectedAddress } = this.props const state = this.state if (!state) { const message = this.context.t('validFileImport') - return this.props.displayWarning(message) + return displayWarning(message) } const { fileContents } = state if (!fileContents) { const message = this.context.t('needImportFile') - return this.props.displayWarning(message) + return displayWarning(message) } const passwordInput = document.getElementById('json-password-box') @@ -101,12 +102,19 @@ class JsonImportSubview extends Component { if (!password) { const message = this.context.t('needImportPassword') - return this.props.displayWarning(message) + return displayWarning(message) } - this.props.importNewJsonAccount([ fileContents, password ]) - // JS runtime requires caught rejections but failures are handled by Redux - .catch() + importNewJsonAccount([ fileContents, password ]) + .then(({ selectedAddress }) => { + if (selectedAddress) { + history.push(DEFAULT_ROUTE) + } else { + displayWarning('Error importing account.') + setSelectedAddress(firstAddress) + } + }) + .catch(err => displayWarning(err)) } } @@ -114,14 +122,17 @@ JsonImportSubview.propTypes = { error: PropTypes.string, goHome: PropTypes.func, displayWarning: PropTypes.func, + firstAddress: PropTypes.string, importNewJsonAccount: PropTypes.func, history: PropTypes.object, + setSelectedAddress: PropTypes.func, t: PropTypes.func, } const mapStateToProps = state => { return { error: state.appState.warning, + firstAddress: Object.keys(state.metamask.accounts)[0], } } @@ -130,6 +141,7 @@ const mapDispatchToProps = dispatch => { goHome: () => dispatch(actions.goHome()), displayWarning: warning => dispatch(actions.displayWarning(warning)), importNewJsonAccount: options => dispatch(actions.importNewAccount('JSON File', options)), + setSelectedAddress: (address) => dispatch(actions.setSelectedAddress(address)), } } diff --git a/ui/app/components/pages/create-account/import-account/private-key.js b/ui/app/components/pages/create-account/import-account/private-key.js index e55a47a3c..5df3777da 100644 --- a/ui/app/components/pages/create-account/import-account/private-key.js +++ b/ui/app/components/pages/create-account/import-account/private-key.js @@ -21,6 +21,7 @@ module.exports = compose( function mapStateToProps (state) { return { error: state.appState.warning, + firstAddress: Object.keys(state.metamask.accounts)[0], } } @@ -29,7 +30,8 @@ function mapDispatchToProps (dispatch) { importNewAccount: (strategy, [ privateKey ]) => { return dispatch(actions.importNewAccount(strategy, [ privateKey ])) }, - displayWarning: () => dispatch(actions.displayWarning(null)), + displayWarning: (message) => dispatch(actions.displayWarning(message || null)), + setSelectedAddress: (address) => dispatch(actions.setSelectedAddress(address)), } } @@ -40,7 +42,7 @@ function PrivateKeyImportView () { } PrivateKeyImportView.prototype.render = function () { - const { error } = this.props + const { error, displayWarning } = this.props return ( h('div.new-account-import-form__private-key', [ @@ -60,7 +62,10 @@ PrivateKeyImportView.prototype.render = function () { h('div.new-account-import-form__buttons', {}, [ h('button.btn-default.btn--large.new-account-create-form__button', { - onClick: () => this.props.history.push(DEFAULT_ROUTE), + onClick: () => { + displayWarning(null) + this.props.history.push(DEFAULT_ROUTE) + }, }, [ this.context.t('cancel'), ]), @@ -88,10 +93,16 @@ PrivateKeyImportView.prototype.createKeyringOnEnter = function (event) { PrivateKeyImportView.prototype.createNewKeychain = function () { const input = document.getElementById('private-key-box') const privateKey = input.value - const { importNewAccount, history } = this.props + const { importNewAccount, history, displayWarning, setSelectedAddress, firstAddress } = this.props importNewAccount('Private Key', [ privateKey ]) - // JS runtime requires caught rejections but failures are handled by Redux - .catch() - .then(() => history.push(DEFAULT_ROUTE)) + .then(({ selectedAddress }) => { + if (selectedAddress) { + history.push(DEFAULT_ROUTE) + } else { + displayWarning('Error importing account.') + setSelectedAddress(firstAddress) + } + }) + .catch(err => displayWarning(err)) } diff --git a/ui/app/components/pages/create-account/index.js b/ui/app/components/pages/create-account/index.js index 475261253..6e3b93742 100644 --- a/ui/app/components/pages/create-account/index.js +++ b/ui/app/components/pages/create-account/index.js @@ -22,7 +22,9 @@ class CreateAccountPage extends Component { }), }), onClick: () => history.push(NEW_ACCOUNT_ROUTE), - }, 'Create'), + }, [ + this.context.t('create'), + ]), h('div.new-account__tabs__tab', { className: classnames('new-account__tabs__tab', { @@ -31,14 +33,16 @@ class CreateAccountPage extends Component { }), }), onClick: () => history.push(IMPORT_ACCOUNT_ROUTE), - }, 'Import'), + }, [ + this.context.t('import'), + ]), ]) } render () { return h('div.new-account', {}, [ h('div.new-account__header', [ - h('div.new-account__title', 'New Account'), + h('div.new-account__title', this.context.t('newAccount') ), this.renderTabs(), ]), h('div.new-account__form', [ @@ -62,6 +66,11 @@ class CreateAccountPage extends Component { CreateAccountPage.propTypes = { location: PropTypes.object, history: PropTypes.object, + t: PropTypes.func, +} + +CreateAccountPage.contextTypes = { + t: PropTypes.func, } const mapStateToProps = state => ({ diff --git a/ui/app/components/pages/home.js b/ui/app/components/pages/home.js index 9110f8202..c53413d3b 100644 --- a/ui/app/components/pages/home.js +++ b/ui/app/components/pages/home.js @@ -86,9 +86,9 @@ class Home extends Component { // if (!props.noActiveNotices) { // log.debug('rendering notice screen for unread notices.') // return h(NoticeScreen, { - // notice: props.lastUnreadNotice, + // notice: props.nextUnreadNotice, // key: 'NoticeScreen', - // onConfirm: () => props.dispatch(actions.markNoticeRead(props.lastUnreadNotice)), + // onConfirm: () => props.dispatch(actions.markNoticeRead(props.nextUnreadNotice)), // }) // } else if (props.lostAccounts && props.lostAccounts.length > 0) { // log.debug('rendering notice screen for lost accounts view.') @@ -279,7 +279,7 @@ function mapStateToProps (state) { noActiveNotices, seedWords, unapprovedTxs, - lastUnreadNotice, + nextUnreadNotice, lostAccounts, unapprovedMsgCount, unapprovedPersonalMsgCount, @@ -313,7 +313,7 @@ function mapStateToProps (state) { network: state.metamask.network, provider: state.metamask.provider, forgottenPassword: state.appState.forgottenPassword, - lastUnreadNotice, + nextUnreadNotice, lostAccounts, frequentRpcList: state.metamask.frequentRpcList || [], currentCurrency: state.metamask.currentCurrency, diff --git a/ui/app/components/pages/notice.js b/ui/app/components/pages/notice.js index 2329a9147..a9077b98b 100644 --- a/ui/app/components/pages/notice.js +++ b/ui/app/components/pages/notice.js @@ -154,11 +154,11 @@ class Notice extends Component { const mapStateToProps = state => { const { metamask } = state - const { noActiveNotices, lastUnreadNotice, lostAccounts } = metamask + const { noActiveNotices, nextUnreadNotice, lostAccounts } = metamask return { noActiveNotices, - lastUnreadNotice, + nextUnreadNotice, lostAccounts, } } @@ -171,21 +171,21 @@ Notice.propTypes = { const mapDispatchToProps = dispatch => { return { - markNoticeRead: lastUnreadNotice => dispatch(actions.markNoticeRead(lastUnreadNotice)), + markNoticeRead: nextUnreadNotice => dispatch(actions.markNoticeRead(nextUnreadNotice)), markAccountsFound: () => dispatch(actions.markAccountsFound()), } } const mergeProps = (stateProps, dispatchProps, ownProps) => { - const { noActiveNotices, lastUnreadNotice, lostAccounts } = stateProps + const { noActiveNotices, nextUnreadNotice, lostAccounts } = stateProps const { markNoticeRead, markAccountsFound } = dispatchProps let notice let onConfirm if (!noActiveNotices) { - notice = lastUnreadNotice - onConfirm = () => markNoticeRead(lastUnreadNotice) + notice = nextUnreadNotice + onConfirm = () => markNoticeRead(nextUnreadNotice) } else if (lostAccounts && lostAccounts.length > 0) { notice = generateLostAccountsNotice(lostAccounts) onConfirm = () => markAccountsFound() diff --git a/ui/app/components/pages/settings/index.js b/ui/app/components/pages/settings/index.js index 384ae4b41..aee17e0e8 100644 --- a/ui/app/components/pages/settings/index.js +++ b/ui/app/components/pages/settings/index.js @@ -14,8 +14,8 @@ class Config extends Component { return h('div.settings__tabs', [ h(TabBar, { tabs: [ - { content: 'Settings', key: SETTINGS_ROUTE }, - { content: 'Info', key: INFO_ROUTE }, + { content: this.context.t('settings'), key: SETTINGS_ROUTE }, + { content: this.context.t('info'), key: INFO_ROUTE }, ], isActive: key => matchPath(location.pathname, { path: key, exact: true }), onSelect: key => history.push(key), @@ -54,6 +54,11 @@ class Config extends Component { Config.propTypes = { location: PropTypes.object, history: PropTypes.object, + t: PropTypes.func, +} + +Config.contextTypes = { + t: PropTypes.func, } module.exports = Config diff --git a/ui/app/components/pages/unlock-page/unlock-page.component.js b/ui/app/components/pages/unlock-page/unlock-page.component.js index 8bc3897da..a1d3f9181 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.component.js +++ b/ui/app/components/pages/unlock-page/unlock-page.component.js @@ -120,7 +120,7 @@ class UnlockPage extends Component { > <TextField id="password" - label="Password" + label={this.context.t('password')} type="password" value={this.state.password} onChange={event => this.handleInputChange(event)} 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..bbf5683f0 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, @@ -643,7 +647,7 @@ ConfirmSendEther.prototype.gatherTxMeta = function () { const state = this.state const txData = clone(state.txData) || clone(props.txData) - const { gasPrice: sendGasPrice, gas: sendGasLimit } = props.send + const { gasPrice: sendGasPrice, gasLimit: sendGasLimit } = props.send const { lastGasPrice, txParams: { diff --git a/ui/app/components/pending-tx/confirm-send-token.js b/ui/app/components/pending-tx/confirm-send-token.js index ddaa13d22..ee066b8f4 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', @@ -647,7 +651,7 @@ ConfirmSendToken.prototype.gatherTxMeta = function () { const state = this.state const txData = clone(state.txData) || clone(props.txData) - const { gasPrice: sendGasPrice, gas: sendGasLimit } = props.send + const { gasPrice: sendGasPrice, gasLimit: sendGasLimit } = props.send const { lastGasPrice, txParams: { 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..e410bc070 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,52 @@ 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', + toNumericBase: 'dec', 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 +92,22 @@ CurrencyDisplay.prototype.getConvertedValueToRender = function (nonFormattedValu : convertedValue } +function removeLeadingZeroes (str) { + return str.replace(/^0*(?=\d)/, '') +} + +CurrencyDisplay.prototype.handleChange = function (newVal) { + this.setState({ valueToRender: removeLeadingZeroes(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 +117,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 +128,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..8da36d3b7 --- /dev/null +++ b/ui/app/components/send_/send-content/send-amount-row/send-amount-row.component.js @@ -0,0 +1,108 @@ +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, + updateGas: 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) + } + + updateGas (amount) { + const { selectedToken, updateGas } = this.props + + if (selectedToken) { + updateGas({ amount }) + } + } + + render () { + const { + amount, + amountConversionRate, + convertedCurrency, + gasTotal, + inError, + primaryCurrency, + selectedToken, + } = this.props + + return ( + <SendRowWrapper + label={`${this.context.t('amount')}:`} + showError={inError} + errorType={'amount'} + > + {!inError && gasTotal && <AmountMaxButton />} + <CurrencyDisplay + conversionRate={amountConversionRate} + convertedCurrency={convertedCurrency} + onBlur={newAmount => { + this.updateGas(newAmount) + this.updateAmount(newAmount) + }} + onChange={newAmount => this.validateAmount(newAmount)} + inError={inError} + primaryCurrency={primaryCurrency || 'ETH'} + selectedToken={selectedToken} + value={amount} + /> + </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..579e18585 --- /dev/null +++ b/ui/app/components/send_/send-content/send-amount-row/tests/send-amount-row-component.test.js @@ -0,0 +1,173 @@ +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(), + updateGas: sinon.spy(), +} + +sinon.spy(SendAmountRow.prototype, 'updateAmount') +sinon.spy(SendAmountRow.prototype, 'validateAmount') +sinon.spy(SendAmountRow.prototype, 'updateGas') + +describe('SendAmountRow Component', function () { + let wrapper + let instance + + beforeEach(() => { + wrapper = shallow(<SendAmountRow + amount={'mockAmount'} + amountConversionRate={'mockAmountConversionRate'} + balance={'mockBalance'} + conversionRate={7} + convertedCurrency={'mockConvertedCurrency'} + gasTotal={'mockGasTotal'} + inError={false} + primaryCurrency={'mockPrimaryCurrency'} + selectedToken={ { address: 'mockTokenAddress' } } + setMaxModeTo={propsMethodSpies.setMaxModeTo} + tokenBalance={'mockTokenBalance'} + updateSendAmount={propsMethodSpies.updateSendAmount} + updateSendAmountError={propsMethodSpies.updateSendAmountError} + updateGas={propsMethodSpies.updateGas} + />, { context: { t: str => str + '_t' } }) + instance = wrapper.instance() + }) + + afterEach(() => { + propsMethodSpies.setMaxModeTo.resetHistory() + propsMethodSpies.updateSendAmount.resetHistory() + propsMethodSpies.updateSendAmountError.resetHistory() + 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.updateGas.callCount, 0) + assert.equal(SendAmountRow.prototype.updateAmount.callCount, 0) + onBlur('mockNewAmount') + assert.equal(SendAmountRow.prototype.updateGas.callCount, 1) + assert.deepEqual( + SendAmountRow.prototype.updateGas.getCall(0).args, + ['mockNewAmount'] + ) + assert.equal(SendAmountRow.prototype.updateAmount.callCount, 1) + assert.deepEqual( + SendAmountRow.prototype.updateAmount.getCall(0).args, + ['mockNewAmount'] + ) + assert.equal(SendAmountRow.prototype.validateAmount.callCount, 0) + onChange('mockNewAmount') + assert.equal(SendAmountRow.prototype.validateAmount.callCount, 1) + assert.deepEqual( + SendAmountRow.prototype.validateAmount.getCall(0).args, + ['mockNewAmount'] + ) + }) + + it('should pass the default primaryCurrency to the CurrencyDisplay if primaryCurrency is falsy', () => { + wrapper.setProps({ primaryCurrency: null }) + const { primaryCurrency } = wrapper.find(SendRowWrapper).childAt(1).props() + assert.equal(primaryCurrency, 'ETH') + }) + }) +}) diff --git a/ui/app/components/send_/send-content/send-amount-row/tests/send-amount-row-container.test.js b/ui/app/components/send_/send-content/send-amount-row/tests/send-amount-row-container.test.js new file mode 100644 index 000000000..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..adc114c0e --- /dev/null +++ b/ui/app/components/send_/send-content/send-content.component.js @@ -0,0 +1,28 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import PageContainerContent from '../../page-container/page-container-content.component' +import SendAmountRow from './send-amount-row/' +import SendFromRow from './send-from-row/' +import SendGasRow from './send-gas-row/' +import SendToRow from './send-to-row/' + +export default class SendContent extends Component { + + static propTypes = { + updateGas: PropTypes.func, + }; + + render () { + return ( + <PageContainerContent> + <div className="send-v2__form"> + <SendFromRow /> + <SendToRow updateGas={(updateData) => this.props.updateGas(updateData)} /> + <SendAmountRow updateGas={(updateData) => this.props.updateGas(updateData)} /> + <SendGasRow /> + </div> + </PageContainerContent> + ) + } + +} diff --git a/ui/app/components/send_/send-content/send-content.scss b/ui/app/components/send_/send-content/send-content.scss new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ui/app/components/send_/send-content/send-content.scss diff --git a/ui/app/components/send_/send-content/send-dropdown-list/index.js b/ui/app/components/send_/send-content/send-dropdown-list/index.js new file mode 100644 index 000000000..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..219b362f2 --- /dev/null +++ b/ui/app/components/send_/send.component.js @@ -0,0 +1,162 @@ +import React from 'react' +import PropTypes from 'prop-types' +import PersistentForm from '../../../lib/persistent-form' +import { + getAmountErrorObject, + getToAddressForGasUpdate, + doesAmountErrorRequireUpdate, +} from './send.utils' + +import SendHeader from './send-header/' +import SendContent from './send-content/' +import SendFooter from './send-footer/' + +export default class SendTransactionScreen extends PersistentForm { + + static propTypes = { + amount: PropTypes.string, + amountConversionRate: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]), + blockGasLimit: PropTypes.string, + conversionRate: PropTypes.number, + editingTransactionId: PropTypes.string, + from: PropTypes.object, + gasLimit: PropTypes.string, + gasPrice: PropTypes.string, + gasTotal: PropTypes.string, + history: PropTypes.object, + network: PropTypes.string, + primaryCurrency: PropTypes.string, + recentBlocks: PropTypes.array, + selectedAddress: PropTypes.string, + selectedToken: PropTypes.object, + tokenBalance: PropTypes.string, + tokenContract: PropTypes.object, + updateAndSetGasTotal: PropTypes.func, + updateSendErrors: PropTypes.func, + updateSendTokenBalance: PropTypes.func, + }; + + updateGas ({ to: updatedToAddress, amount: value } = {}) { + const { + amount, + blockGasLimit, + editingTransactionId, + gasLimit, + gasPrice, + recentBlocks, + selectedAddress, + selectedToken = {}, + to: currentToAddress, + updateAndSetGasTotal, + } = this.props + + updateAndSetGasTotal({ + blockGasLimit, + editingTransactionId, + gasLimit, + gasPrice, + recentBlocks, + selectedAddress, + selectedToken, + to: getToAddressForGasUpdate(updatedToAddress, currentToAddress), + value: value || amount, + }) + } + + componentDidUpdate (prevProps) { + const { + amount, + amountConversionRate, + conversionRate, + from: { address, balance }, + gasTotal, + network, + primaryCurrency, + selectedToken, + tokenBalance, + updateSendErrors, + updateSendTokenBalance, + tokenContract, + } = this.props + + const { + from: { balance: prevBalance }, + gasTotal: prevGasTotal, + tokenBalance: prevTokenBalance, + network: prevNetwork, + } = prevProps + + const uninitialized = [prevBalance, prevGasTotal].every(n => n === null) + + const amountErrorRequiresUpdate = doesAmountErrorRequireUpdate({ + balance, + gasTotal, + prevBalance, + prevGasTotal, + prevTokenBalance, + selectedToken, + tokenBalance, + }) + + if (amountErrorRequiresUpdate) { + const amountErrorObject = getAmountErrorObject({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + }) + 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..185653c5f --- /dev/null +++ b/ui/app/components/send_/send.container.js @@ -0,0 +1,91 @@ +import { connect } from 'react-redux' +import SendEther from './send.component' +import { withRouter } from 'react-router-dom' +import { compose } from 'recompose' +import { + getAmountConversionRate, + getBlockGasLimit, + getConversionRate, + getCurrentNetwork, + getGasLimit, + getGasPrice, + getGasTotal, + getPrimaryCurrency, + getRecentBlocks, + getSelectedAddress, + getSelectedToken, + getSelectedTokenContract, + getSelectedTokenToFiatRate, + getSendAmount, + getSendEditingTransactionId, + getSendFromObject, + getSendTo, + getTokenBalance, +} from './send.selectors' +import { + updateSendTokenBalance, + updateGasData, + setGasTotal, +} from '../../actions' +import { + updateSendErrors, +} from '../../ducks/send.duck' +import { + calcGasTotal, +} from './send.utils.js' + +module.exports = compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(SendEther) + +function mapStateToProps (state) { + return { + amount: getSendAmount(state), + amountConversionRate: getAmountConversionRate(state), + blockGasLimit: getBlockGasLimit(state), + conversionRate: getConversionRate(state), + editingTransactionId: getSendEditingTransactionId(state), + from: getSendFromObject(state), + gasLimit: getGasLimit(state), + gasPrice: getGasPrice(state), + gasTotal: getGasTotal(state), + network: getCurrentNetwork(state), + primaryCurrency: getPrimaryCurrency(state), + recentBlocks: getRecentBlocks(state), + selectedAddress: getSelectedAddress(state), + selectedToken: getSelectedToken(state), + to: getSendTo(state), + tokenBalance: getTokenBalance(state), + tokenContract: getSelectedTokenContract(state), + tokenToFiatRate: getSelectedTokenToFiatRate(state), + } +} + +function mapDispatchToProps (dispatch) { + return { + updateAndSetGasTotal: ({ + blockGasLimit, + editingTransactionId, + gasLimit, + gasPrice, + recentBlocks, + selectedAddress, + selectedToken, + to, + value, + }) => { + !editingTransactionId + ? dispatch(updateGasData({ recentBlocks, selectedAddress, selectedToken, blockGasLimit, to, value })) + : dispatch(setGasTotal(calcGasTotal(gasLimit, gasPrice))) + }, + updateSendTokenBalance: ({ selectedToken, tokenContract, address }) => { + dispatch(updateSendTokenBalance({ + selectedToken, + tokenContract, + address, + })) + }, + updateSendErrors: newError => dispatch(updateSendErrors(newError)), + } +} 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..dfd459731 --- /dev/null +++ b/ui/app/components/send_/send.utils.js @@ -0,0 +1,275 @@ +const { + addCurrencies, + conversionUtil, + conversionGTE, + multiplyCurrencies, + conversionGreaterThan, + conversionLessThan, +} = 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 = { + addGasBuffer, + calcGasTotal, + calcTokenBalance, + doesAmountErrorRequireUpdate, + estimateGas, + estimateGasPriceFromRecentBlocks, + generateTokenTransferData, + getAmountErrorObject, + getToAddressForGasUpdate, + 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: + if (!selectedToken) { + const code = Boolean(to) && 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) { + const estimateWithBuffer = addGasBuffer(paramsForGasEstimate.gas, blockGasLimit, 1.5) + return resolve(ethUtil.addHexPrefix(estimateWithBuffer)) + } else { + return reject(err) + } + } + const estimateWithBuffer = addGasBuffer(estimatedGas.toString(16), blockGasLimit, 1.5) + return resolve(ethUtil.addHexPrefix(estimateWithBuffer)) + }) + }) +} + +function addGasBuffer (initialGasLimitHex, blockGasLimitHex, bufferMultiplier = 1.5) { + const upperGasLimit = multiplyCurrencies(blockGasLimitHex, 0.9, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + numberOfDecimals: '0', + }) + const bufferedGasLimit = multiplyCurrencies(initialGasLimitHex, bufferMultiplier, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + numberOfDecimals: '0', + }) + + // if initialGasLimit is above blockGasLimit, dont modify it + if (conversionGreaterThan( + { value: initialGasLimitHex, fromNumericBase: 'hex' }, + { value: upperGasLimit, fromNumericBase: 'hex' }, + )) return initialGasLimitHex + // if bufferedGasLimit is below blockGasLimit, use bufferedGasLimit + if (conversionLessThan( + { value: bufferedGasLimit, fromNumericBase: 'hex' }, + { value: upperGasLimit, fromNumericBase: 'hex' }, + )) return bufferedGasLimit + // otherwise use blockGasLimit + return upperGasLimit +} + +function generateTokenTransferData ({ toAddress = '0x0', amount = '0x0', selectedToken }) { + if (!selectedToken) return + return TOKEN_TRANSFER_FUNCTION_SIGNATURE + Array.prototype.map.call( + abi.rawEncode(['address', 'uint256'], [toAddress, ethUtil.addHexPrefix(amount)]), + x => ('00' + x.toString(16)).slice(-2) + ).join('') +} + +function estimateGasPriceFromRecentBlocks (recentBlocks) { + // Return 1 gwei if no blocks have been observed: + if (!recentBlocks || recentBlocks.length === 0) { + return ONE_GWEI_IN_WEI_HEX + } + + const lowestPrices = recentBlocks.map((block) => { + if (!block.gasPrices || block.gasPrices.length < 1) { + return ONE_GWEI_IN_WEI_HEX + } + return block.gasPrices.reduce((currentLowest, next) => { + return parseInt(next, 16) < parseInt(currentLowest, 16) ? next : currentLowest + }) + }) + .sort((a, b) => parseInt(a, 16) > parseInt(b, 16) ? 1 : -1) + + return lowestPrices[Math.floor(lowestPrices.length / 2)] +} + +function getToAddressForGasUpdate (...addresses) { + return [...addresses, ''].find(str => str !== undefined && str !== null).toLowerCase() +} 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..4ba9b226d --- /dev/null +++ b/ui/app/components/send_/tests/send-component.test.js @@ -0,0 +1,261 @@ +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 if no to prop is passed', () => { + propsMethodSpies.updateAndSetGasTotal.resetHistory() + wrapper.instance().updateGas() + assert.equal(propsMethodSpies.updateAndSetGasTotal.callCount, 1) + assert.deepEqual( + propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0], + { + blockGasLimit: 'mockBlockGasLimit', + editingTransactionId: 'mockEditingTransactionId', + gasLimit: 'mockGasLimit', + gasPrice: 'mockGasPrice', + recentBlocks: ['mockBlock'], + selectedAddress: 'mockSelectedAddress', + selectedToken: 'mockSelectedToken', + to: '', + value: 'mockAmount', + } + ) + }) + + it('should call updateAndSetGasTotal with the correct params if a to prop is passed', () => { + propsMethodSpies.updateAndSetGasTotal.resetHistory() + wrapper.setProps({ to: 'someAddress' }) + wrapper.instance().updateGas() + assert.equal( + propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0].to, + 'someaddress', + ) + }) + + it('should call updateAndSetGasTotal with to set to lowercase if passed', () => { + propsMethodSpies.updateAndSetGasTotal.resetHistory() + wrapper.instance().updateGas({ to: '0xABC' }) + assert.equal(propsMethodSpies.updateAndSetGasTotal.getCall(0).args[0].to, '0xabc') + }) + }) + + describe('render', () => { + it('should render a page-container class', () => { + assert.equal(wrapper.find('.page-container').length, 1) + }) + + it('should render SendHeader, SendContent and SendFooter', () => { + assert.equal(wrapper.find(SendHeader).length, 1) + assert.equal(wrapper.find(SendContent).length, 1) + assert.equal(wrapper.find(SendFooter).length, 1) + }) + + it('should pass the history prop to SendHeader and SendFooter', () => { + assert.deepEqual( + wrapper.find(SendFooter).props(), + { + history: { mockProp: 'history-abc' }, + } + ) + }) + }) +}) diff --git a/ui/app/components/send_/tests/send-container.test.js b/ui/app/components/send_/tests/send-container.test.js new file mode 100644 index 000000000..91484f4d8 --- /dev/null +++ b/ui/app/components/send_/tests/send-container.test.js @@ -0,0 +1,157 @@ +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}`, + getSendTo: (s) => `mockTo:${s}`, + getSendEditingTransactionId: (s) => `mockEditingTransactionId:${s}`, + getSendFromObject: (s) => `mockFrom:${s}`, + getTokenBalance: (s) => `mockTokenBalance:${s}`, + }, + '../../actions': actionSpies, + '../../ducks/send.duck': duckActionSpies, + './send.utils.js': { + calcGasTotal: (gasLimit, gasPrice) => gasLimit + gasPrice, + }, +}) + +describe('send container', () => { + + describe('mapStateToProps()', () => { + + it('should map the correct properties to props', () => { + assert.deepEqual(mapStateToProps('mockState'), { + amount: 'mockAmount:mockState', + amountConversionRate: 'mockAmountConversionRate:mockState', + blockGasLimit: 'mockBlockGasLimit:mockState', + conversionRate: 'mockConversionRate:mockState', + editingTransactionId: 'mockEditingTransactionId:mockState', + from: 'mockFrom:mockState', + gasLimit: 'mockGasLimit:mockState', + gasPrice: 'mockGasPrice:mockState', + gasTotal: 'mockGasTotal:mockState', + network: 'mockNetwork:mockState', + primaryCurrency: 'mockPrimaryCurrency:mockState', + recentBlocks: 'mockRecentBlocks:mockState', + selectedAddress: 'mockSelectedAddress:mockState', + selectedToken: 'mockSelectedToken:mockState', + to: 'mockTo:mockState', + tokenBalance: 'mockTokenBalance:mockState', + tokenContract: 'mockTokenContract:mockState', + tokenToFiatRate: 'mockTokenToFiatRate:mockState', + }) + }) + + }) + + describe('mapDispatchToProps()', () => { + let dispatchSpy + let mapDispatchToPropsObject + + beforeEach(() => { + dispatchSpy = sinon.spy() + mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + }) + + describe('updateAndSetGasTotal()', () => { + const mockProps = { + blockGasLimit: 'mockBlockGasLimit', + editingTransactionId: '0x2', + gasLimit: '0x3', + gasPrice: '0x4', + recentBlocks: ['mockBlock'], + selectedAddress: '0x4', + selectedToken: { address: '0x1' }, + to: 'mockTo', + value: 'mockValue', + } + + it('should dispatch a setGasTotal action when editingTransactionId is truthy', () => { + mapDispatchToPropsObject.updateAndSetGasTotal(mockProps) + assert(dispatchSpy.calledOnce) + assert.equal( + actionSpies.setGasTotal.getCall(0).args[0], + '0x30x4' + ) + }) + + it('should dispatch an updateGasData action when editingTransactionId is falsy', () => { + const { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value } = mockProps + mapDispatchToPropsObject.updateAndSetGasTotal( + Object.assign({}, mockProps, {editingTransactionId: false}) + ) + assert(dispatchSpy.calledOnce) + assert.deepEqual( + actionSpies.updateGasData.getCall(0).args[0], + { selectedAddress, selectedToken, recentBlocks, blockGasLimit, to, value } + ) + }) + }) + + describe('updateSendTokenBalance()', () => { + const mockProps = { + address: '0x10', + tokenContract: '0x00a', + selectedToken: {address: '0x1'}, + } + + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendTokenBalance(Object.assign({}, mockProps)) + assert(dispatchSpy.calledOnce) + assert.deepEqual( + actionSpies.updateSendTokenBalance.getCall(0).args[0], + mockProps + ) + }) + }) + + describe('updateSendErrors()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateSendErrors('mockError') + assert(dispatchSpy.calledOnce) + assert.equal( + duckActionSpies.updateSendErrors.getCall(0).args[0], + 'mockError' + ) + }) + }) + + }) + +}) 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..f3d5674b7 --- /dev/null +++ b/ui/app/components/send_/tests/send-utils.test.js @@ -0,0 +1,436 @@ +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]), + conversionGreaterThan: sinon.stub().callsFake((obj1, obj2) => obj1.value > obj2.value), + conversionLessThan: sinon.stub().callsFake((obj1, obj2) => obj1.value < obj2.value), +} + +const sendUtils = proxyquire('../send.utils.js', { + '../../conversion-util': { + addCurrencies: stubs.addCurrencies, + conversionUtil: stubs.conversionUtil, + conversionGTE: stubs.conversionGTE, + multiplyCurrencies: stubs.multiplyCurrencies, + conversionGreaterThan: stubs.conversionGreaterThan, + conversionLessThan: stubs.conversionLessThan, + }, + '../../token-util': { calcTokenAmount: stubs.calcTokenAmount }, + 'ethereumjs-abi': { + rawEncode: stubs.rawEncode, + }, +}) + +const { + calcGasTotal, + estimateGas, + doesAmountErrorRequireUpdate, + estimateGasPriceFromRecentBlocks, + generateTokenTransferData, + getAmountErrorObject, + getToAddressForGasUpdate, + calcTokenBalance, + isBalanceSufficient, + isTokenBalanceSufficient, +} = sendUtils + +describe('send utils', () => { + + describe('calcGasTotal()', () => { + it('should call multiplyCurrencies with the correct params and return the multiplyCurrencies return', () => { + const result = calcGasTotal(12, 15) + assert.equal(result, '12x15') + const call_ = stubs.multiplyCurrencies.getCall(0).args + assert.deepEqual( + call_, + [12, 15, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + } ] + ) + }) + }) + + describe('doesAmountErrorRequireUpdate()', () => { + const config = { + 'should return true if balances are different': { + balance: 0, + prevBalance: 1, + expectedResult: true, + }, + 'should return true if gasTotals are different': { + gasTotal: 0, + prevGasTotal: 1, + expectedResult: true, + }, + 'should return true if token balances are different': { + tokenBalance: 0, + prevTokenBalance: 1, + selectedToken: 'someToken', + expectedResult: true, + }, + 'should return false if they are all the same': { + balance: 1, + prevBalance: 1, + gasTotal: 1, + prevGasTotal: 1, + tokenBalance: 1, + prevTokenBalance: 1, + selectedToken: 'someToken', + expectedResult: false, + }, + } + Object.entries(config).map(([description, obj]) => { + it(description, () => { + assert.equal(doesAmountErrorRequireUpdate(obj), obj.expectedResult) + }) + }) + + }) + + describe('generateTokenTransferData()', () => { + it('should return undefined if not passed a selected token', () => { + assert.equal(generateTokenTransferData({ toAddress: 'mockAddress', amount: '0xa', selectedToken: false}), undefined) + }) + + it('should call abi.rawEncode with the correct params', () => { + stubs.rawEncode.resetHistory() + generateTokenTransferData({ toAddress: 'mockAddress', amount: 'ab', selectedToken: true}) + assert.deepEqual( + stubs.rawEncode.getCall(0).args, + [['address', 'uint256'], ['mockAddress', '0xab']] + ) + }) + + it('should return encoded token transfer data', () => { + assert.equal( + generateTokenTransferData({ toAddress: 'mockAddress', amount: '0xa', selectedToken: true}), + '0xa9059cbb104c' + ) + }) + }) + + describe('getAmountErrorObject()', () => { + const config = { + 'should return insufficientFunds error if isBalanceSufficient returns false': { + amount: 15, + amountConversionRate: 2, + balance: 1, + conversionRate: 3, + gasTotal: 17, + primaryCurrency: 'ABC', + expectedResult: { amount: INSUFFICIENT_FUNDS_ERROR }, + }, + 'should 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) => `0xabc${n}` } + ) + ), + } + const baseExpectedCall = { + from: 'mockAddress', + gas: '0x64x0.95', + to: '0xisContract', + } + + beforeEach(() => { + global.eth = { + getCode: sinon.stub().callsFake( + (address) => Promise.resolve(address.match(/isContract/) ? 'not-0x' : '0x') + ), + } + }) + + afterEach(() => { + baseMockParams.estimateGasMethod.resetHistory() + global.eth.getCode.resetHistory() + }) + + it('should call ethQuery.estimateGas with the expected params', async () => { + const result = await sendUtils.estimateGas(baseMockParams) + assert.equal(baseMockParams.estimateGasMethod.callCount, 1) + assert.deepEqual( + baseMockParams.estimateGasMethod.getCall(0).args[0], + Object.assign({ gasPrice: undefined, value: undefined }, baseExpectedCall) + ) + assert.equal(result, '0xabc16') + }) + + it('should call ethQuery.estimateGas with the expected params when initialGasLimitHex is lower than the upperGasLimit', async () => { + const result = await estimateGas(Object.assign({}, baseMockParams, { blockGasLimit: '0xbcd' })) + assert.equal(baseMockParams.estimateGasMethod.callCount, 1) + assert.deepEqual( + baseMockParams.estimateGasMethod.getCall(0).args[0], + Object.assign({ gasPrice: undefined, value: undefined }, baseExpectedCall, { gas: '0xbcdx0.95' }) + ) + assert.equal(result, '0xabc16x1.5') + }) + + it('should call ethQuery.estimateGas with a value of 0x0 and the expected data and to if passed a selectedToken', async () => { + const result = await estimateGas(Object.assign({ data: 'mockData', selectedToken: { address: 'mockAddress' } }, baseMockParams)) + assert.equal(baseMockParams.estimateGasMethod.callCount, 1) + assert.deepEqual( + baseMockParams.estimateGasMethod.getCall(0).args[0], + Object.assign({}, baseExpectedCall, { + gasPrice: undefined, + value: '0x0', + data: '0xa9059cbb104c', + to: 'mockAddress', + }) + ) + assert.equal(result, '0xabc16') + }) + + it(`should return ${SIMPLE_GAS_COST} if ethQuery.getCode does not return '0x'`, async () => { + assert.equal(baseMockParams.estimateGasMethod.callCount, 0) + const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123' })) + assert.equal(result, SIMPLE_GAS_COST) + }) + + it(`should return ${SIMPLE_GAS_COST} if not passed a selectedToken or truthy to address`, async () => { + assert.equal(baseMockParams.estimateGasMethod.callCount, 0) + const result = await estimateGas(Object.assign({}, baseMockParams, { to: null })) + assert.equal(result, SIMPLE_GAS_COST) + }) + + it(`should not return ${SIMPLE_GAS_COST} if passed a selectedToken`, async () => { + assert.equal(baseMockParams.estimateGasMethod.callCount, 0) + const result = await estimateGas(Object.assign({}, baseMockParams, { to: '0x123', selectedToken: { address: '' } })) + assert.notEqual(result, SIMPLE_GAS_COST) + }) + + it(`should return the adjusted blockGasLimit if it fails with a 'Transaction execution error.'`, async () => { + const result = await estimateGas(Object.assign({}, baseMockParams, { + to: 'isContract willFailBecauseOf:Transaction execution error.', + })) + assert.equal(result, '0x64x0.95') + }) + + it(`should return the adjusted blockGasLimit if it fails with a 'gas required exceeds allowance or always failing transaction.'`, async () => { + const result = await estimateGas(Object.assign({}, baseMockParams, { + to: 'isContract willFailBecauseOf:gas required exceeds allowance or always failing transaction.', + })) + assert.equal(result, '0x64x0.95') + }) + + it(`should reject other errors`, async () => { + try { + await estimateGas(Object.assign({}, baseMockParams, { + to: 'isContract willFailBecauseOf:some other error', + })) + } catch (err) { + assert.deepEqual(err, { message: 'some other error' }) + } + }) + }) + + describe('estimateGasPriceFromRecentBlocks', () => { + const ONE_GWEI_IN_WEI_HEX_PLUS_ONE = addCurrencies(ONE_GWEI_IN_WEI_HEX, '0x1', { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + const ONE_GWEI_IN_WEI_HEX_PLUS_TWO = addCurrencies(ONE_GWEI_IN_WEI_HEX, '0x2', { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + const ONE_GWEI_IN_WEI_HEX_MINUS_ONE = subtractCurrencies(ONE_GWEI_IN_WEI_HEX, '0x1', { + aBase: 16, + bBase: 16, + toNumericBase: 'hex', + }) + + it(`should return ${ONE_GWEI_IN_WEI_HEX} if recentBlocks is falsy`, () => { + assert.equal(estimateGasPriceFromRecentBlocks(), ONE_GWEI_IN_WEI_HEX) + }) + + it(`should return ${ONE_GWEI_IN_WEI_HEX} if recentBlocks is empty`, () => { + assert.equal(estimateGasPriceFromRecentBlocks([]), ONE_GWEI_IN_WEI_HEX) + }) + + it(`should estimate a block's gasPrice as ${ONE_GWEI_IN_WEI_HEX} if it has no gas prices`, () => { + const mockRecentBlocks = [ + { gasPrices: null }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, + ] + assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX) + }) + + it(`should estimate a block's gasPrice as ${ONE_GWEI_IN_WEI_HEX} if it has empty gas prices`, () => { + const mockRecentBlocks = [ + { gasPrices: [] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, + ] + assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX) + }) + + it(`should return the middle value of all blocks lowest prices`, () => { + const mockRecentBlocks = [ + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_TWO ] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_MINUS_ONE ] }, + { gasPrices: [ ONE_GWEI_IN_WEI_HEX_PLUS_ONE ] }, + ] + assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), ONE_GWEI_IN_WEI_HEX_PLUS_ONE) + }) + + it(`should work if a block has multiple gas prices`, () => { + const mockRecentBlocks = [ + { gasPrices: [ '0x1', '0x2', '0x3', '0x4', '0x5' ] }, + { gasPrices: [ '0x101', '0x100', '0x103', '0x104', '0x102' ] }, + { gasPrices: [ '0x150', '0x50', '0x100', '0x200', '0x5' ] }, + ] + assert.equal(estimateGasPriceFromRecentBlocks(mockRecentBlocks), '0x5') + }) + }) + + describe('getToAddressForGasUpdate()', () => { + it('should return empty string if all params are undefined or null', () => { + assert.equal(getToAddressForGasUpdate(undefined, null), '') + }) + + it('should return the first string that is not defined or null in lower case', () => { + assert.equal(getToAddressForGasUpdate('A', null), 'a') + assert.equal(getToAddressForGasUpdate(undefined, 'B'), 'b') + }) + }) +}) diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js index ab780dcf4..2a3e929fe 100644 --- a/ui/app/components/signature-request.js +++ b/ui/app/components/signature-request.js @@ -178,7 +178,14 @@ SignatureRequest.prototype.renderBody = function () { rows = data } else if (type === 'eth_sign') { rows = [{ name: this.context.t('message'), value: data }] - notice = this.context.t('signNotice') + notice = [this.context.t('signNotice'), + h('span.request-signature__help-link', { + onClick: () => { + global.platform.openWindow({ + url: 'https://consensys.zendesk.com/hc/en-us/articles/360004427792', + }) + }, + }, this.context.t('learnMore'))] } return h('div.request-signature__body', {}, [ 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 } |