diff options
author | Dan Finlay <542863+danfinlay@users.noreply.github.com> | 2018-08-08 02:31:42 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-08-08 02:31:42 +0800 |
commit | 0601df9dae488d997277bb6b52c28df27546e27c (patch) | |
tree | ae33a0c4610b9a9e865c797258232542a72f57b6 /ui/app/components | |
parent | 4598554fea7b9435e5cbecc4735c479ffbadf37e (diff) | |
parent | f6490a2a6eda943a374c01df5884acb07ba28869 (diff) | |
download | tangerine-wallet-browser-0601df9dae488d997277bb6b52c28df27546e27c.tar tangerine-wallet-browser-0601df9dae488d997277bb6b52c28df27546e27c.tar.gz tangerine-wallet-browser-0601df9dae488d997277bb6b52c28df27546e27c.tar.bz2 tangerine-wallet-browser-0601df9dae488d997277bb6b52c28df27546e27c.tar.lz tangerine-wallet-browser-0601df9dae488d997277bb6b52c28df27546e27c.tar.xz tangerine-wallet-browser-0601df9dae488d997277bb6b52c28df27546e27c.tar.zst tangerine-wallet-browser-0601df9dae488d997277bb6b52c28df27546e27c.zip |
Merge pull request #4954 from MetaMask/v4.9.0
V4.9.0
Diffstat (limited to 'ui/app/components')
257 files changed, 5462 insertions, 2750 deletions
diff --git a/ui/app/components/account-menu/index.js b/ui/app/components/account-menu/index.js index f34631ca8..9c063d31e 100644 --- a/ui/app/components/account-menu/index.js +++ b/ui/app/components/account-menu/index.js @@ -9,11 +9,17 @@ const actions = require('../../actions') const { Menu, Item, Divider, CloseArea } = require('../dropdowns/components/menu') const Identicon = require('../identicon') const { formatBalance } = require('../../util') +const { ENVIRONMENT_TYPE_POPUP } = require('../../../../app/scripts/lib/enums') +const { getEnvironmentType } = require('../../../../app/scripts/lib/util') +const Tooltip = require('../tooltip') + + const { SETTINGS_ROUTE, INFO_ROUTE, NEW_ACCOUNT_ROUTE, IMPORT_ACCOUNT_ROUTE, + CONNECT_HARDWARE_ROUTE, DEFAULT_ROUTE, } = require('../../routes') @@ -63,6 +69,9 @@ function mapDispatchToProps (dispatch) { dispatch(actions.hideSidebar()) dispatch(actions.toggleAccountMenu()) }, + showRemoveAccountConfirmationModal: (identity) => { + return dispatch(actions.showModal({ name: 'CONFIRM_REMOVE_ACCOUNT', identity })) + }, } } @@ -106,6 +115,18 @@ AccountMenu.prototype.render = function () { icon: h('img.account-menu__item-icon', { src: 'images/import-account.svg' }), text: this.context.t('importAccount'), }), + h(Item, { + onClick: () => { + toggleAccountMenu() + if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { + global.platform.openExtensionInBrowser(CONNECT_HARDWARE_ROUTE) + } else { + history.push(CONNECT_HARDWARE_ROUTE) + } + }, + icon: h('img.account-menu__item-icon', { src: 'images/connect-icon.svg' }), + text: this.context.t('connectHardwareWallet'), + }), h(Divider), h(Item, { onClick: () => { @@ -136,7 +157,8 @@ AccountMenu.prototype.renderAccounts = function () { } = this.props const accountOrder = keyrings.reduce((list, keyring) => list.concat(keyring.accounts), []) - return accountOrder.map((address) => { + return accountOrder.filter(address => !!identities[address]).map((address) => { + const identity = identities[address] const isSelected = identity.address === selectedAddress @@ -170,16 +192,53 @@ AccountMenu.prototype.renderAccounts = function () { h('div.account-menu__balance', formattedBalance), ]), - this.indicateIfLoose(keyring), + this.renderKeyringType(keyring), + this.renderRemoveAccount(keyring, identity), ], ) }) } -AccountMenu.prototype.indicateIfLoose = function (keyring) { +AccountMenu.prototype.renderRemoveAccount = function (keyring, identity) { + // Any account that's not from the HD wallet Keyring can be removed + const type = keyring.type + const isRemovable = type !== 'HD Key Tree' + if (isRemovable) { + return h(Tooltip, { + title: this.context.t('removeAccount'), + position: 'bottom', + }, [ + h('a.remove-account-icon', { + onClick: (e) => this.removeAccount(e, identity), + }, ''), + ]) + } + return null +} + +AccountMenu.prototype.removeAccount = function (e, identity) { + e.preventDefault() + e.stopPropagation() + const { showRemoveAccountConfirmationModal } = this.props + showRemoveAccountConfirmationModal(identity) +} + +AccountMenu.prototype.renderKeyringType = function (keyring) { try { // Sometimes keyrings aren't loaded yet: const type = keyring.type - const isLoose = type !== 'HD Key Tree' - return isLoose ? h('.keyring-label.allcaps', this.context.t('imported')) : null + let label + switch (type) { + case 'Trezor Hardware': + label = this.context.t('hardware') + break + case 'Simple Key Pair': + label = this.context.t('imported') + break + default: + label = '' + } + + return label !== '' ? h('.keyring-label.allcaps', label) : null + } catch (e) { return } } diff --git a/ui/app/components/alert/index.js b/ui/app/components/alert/index.js new file mode 100644 index 000000000..5620d847a --- /dev/null +++ b/ui/app/components/alert/index.js @@ -0,0 +1,62 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') + +class Alert extends Component { + + constructor (props) { + super(props) + + this.state = { + visble: false, + msg: false, + className: '', + } + } + + componentWillReceiveProps (nextProps) { + if (!this.props.visible && nextProps.visible) { + this.animateIn(nextProps) + } else if (this.props.visible && !nextProps.visible) { + this.animateOut(nextProps) + } + } + + animateIn (props) { + this.setState({ + msg: props.msg, + visible: true, + className: '.visible', + }) + } + + animateOut (props) { + this.setState({ + msg: null, + className: '.hidden', + }) + + setTimeout(_ => { + this.setState({visible: false}) + }, 500) + + } + + render () { + if (this.state.visible) { + return ( + h(`div.global-alert${this.state.className}`, {}, + h('a.msg', {}, this.state.msg) + ) + ) + } + return null + } +} + +Alert.propTypes = { + visible: PropTypes.bool.isRequired, + msg: PropTypes.string, +} +module.exports = Alert + diff --git a/ui/app/components/app-header/app-header.component.js b/ui/app/components/app-header/app-header.component.js index 62b04562a..07ca6cf84 100644 --- a/ui/app/components/app-header/app-header.component.js +++ b/ui/app/components/app-header/app-header.component.js @@ -91,7 +91,6 @@ class AppHeader extends Component { network, provider, history, - location, isUnlocked, } = this.props @@ -126,7 +125,7 @@ class AppHeader extends Component { network={network} provider={provider} onClick={event => this.handleNetworkIndicatorClick(event)} - disabled={location.pathname === CONFIRM_TRANSACTION_ROUTE} + disabled={this.isConfirming()} /> </div> { this.renderAccountMenu() } diff --git a/ui/app/components/button-group/button-group.component.js b/ui/app/components/button-group/button-group.component.js new file mode 100644 index 000000000..f99f710ce --- /dev/null +++ b/ui/app/components/button-group/button-group.component.js @@ -0,0 +1,61 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +export default class ButtonGroup extends PureComponent { + static propTypes = { + defaultActiveButtonIndex: PropTypes.number, + disabled: PropTypes.bool, + children: PropTypes.array, + className: PropTypes.string, + style: PropTypes.object, + } + + static defaultProps = { + className: 'button-group', + } + + state = { + activeButtonIndex: this.props.defaultActiveButtonIndex || 0, + } + + handleButtonClick (activeButtonIndex) { + this.setState({ activeButtonIndex }) + } + + renderButtons () { + const { children, disabled } = this.props + + return React.Children.map(children, (child, index) => { + return child && ( + <button + className={classnames( + 'button-group__button', + { 'button-group__button--active': index === this.state.activeButtonIndex }, + )} + onClick={() => { + this.handleButtonClick(index) + child.props.onClick && child.props.onClick() + }} + disabled={disabled || child.props.disabled} + key={index} + > + { child.props.children } + </button> + ) + }) + } + + render () { + const { className, style } = this.props + + return ( + <div + className={className} + style={style} + > + { this.renderButtons() } + </div> + ) + } +} diff --git a/ui/app/components/button-group/button-group.stories.js b/ui/app/components/button-group/button-group.stories.js new file mode 100644 index 000000000..14e1a7e49 --- /dev/null +++ b/ui/app/components/button-group/button-group.stories.js @@ -0,0 +1,49 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' +import { action } from '@storybook/addon-actions' +import ButtonGroup from './' +import Button from '../button' +import { text, boolean } from '@storybook/addon-knobs/react' + +storiesOf('ButtonGroup', module) + .add('with Buttons', () => + <ButtonGroup + style={{ width: '300px' }} + disabled={boolean('Disabled', false)} + defaultActiveButtonIndex={1} + > + <Button + onClick={action('cheap')} + > + {text('Button1', 'Cheap')} + </Button> + <Button + onClick={action('average')} + > + {text('Button2', 'Average')} + </Button> + <Button + onClick={action('fast')} + > + {text('Button3', 'Fast')} + </Button> + </ButtonGroup> + ) + .add('with a disabled Button', () => + <ButtonGroup + style={{ width: '300px' }} + disabled={boolean('Disabled', false)} + > + <Button + onClick={action('enabled')} + > + {text('Button1', 'Enabled')} + </Button> + <Button + onClick={action('disabled')} + disabled + > + {text('Button2', 'Disabled')} + </Button> + </ButtonGroup> + ) diff --git a/ui/app/components/button-group/index.js b/ui/app/components/button-group/index.js new file mode 100644 index 000000000..df470bd57 --- /dev/null +++ b/ui/app/components/button-group/index.js @@ -0,0 +1 @@ +export { default } from './button-group.component' diff --git a/ui/app/components/button-group/index.scss b/ui/app/components/button-group/index.scss new file mode 100644 index 000000000..29713c75b --- /dev/null +++ b/ui/app/components/button-group/index.scss @@ -0,0 +1,38 @@ +.button-group { + display: flex; + justify-content: center; + align-items: center; + + &__button { + font-family: Roboto; + font-size: 1rem; + color: $tundora; + border-style: solid; + border-color: $alto; + border-width: 1px 1px 1px; + border-left: 0; + flex: 1; + padding: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:first-child { + border-left: 1px solid $alto; + border-radius: 4px 0 0 4px; + } + + &:last-child { + border-radius: 0 4px 4px 0; + } + + &--active { + background-color: $dodger-blue; + color: $white; + } + + &:disabled { + opacity: .5; + } + } +}
\ No newline at end of file diff --git a/ui/app/components/button-group/tests/button-group-component.test.js b/ui/app/components/button-group/tests/button-group-component.test.js new file mode 100644 index 000000000..f07bb97c8 --- /dev/null +++ b/ui/app/components/button-group/tests/button-group-component.test.js @@ -0,0 +1,97 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import ButtonGroup from '../button-group.component.js' + +const childButtonSpies = { + onClick: sinon.spy(), +} + +sinon.spy(ButtonGroup.prototype, 'handleButtonClick') +sinon.spy(ButtonGroup.prototype, 'renderButtons') + +const mockButtons = [ + <button onClick={childButtonSpies.onClick} key={'a'}><div className="mockClass" /></button>, + <button onClick={childButtonSpies.onClick} key={'b'}></button>, + <button onClick={childButtonSpies.onClick} key={'c'}></button>, +] + +describe('ButtonGroup Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<ButtonGroup + defaultActiveButtonIndex={1} + disabled={false} + className="someClassName" + style={ { color: 'red' } } + >{mockButtons}</ButtonGroup>) + }) + + afterEach(() => { + childButtonSpies.onClick.resetHistory() + ButtonGroup.prototype.handleButtonClick.resetHistory() + ButtonGroup.prototype.renderButtons.resetHistory() + }) + + describe('handleButtonClick', () => { + it('should set the activeButtonIndex', () => { + assert.equal(wrapper.state('activeButtonIndex'), 1) + wrapper.instance().handleButtonClick(2) + assert.equal(wrapper.state('activeButtonIndex'), 2) + }) + }) + + describe('renderButtons', () => { + it('should render a button for each child', () => { + const childButtons = wrapper.find('.button-group__button') + assert.equal(childButtons.length, 3) + }) + + it('should render the correct button with an active state', () => { + const childButtons = wrapper.find('.button-group__button') + const activeChildButton = wrapper.find('.button-group__button--active') + assert.deepEqual(childButtons.get(1), activeChildButton.get(0)) + }) + + it('should call handleButtonClick and the respective button\'s onClick method when a button is clicked', () => { + assert.equal(ButtonGroup.prototype.handleButtonClick.callCount, 0) + assert.equal(childButtonSpies.onClick.callCount, 0) + const childButtons = wrapper.find('.button-group__button') + childButtons.at(0).props().onClick() + childButtons.at(1).props().onClick() + childButtons.at(2).props().onClick() + assert.equal(ButtonGroup.prototype.handleButtonClick.callCount, 3) + assert.equal(childButtonSpies.onClick.callCount, 3) + }) + + it('should render all child buttons as disabled if props.disabled is true', () => { + const childButtons = wrapper.find('.button-group__button') + childButtons.forEach(button => { + assert.equal(button.props().disabled, undefined) + }) + wrapper.setProps({ disabled: true }) + const disabledChildButtons = wrapper.find('[disabled=true]') + assert.equal(disabledChildButtons.length, 3) + }) + + it('should render the children of the button', () => { + const mockClass = wrapper.find('.mockClass') + assert.equal(mockClass.length, 1) + }) + }) + + describe('render', () => { + it('should render a div with the expected class and style', () => { + assert.equal(wrapper.find('div').at(0).props().className, 'someClassName') + assert.deepEqual(wrapper.find('div').at(0).props().style, { color: 'red' }) + }) + + it('should call renderButtons when rendering', () => { + assert.equal(ButtonGroup.prototype.renderButtons.callCount, 1) + wrapper.instance().render() + assert.equal(ButtonGroup.prototype.renderButtons.callCount, 2) + }) + }) +}) diff --git a/ui/app/components/button/button.component.js b/ui/app/components/button/button.component.js index e8e798445..1e0ef1b64 100644 --- a/ui/app/components/button/button.component.js +++ b/ui/app/components/button/button.component.js @@ -5,15 +5,24 @@ import classnames from 'classnames' const CLASSNAME_DEFAULT = 'btn-default' const CLASSNAME_PRIMARY = 'btn-primary' const CLASSNAME_SECONDARY = 'btn-secondary' +const CLASSNAME_CONFIRM = 'btn-confirm' const CLASSNAME_LARGE = 'btn--large' const typeHash = { default: CLASSNAME_DEFAULT, primary: CLASSNAME_PRIMARY, secondary: CLASSNAME_SECONDARY, + confirm: CLASSNAME_CONFIRM, } -class Button extends Component { +export default class Button extends Component { + static propTypes = { + type: PropTypes.string, + large: PropTypes.bool, + className: PropTypes.string, + children: PropTypes.string, + } + render () { const { type, large, className, ...buttonProps } = this.props @@ -31,13 +40,3 @@ class Button extends Component { ) } } - -Button.propTypes = { - type: PropTypes.string, - large: PropTypes.bool, - className: PropTypes.string, - children: PropTypes.string, -} - -export default Button - diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js b/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js new file mode 100644 index 000000000..f0703dde2 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-detail-row/confirm-detail-row.component.js @@ -0,0 +1,52 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +const ConfirmDetailRow = props => { + const { + label, + fiatText, + ethText, + onHeaderClick, + fiatTextColor, + headerText, + headerTextClassName, + } = props + + return ( + <div className="confirm-detail-row"> + <div className="confirm-detail-row__label"> + { label } + </div> + <div className="confirm-detail-row__details"> + <div + className={classnames('confirm-detail-row__header-text', headerTextClassName)} + onClick={() => onHeaderClick && onHeaderClick()} + > + { headerText } + </div> + <div + className="confirm-detail-row__fiat" + style={{ color: fiatTextColor }} + > + { fiatText } + </div> + <div className="confirm-detail-row__eth"> + { ethText } + </div> + </div> + </div> + ) +} + +ConfirmDetailRow.propTypes = { + label: PropTypes.string, + fiatText: PropTypes.string, + ethText: PropTypes.string, + fiatTextColor: PropTypes.string, + onHeaderClick: PropTypes.func, + headerText: PropTypes.string, + headerTextClassName: PropTypes.string, +} + +export default ConfirmDetailRow diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/index.js b/ui/app/components/confirm-page-container/confirm-detail-row/index.js new file mode 100644 index 000000000..056afff04 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-detail-row/index.js @@ -0,0 +1 @@ +export { default } from './confirm-detail-row.component' diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/index.scss b/ui/app/components/confirm-page-container/confirm-detail-row/index.scss new file mode 100644 index 000000000..dd6f87c17 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-detail-row/index.scss @@ -0,0 +1,50 @@ +.confirm-detail-row { + padding: 14px 0; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + &__label { + font-size: .75rem; + font-weight: 500; + color: $scorpion; + text-transform: uppercase; + } + + &__details { + flex: 1; + text-align: end; + min-width: 0; + } + + &__fiat { + font-size: 1.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__eth { + color: $oslo-gray; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__header-text { + font-size: .75rem; + text-transform: uppercase; + margin-bottom: 6px; + color: $scorpion; + + &--edit { + color: $curious-blue; + cursor: pointer; + } + + &--total { + font-size: .625rem; + } + } +} diff --git a/ui/app/components/confirm-page-container/confirm-detail-row/tests/confirm-detail-row.component.test.js b/ui/app/components/confirm-page-container/confirm-detail-row/tests/confirm-detail-row.component.test.js new file mode 100644 index 000000000..6f2489071 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-detail-row/tests/confirm-detail-row.component.test.js @@ -0,0 +1,64 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import ConfirmDetailRow from '../confirm-detail-row.component.js' +import sinon from 'sinon' + +const propsMethodSpies = { + onHeaderClick: sinon.spy(), +} + +describe('Confirm Detail Row Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<ConfirmDetailRow + errorType={'mockErrorType'} + label={'mockLabel'} + showError={false} + fiatText = {'mockFiatText'} + ethText = {'mockEthText'} + fiatTextColor= {'mockColor'} + onHeaderClick= {propsMethodSpies.onHeaderClick} + headerText = {'mockHeaderText'} + headerTextClassName = {'mockHeaderClass'} + />) + }) + + describe('render', () => { + it('should render a div with a confirm-detail-row class', () => { + assert.equal(wrapper.find('div.confirm-detail-row').length, 1) + }) + + it('should render the label as a child of the confirm-detail-row__label', () => { + assert.equal(wrapper.find('.confirm-detail-row > .confirm-detail-row__label').childAt(0).text(), 'mockLabel') + }) + + it('should render the headerText as a child of the confirm-detail-row__header-text', () => { + assert.equal(wrapper.find('.confirm-detail-row__details > .confirm-detail-row__header-text').childAt(0).text(), 'mockHeaderText') + }) + + it('should render the fiatText as a child of the confirm-detail-row__fiat', () => { + assert.equal(wrapper.find('.confirm-detail-row__details > .confirm-detail-row__fiat').childAt(0).text(), 'mockFiatText') + }) + + it('should render the ethText as a child of the confirm-detail-row__eth', () => { + assert.equal(wrapper.find('.confirm-detail-row__details > .confirm-detail-row__eth').childAt(0).text(), 'mockEthText') + }) + + it('should set the fiatTextColor on confirm-detail-row__fiat', () => { + assert.equal(wrapper.find('.confirm-detail-row__fiat').props().style.color, 'mockColor') + }) + + it('should assure the confirm-detail-row__header-text classname is correct', () => { + assert.equal(wrapper.find('.confirm-detail-row__header-text').props().className, 'confirm-detail-row__header-text mockHeaderClass') + }) + + it('should call onHeaderClick when headerText div gets clicked', () => { + wrapper.find('.confirm-detail-row__header-text').props().onClick() + assert.equal(assert.equal(propsMethodSpies.onHeaderClick.callCount, 1)) + }) + + + }) +}) diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js new file mode 100644 index 000000000..08923af88 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -0,0 +1,105 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { Tabs, Tab } from '../../tabs' +import { + ConfirmPageContainerSummary, + ConfirmPageContainerError, + ConfirmPageContainerWarning, +} from './' + +export default class ConfirmPageContainerContent extends Component { + static propTypes = { + action: PropTypes.string, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + hideSubtitle: PropTypes.bool, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, + subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + summaryComponent: PropTypes.node, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + titleComponent: PropTypes.func, + warning: PropTypes.string, + } + + renderContent () { + const { detailsComponent, dataComponent } = this.props + + if (detailsComponent && dataComponent) { + return this.renderTabs() + } else { + return detailsComponent || dataComponent + } + } + + renderTabs () { + const { detailsComponent, dataComponent } = this.props + + return ( + <Tabs> + <Tab name="Details"> + { detailsComponent } + </Tab> + <Tab name="Data"> + { dataComponent } + </Tab> + </Tabs> + ) + } + + render () { + const { + action, + errorKey, + errorMessage, + title, + subtitle, + hideSubtitle, + identiconAddress, + nonce, + summaryComponent, + detailsComponent, + dataComponent, + warning, + } = this.props + + return ( + <div className="confirm-page-container-content"> + { + warning && ( + <ConfirmPageContainerWarning warning={warning} /> + ) + } + { + summaryComponent || ( + <ConfirmPageContainerSummary + className={classnames({ + 'confirm-page-container-summary--border': !detailsComponent || !dataComponent, + })} + action={action} + title={title} + subtitle={subtitle} + hideSubtitle={hideSubtitle} + identiconAddress={identiconAddress} + nonce={nonce} + /> + ) + } + { this.renderContent() } + { + (errorKey || errorMessage) && ( + <div className="confirm-page-container-content__error-container"> + <ConfirmPageContainerError + errorMessage={errorMessage} + errorKey={errorKey} + /> + </div> + ) + } + </div> + ) + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/confirm-page-container-error.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/confirm-page-container-error.component.js new file mode 100644 index 000000000..70ebdeb20 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/confirm-page-container-error.component.js @@ -0,0 +1,28 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const ConfirmPageContainerError = (props, context) => { + const { errorMessage, errorKey } = props + const error = errorKey ? context.t(errorKey) : errorMessage + + return ( + <div className="confirm-page-container-error"> + <img + src="/images/alert-red.svg" + className="confirm-page-container-error__icon" + /> + { `ALERT: ${error}` } + </div> + ) +} + +ConfirmPageContainerError.propTypes = { + errorMessage: PropTypes.string, + errorKey: PropTypes.string, +} + +ConfirmPageContainerError.contextTypes = { + t: PropTypes.func, +} + +export default ConfirmPageContainerError diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.js new file mode 100644 index 000000000..4ac95d0e3 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-error.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.scss new file mode 100644 index 000000000..e99b0f631 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-error/index.scss @@ -0,0 +1,17 @@ +.confirm-page-container-error { + height: 32px; + border: 1px solid $monzo; + color: $monzo; + background: lighten($monzo, 56%); + border-radius: 4px; + font-size: .75rem; + display: flex; + justify-content: flex-start; + align-items: center; + padding-left: 16px; + + &__icon { + margin-right: 8px; + flex: 0 0 auto; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js new file mode 100644 index 000000000..3b1ee62c5 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js @@ -0,0 +1,56 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import Identicon from '../../../identicon' + +const ConfirmPageContainerSummary = props => { + const { action, title, subtitle, hideSubtitle, className, identiconAddress, nonce } = props + + return ( + <div className={classnames('confirm-page-container-summary', className)}> + <div className="confirm-page-container-summary__action-row"> + <div className="confirm-page-container-summary__action"> + { action } + </div> + { + nonce && ( + <div className="confirm-page-container-summary__nonce"> + { `#${nonce}` } + </div> + ) + } + </div> + <div className="confirm-page-container-summary__title"> + { + identiconAddress && ( + <Identicon + className="confirm-page-container-summary__identicon" + diameter={36} + address={identiconAddress} + /> + ) + } + <div className="confirm-page-container-summary__title-text"> + { title } + </div> + </div> + { + hideSubtitle || <div className="confirm-page-container-summary__subtitle"> + { subtitle } + </div> + } + </div> + ) +} + +ConfirmPageContainerSummary.propTypes = { + action: PropTypes.string, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + hideSubtitle: PropTypes.bool, + className: PropTypes.string, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, +} + +export default ConfirmPageContainerSummary diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js new file mode 100644 index 000000000..ed1b28cf2 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-summary.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss new file mode 100644 index 000000000..7f0f5d37a --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/index.scss @@ -0,0 +1,54 @@ +.confirm-page-container-summary { + padding: 16px 24px 0; + background-color: #f9fafa; + height: 133px; + box-sizing: border-box; + + &__action-row { + display: flex; + justify-content: space-between; + } + + &__action { + text-transform: uppercase; + color: $oslo-gray; + font-size: .75rem; + padding: 3px 8px; + border: 1px solid $oslo-gray; + border-radius: 4px; + display: inline-block; + } + + &__nonce { + color: $oslo-gray; + } + + &__title { + padding: 4px 0; + display: flex; + align-items: center; + } + + &__identicon { + flex: 0 0 auto; + margin-right: 8px; + } + + &__title-text { + font-size: 2.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__subtitle { + color: $oslo-gray; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &--border { + border-bottom: 1px solid $geyser; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js new file mode 100644 index 000000000..79901c8fc --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/confirm-page-container-warning.component.js @@ -0,0 +1,22 @@ +import React from 'react' +import PropTypes from 'prop-types' + +const ConfirmPageContainerWarning = props => { + return ( + <div className="confirm-page-container-warning"> + <img + className="confirm-page-container-warning__icon" + src="/images/alert.svg" + /> + <div className="confirm-page-container-warning__warning"> + { props.warning } + </div> + </div> + ) +} + +ConfirmPageContainerWarning.propTypes = { + warning: PropTypes.string, +} + +export default ConfirmPageContainerWarning diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js new file mode 100644 index 000000000..6e48bd144 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-warning.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss new file mode 100644 index 000000000..50545a1a2 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/confirm-page-container-warning/index.scss @@ -0,0 +1,18 @@ +.confirm-page-container-warning { + background-color: #fffcdb; + display: flex; + justify-content: center; + align-items: center; + border-bottom: 1px solid $geyser; + padding: 12px 24px; + + &__icon { + flex: 0 0 auto; + margin-right: 16px; + } + + &__warning { + font-size: .75rem; + color: #5f5922; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/index.js b/ui/app/components/confirm-page-container/confirm-page-container-content/index.js new file mode 100644 index 000000000..1469dd438 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/index.js @@ -0,0 +1,4 @@ +export { default } from './confirm-page-container-content.component' +export { default as ConfirmPageContainerSummary } from './confirm-page-container-summary' +export { default as ConfirmPageContainerError } from './confirm-page-container-error' +export { default as ConfirmPageContainerWarning } from './confirm-page-container-warning' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss new file mode 100644 index 000000000..39797a43f --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-content/index.scss @@ -0,0 +1,66 @@ +@import './confirm-page-container-error/index'; + +@import './confirm-page-container-warning/index'; + +@import './confirm-page-container-summary/index'; + +.confirm-page-container-content { + overflow-y: auto; + flex: 1; + + &__error-container { + padding: 0 16px 16px 16px; + } + + &__details { + box-sizing: border-box; + padding: 0 24px; + } + + &__data { + padding: 16px; + color: $oslo-gray; + } + + &__data-box { + background-color: #f9fafa; + padding: 12px; + font-size: .75rem; + margin-bottom: 16px; + word-wrap: break-word; + max-height: 200px; + overflow-y: auto; + + &-label { + text-transform: uppercase; + padding: 8px 0 12px; + font-size: 12px; + } + } + + &__data-field { + display: flex; + flex-direction: row; + + &-label { + font-weight: 500; + padding-right: 16px; + } + + &:not(:last-child) { + margin-bottom: 5px; + } + } + + &__gas-fee { + border-bottom: 1px solid $geyser; + } + + &__function-type { + font-size: .875rem; + font-weight: 500; + text-transform: capitalize; + color: $black; + padding-left: 5px; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js b/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js new file mode 100644 index 000000000..e6fe8f82c --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-header/confirm-page-container-header.component.js @@ -0,0 +1,63 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { + ENVIRONMENT_TYPE_POPUP, + ENVIRONMENT_TYPE_NOTIFICATION, +} from '../../../../../app/scripts/lib/enums' +import NetworkDisplay from '../../network-display' + +export default class ConfirmPageContainer extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + showEdit: PropTypes.bool, + onEdit: PropTypes.func, + children: PropTypes.node, + } + + renderTop () { + const { onEdit, showEdit } = this.props + const windowType = window.METAMASK_UI_TYPE + const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && + windowType !== ENVIRONMENT_TYPE_POPUP + + if (!showEdit && isFullScreen) { + return null + } + + return ( + <div className="confirm-page-container-header__row"> + <div + className="confirm-page-container-header__back-button-container" + style={{ + visibility: showEdit ? 'initial' : 'hidden', + }} + > + <img + src="/images/caret-left.svg" + /> + <span + className="confirm-page-container-header__back-button" + onClick={() => onEdit()} + > + { this.context.t('edit') } + </span> + </div> + { !isFullScreen && <NetworkDisplay /> } + </div> + ) + } + + render () { + const { children } = this.props + + return ( + <div className="confirm-page-container-header"> + { this.renderTop() } + { children } + </div> + ) + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/index.js b/ui/app/components/confirm-page-container/confirm-page-container-header/index.js new file mode 100644 index 000000000..71feb6931 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-header/index.js @@ -0,0 +1 @@ +export { default } from './confirm-page-container-header.component' diff --git a/ui/app/components/confirm-page-container/confirm-page-container-header/index.scss b/ui/app/components/confirm-page-container/confirm-page-container-header/index.scss new file mode 100644 index 000000000..43e1e4427 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container-header/index.scss @@ -0,0 +1,27 @@ +.confirm-page-container-header { + display: flex; + flex-direction: column; + flex: 0 0 auto; + + &__row { + display: flex; + justify-content: space-between; + border-bottom: 1px solid $geyser; + padding: 13px 13px 13px 24px; + flex: 0 0 auto; + } + + &__back-button-container { + display: flex; + justify-content: center; + align-items: center; + } + + &__back-button { + color: #2f9ae0; + font-size: 1rem; + cursor: pointer; + font-weight: 400; + padding-left: 5px; + } +} diff --git a/ui/app/components/confirm-page-container/confirm-page-container.component.js b/ui/app/components/confirm-page-container/confirm-page-container.component.js new file mode 100644 index 000000000..24ff05353 --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-page-container.component.js @@ -0,0 +1,118 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SenderToRecipient from '../sender-to-recipient' +import { PageContainerFooter } from '../page-container' +import { ConfirmPageContainerHeader, ConfirmPageContainerContent } from './' + +export default class ConfirmPageContainer extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + // Header + action: PropTypes.string, + hideSubtitle: PropTypes.bool, + onEdit: PropTypes.func, + showEdit: PropTypes.bool, + subtitle: PropTypes.string, + title: PropTypes.string, + titleComponent: PropTypes.func, + // Sender to Recipient + fromAddress: PropTypes.string, + fromName: PropTypes.string, + toAddress: PropTypes.string, + toName: PropTypes.string, + // Content + contentComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + fiatTransactionAmount: PropTypes.string, + fiatTransactionFee: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + ethTransactionAmount: PropTypes.string, + ethTransactionFee: PropTypes.string, + ethTransactionTotal: PropTypes.string, + onEditGas: PropTypes.func, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + identiconAddress: PropTypes.string, + nonce: PropTypes.string, + summaryComponent: PropTypes.node, + warning: PropTypes.string, + // Footer + onCancel: PropTypes.func, + onSubmit: PropTypes.func, + disabled: PropTypes.bool, + } + + render () { + const { + showEdit, + onEdit, + fromName, + fromAddress, + toName, + toAddress, + disabled, + errorKey, + errorMessage, + contentComponent, + action, + title, + titleComponent, + subtitle, + hideSubtitle, + summaryComponent, + detailsComponent, + dataComponent, + onCancel, + onSubmit, + identiconAddress, + nonce, + warning, + } = this.props + + return ( + <div className="page-container"> + <ConfirmPageContainerHeader + showEdit={showEdit} + onEdit={() => onEdit()} + > + <SenderToRecipient + senderName={fromName} + senderAddress={fromAddress} + recipientName={toName} + recipientAddress={toAddress} + /> + </ConfirmPageContainerHeader> + { + contentComponent || ( + <ConfirmPageContainerContent + action={action} + title={title} + titleComponent={titleComponent} + subtitle={subtitle} + hideSubtitle={hideSubtitle} + summaryComponent={summaryComponent} + detailsComponent={detailsComponent} + dataComponent={dataComponent} + errorMessage={errorMessage} + errorKey={errorKey} + identiconAddress={identiconAddress} + nonce={nonce} + warning={warning} + /> + ) + } + <PageContainerFooter + onCancel={() => onCancel()} + onSubmit={() => onSubmit()} + submitText={this.context.t('confirm')} + submitButtonType="confirm" + disabled={disabled} + /> + </div> + ) + } +} diff --git a/ui/app/components/confirm-page-container/index.js b/ui/app/components/confirm-page-container/index.js new file mode 100644 index 000000000..ee88aa5d3 --- /dev/null +++ b/ui/app/components/confirm-page-container/index.js @@ -0,0 +1,8 @@ +export { default } from './confirm-page-container.component' +export { default as ConfirmPageContainerHeader } from './confirm-page-container-header' +export { default as ConfirmDetailRow } from './confirm-detail-row' +export { + default as ConfirmPageContainerContent, + ConfirmPageContainerSummary, + ConfirmPageContainerError, +} from './confirm-page-container-content' diff --git a/ui/app/components/confirm-page-container/index.scss b/ui/app/components/confirm-page-container/index.scss new file mode 100644 index 000000000..af7a5b555 --- /dev/null +++ b/ui/app/components/confirm-page-container/index.scss @@ -0,0 +1,5 @@ +@import './confirm-page-container-content/index'; + +@import './confirm-page-container-header/index'; + +@import './confirm-detail-row/index'; diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js index c8522a3c7..c255fd64d 100644 --- a/ui/app/components/customize-gas-modal/index.js +++ b/ui/app/components/customize-gas-modal/index.js @@ -16,11 +16,11 @@ 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, @@ -31,8 +31,7 @@ const { } = require('../../conversion-util') const { - getGasPrice, - getGasLimit, + getGasIsLoading, getForceGasMin, conversionRateSelector, getSendAmount, @@ -43,6 +42,11 @@ const { getSendMaxModeState, } = require('../../selectors') +const { + getGasPrice, + getGasLimit, +} = require('../send/send.selectors') + function mapStateToProps (state) { const selectedToken = getSelectedToken(state) const currentAccount = getSendFrom(state) || getCurrentAccountWithSendEtherInfo(state) @@ -51,6 +55,7 @@ function mapStateToProps (state) { return { gasPrice: getGasPrice(state), gasLimit: getGasLimit(state), + gasIsLoading: getGasIsLoading(state), forceGasMin: getForceGasMin(state), conversionRate, amount: getSendAmount(state), @@ -73,7 +78,7 @@ function mapDispatchToProps (dispatch) { } } -function getOriginalState (props) { +function getFreshState (props) { const gasPrice = props.gasPrice || MIN_GAS_PRICE_DEC const gasLimit = props.gasLimit || MIN_GAS_LIMIT_DEC @@ -97,7 +102,11 @@ inherits(CustomizeGasModal, Component) function CustomizeGasModal (props) { Component.call(this) - this.state = getOriginalState(props) + const originalState = getFreshState(props) + this.state = { + ...originalState, + originalState, + } } CustomizeGasModal.contextTypes = { @@ -106,6 +115,36 @@ CustomizeGasModal.contextTypes = { module.exports = connect(mapStateToProps, mapDispatchToProps)(CustomizeGasModal) +CustomizeGasModal.prototype.componentWillReceiveProps = function (nextProps) { + const currentState = getFreshState(this.props) + const { + gasPrice: currentGasPrice, + gasLimit: currentGasLimit, + } = currentState + const newState = getFreshState(nextProps) + const { + gasPrice: newGasPrice, + gasLimit: newGasLimit, + gasTotal: newGasTotal, + } = newState + const gasPriceChanged = currentGasPrice !== newGasPrice + const gasLimitChanged = currentGasLimit !== newGasLimit + + if (gasPriceChanged) { + this.setState({ + gasPrice: newGasPrice, + gasTotal: newGasTotal, + priceSigZeros: '', + priceSigDec: '', + }) + } + if (gasLimitChanged) { + this.setState({ gasLimit: newGasLimit, gasTotal: newGasTotal }) + } + if (gasLimitChanged || gasPriceChanged) { + this.validate({ gasLimit: newGasLimit, gasTotal: newGasTotal }) + } +} CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { const { @@ -137,7 +176,7 @@ CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { } CustomizeGasModal.prototype.revert = function () { - this.setState(getOriginalState(this.props)) + this.setState(this.state.originalState) } CustomizeGasModal.prototype.validate = function ({ gasTotal, gasLimit }) { @@ -233,7 +272,7 @@ CustomizeGasModal.prototype.convertAndSetGasPrice = function (newGasPrice) { } CustomizeGasModal.prototype.render = function () { - const { hideModal, forceGasMin } = this.props + const { hideModal, forceGasMin, gasIsLoading } = this.props const { gasPrice, gasLimit, gasTotal, error, priceSigZeros, priceSigDec } = this.state let convertedGasPrice = conversionUtil(gasPrice, { @@ -266,7 +305,7 @@ CustomizeGasModal.prototype.render = function () { toNumericBase: 'dec', }) - return h('div.send-v2__customize-gas', {}, [ + return !gasIsLoading && h('div.send-v2__customize-gas', {}, [ h('div.send-v2__customize-gas__content', { }, [ h('div.send-v2__customize-gas__header', {}, [ @@ -288,6 +327,7 @@ CustomizeGasModal.prototype.render = function () { onChange: value => this.convertAndSetGasPrice(value), title: this.context.t('gasPrice'), copy: this.context.t('gasPriceCalculation'), + gasIsLoading, }), h(GasModalCard, { @@ -297,6 +337,7 @@ CustomizeGasModal.prototype.render = function () { onChange: value => this.convertAndSetGasLimit(value), title: this.context.t('gasLimit'), copy: this.context.t('gasLimitCalculation'), + gasIsLoading, }), ]), diff --git a/ui/app/components/dropdowns/account-dropdown-mini.js b/ui/app/components/dropdowns/account-dropdown-mini.js index a7a908d3b..261eb0aa2 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/account-list-item.component').default +const AccountListItem = require('../send/account-list-item/account-list-item.component').default module.exports = AccountDropdownMini diff --git a/ui/app/components/dropdowns/components/dropdown.js b/ui/app/components/dropdowns/components/dropdown.js index 0336dbb8b..149f063a7 100644 --- a/ui/app/components/dropdowns/components/dropdown.js +++ b/ui/app/components/dropdowns/components/dropdown.js @@ -87,7 +87,6 @@ class DropdownMenuItem extends Component { padding: '8px 0px', fontSize: '18px', fontStyle: 'normal', - fontFamily: 'Montserrat Regular', cursor: 'pointer', display: 'flex', justifyContent: 'flex-start', diff --git a/ui/app/components/dropdowns/components/network-dropdown-icon.js b/ui/app/components/dropdowns/components/network-dropdown-icon.js index 7e94e0af5..a45da4c10 100644 --- a/ui/app/components/dropdowns/components/network-dropdown-icon.js +++ b/ui/app/components/dropdowns/components/network-dropdown-icon.js @@ -15,6 +15,7 @@ NetworkDropdownIcon.prototype.render = function () { backgroundColor, isSelected, innerBorder = 'none', + diameter = '12', } = this.props return h(`.menu-icon-circle${isSelected ? '--active' : ''}`, {}, @@ -22,6 +23,8 @@ NetworkDropdownIcon.prototype.render = function () { style: { background: backgroundColor, border: innerBorder, + height: `${diameter}px`, + width: `${diameter}px`, }, }) ) diff --git a/ui/app/components/dropdowns/network-dropdown.js b/ui/app/components/dropdowns/network-dropdown.js index dcd6b4370..e5363ff56 100644 --- a/ui/app/components/dropdowns/network-dropdown.js +++ b/ui/app/components/dropdowns/network-dropdown.js @@ -71,7 +71,6 @@ NetworkDropdown.prototype.render = function () { const rpcList = props.frequentRpcList const isOpen = this.props.networkDropdownOpen const dropdownMenuItemStyle = { - fontFamily: 'DIN OT', fontSize: '16px', lineHeight: '20px', padding: '12px 0', @@ -286,7 +285,6 @@ NetworkDropdown.prototype.renderCommonRpc = function (rpcList, provider) { closeMenu: () => this.props.hideNetworkDropdown(), onClick: () => props.setRpcTarget(rpc), style: { - fontFamily: 'DIN OT', fontSize: '16px', lineHeight: '20px', padding: '12px 0', @@ -325,7 +323,6 @@ NetworkDropdown.prototype.renderCustomOption = function (provider) { onClick: () => props.setRpcTarget(rpcTarget), closeMenu: () => this.props.hideNetworkDropdown(), style: { - fontFamily: 'DIN OT', fontSize: '16px', lineHeight: '20px', padding: '12px 0', diff --git a/ui/app/components/dropdowns/token-menu-dropdown.js b/ui/app/components/dropdowns/token-menu-dropdown.js index fac7c451b..5a794c7c1 100644 --- a/ui/app/components/dropdowns/token-menu-dropdown.js +++ b/ui/app/components/dropdowns/token-menu-dropdown.js @@ -54,7 +54,7 @@ TokenMenuDropdown.prototype.render = function () { showHideTokenConfirmationModal(this.props.token) this.props.onClose() }, - text: this.context.t('hideToken'), + text: this.context.t('hideToken'), }), h(Item, { onClick: (e) => { @@ -62,7 +62,7 @@ TokenMenuDropdown.prototype.render = function () { copyToClipboard(this.props.token.address) this.props.onClose() }, - text: this.context.t('copyContractAddress'), + text: this.context.t('copyContractAddress'), }), h(Item, { onClick: (e) => { @@ -71,7 +71,7 @@ TokenMenuDropdown.prototype.render = function () { global.platform.openWindow({ url }) this.props.onClose() }, - text: this.context.t('viewOnEtherscan'), + text: this.context.t('viewOnEtherscan'), }), ]) } diff --git a/ui/app/components/ens-input.js b/ui/app/components/ens-input.js index aff4b6ef6..b9f99b3d1 100644 --- a/ui/app/components/ens-input.js +++ b/ui/app/components/ens-input.js @@ -10,8 +10,9 @@ const networkMap = require('ethjs-ens/lib/network-map.json') const ensRE = /.+\..+$/ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' const connect = require('react-redux').connect -const ToAutoComplete = require('./send/to-autocomplete') +const ToAutoComplete = require('./send/to-autocomplete').default const log = require('loglevel') +const { isValidENSAddress } = require('../util') EnsInput.contextTypes = { t: PropTypes.func, @@ -25,31 +26,34 @@ function EnsInput () { Component.call(this) } -EnsInput.prototype.render = function () { - const props = this.props - const opts = extend(props, { - list: 'addresses', - onChange: (recipient) => { - const network = this.props.network - const networkHasEnsSupport = getNetworkEnsSupport(network) +EnsInput.prototype.onChange = function (recipient) { + const network = this.props.network + const networkHasEnsSupport = getNetworkEnsSupport(network) - props.onChange(recipient) + this.props.onChange({ toAddress: recipient }) - if (!networkHasEnsSupport) return + if (!networkHasEnsSupport) return - if (recipient.match(ensRE) === null) { - return this.setState({ - loadingEns: false, - ensResolution: null, - ensFailure: null, - }) - } + if (recipient.match(ensRE) === null) { + return this.setState({ + loadingEns: false, + ensResolution: null, + ensFailure: null, + toError: null, + }) + } - this.setState({ - loadingEns: true, - }) - this.checkName(recipient) - }, + this.setState({ + loadingEns: true, + }) + this.checkName(recipient) +} + +EnsInput.prototype.render = function () { + const props = this.props + const opts = extend(props, { + list: 'addresses', + onChange: this.onChange.bind(this), }) return h('div', { style: { width: '100%', position: 'relative' }, @@ -85,17 +89,27 @@ EnsInput.prototype.lookupEnsName = function (recipient) { nickname: recipient.trim(), hoverText: address + '\n' + this.context.t('clickCopy'), ensFailure: false, + toError: null, }) } }) .catch((reason) => { - log.error(reason) - return this.setState({ + const setStateObj = { loadingEns: false, - ensResolution: ZERO_ADDRESS, + ensResolution: recipient, ensFailure: true, - hoverText: reason.message, - }) + toError: null, + } + if (isValidENSAddress(recipient) && reason.message === 'ENS name not defined.') { + setStateObj.hoverText = this.context.t('ensNameNotFound') + setStateObj.toError = 'ensNameNotFound' + setStateObj.ensFailure = false + } else { + log.error(reason) + setStateObj.hoverText = reason.message + } + + return this.setState(setStateObj) }) } @@ -105,9 +119,14 @@ EnsInput.prototype.componentDidUpdate = function (prevProps, prevState) { // If an address is sent without a nickname, meaning not from ENS or from // the user's own accounts, a default of a one-space string is used. const nickname = state.nickname || ' ' + if (prevProps.network !== this.props.network) { + const provider = global.ethereumProvider + this.ens = new ENS({ provider, network: this.props.network }) + this.onChange(ensResolution) + } if (prevState && ensResolution && this.props.onChange && ensResolution !== prevState.ensResolution) { - this.props.onChange(ensResolution, nickname) + this.props.onChange({ toAddress: ensResolution, nickname, toError: state.toError }) } } @@ -124,7 +143,9 @@ EnsInput.prototype.ensIcon = function (recipient) { } EnsInput.prototype.ensIconContents = function (recipient) { - const { loadingEns, ensFailure, ensResolution } = this.state || { ensResolution: ZERO_ADDRESS} + const { loadingEns, ensFailure, ensResolution, toError } = this.state || { ensResolution: ZERO_ADDRESS } + + if (toError) return if (loadingEns) { return h('img', { diff --git a/ui/app/components/identicon.js b/ui/app/components/identicon.js index dce9b0449..424048745 100644 --- a/ui/app/components/identicon.js +++ b/ui/app/components/identicon.js @@ -36,6 +36,7 @@ IdenticonComponent.prototype.render = function () { key: 'identicon-' + address, style: { display: 'flex', + flexShrink: 0, alignItems: 'center', justifyContent: 'center', height: diameter, diff --git a/ui/app/components/index.scss b/ui/app/components/index.scss index 351640f6e..b3e14ce23 100644 --- a/ui/app/components/index.scss +++ b/ui/app/components/index.scss @@ -1,9 +1,21 @@ +@import './button-group/index'; + @import './export-text-container/index'; @import './selected-account/index'; @import './info-box/index'; +@import './network-display/index'; + +@import './confirm-page-container/index'; + +@import './page-container/index'; + @import './pages/index'; @import './modals/index'; + +@import './sender-to-recipient/index'; + +@import './tabs/index'; diff --git a/ui/app/components/input-number.js b/ui/app/components/input-number.js index de5fcca54..59c6842ef 100644 --- a/ui/app/components/input-number.js +++ b/ui/app/components/input-number.js @@ -22,12 +22,16 @@ function isValidInput (text) { return re.test(text) } +function removeLeadingZeroes (str) { + return str.replace(/^0*(?=\d)/, '') +} + InputNumber.prototype.setValue = function (newValue) { + newValue = removeLeadingZeroes(newValue) if (newValue && !isValidInput(newValue)) return const { fixed, min = -1, max = Infinity, onChange } = this.props newValue = fixed ? newValue.toFixed(4) : newValue - const newValueGreaterThanMin = conversionGTE( { value: newValue || '0', fromNumericBase: 'dec' }, { value: min, fromNumericBase: 'hex' }, @@ -47,7 +51,7 @@ InputNumber.prototype.setValue = function (newValue) { } InputNumber.prototype.render = function () { - const { unitLabel, step = 1, placeholder, value = 0 } = this.props + const { unitLabel, step = 1, placeholder, value } = this.props return h('div.customize-gas-input-wrapper', {}, [ h('input', { @@ -63,11 +67,11 @@ InputNumber.prototype.render = function () { h('span.gas-tooltip-input-detail', {}, [unitLabel]), h('div.gas-tooltip-input-arrows', {}, [ h('i.fa.fa-angle-up', { - onClick: () => this.setValue(addCurrencies(value, step)), + onClick: () => this.setValue(addCurrencies(value, step, { toNumericBase: 'dec' })), }), h('i.fa.fa-angle-down', { style: { cursor: 'pointer' }, - onClick: () => this.setValue(subtractCurrencies(value, step)), + onClick: () => this.setValue(subtractCurrencies(value, step, { toNumericBase: 'dec' })), }), ]), ]) diff --git a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js new file mode 100644 index 000000000..5a9f0f289 --- /dev/null +++ b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.component.js @@ -0,0 +1,93 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import Button from '../../button' +import { addressSummary } from '../../../util' +import Identicon from '../../identicon' +import genAccountLink from '../../../../lib/account-link' + +class ConfirmRemoveAccount extends Component { + static propTypes = { + hideModal: PropTypes.func.isRequired, + removeAccount: PropTypes.func.isRequired, + identity: PropTypes.object.isRequired, + network: PropTypes.string.isRequired, + } + + static contextTypes = { + t: PropTypes.func, + } + + handleRemove () { + this.props.removeAccount(this.props.identity.address) + .then(() => this.props.hideModal()) + } + + renderSelectedAccount () { + const { identity } = this.props + return ( + <div className="modal-container__account"> + <div className="modal-container__account__identicon"> + <Identicon + address={identity.address} + diameter={32} + /> + </div> + <div className="modal-container__account__name"> + <span className="modal-container__account__label">Name</span> + <span className="account_value">{identity.name}</span> + </div> + <div className="modal-container__account__address"> + <span className="modal-container__account__label">Public Address</span> + <span className="account_value">{ addressSummary(identity.address, 4, 4) }</span> + </div> + <div className="modal-container__account__link"> + <a + className="" + href={genAccountLink(identity.address, this.props.network)} + target={'_blank'} + title={this.context.t('etherscanView')} + > + <img src="images/popout.svg" /> + </a> + </div> + </div> + ) + } + + render () { + const { t } = this.context + + return ( + <div className="modal-container"> + <div className="modal-container__content"> + <div className="modal-container__title"> + { `${t('removeAccount')}` }? + </div> + { this.renderSelectedAccount() } + <div className="modal-container__description"> + { t('removeAccountDescription') } + <a className="modal-container__link" rel="noopener noreferrer" target="_blank" href="https://consensys.zendesk.com/hc/en-us/articles/360004180111-What-are-imported-accounts-New-UI-">{ t('learnMore') }</a> + </div> + </div> + <div className="modal-container__footer"> + <Button + type="default" + className="modal-container__footer-button" + onClick={() => this.props.hideModal()} + > + { t('nevermind') } + </Button> + <Button + type="secondary" + className="modal-container__footer-button" + onClick={() => this.handleRemove()} + > + { t('remove') } + </Button> + </div> + </div> + ) + } +} + +export default ConfirmRemoveAccount diff --git a/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js new file mode 100644 index 000000000..4b194c995 --- /dev/null +++ b/ui/app/components/modals/confirm-remove-account/confirm-remove-account.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import ConfirmRemoveAccount from './confirm-remove-account.component' + +const { hideModal, removeAccount } = require('../../../actions') + +const mapStateToProps = state => { + return { + identity: state.appState.modal.modalState.props.identity, + network: state.metamask.network, + } +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => dispatch(hideModal()), + removeAccount: (address) => dispatch(removeAccount(address)), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(ConfirmRemoveAccount) diff --git a/ui/app/components/modals/confirm-remove-account/index.js b/ui/app/components/modals/confirm-remove-account/index.js new file mode 100644 index 000000000..9763fbe05 --- /dev/null +++ b/ui/app/components/modals/confirm-remove-account/index.js @@ -0,0 +1,2 @@ +import ConfirmRemoveAccount from './confirm-remove-account.container' +module.exports = ConfirmRemoveAccount diff --git a/ui/app/components/modals/customize-gas/customize-gas.component.js b/ui/app/components/modals/customize-gas/customize-gas.component.js new file mode 100644 index 000000000..0337c5413 --- /dev/null +++ b/ui/app/components/modals/customize-gas/customize-gas.component.js @@ -0,0 +1,140 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import GasModalCard from '../../customize-gas-modal/gas-modal-card' +import { MIN_GAS_PRICE_GWEI } from '../../send/send.constants' + +import { + getDecimalGasLimit, + getDecimalGasPrice, + getPrefixedHexGasLimit, + getPrefixedHexGasPrice, +} from './customize-gas.util' + +export default class CustomizeGas extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + txData: PropTypes.object.isRequired, + hideModal: PropTypes.func, + validate: PropTypes.func, + onSubmit: PropTypes.func, + } + + state = { + gasPrice: 0, + gasLimit: 0, + originalGasPrice: 0, + originalGasLimit: 0, + } + + componentDidMount () { + const { txData = {} } = this.props + const { txParams: { gas: hexGasLimit, gasPrice: hexGasPrice } = {} } = txData + + const gasLimit = getDecimalGasLimit(hexGasLimit) + const gasPrice = getDecimalGasPrice(hexGasPrice) + + this.setState({ + gasPrice, + gasLimit, + originalGasPrice: gasPrice, + originalGasLimit: gasLimit, + }) + } + + handleRevert () { + const { originalGasPrice, originalGasLimit } = this.state + + this.setState({ + gasPrice: originalGasPrice, + gasLimit: originalGasLimit, + }) + } + + handleSave () { + const { onSubmit, hideModal } = this.props + const { gasLimit, gasPrice } = this.state + const prefixedHexGasPrice = getPrefixedHexGasPrice(gasPrice) + const prefixedHexGasLimit = getPrefixedHexGasLimit(gasLimit) + + Promise.resolve(onSubmit({ gasPrice: prefixedHexGasPrice, gasLimit: prefixedHexGasLimit })) + .then(() => hideModal()) + } + + validate () { + const { gasLimit, gasPrice } = this.state + return this.props.validate({ + gasPrice: getPrefixedHexGasPrice(gasPrice), + gasLimit: getPrefixedHexGasLimit(gasLimit), + }) + } + + render () { + const { t } = this.context + const { hideModal } = this.props + const { gasPrice, gasLimit } = this.state + const { valid, errorKey } = this.validate() + + return ( + <div className="customize-gas"> + <div className="customize-gas__content"> + <div className="customize-gas__header"> + <div className="customize-gas__title"> + { this.context.t('customGas') } + </div> + <div + className="customize-gas__close" + onClick={() => hideModal()} + /> + </div> + <div className="customize-gas__body"> + <GasModalCard + value={gasPrice} + min={MIN_GAS_PRICE_GWEI} + step={1} + onChange={value => this.setState({ gasPrice: value })} + title={t('gasPrice')} + copy={t('gasPriceCalculation')} + /> + <GasModalCard + value={gasLimit} + min={1} + step={1} + onChange={value => this.setState({ gasLimit: value })} + title={t('gasLimit')} + copy={t('gasLimitCalculation')} + /> + </div> + <div className="customize-gas__footer"> + { !valid && <div className="customize-gas__error-message">{ t(errorKey) }</div> } + <div + className="customize-gas__revert" + onClick={() => this.handleRevert()} + > + { t('revert') } + </div> + <div className="customize-gas__buttons"> + <button + className="btn-default customize-gas__cancel" + onClick={() => hideModal()} + style={{ marginRight: '10px' }} + > + { t('cancel') } + </button> + <button + className="btn-primary customize-gas__save" + onClick={() => this.handleSave()} + style={{ marginRight: '10px' }} + disabled={!valid} + > + { t('save') } + </button> + </div> + </div> + </div> + </div> + ) + } +} diff --git a/ui/app/components/modals/customize-gas/customize-gas.container.js b/ui/app/components/modals/customize-gas/customize-gas.container.js new file mode 100644 index 000000000..46a799795 --- /dev/null +++ b/ui/app/components/modals/customize-gas/customize-gas.container.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux' +import CustomizeGas from './customize-gas.component' +import { hideModal } from '../../../actions' + +const mapStateToProps = state => { + const { appState: { modal: { modalState: { props } } } } = state + const { txData, onSubmit, validate } = props + + return { + txData, + onSubmit, + validate, + } +} + +const mapDispatchToProps = dispatch => { + return { + hideModal: () => dispatch(hideModal()), + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(CustomizeGas) diff --git a/ui/app/components/modals/customize-gas/customize-gas.util.js b/ui/app/components/modals/customize-gas/customize-gas.util.js new file mode 100644 index 000000000..6ba4a7705 --- /dev/null +++ b/ui/app/components/modals/customize-gas/customize-gas.util.js @@ -0,0 +1,34 @@ +import ethUtil from 'ethereumjs-util' +import { conversionUtil } from '../../../conversion-util' + +export function getDecimalGasLimit (hexGasLimit) { + return conversionUtil(hexGasLimit, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) +} + +export function getDecimalGasPrice (hexGasPrice) { + return conversionUtil(hexGasPrice, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromDenomination: 'WEI', + toDenomination: 'GWEI', + }) +} + +export function getPrefixedHexGasLimit (gasLimit) { + return ethUtil.addHexPrefix(conversionUtil(gasLimit, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + })) +} + +export function getPrefixedHexGasPrice (gasPrice) { + return ethUtil.addHexPrefix(conversionUtil(gasPrice, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + fromDenomination: 'GWEI', + toDenomination: 'WEI', + })) +} diff --git a/ui/app/components/modals/customize-gas/index.js b/ui/app/components/modals/customize-gas/index.js new file mode 100644 index 000000000..3a0ab7edc --- /dev/null +++ b/ui/app/components/modals/customize-gas/index.js @@ -0,0 +1 @@ +export { default } from './customize-gas.container' diff --git a/ui/app/components/modals/customize-gas/index.scss b/ui/app/components/modals/customize-gas/index.scss new file mode 100644 index 000000000..e10452691 --- /dev/null +++ b/ui/app/components/modals/customize-gas/index.scss @@ -0,0 +1,110 @@ +.customize-gas { + border: 1px solid #D8D8D8; + border-radius: 4px; + background-color: #FFFFFF; + box-shadow: 0 2px 4px 0 rgba(0,0,0,0.14); + font-family: Roboto; + display: flex; + flex-flow: column; + + @media screen and (max-width: $break-small) { + width: 100vw; + height: 100vh; + } + + &__header { + height: 52px; + border-bottom: 1px solid $alto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 22px; + + @media screen and (max-width: $break-small) { + flex: 0 0 auto; + } + } + + &__title { + margin-left: 19.25px; + } + + &__close::after { + content: '\00D7'; + font-size: 1.8em; + color: $dusty-gray; + font-family: sans-serif; + cursor: pointer; + margin-right: 19.25px; + } + + &__content { + display: flex; + flex-flow: column nowrap; + height: 100%; + } + + &__body { + display: flex; + margin-bottom: 24px; + + @media screen and (max-width: $break-small) { + flex-flow: column; + flex: 1 1 auto; + } + } + + &__footer { + height: 75px; + border-top: 1px solid $alto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 22px; + position: relative; + + @media screen and (max-width: $break-small) { + flex: 0 0 auto; + } + } + + &__buttons { + display: flex; + justify-content: space-between; + margin-right: 21.25px; + } + + &__revert, &__cancel, &__save, &__save__error { + display: flex; + justify-content: center; + align-items: center; + padding: 0 3px; + cursor: pointer; + } + + &__revert { + color: $silver-chalice; + font-size: 16px; + margin-left: 21.25px; + } + + &__cancel, &__save, &__save__error { + width: 85.74px; + min-width: initial; + } + + &__save__error { + opacity: 0.5; + cursor: auto; + } + + &__error-message { + display: block; + position: absolute; + top: 4px; + right: 4px; + font-size: 12px; + line-height: 12px; + color: $red; + } +} diff --git a/ui/app/components/modals/index.scss b/ui/app/components/modals/index.scss index ad6fe16d3..e198cca44 100644 --- a/ui/app/components/modals/index.scss +++ b/ui/app/components/modals/index.scss @@ -1,3 +1,5 @@ +@import './customize-gas/index'; + .modal-container { width: 100%; height: 100%; @@ -18,6 +20,58 @@ font-size: .875rem; } + &__account { + border: 1px solid #b7b7b7; + border-radius: 4px; + padding: 10px; + display: flex; + margin-top: 10px; + margin-bottom: 20px; + width: 100%; + + &__identicon { + margin-right: 10px; + } + + &__name, + &__address { + margin-right: 10px; + font-size: 14px; + } + + &__name { + width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__label { + font-size: 11px; + display: block; + color: #9b9b9b; + } + + &__link { + margin-top: 14px; + + img { + width: 15px; + height: 15px; + } + } + + @media screen and (max-width: 575px) { + &__name { + width: 90px; + } + } + } + + &__link { + color: #2f9ae0; + } + &__content { overflow-y: auto; flex: 1; diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index 85e85597a..f59825ed1 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -20,10 +20,13 @@ const HideTokenConfirmationModal = require('./hide-token-confirmation-modal') const CustomizeGasModal = require('../customize-gas-modal') const NotifcationModal = require('./notification-modal') const ConfirmResetAccount = require('./confirm-reset-account') +const ConfirmRemoveAccount = require('./confirm-remove-account') const TransactionConfirmed = require('./transaction-confirmed') const WelcomeBeta = require('./welcome-beta') const Notification = require('./notification') +import ConfirmCustomizeGasModal from './customize-gas' + const modalContainerBaseStyle = { transform: 'translate3d(-50%, 0, 0px)', border: '1px solid #CCCFD1', @@ -241,6 +244,19 @@ const MODALS = { }, }, + CONFIRM_REMOVE_ACCOUNT: { + contents: h(ConfirmRemoveAccount), + mobileModalStyle: { + ...modalContainerMobileStyle, + }, + laptopModalStyle: { + ...modalContainerLaptopStyle, + }, + contentStyle: { + borderRadius: '8px', + }, + }, + NEW_ACCOUNT: { contents: [ h(NewAccountModal, {}, []), @@ -267,7 +283,31 @@ const MODALS = { CUSTOMIZE_GAS: { contents: [ - h(CustomizeGasModal, {}, []), + h(CustomizeGasModal), + ], + mobileModalStyle: { + width: '100vw', + height: '100vh', + top: '0', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + laptopModalStyle: { + width: '720px', + height: '377px', + top: '80px', + transform: 'none', + left: '0', + right: '0', + margin: '0 auto', + }, + }, + + CONFIRM_CUSTOMIZE_GAS: { + contents: [ + h(ConfirmCustomizeGasModal), ], mobileModalStyle: { width: '100vw', diff --git a/ui/app/components/network-display.js b/ui/app/components/network-display.js deleted file mode 100644 index 59719d9a4..000000000 --- a/ui/app/components/network-display.js +++ /dev/null @@ -1,56 +0,0 @@ -const { Component } = require('react') -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const NetworkDropdownIcon = require('./dropdowns/components/network-dropdown-icon') - -const networkToColorHash = { - 1: '#038789', - 3: '#e91550', - 42: '#690496', - 4: '#ebb33f', -} - -class NetworkDisplay extends Component { - renderNetworkIcon () { - const { network } = this.props - const networkColor = networkToColorHash[network] - - return networkColor - ? h(NetworkDropdownIcon, { backgroundColor: networkColor }) - : h('i.fa.fa-question-circle.fa-med', { - style: { - margin: '0 4px', - color: 'rgb(125, 128, 130)', - }, - }) - } - - render () { - const { provider: { type } } = this.props - return h('.network-display__container', [ - this.renderNetworkIcon(), - h('.network-name', this.context.t(type)), - ]) - } -} - -NetworkDisplay.propTypes = { - network: PropTypes.string, - provider: PropTypes.object, - t: PropTypes.func, -} - -const mapStateToProps = ({ metamask: { network, provider } }) => { - return { - network, - provider, - } -} - -NetworkDisplay.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps)(NetworkDisplay) - diff --git a/ui/app/components/network-display/index.js b/ui/app/components/network-display/index.js new file mode 100644 index 000000000..f6878ae5b --- /dev/null +++ b/ui/app/components/network-display/index.js @@ -0,0 +1,2 @@ +import NetworkDisplay from './network-display.container' +module.exports = NetworkDisplay diff --git a/ui/app/components/network-display/index.scss b/ui/app/components/network-display/index.scss new file mode 100644 index 000000000..2085cff67 --- /dev/null +++ b/ui/app/components/network-display/index.scss @@ -0,0 +1,54 @@ +.network-display { + &__container { + display: flex; + align-items: center; + justify-content: flex-start; + background-color: lighten(rgb(125, 128, 130), 45%); + padding: 0 10px; + border-radius: 4px; + height: 25px; + + &--mainnet { + background-color: lighten($blue-lagoon, 68%); + } + + &--ropsten { + background-color: lighten($crimson, 45%); + } + + &--kovan { + background-color: lighten($purple, 65%); + } + + &--rinkeby { + background-color: lighten($tulip-tree, 35%); + } + } + + &__name { + font-size: .75rem; + padding-left: 5px; + } + + &__icon { + height: 10px; + width: 10px; + border-radius: 10px; + + &--mainnet { + background-color: $blue-lagoon; + } + + &--ropsten { + background-color: $crimson; + } + + &--kovan { + background-color: $purple; + } + + &--rinkeby { + background-color: $tulip-tree; + } + } +} diff --git a/ui/app/components/network-display/network-display.component.js b/ui/app/components/network-display/network-display.component.js new file mode 100644 index 000000000..38626af20 --- /dev/null +++ b/ui/app/components/network-display/network-display.component.js @@ -0,0 +1,69 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { + MAINNET_CODE, + ROPSTEN_CODE, + RINKEYBY_CODE, + KOVAN_CODE, +} from '../../../../app/scripts/controllers/network/enums' + +const networkToClassHash = { + [MAINNET_CODE]: 'mainnet', + [ROPSTEN_CODE]: 'ropsten', + [RINKEYBY_CODE]: 'rinkeby', + [KOVAN_CODE]: 'kovan', +} + +export default class NetworkDisplay extends Component { + static propTypes = { + network: PropTypes.string, + provider: PropTypes.object, + } + + static contextTypes = { + t: PropTypes.func, + } + + renderNetworkIcon () { + const { network } = this.props + const networkClass = networkToClassHash[network] + + return networkClass + ? <div className={`network-display__icon network-display__icon--${networkClass}`} /> + : <div + className="i fa fa-question-circle fa-med" + style={{ + margin: '0 4px', + color: 'rgb(125, 128, 130)', + }} + /> + } + + render () { + const { network, provider: { type } } = this.props + const networkClass = networkToClassHash[network] + + return ( + <div className={classnames( + 'network-display__container', + networkClass && ('network-display__container--' + networkClass) + )}> + { + networkClass + ? <div className={`network-display__icon network-display__icon--${networkClass}`} /> + : <div + className="i fa fa-question-circle fa-med" + style={{ + margin: '0 4px', + color: 'rgb(125, 128, 130)', + }} + /> + } + <div className="network-display__name"> + { this.context.t(type) } + </div> + </div> + ) + } +} diff --git a/ui/app/components/network-display/network-display.container.js b/ui/app/components/network-display/network-display.container.js new file mode 100644 index 000000000..99a14fff4 --- /dev/null +++ b/ui/app/components/network-display/network-display.container.js @@ -0,0 +1,11 @@ +import { connect } from 'react-redux' +import NetworkDisplay from './network-display.component' + +const mapStateToProps = ({ metamask: { network, provider } }) => { + return { + network, + provider, + } +} + +export default connect(mapStateToProps)(NetworkDisplay) diff --git a/ui/app/components/page-container/index.js b/ui/app/components/page-container/index.js index 415870b37..913b8c9c6 100644 --- a/ui/app/components/page-container/index.js +++ b/ui/app/components/page-container/index.js @@ -1 +1,4 @@ +import PageContainerHeader from './page-container-header' +import PageContainerFooter from './page-container-footer' export { default } from './page-container.component' +export { PageContainerHeader, PageContainerFooter } diff --git a/ui/app/components/page-container/index.scss b/ui/app/components/page-container/index.scss new file mode 100644 index 000000000..06c3ef709 --- /dev/null +++ b/ui/app/components/page-container/index.scss @@ -0,0 +1,186 @@ +.page-container { + width: 408px; + background-color: $white; + box-shadow: 0 0 7px 0 rgba(0, 0, 0, .08); + z-index: 25; + display: flex; + flex-flow: column; + border-radius: 8px; + + &__header { + display: flex; + flex-flow: column; + border-bottom: 1px solid $geyser; + padding: 16px; + flex: 0 0 auto; + position: relative; + + &--no-padding-bottom { + padding-bottom: 0; + } + } + + &__header-close { + color: $tundora; + position: absolute; + top: 16px; + right: 16px; + cursor: pointer; + overflow: hidden; + + &::after { + content: '\00D7'; + font-size: 40px; + line-height: 20px; + } + } + + &__header-row { + padding-bottom: 10px; + display: flex; + justify-content: space-between; + } + + &__footer { + display: flex; + flex-flow: row; + justify-content: center; + border-top: 1px solid $geyser; + padding: 16px; + flex: 0 0 auto; + + .btn-default, + .btn-confirm { + font-size: 1rem; + } + } + + &__footer-button { + height: 55px; + font-size: 1rem; + text-transform: uppercase; + margin-right: 16px; + + &:last-of-type { + margin-right: 0; + } + } + + &__back-button { + color: #2f9ae0; + font-size: 1rem; + cursor: pointer; + font-weight: 400; + } + + &__title { + color: $black; + font-size: 2rem; + font-weight: 500; + line-height: 2rem; + } + + &__subtitle { + padding-top: .5rem; + line-height: initial; + font-size: .9rem; + color: $gray; + } + + &__tabs { + display: flex; + margin-top: 16px; + } + + &__tab { + min-width: 5rem; + padding: 8px; + color: $dusty-gray; + font-family: Roboto; + font-size: 1rem; + text-align: center; + cursor: pointer; + border-bottom: none; + margin-right: 16px; + + &:last-of-type { + margin-right: 0; + } + + &--selected { + color: $curious-blue; + border-bottom: 3px solid $curious-blue; + } + } + + &--full-width { + width: 100% !important; + } + + &--full-height { + height: 100% !important; + max-height: initial !important; + min-height: initial !important; + } + + &__content { + overflow-y: auto; + flex: 1; + } + + &__warning-container { + background: $linen; + padding: 20px; + display: flex; + align-items: start; + } + + &__warning-message { + padding-left: 15px; + } + + &__warning-title { + font-weight: 500; + } + + &__warning-icon { + padding-top: 5px; + } +} + +@media screen and (max-width: 250px) { + .page-container { + &__footer { + flex-flow: column-reverse; + } + + &__footer-button { + width: 100%; + margin-bottom: 1rem; + margin-right: 0; + + &:first-of-type { + margin-bottom: 0; + } + } + } +} + +@media screen and (max-width: 575px) { + .page-container { + height: 100%; + width: 100%; + overflow-y: auto; + background-color: $white; + border-radius: 0; + flex: 1; + } +} + +@media screen and (min-width: 576px) { + .page-container { + max-height: 82vh; + min-height: 570px; + flex: 0 0 auto; + } +} 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 index 0458ae78a..3d15df294 100644 --- 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 @@ -10,6 +10,7 @@ export default class PageContainerFooter extends Component { onSubmit: PropTypes.func, submitText: PropTypes.string, disabled: PropTypes.bool, + submitButtonType: PropTypes.string, } static contextTypes = { @@ -23,6 +24,7 @@ export default class PageContainerFooter extends Component { onSubmit, submitText, disabled, + submitButtonType, } = this.props return ( @@ -30,16 +32,16 @@ export default class PageContainerFooter extends Component { <Button type="default" - large={true} + large className="page-container__footer-button" - onClick={() => onCancel()} + onClick={e => onCancel(e)} > { cancelText || this.context.t('cancel') } </Button> <Button - type="primary" - large={true} + type={submitButtonType || 'primary'} + large className="page-container__footer-button" disabled={disabled} onClick={e => onSubmit(e)} diff --git a/ui/app/components/page-container/page-container-header.component.js b/ui/app/components/page-container/page-container-header.component.js deleted file mode 100644 index 5c9d63221..000000000 --- a/ui/app/components/page-container/page-container-header.component.js +++ /dev/null @@ -1,35 +0,0 @@ -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/page-container-header.component.js b/ui/app/components/page-container/page-container-header/page-container-header.component.js index 28882edce..5a5de1e5a 100644 --- 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 @@ -4,13 +4,14 @@ import PropTypes from 'prop-types' export default class PageContainerHeader extends Component { static propTypes = { - title: PropTypes.string.isRequired, + title: PropTypes.string, subtitle: PropTypes.string, onClose: PropTypes.func, showBackButton: PropTypes.bool, onBackButtonClick: PropTypes.func, backButtonStyles: PropTypes.object, backButtonString: PropTypes.string, + children: PropTypes.node, }; renderHeaderRow () { @@ -30,25 +31,33 @@ export default class PageContainerHeader extends Component { } render () { - const { title, subtitle, onClose } = this.props + const { title, subtitle, onClose, children } = this.props return ( <div className="page-container__header"> { this.renderHeaderRow() } - <div className="page-container__title"> - {title} - </div> + { children } - <div className="page-container__subtitle"> - {subtitle} - </div> + { + title && <div className="page-container__title"> + { title } + </div> + } - <div - className="page-container__header-close" - onClick={() => onClose()} - /> + { + subtitle && <div className="page-container__subtitle"> + { subtitle } + </div> + } + + { + onClose && <div + className="page-container__header-close" + onClick={() => onClose()} + /> + } </div> ) diff --git a/ui/app/components/pages/confirm-approve/confirm-approve.component.js b/ui/app/components/pages/confirm-approve/confirm-approve.component.js new file mode 100644 index 000000000..b71eaa1d4 --- /dev/null +++ b/ui/app/components/pages/confirm-approve/confirm-approve.component.js @@ -0,0 +1,21 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTokenTransactionBase from '../confirm-token-transaction-base' + +export default class ConfirmApprove extends Component { + static propTypes = { + tokenAmount: PropTypes.number, + tokenSymbol: PropTypes.string, + } + + render () { + const { tokenAmount, tokenSymbol } = this.props + + return ( + <ConfirmTokenTransactionBase + tokenAmount={tokenAmount} + warning={`By approving this action, you grant permission for this contract to spend up to ${tokenAmount} of your ${tokenSymbol}.`} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-approve/confirm-approve.container.js b/ui/app/components/pages/confirm-approve/confirm-approve.container.js new file mode 100644 index 000000000..4ef9f4ced --- /dev/null +++ b/ui/app/components/pages/confirm-approve/confirm-approve.container.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux' +import ConfirmApprove from './confirm-approve.component' +import { approveTokenAmountAndToAddressSelector } from '../../../selectors/confirm-transaction' + +const mapStateToProps = state => { + const { confirmTransaction: { tokenProps: { tokenSymbol } = {} } } = state + const { tokenAmount } = approveTokenAmountAndToAddressSelector(state) + + return { + tokenAmount, + tokenSymbol, + } +} + +export default connect(mapStateToProps)(ConfirmApprove) diff --git a/ui/app/components/pages/confirm-approve/index.js b/ui/app/components/pages/confirm-approve/index.js new file mode 100644 index 000000000..791297be7 --- /dev/null +++ b/ui/app/components/pages/confirm-approve/index.js @@ -0,0 +1 @@ +export { default } from './confirm-approve.container' diff --git a/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.component.js b/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.component.js new file mode 100644 index 000000000..9bc0daab9 --- /dev/null +++ b/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.component.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ethUtil from 'ethereumjs-util' +import ConfirmTransactionBase from '../confirm-transaction-base' + +export default class ConfirmDeployContract extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + txData: PropTypes.object, + } + + renderData () { + const { t } = this.context + const { + txData: { + origin, + txParams: { + data, + } = {}, + } = {}, + } = this.props + + return ( + <div className="confirm-page-container-content__data"> + <div className="confirm-page-container-content__data-box"> + <div className="confirm-page-container-content__data-field"> + <div className="confirm-page-container-content__data-field-label"> + { `${t('origin')}:` } + </div> + <div> + { origin } + </div> + </div> + <div className="confirm-page-container-content__data-field"> + <div className="confirm-page-container-content__data-field-label"> + { `${t('bytes')}:` } + </div> + <div> + { ethUtil.toBuffer(data).length } + </div> + </div> + </div> + <div className="confirm-page-container-content__data-box-label"> + { `${t('hexData')}:` } + </div> + <div className="confirm-page-container-content__data-box"> + { data } + </div> + </div> + ) + } + + render () { + return ( + <ConfirmTransactionBase + action={this.context.t('contractDeployment')} + dataComponent={this.renderData()} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.container.js b/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.container.js new file mode 100644 index 000000000..336ee83ea --- /dev/null +++ b/ui/app/components/pages/confirm-deploy-contract/confirm-deploy-contract.container.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux' +import ConfirmDeployContract from './confirm-deploy-contract.component' + +const mapStateToProps = state => { + const { confirmTransaction: { txData } = {} } = state + + return { + txData, + } +} + +export default connect(mapStateToProps)(ConfirmDeployContract) diff --git a/ui/app/components/pages/confirm-deploy-contract/index.js b/ui/app/components/pages/confirm-deploy-contract/index.js new file mode 100644 index 000000000..c4fb01b52 --- /dev/null +++ b/ui/app/components/pages/confirm-deploy-contract/index.js @@ -0,0 +1 @@ +export { default } from './confirm-deploy-contract.container' diff --git a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js new file mode 100644 index 000000000..442a478b8 --- /dev/null +++ b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.component.js @@ -0,0 +1,39 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTransactionBase from '../confirm-transaction-base' +import { SEND_ROUTE } from '../../../routes' + +export default class ConfirmSendEther extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + editTransaction: PropTypes.func, + history: PropTypes.object, + txParams: PropTypes.object, + } + + handleEdit ({ txData }) { + const { editTransaction, history } = this.props + editTransaction(txData) + history.push(SEND_ROUTE) + } + + shouldHideData () { + const { txParams = {} } = this.props + return !txParams.data + } + + render () { + const hideData = this.shouldHideData() + + return ( + <ConfirmTransactionBase + action={this.context.t('confirm')} + hideData={hideData} + onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js new file mode 100644 index 000000000..e48ef54a8 --- /dev/null +++ b/ui/app/components/pages/confirm-send-ether/confirm-send-ether.container.js @@ -0,0 +1,45 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import { updateSend } from '../../../actions' +import { clearConfirmTransaction } from '../../../ducks/confirm-transaction.duck' +import ConfirmSendEther from './confirm-send-ether.component' + +const mapStateToProps = state => { + const { confirmTransaction: { txData: { txParams } = {} } } = state + + return { + txParams, + } +} + +const mapDispatchToProps = dispatch => { + return { + editTransaction: txData => { + const { id, txParams } = txData + const { + gas: gasLimit, + gasPrice, + to, + value: amount, + } = txParams + + dispatch(updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount, + errors: { to: null, amount: null }, + editingTransactionId: id && id.toString(), + })) + + dispatch(clearConfirmTransaction()) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmSendEther) diff --git a/ui/app/components/pages/confirm-send-ether/index.js b/ui/app/components/pages/confirm-send-ether/index.js new file mode 100644 index 000000000..2d5767c39 --- /dev/null +++ b/ui/app/components/pages/confirm-send-ether/index.js @@ -0,0 +1 @@ +export { default } from './confirm-send-ether.container' diff --git a/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js b/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js new file mode 100644 index 000000000..cb39e3d7b --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/confirm-send-token.component.js @@ -0,0 +1,29 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTokenTransactionBase from '../confirm-token-transaction-base' +import { SEND_ROUTE } from '../../../routes' + +export default class ConfirmSendToken extends Component { + static propTypes = { + history: PropTypes.object, + editTransaction: PropTypes.func, + tokenAmount: PropTypes.number, + } + + handleEdit (confirmTransactionData) { + const { editTransaction, history } = this.props + editTransaction(confirmTransactionData) + history.push(SEND_ROUTE) + } + + render () { + const { tokenAmount } = this.props + + return ( + <ConfirmTokenTransactionBase + onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} + tokenAmount={tokenAmount} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js b/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js new file mode 100644 index 000000000..d60911e59 --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js @@ -0,0 +1,52 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import ConfirmSendToken from './confirm-send-token.component' +import { clearConfirmTransaction } from '../../../ducks/confirm-transaction.duck' +import { setSelectedToken, updateSend, showSendTokenPage } from '../../../actions' +import { conversionUtil } from '../../../conversion-util' +import { sendTokenTokenAmountAndToAddressSelector } from '../../../selectors/confirm-transaction' + +const mapStateToProps = state => { + const { tokenAmount } = sendTokenTokenAmountAndToAddressSelector(state) + + return { + tokenAmount, + } +} + +const mapDispatchToProps = dispatch => { + return { + editTransaction: ({ txData, tokenData, tokenProps }) => { + const { txParams: { to: tokenAddress, gas: gasLimit, gasPrice } = {}, id } = txData + const { params = [] } = tokenData + const { value: to } = params[0] || {} + const { value: tokenAmountInDec } = params[1] || {} + const tokenAmountInHex = conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }) + dispatch(setSelectedToken(tokenAddress)) + dispatch(updateSend({ + gasLimit, + gasPrice, + gasTotal: null, + to, + amount: tokenAmountInHex, + errors: { to: null, amount: null }, + editingTransactionId: id && id.toString(), + token: { + ...tokenProps, + address: tokenAddress, + }, + })) + dispatch(clearConfirmTransaction()) + dispatch(showSendTokenPage()) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(ConfirmSendToken) diff --git a/ui/app/components/pages/confirm-send-token/index.js b/ui/app/components/pages/confirm-send-token/index.js new file mode 100644 index 000000000..409b6ef3d --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/index.js @@ -0,0 +1 @@ +export { default } from './confirm-send-token.container' diff --git a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js b/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js new file mode 100644 index 000000000..acaed383a --- /dev/null +++ b/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.component.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTransactionBase from '../confirm-transaction-base' +import { + formatCurrency, + convertTokenToFiat, + addFiat, + roundExponential, +} from '../../../helpers/confirm-transaction/util' + +export default class ConfirmTokenTransactionBase extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + tokenAddress: PropTypes.string, + toAddress: PropTypes.string, + tokenAmount: PropTypes.number, + tokenSymbol: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + ethTransactionTotal: PropTypes.string, + contractExchangeRate: PropTypes.number, + conversionRate: PropTypes.number, + currentCurrency: PropTypes.string, + } + + getFiatTransactionAmount () { + const { tokenAmount, currentCurrency, conversionRate, contractExchangeRate } = this.props + + return convertTokenToFiat({ + value: tokenAmount, + toCurrency: currentCurrency, + conversionRate, + contractExchangeRate, + }) + } + + getSubtitle () { + const { currentCurrency, contractExchangeRate } = this.props + + if (typeof contractExchangeRate === 'undefined') { + return this.context.t('noConversionRateAvailable') + } else { + const fiatTransactionAmount = this.getFiatTransactionAmount() + const roundedFiatTransactionAmount = roundExponential(fiatTransactionAmount) + return formatCurrency(roundedFiatTransactionAmount, currentCurrency) + } + } + + getFiatTotalTextOverride () { + const { fiatTransactionTotal, currentCurrency, contractExchangeRate } = this.props + + if (typeof contractExchangeRate === 'undefined') { + return formatCurrency(fiatTransactionTotal, currentCurrency) + } else { + const fiatTransactionAmount = this.getFiatTransactionAmount() + const fiatTotal = addFiat(fiatTransactionAmount, fiatTransactionTotal) + const roundedFiatTotal = roundExponential(fiatTotal) + return formatCurrency(roundedFiatTotal, currentCurrency) + } + } + + render () { + const { + toAddress, + tokenAddress, + tokenSymbol, + tokenAmount, + ethTransactionTotal, + ...restProps + } = this.props + + const tokensText = `${tokenAmount} ${tokenSymbol}` + + return ( + <ConfirmTransactionBase + toAddress={toAddress} + identiconAddress={tokenAddress} + title={tokensText} + subtitle={this.getSubtitle()} + ethTotalTextOverride={`${tokensText} + \u2666 ${ethTransactionTotal}`} + fiatTotalTextOverride={this.getFiatTotalTextOverride()} + {...restProps} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js b/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js new file mode 100644 index 000000000..be38acdb0 --- /dev/null +++ b/ui/app/components/pages/confirm-token-transaction-base/confirm-token-transaction-base.container.js @@ -0,0 +1,34 @@ +import { connect } from 'react-redux' +import ConfirmTokenTransactionBase from './confirm-token-transaction-base.component' +import { + tokenAmountAndToAddressSelector, + contractExchangeRateSelector, +} from '../../../selectors/confirm-transaction' + +const mapStateToProps = (state, ownProps) => { + const { tokenAmount: ownTokenAmount } = ownProps + const { confirmTransaction, metamask: { currentCurrency, conversionRate } } = state + const { + txData: { txParams: { to: tokenAddress } = {} } = {}, + tokenProps: { tokenSymbol } = {}, + fiatTransactionTotal, + ethTransactionTotal, + } = confirmTransaction + + const { tokenAmount, toAddress } = tokenAmountAndToAddressSelector(state) + const contractExchangeRate = contractExchangeRateSelector(state) + + return { + toAddress, + tokenAddress, + tokenAmount: typeof ownTokenAmount !== 'undefined' ? ownTokenAmount : tokenAmount, + tokenSymbol, + currentCurrency, + conversionRate, + contractExchangeRate, + fiatTransactionTotal, + ethTransactionTotal, + } +} + +export default connect(mapStateToProps)(ConfirmTokenTransactionBase) diff --git a/ui/app/components/pages/confirm-token-transaction-base/index.js b/ui/app/components/pages/confirm-token-transaction-base/index.js new file mode 100644 index 000000000..e15c5d56b --- /dev/null +++ b/ui/app/components/pages/confirm-token-transaction-base/index.js @@ -0,0 +1,2 @@ +export { default } from './confirm-token-transaction-base.container' +export { default as ConfirmTokenTransactionBase } from './confirm-token-transaction-base.component' diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js new file mode 100644 index 000000000..b170880b4 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -0,0 +1,345 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmPageContainer, { ConfirmDetailRow } from '../../confirm-page-container' +import { formatCurrency } from '../../../helpers/confirm-transaction/util' +import { isBalanceSufficient } from '../../send/send.utils' +import { DEFAULT_ROUTE } from '../../../routes' +import { + INSUFFICIENT_FUNDS_ERROR_KEY, + TRANSACTION_ERROR_KEY, +} from '../../../constants/error-keys' + +export default class ConfirmTransactionBase extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + // react-router props + match: PropTypes.object, + history: PropTypes.object, + // Redux props + balance: PropTypes.string, + cancelTransaction: PropTypes.func, + clearConfirmTransaction: PropTypes.func, + clearSend: PropTypes.func, + conversionRate: PropTypes.number, + currentCurrency: PropTypes.string, + editTransaction: PropTypes.func, + ethTransactionAmount: PropTypes.string, + ethTransactionFee: PropTypes.string, + ethTransactionTotal: PropTypes.string, + fiatTransactionAmount: PropTypes.string, + fiatTransactionFee: PropTypes.string, + fiatTransactionTotal: PropTypes.string, + fromAddress: PropTypes.string, + fromName: PropTypes.string, + hexGasTotal: PropTypes.string, + isTxReprice: PropTypes.bool, + methodData: PropTypes.object, + nonce: PropTypes.string, + sendTransaction: PropTypes.func, + showCustomizeGasModal: PropTypes.func, + showTransactionConfirmedModal: PropTypes.func, + toAddress: PropTypes.string, + tokenData: PropTypes.object, + tokenProps: PropTypes.object, + toName: PropTypes.string, + transactionStatus: PropTypes.string, + txData: PropTypes.object, + // Component props + action: PropTypes.string, + contentComponent: PropTypes.node, + dataComponent: PropTypes.node, + detailsComponent: PropTypes.node, + errorKey: PropTypes.string, + errorMessage: PropTypes.string, + ethTotalTextOverride: PropTypes.string, + fiatTotalTextOverride: PropTypes.string, + hideData: PropTypes.bool, + hideDetails: PropTypes.bool, + hideSubtitle: PropTypes.bool, + identiconAddress: PropTypes.string, + onCancel: PropTypes.func, + onEdit: PropTypes.func, + onEditGas: PropTypes.func, + onSubmit: PropTypes.func, + subtitle: PropTypes.string, + summaryComponent: PropTypes.node, + title: PropTypes.string, + valid: PropTypes.bool, + warning: PropTypes.string, + } + + state = { + submitting: false, + } + + componentDidUpdate () { + const { + transactionStatus, + showTransactionConfirmedModal, + history, + clearConfirmTransaction, + } = this.props + + if (transactionStatus === 'dropped') { + showTransactionConfirmedModal({ + onHide: () => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }, + }) + + return + } + } + + getErrorKey () { + const { + balance, + conversionRate, + hexGasTotal, + txData: { + simulationFails, + txParams: { + value: amount, + } = {}, + } = {}, + } = this.props + + const insufficientBalance = balance && !isBalanceSufficient({ + amount, + gasTotal: hexGasTotal || '0x0', + balance, + conversionRate, + }) + + if (insufficientBalance) { + return { + valid: false, + errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, + } + } + + if (simulationFails) { + return { + valid: false, + errorKey: TRANSACTION_ERROR_KEY, + } + } + + return { + valid: true, + } + } + + handleEditGas () { + const { onEditGas, showCustomizeGasModal } = this.props + + if (onEditGas) { + onEditGas() + } else { + showCustomizeGasModal() + } + } + + renderDetails () { + const { + detailsComponent, + fiatTransactionFee, + ethTransactionFee, + currentCurrency, + fiatTransactionTotal, + ethTransactionTotal, + fiatTotalTextOverride, + ethTotalTextOverride, + hideDetails, + } = this.props + + if (hideDetails) { + return null + } + + const formattedCurrency = formatCurrency(fiatTransactionTotal, currentCurrency) + + return ( + detailsComponent || ( + <div className="confirm-page-container-content__details"> + <div className="confirm-page-container-content__gas-fee"> + <ConfirmDetailRow + label="Gas Fee" + fiatText={formatCurrency(fiatTransactionFee, currentCurrency)} + ethText={`\u2666 ${ethTransactionFee}`} + headerText="Edit" + headerTextClassName="confirm-detail-row__header-text--edit" + onHeaderClick={() => this.handleEditGas()} + /> + </div> + <div> + <ConfirmDetailRow + label="Total" + fiatText={fiatTotalTextOverride || formattedCurrency} + ethText={ethTotalTextOverride || `\u2666 ${ethTransactionTotal}`} + headerText="Amount + Gas Fee" + headerTextClassName="confirm-detail-row__header-text--total" + fiatTextColor="#2f9ae0" + /> + </div> + </div> + ) + ) + } + + renderData () { + const { t } = this.context + const { + txData: { + txParams: { + data, + } = {}, + } = {}, + methodData: { + name, + params, + } = {}, + hideData, + dataComponent, + } = this.props + + if (hideData) { + return null + } + + return dataComponent || ( + <div className="confirm-page-container-content__data"> + <div className="confirm-page-container-content__data-box-label"> + {`${t('functionType')}:`} + <span className="confirm-page-container-content__function-type"> + { name || t('notFound') } + </span> + </div> + { + params && ( + <div className="confirm-page-container-content__data-box"> + <div className="confirm-page-container-content__data-field-label"> + { `${t('parameters')}:` } + </div> + <div> + <pre>{ JSON.stringify(params, null, 2) }</pre> + </div> + </div> + ) + } + <div className="confirm-page-container-content__data-box-label"> + {`${t('hexData')}:`} + </div> + <div className="confirm-page-container-content__data-box"> + { data } + </div> + </div> + ) + } + + handleEdit () { + const { txData, tokenData, tokenProps, onEdit } = this.props + onEdit({ txData, tokenData, tokenProps }) + } + + handleCancel () { + const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction } = this.props + + if (onCancel) { + onCancel(txData) + } else { + cancelTransaction(txData) + .then(() => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }) + } + } + + handleSubmit () { + const { sendTransaction, clearConfirmTransaction, txData, history, onSubmit } = this.props + const { submitting } = this.state + + if (submitting) { + return + } + + this.setState({ submitting: true }) + + if (onSubmit) { + Promise.resolve(onSubmit(txData)) + .then(this.setState({ submitting: false })) + } else { + sendTransaction(txData) + .then(() => { + clearConfirmTransaction() + this.setState({ submitting: false }) + history.push(DEFAULT_ROUTE) + }) + .catch(() => this.setState({ submitting: false })) + } + } + + render () { + const { + isTxReprice, + fromName, + fromAddress, + toName, + toAddress, + methodData, + ethTransactionAmount, + fiatTransactionAmount, + valid: propsValid = true, + errorMessage, + errorKey: propsErrorKey, + currentCurrency, + action, + title, + subtitle, + hideSubtitle, + identiconAddress, + summaryComponent, + contentComponent, + onEdit, + nonce, + warning, + } = this.props + const { submitting } = this.state + + const { name } = methodData + const fiatConvertedAmount = formatCurrency(fiatTransactionAmount, currentCurrency) + const { valid, errorKey } = this.getErrorKey() + + return ( + <ConfirmPageContainer + fromName={fromName} + fromAddress={fromAddress} + toName={toName} + toAddress={toAddress} + showEdit={onEdit && !isTxReprice} + action={action || name || this.context.t('unknownFunction')} + title={title || `${fiatConvertedAmount} ${currentCurrency.toUpperCase()}`} + subtitle={subtitle || `\u2666 ${ethTransactionAmount}`} + hideSubtitle={hideSubtitle} + summaryComponent={summaryComponent} + detailsComponent={this.renderDetails()} + dataComponent={this.renderData()} + contentComponent={contentComponent} + nonce={nonce} + identiconAddress={identiconAddress} + errorMessage={errorMessage} + errorKey={propsErrorKey || errorKey} + warning={warning} + disabled={!propsValid || !valid || submitting} + onEdit={() => this.handleEdit()} + onCancel={() => this.handleCancel()} + onSubmit={() => this.handleSubmit()} + /> + ) + } +} diff --git a/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js new file mode 100644 index 000000000..0c0deff18 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -0,0 +1,181 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import R from 'ramda' +import contractMap from 'eth-contract-metadata' +import ConfirmTransactionBase from './confirm-transaction-base.component' +import { + clearConfirmTransaction, + updateGasAndCalculate, +} from '../../../ducks/confirm-transaction.duck' +import { clearSend, cancelTx, updateAndApproveTx, showModal } from '../../../actions' +import { + INSUFFICIENT_FUNDS_ERROR_KEY, + GAS_LIMIT_TOO_LOW_ERROR_KEY, +} from '../../../constants/error-keys' +import { getHexGasTotal } from '../../../helpers/confirm-transaction/util' +import { isBalanceSufficient } from '../../send/send.utils' +import { conversionGreaterThan } from '../../../conversion-util' +import { MIN_GAS_LIMIT_DEC } from '../../send/send.constants' +import { addressSlicer } from '../../../util' + +const casedContractMap = Object.keys(contractMap).reduce((acc, base) => { + return { + ...acc, + [base.toLowerCase()]: contractMap[base], + } +}, {}) + +const mapStateToProps = (state, props) => { + const { toAddress: propsToAddress } = props + const { confirmTransaction, metamask } = state + const { + ethTransactionAmount, + ethTransactionFee, + ethTransactionTotal, + fiatTransactionAmount, + fiatTransactionFee, + fiatTransactionTotal, + hexGasTotal, + tokenData, + methodData, + txData, + tokenProps, + nonce, + } = confirmTransaction + const { txParams = {}, lastGasPrice, id: transactionId } = txData + const { from: fromAddress, to: txParamsToAddress } = txParams + const { + conversionRate, + identities, + currentCurrency, + accounts, + selectedAddress, + selectedAddressTxList, + } = metamask + + const { balance } = accounts[selectedAddress] + const { name: fromName } = identities[selectedAddress] + const toAddress = propsToAddress || txParamsToAddress + const toName = identities[toAddress] + ? identities[toAddress].name + : casedContractMap[toAddress] ? casedContractMap[toAddress].name : addressSlicer(toAddress) + + const isTxReprice = Boolean(lastGasPrice) + + const transaction = R.find(({ id }) => id === transactionId)(selectedAddressTxList) + const transactionStatus = transaction ? transaction.status : '' + + return { + balance, + fromAddress, + fromName, + toAddress, + toName, + ethTransactionAmount, + ethTransactionFee, + ethTransactionTotal, + fiatTransactionAmount, + fiatTransactionFee, + fiatTransactionTotal, + hexGasTotal, + txData, + tokenData, + methodData, + tokenProps, + isTxReprice, + currentCurrency, + conversionRate, + transactionStatus, + nonce, + } +} + +const mapDispatchToProps = dispatch => { + return { + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + clearSend: () => dispatch(clearSend()), + showTransactionConfirmedModal: ({ onHide }) => { + return dispatch(showModal({ name: 'TRANSACTION_CONFIRMED', onHide })) + }, + showCustomizeGasModal: ({ txData, onSubmit, validate }) => { + return dispatch(showModal({ name: 'CONFIRM_CUSTOMIZE_GAS', txData, onSubmit, validate })) + }, + updateGasAndCalculate: ({ gasLimit, gasPrice }) => { + return dispatch(updateGasAndCalculate({ gasLimit, gasPrice })) + }, + cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), + sendTransaction: txData => dispatch(updateAndApproveTx(txData)), + } +} + +const getValidateEditGas = ({ balance, conversionRate, txData }) => { + const { txParams: { value: amount } = {} } = txData + + return ({ gasLimit, gasPrice }) => { + const gasTotal = getHexGasTotal({ gasLimit, gasPrice }) + const hasSufficientBalance = isBalanceSufficient({ + amount, + gasTotal, + balance, + conversionRate, + }) + + if (!hasSufficientBalance) { + return { + valid: false, + errorKey: INSUFFICIENT_FUNDS_ERROR_KEY, + } + } + + const gasLimitTooLow = gasLimit && conversionGreaterThan( + { + value: MIN_GAS_LIMIT_DEC, + fromNumericBase: 'dec', + conversionRate, + }, + { + value: gasLimit, + fromNumericBase: 'hex', + }, + ) + + if (gasLimitTooLow) { + return { + valid: false, + errorKey: GAS_LIMIT_TOO_LOW_ERROR_KEY, + } + } + + return { + valid: true, + } + } +} + +const mergeProps = (stateProps, dispatchProps, ownProps) => { + const { balance, conversionRate, txData } = stateProps + const { + showCustomizeGasModal: dispatchShowCustomizeGasModal, + updateGasAndCalculate: dispatchUpdateGasAndCalculate, + ...otherDispatchProps + } = dispatchProps + + const validateEditGas = getValidateEditGas({ balance, conversionRate, txData }) + + return { + ...stateProps, + ...otherDispatchProps, + ...ownProps, + showCustomizeGasModal: () => dispatchShowCustomizeGasModal({ + txData, + onSubmit: txData => dispatchUpdateGasAndCalculate(txData), + validate: validateEditGas, + }), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps, mergeProps) +)(ConfirmTransactionBase) diff --git a/ui/app/components/pages/confirm-transaction-base/index.js b/ui/app/components/pages/confirm-transaction-base/index.js new file mode 100644 index 000000000..9996e9aeb --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-base/index.js @@ -0,0 +1 @@ +export { default } from './confirm-transaction-base.container' diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js new file mode 100644 index 000000000..0280f73c6 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Redirect } from 'react-router-dom' +import Loading from '../../loading-screen' +import { + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_DEPLOY_CONTRACT_PATH, + CONFIRM_SEND_ETHER_PATH, + CONFIRM_SEND_TOKEN_PATH, + CONFIRM_APPROVE_PATH, + CONFIRM_TRANSFER_FROM_PATH, + CONFIRM_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, +} from '../../../routes' +import { isConfirmDeployContract } from './confirm-transaction-switch.util' +import { + TOKEN_METHOD_TRANSFER, + TOKEN_METHOD_APPROVE, + TOKEN_METHOD_TRANSFER_FROM, +} from './confirm-transaction-switch.constants' + +export default class ConfirmTransactionSwitch extends Component { + static propTypes = { + txData: PropTypes.object, + methodData: PropTypes.object, + fetchingMethodData: PropTypes.bool, + } + + redirectToTransaction () { + const { + txData, + methodData: { name }, + fetchingMethodData, + } = this.props + const { id, txParams: { data } = {} } = txData + + if (isConfirmDeployContract(txData)) { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_DEPLOY_CONTRACT_PATH}` + return <Redirect to={{ pathname }} /> + } + + if (fetchingMethodData) { + return <Loading /> + } + + if (data) { + const methodName = name && name.toLowerCase() + + switch (methodName) { + case TOKEN_METHOD_TRANSFER: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_TOKEN_PATH}` + return <Redirect to={{ pathname }} /> + } + case TOKEN_METHOD_APPROVE: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_APPROVE_PATH}` + return <Redirect to={{ pathname }} /> + } + case TOKEN_METHOD_TRANSFER_FROM: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TRANSFER_FROM_PATH}` + return <Redirect to={{ pathname }} /> + } + default: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TOKEN_METHOD_PATH}` + return <Redirect to={{ pathname }} /> + } + } + } + + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SEND_ETHER_PATH}` + return <Redirect to={{ pathname }} /> + } + + render () { + const { txData } = this.props + + if (txData.txParams) { + return this.redirectToTransaction() + } else if (txData.msgParams) { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${txData.id}${SIGNATURE_REQUEST_PATH}` + return <Redirect to={{ pathname }} /> + } + + return <Loading /> + } +} diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.constants.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.constants.js new file mode 100644 index 000000000..9db4a2f96 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.constants.js @@ -0,0 +1,3 @@ +export const TOKEN_METHOD_TRANSFER = 'transfer' +export const TOKEN_METHOD_APPROVE = 'approve' +export const TOKEN_METHOD_TRANSFER_FROM = 'transferfrom' diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js new file mode 100644 index 000000000..3d7fc78cc --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.container.js @@ -0,0 +1,20 @@ +import { connect } from 'react-redux' +import ConfirmTransactionSwitch from './confirm-transaction-switch.component' + +const mapStateToProps = state => { + const { + confirmTransaction: { + txData, + methodData, + fetchingMethodData, + }, + } = state + + return { + txData, + methodData, + fetchingMethodData, + } +} + +export default connect(mapStateToProps)(ConfirmTransactionSwitch) diff --git a/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.util.js b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.util.js new file mode 100644 index 000000000..536aa5212 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.util.js @@ -0,0 +1,4 @@ +export function isConfirmDeployContract (txData = {}) { + const { txParams = {} } = txData + return !txParams.to +} diff --git a/ui/app/components/pages/confirm-transaction-switch/index.js b/ui/app/components/pages/confirm-transaction-switch/index.js new file mode 100644 index 000000000..c288acb1a --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/index.js @@ -0,0 +1,2 @@ +import ConfirmTransactionSwitch from './confirm-transaction-switch.container' +module.exports = ConfirmTransactionSwitch diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js b/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js new file mode 100644 index 000000000..3ac656d73 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js @@ -0,0 +1,157 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { Switch, Route } from 'react-router-dom' +import Loading from '../../loading-screen' +import ConfirmTransactionSwitch from '../confirm-transaction-switch' +import ConfirmTransactionBase from '../confirm-transaction-base' +import ConfirmSendEther from '../confirm-send-ether' +import ConfirmSendToken from '../confirm-send-token' +import ConfirmDeployContract from '../confirm-deploy-contract' +import ConfirmApprove from '../confirm-approve' +import ConfirmTokenTransactionBase from '../confirm-token-transaction-base' +import ConfTx from '../../../conf-tx' +import { + DEFAULT_ROUTE, + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_DEPLOY_CONTRACT_PATH, + CONFIRM_SEND_ETHER_PATH, + CONFIRM_SEND_TOKEN_PATH, + CONFIRM_APPROVE_PATH, + CONFIRM_TRANSFER_FROM_PATH, + CONFIRM_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, +} from '../../../routes' + +export default class ConfirmTransaction extends Component { + static propTypes = { + history: PropTypes.object.isRequired, + totalUnapprovedCount: PropTypes.number.isRequired, + match: PropTypes.object, + send: PropTypes.object, + unconfirmedTransactions: PropTypes.array, + setTransactionToConfirm: PropTypes.func, + confirmTransaction: PropTypes.object, + clearConfirmTransaction: PropTypes.func, + } + + getParamsTransactionId () { + const { match: { params: { id } = {} } } = this.props + return id || null + } + + componentDidMount () { + const { + totalUnapprovedCount = 0, + send = {}, + history, + confirmTransaction: { txData: { id: transactionId } = {} }, + } = this.props + + if (!totalUnapprovedCount && !send.to) { + history.replace(DEFAULT_ROUTE) + return + } + + if (!transactionId) { + this.setTransactionToConfirm() + } + } + + componentDidUpdate () { + const { + setTransactionToConfirm, + confirmTransaction: { txData: { id: transactionId } = {} }, + clearConfirmTransaction, + } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + if (paramsTransactionId && transactionId && paramsTransactionId !== transactionId + '') { + clearConfirmTransaction() + setTransactionToConfirm(paramsTransactionId) + return + } + + if (!transactionId) { + this.setTransactionToConfirm() + } + } + + setTransactionToConfirm () { + const { + history, + unconfirmedTransactions, + setTransactionToConfirm, + } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + if (paramsTransactionId) { + // Check to make sure params ID is valid + const tx = unconfirmedTransactions.find(({ id }) => id + '' === paramsTransactionId) + + if (!tx) { + history.replace(DEFAULT_ROUTE) + } else { + setTransactionToConfirm(paramsTransactionId) + } + } else if (unconfirmedTransactions.length) { + const totalUnconfirmed = unconfirmedTransactions.length + const transaction = unconfirmedTransactions[totalUnconfirmed - 1] + const { id: transactionId, loadingDefaults } = transaction + + if (!loadingDefaults) { + setTransactionToConfirm(transactionId) + } + } + } + + render () { + const { confirmTransaction: { txData: { id } } = {} } = this.props + const paramsTransactionId = this.getParamsTransactionId() + + // Show routes when state.confirmTransaction has been set and when either the ID in the params + // isn't specified or is specified and matches the ID in state.confirmTransaction in order to + // support URLs of /confirm-transaction or /confirm-transaction/<transactionId> + return id && (!paramsTransactionId || paramsTransactionId === id + '') + ? ( + <Switch> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_DEPLOY_CONTRACT_PATH}`} + component={ConfirmDeployContract} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_TOKEN_METHOD_PATH}`} + component={ConfirmTransactionBase} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SEND_ETHER_PATH}`} + component={ConfirmSendEther} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_SEND_TOKEN_PATH}`} + component={ConfirmSendToken} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_APPROVE_PATH}`} + component={ConfirmApprove} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${CONFIRM_TRANSFER_FROM_PATH}`} + component={ConfirmTokenTransactionBase} + /> + <Route + exact + path={`${CONFIRM_TRANSACTION_ROUTE}/:id?${SIGNATURE_REQUEST_PATH}`} + component={ConfTx} + /> + <Route path="*" component={ConfirmTransactionSwitch} /> + </Switch> + ) + : <Loading /> + } +} diff --git a/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js b/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js new file mode 100644 index 000000000..1bc2f1efb --- /dev/null +++ b/ui/app/components/pages/confirm-transaction/confirm-transaction.container.js @@ -0,0 +1,33 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import { + setTransactionToConfirm, + clearConfirmTransaction, +} from '../../../ducks/confirm-transaction.duck' +import ConfirmTransaction from './confirm-transaction.component' +import { getTotalUnapprovedCount } from '../../../selectors' +import { unconfirmedTransactionsListSelector } from '../../../selectors/confirm-transaction' + +const mapStateToProps = state => { + const { metamask: { send }, confirmTransaction } = state + + return { + totalUnapprovedCount: getTotalUnapprovedCount(state), + send, + confirmTransaction, + unconfirmedTransactions: unconfirmedTransactionsListSelector(state), + } +} + +const mapDispatchToProps = dispatch => { + return { + setTransactionToConfirm: transactionId => dispatch(setTransactionToConfirm(transactionId)), + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps), +)(ConfirmTransaction) diff --git a/ui/app/components/pages/confirm-transaction/index.js b/ui/app/components/pages/confirm-transaction/index.js new file mode 100644 index 000000000..4bf42d85c --- /dev/null +++ b/ui/app/components/pages/confirm-transaction/index.js @@ -0,0 +1,2 @@ +import ConfirmTransaction from './confirm-transaction.container' +module.exports = ConfirmTransaction diff --git a/ui/app/components/pages/create-account/connect-hardware/account-list.js b/ui/app/components/pages/create-account/connect-hardware/account-list.js new file mode 100644 index 000000000..c722d1f55 --- /dev/null +++ b/ui/app/components/pages/create-account/connect-hardware/account-list.js @@ -0,0 +1,143 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const genAccountLink = require('../../../../../lib/account-link.js') + +class AccountList extends Component { + constructor (props, context) { + super(props) + } + + renderHeader () { + return ( + h('div.hw-connect', [ + h('h3.hw-connect__title', {}, this.context.t('selectAnAccount')), + h('p.hw-connect__msg', {}, this.context.t('selectAnAccountHelp')), + ]) + ) + } + + renderAccounts () { + return h('div.hw-account-list', [ + this.props.accounts.map((a, i) => { + + return h('div.hw-account-list__item', { key: a.address }, [ + h('div.hw-account-list__item__radio', [ + h('input', { + type: 'radio', + name: 'selectedAccount', + id: `address-${i}`, + value: a.index, + onChange: (e) => this.props.onAccountChange(e.target.value), + checked: this.props.selectedAccount === a.index.toString(), + }), + h( + 'label.hw-account-list__item__label', + { + htmlFor: `address-${i}`, + }, + [ + h('span.hw-account-list__item__index', a.index + 1), + `${a.address.slice(0, 4)}...${a.address.slice(-4)}`, + h('span.hw-account-list__item__balance', `${a.balance}`), + ]), + ]), + h( + 'a.hw-account-list__item__link', + { + href: genAccountLink(a.address, this.props.network), + target: '_blank', + title: this.context.t('etherscanView'), + }, + h('img', { src: 'images/popout.svg' }) + ), + ]) + }), + ]) + } + + renderPagination () { + return h('div.hw-list-pagination', [ + h( + 'button.hw-list-pagination__button', + { + onClick: () => this.props.getPage(-1), + }, + `< ${this.context.t('prev')}` + ), + + h( + 'button.hw-list-pagination__button', + { + onClick: () => this.props.getPage(1), + }, + `${this.context.t('next')} >` + ), + ]) + } + + renderButtons () { + const disabled = this.props.selectedAccount === null + const buttonProps = {} + if (disabled) { + buttonProps.disabled = true + } + + return h('div.new-account-connect-form__buttons', {}, [ + h( + 'button.btn-default.btn--large.new-account-connect-form__button', + { + onClick: this.props.onCancel.bind(this), + }, + [this.context.t('cancel')] + ), + + h( + `button.btn-primary.btn--large.new-account-connect-form__button.unlock ${disabled ? '.btn-primary--disabled' : ''}`, + { + onClick: this.props.onUnlockAccount.bind(this), + ...buttonProps, + }, + [this.context.t('unlock')] + ), + ]) + } + + renderForgetDevice () { + return h('div.hw-forget-device-container', {}, [ + h('a', { + onClick: this.props.onForgetDevice.bind(this), + }, this.context.t('forgetDevice')), + ]) + } + + render () { + return h('div.new-account-connect-form.account-list', {}, [ + this.renderHeader(), + this.renderAccounts(), + this.renderPagination(), + this.renderButtons(), + this.renderForgetDevice(), + ]) + } + +} + + +AccountList.propTypes = { + accounts: PropTypes.array.isRequired, + onAccountChange: PropTypes.func.isRequired, + onForgetDevice: PropTypes.func.isRequired, + getPage: PropTypes.func.isRequired, + network: PropTypes.string, + selectedAccount: PropTypes.string, + history: PropTypes.object, + onUnlockAccount: PropTypes.func, + onCancel: PropTypes.func, +} + +AccountList.contextTypes = { + t: PropTypes.func, +} + +module.exports = AccountList diff --git a/ui/app/components/pages/create-account/connect-hardware/connect-screen.js b/ui/app/components/pages/create-account/connect-hardware/connect-screen.js new file mode 100644 index 000000000..cb2b86595 --- /dev/null +++ b/ui/app/components/pages/create-account/connect-hardware/connect-screen.js @@ -0,0 +1,149 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') + +class ConnectScreen extends Component { + constructor (props, context) { + super(props) + } + + renderUnsupportedBrowser () { + return ( + h('div.new-account-connect-form.unsupported-browser', {}, [ + h('div.hw-connect', [ + h('h3.hw-connect__title', {}, this.context.t('browserNotSupported')), + h('p.hw-connect__msg', {}, this.context.t('chromeRequiredForTrezor')), + ]), + h( + 'button.btn-primary.btn--large', + { + onClick: () => global.platform.openWindow({ + url: 'https://google.com/chrome', + }), + }, + this.context.t('downloadGoogleChrome') + ), + ]) + ) + } + + renderHeader () { + return ( + h('div.hw-connect__header', {}, [ + h('h3.hw-connect__header__title', {}, this.context.t(`hardwareSupport`)), + h('p.hw-connect__header__msg', {}, this.context.t(`hardwareSupportMsg`)), + ]) + ) + } + + renderTrezorAffiliateLink () { + return h('div.hw-connect__get-trezor', {}, [ + h('p.hw-connect__get-trezor__msg', {}, this.context.t(`dontHaveATrezorWallet`)), + h('a.hw-connect__get-trezor__link', { + href: 'https://shop.trezor.io/?a=metamask', + target: '_blank', + }, this.context.t('orderOneHere')), + ]) + } + + renderConnectToTrezorButton () { + return h( + 'button.btn-primary.btn--large', + { onClick: this.props.connectToTrezor.bind(this) }, + this.props.btnText + ) + } + + scrollToTutorial = (e) => { + if (this.referenceNode) this.referenceNode.scrollIntoView({behavior: 'smooth'}) + } + + renderLearnMore () { + return ( + h('p.hw-connect__learn-more', { + onClick: this.scrollToTutorial, + }, [ + this.context.t('learnMore'), + h('img.hw-connect__learn-more__arrow', { src: 'images/caret-right.svg'}), + ]) + ) + } + + renderTutorialSteps () { + const steps = [ + { + asset: 'hardware-wallet-step-1', + dimensions: {width: '225px', height: '75px'}, + }, + { + asset: 'hardware-wallet-step-2', + dimensions: {width: '300px', height: '100px'}, + }, + { + asset: 'hardware-wallet-step-3', + dimensions: {width: '120px', height: '90px'}, + }, + ] + + return h('.hw-tutorial', { + ref: node => { this.referenceNode = node }, + }, + steps.map((step, i) => ( + h('div.hw-connect', {}, [ + h('h3.hw-connect__title', {}, this.context.t(`step${i + 1}HardwareWallet`)), + h('p.hw-connect__msg', {}, this.context.t(`step${i + 1}HardwareWalletMsg`)), + h('img.hw-connect__step-asset', { src: `images/${step.asset}.svg`, ...step.dimensions }), + ]) + )) + ) + } + + renderFooter () { + return ( + h('div.hw-connect__footer', {}, [ + h('h3.hw-connect__footer__title', {}, this.context.t(`readyToConnect`)), + this.renderConnectToTrezorButton(), + h('p.hw-connect__footer__msg', {}, [ + this.context.t(`havingTroubleConnecting`), + h('a.hw-connect__footer__link', { + href: 'https://support.metamask.io/', + target: '_blank', + }, this.context.t('getHelp')), + ]), + ]) + ) + } + + renderConnectScreen () { + return ( + h('div.new-account-connect-form', {}, [ + this.renderHeader(), + this.renderTrezorAffiliateLink(), + this.renderConnectToTrezorButton(), + this.renderLearnMore(), + this.renderTutorialSteps(), + this.renderFooter(), + ]) + ) + } + + render () { + if (this.props.browserSupported) { + return this.renderConnectScreen() + } + return this.renderUnsupportedBrowser() + } +} + +ConnectScreen.propTypes = { + connectToTrezor: PropTypes.func.isRequired, + btnText: PropTypes.string.isRequired, + browserSupported: PropTypes.bool.isRequired, +} + +ConnectScreen.contextTypes = { + t: PropTypes.func, +} + +module.exports = ConnectScreen + diff --git a/ui/app/components/pages/create-account/connect-hardware/index.js b/ui/app/components/pages/create-account/connect-hardware/index.js new file mode 100644 index 000000000..3f66e7098 --- /dev/null +++ b/ui/app/components/pages/create-account/connect-hardware/index.js @@ -0,0 +1,241 @@ +const { Component } = require('react') +const PropTypes = require('prop-types') +const h = require('react-hyperscript') +const connect = require('react-redux').connect +const actions = require('../../../../actions') +const ConnectScreen = require('./connect-screen') +const AccountList = require('./account-list') +const { DEFAULT_ROUTE } = require('../../../../routes') +const { formatBalance } = require('../../../../util') + +class ConnectHardwareForm extends Component { + constructor (props, context) { + super(props) + this.state = { + error: null, + btnText: context.t('connectToTrezor'), + selectedAccount: null, + accounts: [], + browserSupported: true, + unlocked: false, + } + } + + componentWillReceiveProps (nextProps) { + const { accounts } = nextProps + const newAccounts = this.state.accounts.map(a => { + const normalizedAddress = a.address.toLowerCase() + const balanceValue = accounts[normalizedAddress] && accounts[normalizedAddress].balance || null + a.balance = balanceValue ? formatBalance(balanceValue, 6) : '...' + return a + }) + this.setState({accounts: newAccounts}) + } + + + componentDidMount () { + this.checkIfUnlocked() + } + + async checkIfUnlocked () { + const unlocked = await this.props.checkHardwareStatus('trezor') + if (unlocked) { + this.setState({unlocked: true}) + this.getPage(0) + } + } + + connectToTrezor = () => { + if (this.state.accounts.length) { + return null + } + this.setState({ btnText: this.context.t('connecting')}) + this.getPage(0) + } + + onAccountChange = (account) => { + this.setState({selectedAccount: account.toString(), error: null}) + } + + showTemporaryAlert () { + this.props.showAlert(this.context.t('hardwareWalletConnected')) + // Autohide the alert after 5 seconds + setTimeout(_ => { + this.props.hideAlert() + }, 5000) + } + + getPage = (page) => { + this.props + .connectHardware('trezor', page) + .then(accounts => { + if (accounts.length) { + + // If we just loaded the accounts for the first time + // (device previously locked) show the global alert + if (this.state.accounts.length === 0 && !this.state.unlocked) { + this.showTemporaryAlert() + } + + const newState = { unlocked: true } + // Default to the first account + if (this.state.selectedAccount === null) { + accounts.forEach((a, i) => { + if (a.address.toLowerCase() === this.props.address) { + newState.selectedAccount = a.index.toString() + } + }) + // If the page doesn't contain the selected account, let's deselect it + } else if (!accounts.filter(a => a.index.toString() === this.state.selectedAccount).length) { + newState.selectedAccount = null + } + + + // Map accounts with balances + newState.accounts = accounts.map(account => { + const normalizedAddress = account.address.toLowerCase() + const balanceValue = this.props.accounts[normalizedAddress] && this.props.accounts[normalizedAddress].balance || null + account.balance = balanceValue ? formatBalance(balanceValue, 6) : '...' + return account + }) + + this.setState(newState) + } + }) + .catch(e => { + if (e === 'Window blocked') { + this.setState({ browserSupported: false }) + } + this.setState({ btnText: this.context.t('connectToTrezor') }) + }) + } + + onForgetDevice = () => { + this.props.forgetDevice('trezor') + .then(_ => { + this.setState({ + error: null, + btnText: this.context.t('connectToTrezor'), + selectedAccount: null, + accounts: [], + unlocked: false, + }) + }).catch(e => { + this.setState({ error: e.toString() }) + }) + } + + onUnlockAccount = () => { + + if (this.state.selectedAccount === null) { + this.setState({ error: this.context.t('accountSelectionRequired') }) + } + + this.props.unlockTrezorAccount(this.state.selectedAccount) + .then(_ => { + this.props.history.push(DEFAULT_ROUTE) + }).catch(e => { + this.setState({ error: e.toString() }) + }) + } + + onCancel = () => { + this.props.history.push(DEFAULT_ROUTE) + } + + renderError () { + return this.state.error + ? h('span.error', { style: { marginBottom: 40 } }, this.state.error) + : null + } + + renderContent () { + if (!this.state.accounts.length) { + return h(ConnectScreen, { + connectToTrezor: this.connectToTrezor, + btnText: this.state.btnText, + browserSupported: this.state.browserSupported, + }) + } + + return h(AccountList, { + accounts: this.state.accounts, + selectedAccount: this.state.selectedAccount, + onAccountChange: this.onAccountChange, + network: this.props.network, + getPage: this.getPage, + history: this.props.history, + onUnlockAccount: this.onUnlockAccount, + onForgetDevice: this.onForgetDevice, + onCancel: this.onCancel, + }) + } + + render () { + return h('div', [ + this.renderError(), + this.renderContent(), + ]) + } +} + +ConnectHardwareForm.propTypes = { + hideModal: PropTypes.func, + showImportPage: PropTypes.func, + showConnectPage: PropTypes.func, + connectHardware: PropTypes.func, + checkHardwareStatus: PropTypes.func, + forgetDevice: PropTypes.func, + showAlert: PropTypes.func, + hideAlert: PropTypes.func, + unlockTrezorAccount: PropTypes.func, + numberOfExistingAccounts: PropTypes.number, + history: PropTypes.object, + t: PropTypes.func, + network: PropTypes.string, + accounts: PropTypes.object, + address: PropTypes.string, +} + +const mapStateToProps = state => { + const { + metamask: { network, selectedAddress, identities = {}, accounts = [] }, + } = state + const numberOfExistingAccounts = Object.keys(identities).length + + return { + network, + accounts, + address: selectedAddress, + numberOfExistingAccounts, + } +} + +const mapDispatchToProps = dispatch => { + return { + connectHardware: (deviceName, page) => { + return dispatch(actions.connectHardware(deviceName, page)) + }, + checkHardwareStatus: (deviceName) => { + return dispatch(actions.checkHardwareStatus(deviceName)) + }, + forgetDevice: (deviceName) => { + return dispatch(actions.forgetDevice(deviceName)) + }, + unlockTrezorAccount: index => { + return dispatch(actions.unlockTrezorAccount(index)) + }, + showImportPage: () => dispatch(actions.showImportPage()), + showConnectPage: () => dispatch(actions.showConnectPage()), + showAlert: (msg) => dispatch(actions.showAlert(msg)), + hideAlert: () => dispatch(actions.hideAlert()), + } +} + +ConnectHardwareForm.contextTypes = { + t: PropTypes.func, +} + +module.exports = connect(mapStateToProps, mapDispatchToProps)( + ConnectHardwareForm +) 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 1dc2ba534..dd57256a3 100644 --- a/ui/app/components/pages/create-account/import-account/json.js +++ b/ui/app/components/pages/create-account/import-account/json.js @@ -109,12 +109,13 @@ class JsonImportSubview extends Component { .then(({ selectedAddress }) => { if (selectedAddress) { history.push(DEFAULT_ROUTE) + displayWarning(null) } else { displayWarning('Error importing account.') setSelectedAddress(firstAddress) } }) - .catch(err => displayWarning(err)) + .catch(err => err && displayWarning(err.message || err)) } } 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 5df3777da..1db999f2f 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 @@ -99,10 +99,11 @@ PrivateKeyImportView.prototype.createNewKeychain = function () { .then(({ selectedAddress }) => { if (selectedAddress) { history.push(DEFAULT_ROUTE) + displayWarning(null) } else { displayWarning('Error importing account.') setSelectedAddress(firstAddress) } }) - .catch(err => displayWarning(err)) + .catch(err => err && displayWarning(err.message || err)) } diff --git a/ui/app/components/pages/create-account/index.js b/ui/app/components/pages/create-account/index.js index 6e3b93742..d3de1ea01 100644 --- a/ui/app/components/pages/create-account/index.js +++ b/ui/app/components/pages/create-account/index.js @@ -8,7 +8,12 @@ const { getCurrentViewContext } = require('../../../selectors') const classnames = require('classnames') const NewAccountCreateForm = require('./new-account') const NewAccountImportForm = require('./import-account') -const { NEW_ACCOUNT_ROUTE, IMPORT_ACCOUNT_ROUTE } = require('../../../routes') +const ConnectHardwareForm = require('./connect-hardware') +const { + NEW_ACCOUNT_ROUTE, + IMPORT_ACCOUNT_ROUTE, + CONNECT_HARDWARE_ROUTE, +} = require('../../../routes') class CreateAccountPage extends Component { renderTabs () { @@ -36,13 +41,26 @@ class CreateAccountPage extends Component { }, [ this.context.t('import'), ]), + h( + 'div.new-account__tabs__tab', + { + className: classnames('new-account__tabs__tab', { + 'new-account__tabs__selected': matchPath(location.pathname, { + path: CONNECT_HARDWARE_ROUTE, + exact: true, + }), + }), + onClick: () => history.push(CONNECT_HARDWARE_ROUTE), + }, + this.context.t('connect') + ), ]) } render () { return h('div.new-account', {}, [ h('div.new-account__header', [ - h('div.new-account__title', this.context.t('newAccount') ), + h('div.new-account__title', this.context.t('newAccount')), this.renderTabs(), ]), h('div.new-account__form', [ @@ -57,6 +75,11 @@ class CreateAccountPage extends Component { path: IMPORT_ACCOUNT_ROUTE, component: NewAccountImportForm, }), + h(Route, { + exact: true, + path: CONNECT_HARDWARE_ROUTE, + component: ConnectHardwareForm, + }), ]), ]), ]) diff --git a/ui/app/components/pages/create-account/new-account.js b/ui/app/components/pages/create-account/new-account.js index 9c94990e0..402b8f03b 100644 --- a/ui/app/components/pages/create-account/new-account.js +++ b/ui/app/components/pages/create-account/new-account.js @@ -62,6 +62,7 @@ class NewAccountCreateForm extends Component { NewAccountCreateForm.propTypes = { hideModal: PropTypes.func, showImportPage: PropTypes.func, + showConnectPage: PropTypes.func, createAccount: PropTypes.func, numberOfExistingAccounts: PropTypes.number, history: PropTypes.object, @@ -92,6 +93,7 @@ const mapDispatchToProps = dispatch => { }) }, showImportPage: () => dispatch(actions.showImportPage()), + showConnectPage: () => dispatch(actions.showConnectPage()), } } diff --git a/ui/app/components/pages/home.js b/ui/app/components/pages/home.js index c53413d3b..5e3fdc9af 100644 --- a/ui/app/components/pages/home.js +++ b/ui/app/components/pages/home.js @@ -1,6 +1,6 @@ const { Component } = require('react') +const { connect } = require('react-redux') const PropTypes = require('prop-types') -const connect = require('../../metamask-connect') const { Redirect, withRouter } = require('react-router-dom') const { compose } = require('recompose') const h = require('react-hyperscript') @@ -27,19 +27,17 @@ const { NOTICE_ROUTE, } = require('../../routes') +const { unconfirmedTransactionsCountSelector } = require('../../selectors/confirm-transaction') + class Home extends Component { componentDidMount () { const { history, - unapprovedTxs = {}, - unapprovedMsgCount = 0, - unapprovedPersonalMsgCount = 0, - unapprovedTypedMessagesCount = 0, + unconfirmedTransactionsCount = 0, } = this.props // unapprovedTxs and unapproved messages - if (Object.keys(unapprovedTxs).length || - unapprovedTypedMessagesCount + unapprovedMsgCount + unapprovedPersonalMsgCount > 0) { + if (unconfirmedTransactionsCount > 0) { history.push(CONFIRM_TRANSACTION_ROUTE) } } @@ -83,51 +81,6 @@ class Home extends Component { }) } - // if (!props.noActiveNotices) { - // log.debug('rendering notice screen for unread notices.') - // return h(NoticeScreen, { - // notice: props.nextUnreadNotice, - // key: 'NoticeScreen', - // onConfirm: () => props.dispatch(actions.markNoticeRead(props.nextUnreadNotice)), - // }) - // } else if (props.lostAccounts && props.lostAccounts.length > 0) { - // log.debug('rendering notice screen for lost accounts view.') - // return h(NoticeScreen, { - // notice: generateLostAccountsNotice(props.lostAccounts), - // key: 'LostAccountsNotice', - // onConfirm: () => props.dispatch(actions.markAccountsFound()), - // }) - // } - - // if (props.seedWords) { - // log.debug('rendering seed words') - // return h(HDCreateVaultComplete, {key: 'HDCreateVaultComplete'}) - // } - - // show initialize screen - // if (!isInitialized || forgottenPassword) { - // // show current view - // log.debug('rendering an initialize screen') - // // switch (props.currentView.name) { - - // // case 'restoreVault': - // // log.debug('rendering restore vault screen') - // // return h(HDRestoreVaultScreen, {key: 'HDRestoreVaultScreen'}) - - // // default: - // // log.debug('rendering menu screen') - // // return h(InitializeScreen, {key: 'menuScreenInit'}) - // // } - // } - - // // show unlock screen - // if (!props.isUnlocked) { - // return h(MainContainer, { - // currentViewName: props.currentView.name, - // isUnlocked: props.isUnlocked, - // }) - // } - // show current view switch (currentView.name) { @@ -135,59 +88,10 @@ class Home extends Component { log.debug('rendering main container') return h(MainContainer, {key: 'account-detail'}) - // case 'sendTransaction': - // log.debug('rendering send tx screen') - - // // Going to leave this here until we are ready to delete SendTransactionScreen v1 - // // const SendComponentToRender = checkFeatureToggle('send-v2') - // // ? SendTransactionScreen2 - // // : SendTransactionScreen - - // return h(SendTransactionScreen2, {key: 'send-transaction'}) - - // case 'sendToken': - // log.debug('rendering send token screen') - - // // Going to leave this here until we are ready to delete SendTransactionScreen v1 - // // const SendTokenComponentToRender = checkFeatureToggle('send-v2') - // // ? SendTransactionScreen2 - // // : SendTokenScreen - - // return h(SendTransactionScreen2, {key: 'sendToken'}) - case 'newKeychain': log.debug('rendering new keychain screen') return h(NewKeyChainScreen, {key: 'new-keychain'}) - // case 'confTx': - // log.debug('rendering confirm tx screen') - // return h(Redirect, { - // to: { - // pathname: CONFIRM_TRANSACTION_ROUTE, - // }, - // }) - // return h(ConfirmTxScreen, {key: 'confirm-tx'}) - - // case 'add-token': - // log.debug('rendering add-token screen from unlock screen.') - // return h(AddTokenScreen, {key: 'add-token'}) - - // case 'config': - // log.debug('rendering config screen') - // return h(Settings, {key: 'config'}) - - // case 'import-menu': - // log.debug('rendering import screen') - // return h(Import, {key: 'import-menu'}) - - // case 'reveal-seed-conf': - // log.debug('rendering reveal seed confirmation screen') - // return h(RevealSeedConfirmation, {key: 'reveal-seed-conf'}) - - // case 'info': - // log.debug('rendering info screen') - // return h(Settings, {key: 'info', tab: 'info'}) - case 'buyEth': log.debug('rendering buy ether screen') return h(BuyView, {key: 'buyEthView'}) @@ -261,6 +165,7 @@ Home.propTypes = { isPopup: PropTypes.bool, isMouseUser: PropTypes.bool, t: PropTypes.func, + unconfirmedTransactionsCount: PropTypes.number, } function mapStateToProps (state) { @@ -324,6 +229,7 @@ function mapStateToProps (state) { // state needed to get account dropdown temporarily rendering from app bar selected, + unconfirmedTransactionsCount: unconfirmedTransactionsCountSelector(state), } } diff --git a/ui/app/components/pages/keychains/restore-vault.js b/ui/app/components/pages/keychains/restore-vault.js index 33575bfbb..d90a33e49 100644 --- a/ui/app/components/pages/keychains/restore-vault.js +++ b/ui/app/components/pages/keychains/restore-vault.js @@ -1,178 +1,189 @@ -const { withRouter } = require('react-router-dom') -const PropTypes = require('prop-types') -const { compose } = require('recompose') -const PersistentForm = require('../../../../lib/persistent-form') -const connect = require('../../../metamask-connect') -const h = require('react-hyperscript') -const { createNewVaultAndRestore, unMarkPasswordForgotten } = require('../../../actions') -const { DEFAULT_ROUTE } = require('../../../routes') -const log = require('loglevel') - -class RestoreVaultPage extends PersistentForm { - constructor (props) { - super(props) - - this.state = { - error: null, - } +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import {connect} from 'react-redux' +import { + createNewVaultAndRestore, + unMarkPasswordForgotten, +} from '../../../actions' +import { DEFAULT_ROUTE } from '../../../routes' +import TextField from '../../text-field' + +class RestoreVaultPage extends Component { + static contextTypes = { + t: PropTypes.func, } - createOnEnter (event) { - if (event.key === 'Enter') { - this.createNewVaultAndRestore() - } + static propTypes = { + warning: PropTypes.string, + createNewVaultAndRestore: PropTypes.func.isRequired, + leaveImportSeedScreenState: PropTypes.func, + history: PropTypes.object, + isLoading: PropTypes.bool, + }; + + state = { + seedPhrase: '', + password: '', + confirmPassword: '', + seedPhraseError: null, + passwordError: null, + confirmPasswordError: null, } - cancel () { - this.props.unMarkPasswordForgotten() - .then(this.props.history.push(DEFAULT_ROUTE)) + parseSeedPhrase = (seedPhrase) => { + return seedPhrase + .match(/\w+/g) + .join(' ') } - createNewVaultAndRestore () { - this.setState({ error: null }) + handleSeedPhraseChange (seedPhrase) { + let seedPhraseError = null + + if (seedPhrase && this.parseSeedPhrase(seedPhrase).split(' ').length !== 12) { + seedPhraseError = this.context.t('seedPhraseReq') + } + + this.setState({ seedPhrase, seedPhraseError }) + } - // check password - var passwordBox = document.getElementById('password-box') - var password = passwordBox.value - var passwordConfirmBox = document.getElementById('password-box-confirm') - var passwordConfirm = passwordConfirmBox.value + handlePasswordChange (password) { + const { confirmPassword } = this.state + let confirmPasswordError = null + let passwordError = null - if (password.length < 8) { - this.setState({ error: 'Password not long enough' }) - return + if (password && password.length < 8) { + passwordError = this.context.t('passwordNotLongEnough') } - if (password !== passwordConfirm) { - this.setState({ error: 'Passwords don\'t match' }) - return + if (confirmPassword && password !== confirmPassword) { + confirmPasswordError = this.context.t('passwordsDontMatch') } - // check seed - var seedBox = document.querySelector('textarea.twelve-word-phrase') - var seed = seedBox.value.trim() - if (seed.split(' ').length !== 12) { - this.setState({ error: 'Seed phrases are 12 words long' }) - return + this.setState({ password, passwordError, confirmPasswordError }) + } + + handleConfirmPasswordChange (confirmPassword) { + const { password } = this.state + let confirmPasswordError = null + + if (password !== confirmPassword) { + confirmPasswordError = this.context.t('passwordsDontMatch') } - // submit - this.props.createNewVaultAndRestore(password, seed) - .then(() => this.props.history.push(DEFAULT_ROUTE)) - .catch(({ message }) => { - this.setState({ error: message }) - log.error(message) - }) + this.setState({ confirmPassword, confirmPasswordError }) + } + + onClick = () => { + const { password, seedPhrase } = this.state + const { + createNewVaultAndRestore, + leaveImportSeedScreenState, + history, + } = this.props + + leaveImportSeedScreenState() + createNewVaultAndRestore(password, this.parseSeedPhrase(seedPhrase)) + .then(() => history.push(DEFAULT_ROUTE)) + } + + hasError () { + const { passwordError, confirmPasswordError, seedPhraseError } = this.state + return passwordError || confirmPasswordError || seedPhraseError } render () { - const { error } = this.state - this.persistentFormParentId = 'restore-vault-form' + const { + seedPhrase, + password, + confirmPassword, + seedPhraseError, + passwordError, + confirmPasswordError, + } = this.state + const { t } = this.context + const { isLoading } = this.props + const disabled = !seedPhrase || !password || !confirmPassword || isLoading || this.hasError() return ( - h('.initialize-screen.flex-column.flex-center.flex-grow', [ - - h('h3.flex-center.text-transform-uppercase', { - style: { - background: '#EBEBEB', - color: '#AEAEAE', - marginBottom: 24, - width: '100%', - fontSize: '20px', - padding: 6, - }, - }, [ - this.props.t('restoreVault'), - ]), - - // wallet seed entry - h('h3', 'Wallet Seed'), - h('textarea.twelve-word-phrase.letter-spacey', { - dataset: { - persistentFormId: 'wallet-seed', - }, - placeholder: this.props.t('secretPhrase'), - }), - - // password - h('input.large-input.letter-spacey', { - type: 'password', - id: 'password-box', - placeholder: this.props.t('newPassword8Chars'), - dataset: { - persistentFormId: 'password', - }, - style: { - width: 260, - marginTop: 12, - }, - }), - - // confirm password - h('input.large-input.letter-spacey', { - type: 'password', - id: 'password-box-confirm', - placeholder: this.props.t('confirmPassword'), - onKeyPress: this.createOnEnter.bind(this), - dataset: { - persistentFormId: 'password-confirmation', - }, - style: { - width: 260, - marginTop: 16, - }, - }), - - error && ( - h('span.error.in-progress-notification', error) - ), - - // submit - h('.flex-row.flex-space-between', { - style: { - marginTop: 30, - width: '50%', - }, - }, [ - - // cancel - h('button.primary', { - onClick: () => this.cancel(), - }, this.props.t('cancel')), - - // submit - h('button.primary', { - onClick: this.createNewVaultAndRestore.bind(this), - }, this.props.t('ok')), - - ]), - ]) + <div className="first-view-main-wrapper"> + <div className="first-view-main"> + <div className="import-account"> + <a + className="import-account__back-button" + onClick={e => { + e.preventDefault() + this.props.history.goBack() + }} + href="#" + > + {`< Back`} + </a> + <div className="import-account__title"> + { this.context.t('restoreAccountWithSeed') } + </div> + <div className="import-account__selector-label"> + { this.context.t('secretPhrase') } + </div> + <div className="import-account__input-wrapper"> + <label className="import-account__input-label">Wallet Seed</label> + <textarea + className="import-account__secret-phrase" + onChange={e => this.handleSeedPhraseChange(e.target.value)} + value={this.state.seedPhrase} + placeholder={this.context.t('separateEachWord')} + /> + </div> + <span className="error"> + { seedPhraseError } + </span> + <TextField + id="password" + label={t('newPassword')} + type="password" + className="first-time-flow__input" + value={this.state.password} + onChange={event => this.handlePasswordChange(event.target.value)} + error={passwordError} + autoComplete="new-password" + margin="normal" + largeLabel + /> + <TextField + id="confirm-password" + label={t('confirmPassword')} + type="password" + className="first-time-flow__input" + value={this.state.confirmPassword} + onChange={event => this.handleConfirmPasswordChange(event.target.value)} + error={confirmPasswordError} + autoComplete="confirm-password" + margin="normal" + largeLabel + /> + <button + className="first-time-flow__button" + onClick={() => !disabled && this.onClick()} + disabled={disabled} + > + {this.context.t('restore')} + </button> + </div> + </div> + </div> ) } } -RestoreVaultPage.propTypes = { - history: PropTypes.object, -} - -const mapStateToProps = state => { - const { appState: { warning, forgottenPassword } } = state - - return { - warning, - forgottenPassword, - } +RestoreVaultPage.contextTypes = { + t: PropTypes.func, } -const mapDispatchToProps = dispatch => { - return { - createNewVaultAndRestore: (password, seed) => { - return dispatch(createNewVaultAndRestore(password, seed)) +export default connect( + ({ appState: { warning, isLoading } }) => ({ warning, isLoading }), + dispatch => ({ + leaveImportSeedScreenState: () => { + dispatch(unMarkPasswordForgotten()) }, - unMarkPasswordForgotten: () => dispatch(unMarkPasswordForgotten()), - } -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) + createNewVaultAndRestore: (pw, seed) => dispatch(createNewVaultAndRestore(pw, seed)), + }) )(RestoreVaultPage) 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 a1d3f9181..94915df76 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.component.js +++ b/ui/app/components/pages/unlock-page/unlock-page.component.js @@ -2,19 +2,27 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import Button from '@material-ui/core/Button' import TextField from '../../text-field' - -const { ENVIRONMENT_TYPE_POPUP } = require('../../../../../app/scripts/lib/enums') -const { getEnvironmentType } = require('../../../../../app/scripts/lib/util') -const getCaretCoordinates = require('textarea-caret') -const EventEmitter = require('events').EventEmitter -const Mascot = require('../../mascot') -const { DEFAULT_ROUTE, RESTORE_VAULT_ROUTE } = require('../../../routes') - -class UnlockPage extends Component { +import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums' +import { getEnvironmentType } from '../../../../../app/scripts/lib/util' +import getCaretCoordinates from 'textarea-caret' +import { EventEmitter } from 'events' +import Mascot from '../../mascot' +import { DEFAULT_ROUTE, RESTORE_VAULT_ROUTE } from '../../../routes' + +export default class UnlockPage extends Component { static contextTypes = { t: PropTypes.func, } + static propTypes = { + forgotPassword: PropTypes.func, + tryUnlockMetamask: PropTypes.func, + markPasswordForgotten: PropTypes.func, + history: PropTypes.object, + isUnlocked: PropTypes.bool, + useOldInterface: PropTypes.func, + } + constructor (props) { super(props) @@ -23,6 +31,7 @@ class UnlockPage extends Component { error: null, } + this.submitting = false this.animationEventEmitter = new EventEmitter() } @@ -41,20 +50,21 @@ class UnlockPage extends Component { const { password } = this.state const { tryUnlockMetamask, history } = this.props - if (password === '') { + if (password === '' || this.submitting) { return } this.setState({ error: null }) + this.submitting = true try { await tryUnlockMetamask(password) + this.submitting = false + history.push(DEFAULT_ROUTE) } catch ({ message }) { this.setState({ error: message }) - return + this.submitting = false } - - history.push(DEFAULT_ROUTE) } handleInputChange ({ target }) { @@ -98,7 +108,9 @@ class UnlockPage extends Component { } render () { - const { error } = this.state + const { password, error } = this.state + const { t } = this.context + const { markPasswordForgotten, history } = this.props return ( <div className="unlock-page__container"> @@ -111,18 +123,18 @@ class UnlockPage extends Component { /> </div> <h1 className="unlock-page__title"> - { this.context.t('welcomeBack') } + { t('welcomeBack') } </h1> - <div>{ this.context.t('unlockMessage') }</div> + <div>{ t('unlockMessage') }</div> <form className="unlock-page__form" onSubmit={event => this.handleSubmit(event)} > <TextField id="password" - label={this.context.t('password')} + label={t('password')} type="password" - value={this.state.password} + value={password} onChange={event => this.handleInputChange(event)} error={error} autoFocus @@ -136,28 +148,28 @@ class UnlockPage extends Component { <div className="unlock-page__link" onClick={() => { - this.props.markPasswordForgotten() - this.props.history.push(RESTORE_VAULT_ROUTE) + markPasswordForgotten() + history.push(RESTORE_VAULT_ROUTE) if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { global.platform.openExtensionInBrowser() } }} > - { this.context.t('restoreFromSeed') } + { t('restoreFromSeed') } </div> <div className="unlock-page__link unlock-page__link--import" onClick={() => { - this.props.markPasswordForgotten() - this.props.history.push(RESTORE_VAULT_ROUTE) + markPasswordForgotten() + history.push(RESTORE_VAULT_ROUTE) if (getEnvironmentType(window.location.href) === ENVIRONMENT_TYPE_POPUP) { global.platform.openExtensionInBrowser() } }} > - { this.context.t('importUsingSeed') } + { t('importUsingSeed') } </div> </div> </div> @@ -165,15 +177,3 @@ class UnlockPage extends Component { ) } } - -UnlockPage.propTypes = { - forgotPassword: PropTypes.func, - tryUnlockMetamask: PropTypes.func, - markPasswordForgotten: PropTypes.func, - history: PropTypes.object, - isUnlocked: PropTypes.bool, - t: PropTypes.func, - useOldInterface: PropTypes.func, -} - -export default UnlockPage diff --git a/ui/app/components/pending-tx/confirm-deploy-contract.js b/ui/app/components/pending-tx/confirm-deploy-contract.js deleted file mode 100644 index af3a14f57..000000000 --- a/ui/app/components/pending-tx/confirm-deploy-contract.js +++ /dev/null @@ -1,358 +0,0 @@ -const { Component } = require('react') -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const actions = require('../../actions') -const clone = require('clone') -const ethUtil = require('ethereumjs-util') -const BN = ethUtil.BN -const hexToBn = require('../../../../app/scripts/lib/hex-to-bn') -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') - -class ConfirmDeployContract extends Component { - constructor (props) { - super(props) - - this.state = { - valid: false, - submitting: false, - } - } - - onSubmit (event) { - event.preventDefault() - const txMeta = this.gatherTxMeta() - const valid = this.checkValidity() - this.setState({ valid, submitting: true }) - - if (valid && this.verifyGasParams()) { - this.props.sendTransaction(txMeta, event) - } else { - this.props.displayWarning(this.context.t('invalidGasParams')) - this.setState({ submitting: false }) - } - } - - cancel (event, txMeta) { - event.preventDefault() - this.props.cancelTransaction(txMeta) - } - - checkValidity () { - const form = this.getFormEl() - const valid = form.checkValidity() - return valid - } - - getFormEl () { - const form = document.querySelector('form#pending-tx-form') - // Stub out form for unit tests: - if (!form) { - return { checkValidity () { return true } } - } - return form - } - - // After a customizable state value has been updated, - gatherTxMeta () { - const props = this.props - const state = this.state - const txData = clone(state.txData) || clone(props.txData) - - // log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) - return txData - } - - verifyGasParams () { - // We call this in case the gas has not been modified at all - if (!this.state) { return true } - return ( - this._notZeroOrEmptyString(this.state.gas) && - this._notZeroOrEmptyString(this.state.gasPrice) - ) - } - - _notZeroOrEmptyString (obj) { - return obj !== '' && obj !== '0x0' - } - - bnMultiplyByFraction (targetBN, numerator, denominator) { - const numBN = new BN(numerator) - const denomBN = new BN(denominator) - return targetBN.mul(numBN).div(denomBN) - } - - getData () { - const { identities } = this.props - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - - return { - from: { - address: txParams.from, - name: identities[txParams.from].name, - }, - memo: txParams.memo || '', - } - } - - getAmount () { - const { conversionRate, currentCurrency } = this.props - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - - const FIAT = conversionUtil(txParams.value, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromCurrency: 'ETH', - toCurrency: currentCurrency, - numberOfDecimals: 2, - fromDenomination: 'WEI', - conversionRate, - }) - const ETH = conversionUtil(txParams.value, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromCurrency: 'ETH', - toCurrency: 'ETH', - fromDenomination: 'WEI', - conversionRate, - numberOfDecimals: 6, - }) - - return { - fiat: Number(FIAT), - token: Number(ETH), - } - - } - - getGasFee () { - const { conversionRate, currentCurrency } = this.props - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - - // Gas - const gas = txParams.gas - const gasBn = hexToBn(gas) - - // Gas Price - const gasPrice = txParams.gasPrice || MIN_GAS_PRICE_HEX - const gasPriceBn = hexToBn(gasPrice) - - const txFeeBn = gasBn.mul(gasPriceBn) - - const FIAT = conversionUtil(txFeeBn, { - fromNumericBase: 'BN', - toNumericBase: 'dec', - fromDenomination: 'WEI', - fromCurrency: 'ETH', - toCurrency: currentCurrency, - numberOfDecimals: 2, - conversionRate, - }) - const ETH = conversionUtil(txFeeBn, { - fromNumericBase: 'BN', - toNumericBase: 'dec', - fromDenomination: 'WEI', - fromCurrency: 'ETH', - toCurrency: 'ETH', - numberOfDecimals: 6, - conversionRate, - }) - - return { - fiat: Number(FIAT), - eth: Number(ETH), - } - } - - renderGasFee () { - const { currentCurrency } = this.props - const { fiat: fiatGas, eth: ethGas } = this.getGasFee() - - return ( - h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('gasFee') ]), - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', `${fiatGas} ${currentCurrency.toUpperCase()}`), - - h( - 'div.confirm-screen-row-detail', - `${ethGas} ETH` - ), - ]), - ]) - ) - } - - renderHeroAmount () { - const { currentCurrency } = this.props - const { fiat: fiatAmount } = this.getAmount() - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - const { memo = '' } = txParams - - return ( - h('div.confirm-send-token__hero-amount-wrapper', [ - h('h3.flex-center.confirm-screen-send-amount', `${fiatAmount}`), - h('h3.flex-center.confirm-screen-send-amount-currency', currentCurrency.toUpperCase()), - h('div.flex-center.confirm-memo-wrapper', [ - h('h3.confirm-screen-send-memo', memo), - ]), - ]) - ) - } - - renderTotalPlusGas () { - const { currentCurrency } = this.props - const { fiat: fiatAmount, token: tokenAmount } = this.getAmount() - const { fiat: fiatGas, eth: ethGas } = this.getGasFee() - - return ( - h('section.flex-row.flex-center.confirm-screen-row.confirm-screen-total-box ', [ - h('div.confirm-screen-section-column', [ - h('span.confirm-screen-label', [ this.context.t('total') + ' ' ]), - h('div.confirm-screen-total-box__subtitle', [ this.context.t('amountPlusGas') ]), - ]), - - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', `${fiatAmount + fiatGas} ${currentCurrency.toUpperCase()}`), - h('div.confirm-screen-row-detail', `${tokenAmount + ethGas} ETH`), - ]), - ]) - ) - } - - render () { - const { backToAccountDetail, selectedAddress } = this.props - const txMeta = this.gatherTxMeta() - - const { - from: { - address: fromAddress, - name: fromName, - }, - } = this.getData() - - this.inputs = [] - - return ( - h('.page-container', [ - h('.page-container__header', [ - h('.page-container__header-row', [ - h('span.page-container__back-button', { - onClick: () => backToAccountDetail(selectedAddress), - }, this.context.t('back')), - window.METAMASK_UI_TYPE === 'notification' && h(NetworkDisplay), - ]), - h('.page-container__title', this.context.t('confirmContract')), - h('.page-container__subtitle', this.context.t('pleaseReviewTransaction')), - ]), - // Main Send token Card - h('.page-container__content', [ - - h(SenderToRecipient, { - senderName: fromName, - senderAddress: fromAddress, - }), - - // h('h3.flex-center.confirm-screen-sending-to-message', { - // style: { - // textAlign: 'center', - // fontSize: '16px', - // }, - // }, [ - // `You're deploying a new contract.`, - // ]), - - this.renderHeroAmount(), - - h('div.confirm-screen-rows', [ - h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('from') ]), - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', fromName), - h('div.confirm-screen-row-detail', `...${fromAddress.slice(fromAddress.length - 4)}`), - ]), - ]), - - h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('to') ]), - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', this.context.t('newContract')), - ]), - ]), - - this.renderGasFee(), - - this.renderTotalPlusGas(), - - ]), - ]), - - h('form#pending-tx-form', { - onSubmit: event => this.onSubmit(event), - }, [ - h('.page-container__footer', [ - // Cancel Button - h('button.btn-cancel.page-container__footer-button.allcaps', { - onClick: event => this.cancel(event, txMeta), - }, this.context.t('cancel')), - - // Accept Button - h('button.btn-confirm.page-container__footer-button.allcaps', { - onClick: event => this.onSubmit(event), - }, this.context.t('confirm')), - ]), - ]), - ]) - ) - } -} - -ConfirmDeployContract.propTypes = { - sendTransaction: PropTypes.func, - cancelTransaction: PropTypes.func, - backToAccountDetail: PropTypes.func, - displayWarning: PropTypes.func, - identities: PropTypes.object, - conversionRate: PropTypes.number, - currentCurrency: PropTypes.string, - selectedAddress: PropTypes.string, - t: PropTypes.func, -} - -const mapStateToProps = state => { - const { - conversionRate, - identities, - currentCurrency, - } = state.metamask - const accounts = state.metamask.accounts - const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] - return { - currentCurrency, - conversionRate, - identities, - selectedAddress, - } -} - -const mapDispatchToProps = dispatch => { - return { - backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), - cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), - displayWarning: warning => actions.displayWarning(warning), - } -} - -ConfirmDeployContract.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(ConfirmDeployContract) diff --git a/ui/app/components/pending-tx/confirm-send-ether.js b/ui/app/components/pending-tx/confirm-send-ether.js deleted file mode 100644 index bbf5683f0..000000000 --- a/ui/app/components/pending-tx/confirm-send-ether.js +++ /dev/null @@ -1,692 +0,0 @@ -const Component = require('react').Component -const { withRouter } = require('react-router-dom') -const { compose } = require('recompose') -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const inherits = require('util').inherits -const actions = require('../../actions') -const clone = require('clone') -const ethUtil = require('ethereumjs-util') -const BN = ethUtil.BN -const hexToBn = require('../../../../app/scripts/lib/hex-to-bn') -const classnames = require('classnames') -const { - conversionUtil, - addCurrencies, - multiplyCurrencies, -} = require('../../conversion-util') -const { - calcGasTotal, - isBalanceSufficient, -} = 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 { 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, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(ConfirmSendEther) - - -function mapStateToProps (state) { - const { - conversionRate, - identities, - currentCurrency, - send, - } = state.metamask - const accounts = state.metamask.accounts - const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] - const { balance } = accounts[selectedAddress] - return { - conversionRate, - identities, - selectedAddress, - currentCurrency, - send, - balance, - } -} - -function mapDispatchToProps (dispatch) { - return { - clearSend: () => dispatch(actions.clearSend()), - editTransaction: txMeta => { - const { id, txParams } = txMeta - const { - gas: gasLimit, - gasPrice, - to, - value: amount, - } = txParams - - dispatch(actions.updateSend({ - gasLimit, - gasPrice, - gasTotal: null, - to, - amount, - errors: { to: null, amount: null }, - editingTransactionId: id, - })) - }, - cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), - showCustomizeGasModal: (txMeta, sendGasLimit, sendGasPrice, sendGasTotal) => { - const { id, txParams, lastGasPrice } = txMeta - const { gas: txGasLimit, gasPrice: txGasPrice } = txParams - - let forceGasMin - if (lastGasPrice) { - forceGasMin = ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { - multiplicandBase: 16, - multiplierBase: 10, - toNumericBase: 'hex', - fromDenomination: 'WEI', - })) - } - - dispatch(actions.updateSend({ - gasLimit: sendGasLimit || txGasLimit, - gasPrice: sendGasPrice || txGasPrice, - editingTransactionId: id, - gasTotal: sendGasTotal, - forceGasMin, - })) - dispatch(actions.showModal({ name: 'CUSTOMIZE_GAS' })) - }, - updateSendErrors: error => dispatch(updateSendErrors(error)), - } -} - -inherits(ConfirmSendEther, Component) -function ConfirmSendEther () { - Component.call(this) - this.state = {} - this.onSubmit = this.onSubmit.bind(this) -} - -ConfirmSendEther.prototype.updateComponentSendErrors = function (prevProps) { - const { - balance: oldBalance, - conversionRate: oldConversionRate, - } = prevProps - const { - updateSendErrors, - balance, - conversionRate, - send: { - errors: { - simulationFails, - }, - }, - } = this.props - const txMeta = this.gatherTxMeta() - - const shouldUpdateBalanceSendErrors = balance && [ - balance !== oldBalance, - conversionRate !== oldConversionRate, - ].some(x => Boolean(x)) - - if (shouldUpdateBalanceSendErrors) { - const balanceIsSufficient = this.isBalanceSufficient(txMeta) - updateSendErrors({ - insufficientFunds: balanceIsSufficient ? false : 'insufficientFunds', - }) - } - - const shouldUpdateSimulationSendError = Boolean(txMeta.simulationFails) !== Boolean(simulationFails) - - if (shouldUpdateSimulationSendError) { - updateSendErrors({ - simulationFails: !txMeta.simulationFails ? false : 'transactionError', - }) - } -} - -ConfirmSendEther.prototype.componentWillMount = function () { - this.updateComponentSendErrors({}) -} - -ConfirmSendEther.prototype.componentDidUpdate = function (prevProps) { - this.updateComponentSendErrors(prevProps) -} - -ConfirmSendEther.prototype.getAmount = function () { - const { conversionRate, currentCurrency } = this.props - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - - const FIAT = conversionUtil(txParams.value, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromCurrency: 'ETH', - toCurrency: currentCurrency, - numberOfDecimals: 2, - fromDenomination: 'WEI', - conversionRate, - }) - const ETH = conversionUtil(txParams.value, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - fromCurrency: 'ETH', - toCurrency: 'ETH', - fromDenomination: 'WEI', - conversionRate, - numberOfDecimals: 6, - }) - - return { - FIAT, - ETH, - } - -} - -ConfirmSendEther.prototype.getGasFee = function () { - const { conversionRate, currentCurrency } = this.props - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - - // Gas - const gas = txParams.gas - const gasBn = hexToBn(gas) - - // From latest master -// const gasLimit = new BN(parseInt(blockGasLimit)) -// const safeGasLimitBN = this.bnMultiplyByFraction(gasLimit, 19, 20) -// const saferGasLimitBN = this.bnMultiplyByFraction(gasLimit, 18, 20) -// const safeGasLimit = safeGasLimitBN.toString(10) - - // Gas Price - const gasPrice = txParams.gasPrice || MIN_GAS_PRICE_HEX - const gasPriceBn = hexToBn(gasPrice) - - const txFeeBn = gasBn.mul(gasPriceBn) - - const FIAT = conversionUtil(txFeeBn, { - fromNumericBase: 'BN', - toNumericBase: 'dec', - fromDenomination: 'WEI', - fromCurrency: 'ETH', - toCurrency: currentCurrency, - numberOfDecimals: 2, - conversionRate, - }) - const ETH = conversionUtil(txFeeBn, { - fromNumericBase: 'BN', - toNumericBase: 'dec', - fromDenomination: 'WEI', - fromCurrency: 'ETH', - toCurrency: 'ETH', - numberOfDecimals: 6, - conversionRate, - }) - - return { - FIAT, - ETH, - gasFeeInHex: txFeeBn.toString(16), - } -} - -ConfirmSendEther.prototype.getData = function () { - const { identities } = this.props - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - const account = identities ? identities[txParams.from] || {} : {} - const { FIAT: gasFeeInFIAT, ETH: gasFeeInETH, gasFeeInHex } = this.getGasFee() - const { FIAT: amountInFIAT, ETH: amountInETH } = this.getAmount() - - const totalInFIAT = addCurrencies(gasFeeInFIAT, amountInFIAT, { - toNumericBase: 'dec', - numberOfDecimals: 2, - }) - const totalInETH = addCurrencies(gasFeeInETH, amountInETH, { - toNumericBase: 'dec', - numberOfDecimals: 6, - }) - - return { - from: { - address: txParams.from, - name: account.name, - }, - to: { - address: txParams.to, - name: identities[txParams.to] ? identities[txParams.to].name : this.context.t('newRecipient'), - }, - memo: txParams.memo || '', - gasFeeInFIAT, - gasFeeInETH, - amountInFIAT, - amountInETH, - totalInFIAT, - totalInETH, - gasFeeInHex, - } -} - -ConfirmSendEther.prototype.convertToRenderableCurrency = function (value, currencyCode) { - const upperCaseCurrencyCode = currencyCode.toUpperCase() - - return currencies.find(currency => currency.code === upperCaseCurrencyCode) - ? currencyFormatter.format(Number(value), { - code: upperCaseCurrencyCode, - }) - : value -} - -ConfirmSendEther.prototype.editTransaction = function () { - const { editTransaction, history } = this.props - const txMeta = this.gatherTxMeta() - editTransaction(txMeta) - history.push(SEND_ROUTE) -} - -ConfirmSendEther.prototype.renderHeaderRow = function (isTxReprice) { - const windowType = window.METAMASK_UI_TYPE - const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && - windowType !== ENVIRONMENT_TYPE_POPUP - - if (isTxReprice && isFullScreen) { - return null - } - - return ( - h('.page-container__header-row', [ - h('span.page-container__back-button', { - onClick: () => this.editTransaction(), - style: { - visibility: isTxReprice ? 'hidden' : 'initial', - }, - }, 'Edit'), - !isFullScreen && h(NetworkDisplay), - ]) - ) -} - -ConfirmSendEther.prototype.renderHeader = function (isTxReprice) { - const title = isTxReprice ? this.context.t('speedUpTitle') : this.context.t('confirm') - const subtitle = isTxReprice - ? this.context.t('speedUpSubtitle') - : this.context.t('pleaseReviewTransaction') - - return ( - h('.page-container__header', [ - this.renderHeaderRow(isTxReprice), - h('.page-container__title', title), - h('.page-container__subtitle', subtitle), - ]) - ) -} - -ConfirmSendEther.prototype.render = function () { - const { - currentCurrency, - clearSend, - conversionRate, - currentCurrency: convertedCurrency, - showCustomizeGasModal, - send: { - gasTotal, - gasLimit: sendGasLimit, - gasPrice: sendGasPrice, - errors, - }, - } = this.props - const txMeta = this.gatherTxMeta() - const isTxReprice = Boolean(txMeta.lastGasPrice) - const txParams = txMeta.txParams || {} - - const { - from: { - address: fromAddress, - name: fromName, - }, - to: { - address: toAddress, - name: toName, - }, - memo, - gasFeeInHex, - amountInFIAT, - totalInFIAT, - totalInETH, - } = this.getData() - - const convertedAmountInFiat = this.convertToRenderableCurrency(amountInFIAT, currentCurrency) - const convertedTotalInFiat = this.convertToRenderableCurrency(totalInFIAT, currentCurrency) - - // This is from the latest master - // It handles some of the errors that we are not currently handling - // Leaving as comments fo reference - - // const balanceBn = hexToBn(balance) - // const insufficientBalance = balanceBn.lt(maxCost) - // const buyDisabled = insufficientBalance || !this.state.valid || !isValidAddress || this.state.submitting - // const showRejectAll = props.unconfTxListLength > 1 -// const dangerousGasLimit = gasBn.gte(saferGasLimitBN) -// const gasLimitSpecified = txMeta.gasLimitSpecified - - this.inputs = [] - - return ( - // Main Send token Card - h('.page-container', [ - this.renderHeader(isTxReprice), - h('.page-container__content', [ - h(SenderToRecipient, { - senderName: fromName, - senderAddress: fromAddress, - recipientName: toName, - recipientAddress: txParams.to, - }), - - // h('h3.flex-center.confirm-screen-sending-to-message', { - // style: { - // textAlign: 'center', - // fontSize: '16px', - // }, - // }, [ - // `You're sending to Recipient ...${toAddress.slice(toAddress.length - 4)}`, - // ]), - - h('h3.flex-center.confirm-screen-send-amount', [`${convertedAmountInFiat}`]), - h('h3.flex-center.confirm-screen-send-amount-currency', [ currentCurrency.toUpperCase() ]), - h('div.flex-center.confirm-memo-wrapper', [ - h('h3.confirm-screen-send-memo', [ memo ? `"${memo}"` : '' ]), - ]), - - h('div.confirm-screen-rows', [ - h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('from') ]), - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', fromName), - h('div.confirm-screen-row-detail', `...${fromAddress.slice(fromAddress.length - 4)}`), - ]), - ]), - - h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('to') ]), - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', toName), - h('div.confirm-screen-row-detail', `...${toAddress.slice(toAddress.length - 4)}`), - ]), - ]), - - h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('gasFee') ]), - h('div.confirm-screen-section-column', [ - h(GasFeeDisplay, { - gasTotal: gasTotal || gasFeeInHex, - conversionRate, - convertedCurrency, - onClick: () => showCustomizeGasModal(txMeta, sendGasLimit, sendGasPrice, gasTotal), - }), - ]), - ]), - - h('section.flex-row.flex-center.confirm-screen-row.confirm-screen-total-box ', [ - h('div', { - className: classnames({ - 'confirm-screen-section-column--with-error': errors['insufficientFunds'], - 'confirm-screen-section-column': !errors['insufficientFunds'], - }), - }, [ - h('span.confirm-screen-label', [ this.context.t('total') + ' ' ]), - h('div.confirm-screen-total-box__subtitle', [ this.context.t('amountPlusGas') ]), - ]), - - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', `${convertedTotalInFiat} ${currentCurrency.toUpperCase()}`), - h('div.confirm-screen-row-detail', `${totalInETH} ETH`), - ]), - - this.renderErrorMessage('insufficientFunds'), - ]), - ]), - -// These are latest errors handling from master -// Leaving as comments as reference when we start implementing error handling -// h('style', ` -// .conf-buttons button { -// margin-left: 10px; -// text-transform: uppercase; -// } -// `), - -// txMeta.simulationFails ? -// h('.error', { -// style: { -// marginLeft: 50, -// fontSize: '0.9em', -// }, -// }, 'Transaction Error. Exception thrown in contract code.') -// : null, - -// !isValidAddress ? -// h('.error', { -// style: { -// marginLeft: 50, -// fontSize: '0.9em', -// }, -// }, 'Recipient address is invalid. Sending this transaction will result in a loss of ETH.') -// : null, - -// insufficientBalance ? -// h('span.error', { -// style: { -// marginLeft: 50, -// fontSize: '0.9em', -// }, -// }, 'Insufficient balance for transaction') -// : null, - -// // send + cancel -// h('.flex-row.flex-space-around.conf-buttons', { -// style: { -// display: 'flex', -// justifyContent: 'flex-end', -// margin: '14px 25px', -// }, -// }, [ -// h('button', { -// onClick: (event) => { -// this.resetGasFields() -// event.preventDefault() -// }, -// }, 'Reset'), - -// // Accept Button or Buy Button -// insufficientBalance ? h('button.btn-green', { onClick: props.buyEth }, 'Buy Ether') : -// h('input.confirm.btn-green', { -// type: 'submit', -// value: 'SUBMIT', -// style: { marginLeft: '10px' }, -// disabled: buyDisabled, -// }), - -// h('button.cancel.btn-red', { -// onClick: props.cancelTransaction, -// }, 'Reject'), -// ]), -// showRejectAll ? h('.flex-row.flex-space-around.conf-buttons', { -// style: { -// display: 'flex', -// justifyContent: 'flex-end', -// margin: '14px 25px', -// }, -// }, [ -// h('button.cancel.btn-red', { -// onClick: props.cancelAllTransactions, -// }, 'Reject All'), -// ]) : null, -// ]), -// ]) -// ) -// } - ]), - - h('form#pending-tx-form', { - className: 'confirm-screen-form', - onSubmit: this.onSubmit, - }, [ - this.renderErrorMessage('simulationFails'), - h('.page-container__footer', [ - // Cancel Button - h('button.btn-cancel.page-container__footer-button.allcaps', { - onClick: (event) => { - clearSend() - this.cancel(event, txMeta) - }, - }, this.context.t('cancel')), - - // Accept Button - h('button.btn-confirm.page-container__footer-button.allcaps', { - onClick: event => this.onSubmit(event), - }, this.context.t('confirm')), - ]), - ]), - ]) - ) -} - -ConfirmSendEther.prototype.renderErrorMessage = function (message) { - const { send: { errors } } = this.props - - return errors[message] - ? h('div.confirm-screen-error', [ errors[message] ]) - : null -} - -ConfirmSendEther.prototype.onSubmit = function (event) { - event.preventDefault() - const { updateSendErrors } = this.props - const txMeta = this.gatherTxMeta() - const valid = this.checkValidity() - const balanceIsSufficient = this.isBalanceSufficient(txMeta) - this.setState({ valid, submitting: true }) - - if (valid && this.verifyGasParams() && balanceIsSufficient) { - this.props.sendTransaction(txMeta, event) - } else if (!balanceIsSufficient) { - updateSendErrors({ insufficientFunds: 'insufficientFunds' }) - } else { - updateSendErrors({ invalidGasParams: 'invalidGasParams' }) - this.setState({ submitting: false }) - } -} - -ConfirmSendEther.prototype.cancel = function (event, txMeta) { - event.preventDefault() - const { cancelTransaction } = this.props - - cancelTransaction(txMeta) - .then(() => this.props.history.push(DEFAULT_ROUTE)) -} - -ConfirmSendEther.prototype.isBalanceSufficient = function (txMeta) { - const { - balance, - conversionRate, - } = this.props - const { - txParams: { - gas, - gasPrice, - value: amount, - }, - } = txMeta - const gasTotal = calcGasTotal(gas, gasPrice) - - return isBalanceSufficient({ - amount, - gasTotal, - balance, - conversionRate, - }) -} - -ConfirmSendEther.prototype.checkValidity = function () { - const form = this.getFormEl() - const valid = form.checkValidity() - return valid -} - -ConfirmSendEther.prototype.getFormEl = function () { - const form = document.querySelector('form#pending-tx-form') - // Stub out form for unit tests: - if (!form) { - return { checkValidity () { return true } } - } - return form -} - -// After a customizable state value has been updated, -ConfirmSendEther.prototype.gatherTxMeta = function () { - const props = this.props - const state = this.state - const txData = clone(state.txData) || clone(props.txData) - - const { gasPrice: sendGasPrice, gasLimit: sendGasLimit } = props.send - const { - lastGasPrice, - txParams: { - gasPrice: txGasPrice, - gas: txGasLimit, - }, - } = txData - - let forceGasMin - if (lastGasPrice) { - forceGasMin = ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { - multiplicandBase: 16, - multiplierBase: 10, - toNumericBase: 'hex', - })) - } - - txData.txParams.gasPrice = sendGasPrice || forceGasMin || txGasPrice - txData.txParams.gas = sendGasLimit || txGasLimit - - // log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) - return txData -} - -ConfirmSendEther.prototype.verifyGasParams = function () { - // We call this in case the gas has not been modified at all - if (!this.state) { return true } - return ( - this._notZeroOrEmptyString(this.state.gas) && - this._notZeroOrEmptyString(this.state.gasPrice) - ) -} - -ConfirmSendEther.prototype._notZeroOrEmptyString = function (obj) { - return obj !== '' && obj !== '0x0' -} - -ConfirmSendEther.prototype.bnMultiplyByFraction = function (targetBN, numerator, denominator) { - const numBN = new BN(numerator) - const denomBN = new BN(denominator) - return targetBN.mul(numBN).div(denomBN) -} diff --git a/ui/app/components/pending-tx/confirm-send-token.js b/ui/app/components/pending-tx/confirm-send-token.js deleted file mode 100644 index ee066b8f4..000000000 --- a/ui/app/components/pending-tx/confirm-send-token.js +++ /dev/null @@ -1,696 +0,0 @@ -const Component = require('react').Component -const { withRouter } = require('react-router-dom') -const { compose } = require('recompose') -const PropTypes = require('prop-types') -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const inherits = require('util').inherits -const tokenAbi = require('human-standard-token-abi') -const abiDecoder = require('abi-decoder') -abiDecoder.addABI(tokenAbi) -const actions = require('../../actions') -const clone = require('clone') -const Identicon = require('../identicon') -const GasFeeDisplay = require('../send/gas-fee-display-v2.js') -const NetworkDisplay = require('../network-display') -const ethUtil = require('ethereumjs-util') -const BN = ethUtil.BN -const { - conversionUtil, - multiplyCurrencies, - addCurrencies, -} = require('../../conversion-util') -const { - calcGasTotal, - isBalanceSufficient, -} = require('../send_/send.utils') -const { - calcTokenAmount, -} = require('../../token-util') -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 { - getTokenExchangeRate, - getSelectedAddress, - getSelectedTokenContract, -} = require('../../selectors') -const { SEND_ROUTE, DEFAULT_ROUTE } = require('../../routes') - -import { - updateSendErrors, -} from '../../ducks/send.duck' - -const { - ENVIRONMENT_TYPE_POPUP, - ENVIRONMENT_TYPE_NOTIFICATION, -} = require('../../../../app/scripts/lib/enums') - -ConfirmSendToken.contextTypes = { - t: PropTypes.func, -} - -module.exports = compose( - withRouter, - connect(mapStateToProps, mapDispatchToProps) -)(ConfirmSendToken) - - -function mapStateToProps (state, ownProps) { - const { token: { address }, txData } = ownProps - const { txParams } = txData || {} - const tokenData = txParams.data && abiDecoder.decodeMethod(txParams.data) - - const { - conversionRate, - identities, - currentCurrency, - } = state.metamask - const accounts = state.metamask.accounts - const selectedAddress = getSelectedAddress(state) - const tokenExchangeRate = getTokenExchangeRate(state, address) - const { balance } = accounts[selectedAddress] - return { - conversionRate, - identities, - selectedAddress, - tokenExchangeRate, - tokenData: tokenData || {}, - currentCurrency: currentCurrency.toUpperCase(), - send: state.metamask.send, - tokenContract: getSelectedTokenContract(state), - balance, - } -} - -function mapDispatchToProps (dispatch, ownProps) { - return { - backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), - cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), - editTransaction: txMeta => { - const { token: { address } } = ownProps - const { txParams = {}, id } = txMeta - const tokenData = txParams.data && abiDecoder.decodeMethod(txParams.data) || {} - const { params = [] } = tokenData - const { value: to } = params[0] || {} - const { value: tokenAmountInDec } = params[1] || {} - const tokenAmountInHex = conversionUtil(tokenAmountInDec, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }) - const { - gas: gasLimit, - gasPrice, - } = txParams - dispatch(actions.setSelectedToken(address)) - dispatch(actions.updateSend({ - gasLimit, - gasPrice, - gasTotal: null, - to, - amount: tokenAmountInHex, - errors: { to: null, amount: null }, - editingTransactionId: id && id.toString(), - token: ownProps.token, - })) - dispatch(actions.showSendTokenPage()) - }, - showCustomizeGasModal: (txMeta, sendGasLimit, sendGasPrice, sendGasTotal) => { - const { id, txParams, lastGasPrice } = txMeta - const { gas: txGasLimit, gasPrice: txGasPrice } = txParams - const tokenData = txParams.data && abiDecoder.decodeMethod(txParams.data) - const { params = [] } = tokenData - const { value: to } = params[0] || {} - const { value: tokenAmountInDec } = params[1] || {} - const tokenAmountInHex = conversionUtil(tokenAmountInDec, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }) - - let forceGasMin - if (lastGasPrice) { - forceGasMin = ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { - multiplicandBase: 16, - multiplierBase: 10, - toNumericBase: 'hex', - fromDenomination: 'WEI', - })) - } - - dispatch(actions.updateSend({ - gasLimit: sendGasLimit || txGasLimit, - gasPrice: sendGasPrice || txGasPrice, - editingTransactionId: id, - gasTotal: sendGasTotal, - to, - amount: tokenAmountInHex, - forceGasMin, - })) - dispatch(actions.showModal({ name: 'CUSTOMIZE_GAS' })) - }, - updateSendErrors: error => dispatch(updateSendErrors(error)), - } -} - -inherits(ConfirmSendToken, Component) -function ConfirmSendToken () { - Component.call(this) - this.state = {} - this.onSubmit = this.onSubmit.bind(this) -} - -ConfirmSendToken.prototype.editTransaction = function (txMeta) { - const { editTransaction, history } = this.props - editTransaction(txMeta) - history.push(SEND_ROUTE) -} - -ConfirmSendToken.prototype.updateComponentSendErrors = function (prevProps) { - const { - balance: oldBalance, - conversionRate: oldConversionRate, - } = prevProps - const { - updateSendErrors, - balance, - conversionRate, - send: { - errors: { - simulationFails, - }, - }, - } = this.props - const txMeta = this.gatherTxMeta() - - const shouldUpdateBalanceSendErrors = balance && [ - balance !== oldBalance, - conversionRate !== oldConversionRate, - ].some(x => Boolean(x)) - - if (shouldUpdateBalanceSendErrors) { - const balanceIsSufficient = this.isBalanceSufficient(txMeta) - updateSendErrors({ - insufficientFunds: balanceIsSufficient ? false : this.context.t('insufficientFunds'), - }) - } - - const shouldUpdateSimulationSendError = Boolean(txMeta.simulationFails) !== Boolean(simulationFails) - - if (shouldUpdateSimulationSendError) { - updateSendErrors({ - simulationFails: !txMeta.simulationFails ? false : this.context.t('transactionError'), - }) - } -} - -ConfirmSendToken.prototype.componentWillMount = function () { - const { tokenContract, selectedAddress } = this.props - tokenContract && tokenContract - .balanceOf(selectedAddress) - .then(usersToken => { - }) - this.updateComponentSendErrors({}) -} - -ConfirmSendToken.prototype.componentDidUpdate = function (prevProps) { - this.updateComponentSendErrors(prevProps) -} - -ConfirmSendToken.prototype.getAmount = function () { - const { - conversionRate, - tokenExchangeRate, - token, - tokenData, - send: { amount, editingTransactionId }, - } = this.props - const { params = [] } = tokenData - let { value } = params[1] || {} - const { decimals } = token - - if (editingTransactionId) { - value = conversionUtil(amount, { - fromNumericBase: 'hex', - toNumericBase: 'dec', - }) - } - - const sendTokenAmount = calcTokenAmount(value, decimals) - - return { - fiat: tokenExchangeRate - ? +(sendTokenAmount * tokenExchangeRate * conversionRate).toFixed(2) - : null, - token: typeof value === 'undefined' - ? this.context.t('unknown') - : +sendTokenAmount.toFixed(decimals), - } - -} - -ConfirmSendToken.prototype.getGasFee = function () { - const { conversionRate, tokenExchangeRate, token, currentCurrency } = this.props - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - const { decimals } = token - - const gas = txParams.gas - const gasPrice = txParams.gasPrice || MIN_GAS_PRICE_HEX - const gasTotal = multiplyCurrencies(gas, gasPrice, { - multiplicandBase: 16, - multiplierBase: 16, - }) - - const FIAT = conversionUtil(gasTotal, { - fromNumericBase: 'BN', - toNumericBase: 'dec', - fromDenomination: 'WEI', - fromCurrency: 'ETH', - toCurrency: currentCurrency, - numberOfDecimals: 2, - conversionRate, - }) - const ETH = conversionUtil(gasTotal, { - fromNumericBase: 'BN', - toNumericBase: 'dec', - fromDenomination: 'WEI', - fromCurrency: 'ETH', - toCurrency: 'ETH', - numberOfDecimals: 6, - conversionRate, - }) - const tokenGas = multiplyCurrencies(gas, gasPrice, { - toNumericBase: 'dec', - multiplicandBase: 16, - multiplierBase: 16, - toCurrency: 'BAT', - conversionRate: tokenExchangeRate, - invertConversionRate: true, - fromDenomination: 'WEI', - numberOfDecimals: decimals || 4, - }) - - return { - fiat: +Number(FIAT).toFixed(2), - eth: ETH, - token: tokenExchangeRate - ? tokenGas - : null, - gasFeeInHex: gasTotal.toString(16), - } -} - -ConfirmSendToken.prototype.getData = function () { - const { identities, tokenData } = this.props - const { params = [] } = tokenData - const { value } = params[0] || {} - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - - return { - from: { - address: txParams.from, - name: identities[txParams.from].name, - }, - to: { - address: value, - name: identities[value] ? identities[value].name : this.context.t('newRecipient'), - }, - memo: txParams.memo || '', - } -} - -ConfirmSendToken.prototype.renderHeroAmount = function () { - const { token: { symbol }, currentCurrency } = this.props - const { fiat: fiatAmount, token: tokenAmount } = this.getAmount() - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - const { memo = '' } = txParams - - const convertedAmountInFiat = this.convertToRenderableCurrency(fiatAmount, currentCurrency) - - return fiatAmount - ? ( - h('div.confirm-send-token__hero-amount-wrapper', [ - h('h3.flex-center.confirm-screen-send-amount', `${convertedAmountInFiat}`), - h('h3.flex-center.confirm-screen-send-amount-currency', currentCurrency), - h('div.flex-center.confirm-memo-wrapper', [ - h('h3.confirm-screen-send-memo', [ memo ? `"${memo}"` : '' ]), - ]), - ]) - ) - : ( - h('div.confirm-send-token__hero-amount-wrapper', [ - h('h3.flex-center.confirm-screen-send-amount', tokenAmount), - h('h3.flex-center.confirm-screen-send-amount-currency', symbol), - h('div.flex-center.confirm-memo-wrapper', [ - h('h3.confirm-screen-send-memo', [ memo ? `"${memo}"` : '' ]), - ]), - ]) - ) -} - -ConfirmSendToken.prototype.renderGasFee = function () { - const { - currentCurrency: convertedCurrency, - conversionRate, - send: { gasTotal, gasLimit: sendGasLimit, gasPrice: sendGasPrice }, - showCustomizeGasModal, - } = this.props - const txMeta = this.gatherTxMeta() - const { gasFeeInHex } = this.getGasFee() - - return ( - h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('gasFee') ]), - h('div.confirm-screen-section-column', [ - h(GasFeeDisplay, { - gasTotal: gasTotal || gasFeeInHex, - conversionRate, - convertedCurrency, - onClick: () => showCustomizeGasModal(txMeta, sendGasLimit, sendGasPrice, gasTotal), - }), - ]), - ]) - ) -} - -ConfirmSendToken.prototype.renderTotalPlusGas = function () { - const { token: { symbol }, currentCurrency, send: { errors } } = this.props - const { fiat: fiatAmount, token: tokenAmount } = this.getAmount() - const { fiat: fiatGas, token: tokenGas } = this.getGasFee() - - const totalInFIAT = fiatAmount && fiatGas && addCurrencies(fiatAmount, fiatGas) - const convertedTotalInFiat = this.convertToRenderableCurrency(totalInFIAT, currentCurrency) - - return fiatAmount && fiatGas - ? ( - h('section.flex-row.flex-center.confirm-screen-row.confirm-screen-total-box ', [ - h('div.confirm-screen-section-column', [ - h('span.confirm-screen-label', [ this.context.t('total') + ' ' ]), - h('div.confirm-screen-total-box__subtitle', [ this.context.t('amountPlusGas') ]), - ]), - - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', `${convertedTotalInFiat} ${currentCurrency}`), - h('div.confirm-screen-row-detail', `${addCurrencies(tokenAmount, tokenGas || '0')} ${symbol}`), - ]), - ]) - ) - : ( - h('section.flex-row.flex-center.confirm-screen-row.confirm-screen-total-box ', [ - h('div', { - className: classnames({ - 'confirm-screen-section-column--with-error': errors['insufficientFunds'], - 'confirm-screen-section-column': !errors['insufficientFunds'], - }), - }, [ - h('span.confirm-screen-label', [ this.context.t('total') + ' ' ]), - h('div.confirm-screen-total-box__subtitle', [ this.context.t('amountPlusGas') ]), - ]), - - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', `${tokenAmount} ${symbol}`), - h('div.confirm-screen-row-detail', `+ ${fiatGas} ${currentCurrency} ${this.context.t('gas')}`), - ]), - - this.renderErrorMessage('insufficientFunds'), - ]) - ) -} - -ConfirmSendToken.prototype.renderErrorMessage = function (message) { - const { send: { errors } } = this.props - - return errors[message] - ? h('div.confirm-screen-error', [ errors[message] ]) - : null -} - -ConfirmSendToken.prototype.convertToRenderableCurrency = function (value, currencyCode) { - const upperCaseCurrencyCode = currencyCode.toUpperCase() - - return currencies.find(currency => currency.code === upperCaseCurrencyCode) - ? currencyFormatter.format(Number(value), { - code: upperCaseCurrencyCode, - }) - : value -} - -ConfirmSendToken.prototype.renderHeaderRow = function (isTxReprice) { - const windowType = window.METAMASK_UI_TYPE - const isFullScreen = windowType !== ENVIRONMENT_TYPE_NOTIFICATION && - windowType !== ENVIRONMENT_TYPE_POPUP - - if (isTxReprice && isFullScreen) { - return null - } - - return ( - h('.page-container__header-row', [ - h('span.page-container__back-button', { - onClick: () => this.editTransaction(), - style: { - visibility: isTxReprice ? 'hidden' : 'initial', - }, - }, 'Edit'), - !isFullScreen && h(NetworkDisplay), - ]) - ) -} - -ConfirmSendToken.prototype.renderHeader = function (isTxReprice) { - const title = isTxReprice ? this.context.t('speedUpTitle') : this.context.t('confirm') - const subtitle = isTxReprice - ? this.context.t('speedUpSubtitle') - : this.context.t('pleaseReviewTransaction') - - return ( - h('.page-container__header', [ - this.renderHeaderRow(isTxReprice), - h('.page-container__title', title), - h('.page-container__subtitle', subtitle), - ]) - ) -} - -ConfirmSendToken.prototype.render = function () { - const txMeta = this.gatherTxMeta() - const { - from: { - address: fromAddress, - name: fromName, - }, - to: { - address: toAddress, - name: toName, - }, - } = this.getData() - - const isTxReprice = Boolean(txMeta.lastGasPrice) - - return ( - h('div.confirm-screen-container.confirm-send-token', [ - // Main Send token Card - h('div.page-container', [ - this.renderHeader(isTxReprice), - h('.page-container__content', [ - h('div.flex-row.flex-center.confirm-screen-identicons', [ - h('div.confirm-screen-account-wrapper', [ - h( - Identicon, - { - address: fromAddress, - diameter: 60, - }, - ), - h('span.confirm-screen-account-name', fromName), - // h('span.confirm-screen-account-number', fromAddress.slice(fromAddress.length - 4)), - ]), - h('i.fa.fa-arrow-right.fa-lg'), - h('div.confirm-screen-account-wrapper', [ - h( - Identicon, - { - address: toAddress, - diameter: 60, - }, - ), - h('span.confirm-screen-account-name', toName), - // h('span.confirm-screen-account-number', toAddress.slice(toAddress.length - 4)), - ]), - ]), - - // h('h3.flex-center.confirm-screen-sending-to-message', { - // style: { - // textAlign: 'center', - // fontSize: '16px', - // }, - // }, [ - // `You're sending to Recipient ...${toAddress.slice(toAddress.length - 4)}`, - // ]), - - this.renderHeroAmount(), - - h('div.confirm-screen-rows', [ - h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('from') ]), - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', fromName), - h('div.confirm-screen-row-detail', `...${fromAddress.slice(fromAddress.length - 4)}`), - ]), - ]), - - toAddress && h('section.flex-row.flex-center.confirm-screen-row', [ - h('span.confirm-screen-label.confirm-screen-section-column', [ this.context.t('to') ]), - h('div.confirm-screen-section-column', [ - h('div.confirm-screen-row-info', toName), - h('div.confirm-screen-row-detail', `...${toAddress.slice(toAddress.length - 4)}`), - ]), - ]), - - this.renderGasFee(), - - this.renderTotalPlusGas(), - - ]), - - ]), - - h('form#pending-tx-form', { - className: 'confirm-screen-form', - onSubmit: this.onSubmit, - }, [ - this.renderErrorMessage('simulationFails'), - h('.page-container__footer', [ - // Cancel Button - h('button.btn-cancel.page-container__footer-button.allcaps', { - onClick: (event) => this.cancel(event, txMeta), - }, this.context.t('cancel')), - - // Accept Button - h('button.btn-confirm.page-container__footer-button.allcaps', { - onClick: event => this.onSubmit(event), - }, [this.context.t('confirm')]), - ]), - ]), - ]), - ]) - ) -} - -ConfirmSendToken.prototype.onSubmit = function (event) { - event.preventDefault() - const { updateSendErrors } = this.props - const txMeta = this.gatherTxMeta() - const valid = this.checkValidity() - const balanceIsSufficient = this.isBalanceSufficient(txMeta) - this.setState({ valid, submitting: true }) - - if (valid && this.verifyGasParams() && balanceIsSufficient) { - this.props.sendTransaction(txMeta, event) - } else if (!balanceIsSufficient) { - updateSendErrors({ insufficientFunds: 'insufficientFunds' }) - } else { - updateSendErrors({ invalidGasParams: 'invalidGasParams' }) - this.setState({ submitting: false }) - } -} - -ConfirmSendToken.prototype.isBalanceSufficient = function (txMeta) { - const { - balance, - conversionRate, - } = this.props - const { - txParams: { - gas, - gasPrice, - }, - } = txMeta - const gasTotal = calcGasTotal(gas, gasPrice) - - return isBalanceSufficient({ - amount: '0', - gasTotal, - balance, - conversionRate, - }) -} - - -ConfirmSendToken.prototype.cancel = function (event, txMeta) { - event.preventDefault() - const { cancelTransaction } = this.props - - cancelTransaction(txMeta) - .then(() => this.props.history.push(DEFAULT_ROUTE)) -} - -ConfirmSendToken.prototype.checkValidity = function () { - const form = this.getFormEl() - const valid = form.checkValidity() - return valid -} - -ConfirmSendToken.prototype.getFormEl = function () { - const form = document.querySelector('form#pending-tx-form') - // Stub out form for unit tests: - if (!form) { - return { checkValidity () { return true } } - } - return form -} - -// After a customizable state value has been updated, -ConfirmSendToken.prototype.gatherTxMeta = function () { - const props = this.props - const state = this.state - const txData = clone(state.txData) || clone(props.txData) - - const { gasPrice: sendGasPrice, gasLimit: sendGasLimit } = props.send - const { - lastGasPrice, - txParams: { - gasPrice: txGasPrice, - gas: txGasLimit, - }, - } = txData - - let forceGasMin - if (lastGasPrice) { - forceGasMin = ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { - multiplicandBase: 16, - multiplierBase: 10, - toNumericBase: 'hex', - })) - } - - txData.txParams.gasPrice = sendGasPrice || forceGasMin || txGasPrice - txData.txParams.gas = sendGasLimit || txGasLimit - - // log.debug(`UI has defaulted to tx meta ${JSON.stringify(txData)}`) - return txData -} - -ConfirmSendToken.prototype.verifyGasParams = function () { - // We call this in case the gas has not been modified at all - if (!this.state) { return true } - return ( - this._notZeroOrEmptyString(this.state.gas) && - this._notZeroOrEmptyString(this.state.gasPrice) - ) -} - -ConfirmSendToken.prototype._notZeroOrEmptyString = function (obj) { - return obj !== '' && obj !== '0x0' -} - -ConfirmSendToken.prototype.bnMultiplyByFraction = function (targetBN, numerator, denominator) { - const numBN = new BN(numerator) - const denomBN = new BN(denominator) - return targetBN.mul(numBN).div(denomBN) -} diff --git a/ui/app/components/pending-tx/index.js b/ui/app/components/pending-tx/index.js deleted file mode 100644 index 3f8cd8823..000000000 --- a/ui/app/components/pending-tx/index.js +++ /dev/null @@ -1,165 +0,0 @@ -const Component = require('react').Component -const connect = require('react-redux').connect -const h = require('react-hyperscript') -const PropTypes = require('prop-types') -const clone = require('clone') -const abi = require('human-standard-token-abi') -const abiDecoder = require('abi-decoder') -abiDecoder.addABI(abi) -const inherits = require('util').inherits -const actions = require('../../actions') -const { getSymbolAndDecimals } = require('../../token-util') -const ConfirmSendEther = require('./confirm-send-ether') -const ConfirmSendToken = require('./confirm-send-token') -const ConfirmDeployContract = require('./confirm-deploy-contract') -const Loading = require('../loading-screen') - -const TX_TYPES = { - DEPLOY_CONTRACT: 'deploy_contract', - SEND_ETHER: 'send_ether', - SEND_TOKEN: 'send_token', -} - -module.exports = connect(mapStateToProps, mapDispatchToProps)(PendingTx) - -function mapStateToProps (state) { - const { - conversionRate, - identities, - tokens: existingTokens, - } = state.metamask - const accounts = state.metamask.accounts - const selectedAddress = state.metamask.selectedAddress || Object.keys(accounts)[0] - return { - conversionRate, - identities, - selectedAddress, - existingTokens, - } -} - -function mapDispatchToProps (dispatch) { - return { - backToAccountDetail: address => dispatch(actions.backToAccountDetail(address)), - cancelTransaction: ({ id }) => dispatch(actions.cancelTx({ id })), - } -} - -inherits(PendingTx, Component) -function PendingTx () { - Component.call(this) - this.state = { - isFetching: true, - transactionType: '', - tokenAddress: '', - tokenSymbol: '', - tokenDecimals: '', - } -} - -PendingTx.prototype.componentDidMount = function () { - this.setTokenData() -} - -PendingTx.prototype.componentDidUpdate = function (prevProps, prevState) { - if (prevState.isFetching) { - this.setTokenData() - } -} - -PendingTx.prototype.setTokenData = async function () { - const { existingTokens } = this.props - const txMeta = this.gatherTxMeta() - const txParams = txMeta.txParams || {} - - if (txMeta.loadingDefaults) { - return - } - - if (!txParams.to) { - return this.setState({ - transactionType: TX_TYPES.DEPLOY_CONTRACT, - isFetching: false, - }) - } - - // inspect tx data for supported special confirmation screens - let isTokenTransaction = false - if (txParams.data) { - const tokenData = abiDecoder.decodeMethod(txParams.data) - const { name: tokenMethodName } = tokenData || {} - isTokenTransaction = (tokenMethodName === 'transfer') - } - - if (isTokenTransaction) { - const { symbol, decimals } = await getSymbolAndDecimals(txParams.to, existingTokens) - - this.setState({ - transactionType: TX_TYPES.SEND_TOKEN, - tokenAddress: txParams.to, - tokenSymbol: symbol, - tokenDecimals: decimals, - isFetching: false, - }) - } else { - this.setState({ - transactionType: TX_TYPES.SEND_ETHER, - isFetching: false, - }) - } -} - -PendingTx.prototype.gatherTxMeta = function () { - const props = this.props - const state = this.state - const txData = clone(state.txData) || clone(props.txData) - - return txData -} - -PendingTx.prototype.render = function () { - const { - isFetching, - transactionType, - tokenAddress, - tokenSymbol, - tokenDecimals, - } = this.state - - const { sendTransaction } = this.props - - if (isFetching) { - return h(Loading, { - loadingMessage: this.context.t('generatingTransaction'), - }) - } - - switch (transactionType) { - case TX_TYPES.SEND_ETHER: - return h(ConfirmSendEther, { - txData: this.gatherTxMeta(), - sendTransaction, - }) - case TX_TYPES.SEND_TOKEN: - return h(ConfirmSendToken, { - txData: this.gatherTxMeta(), - sendTransaction, - token: { - address: tokenAddress, - symbol: tokenSymbol, - decimals: tokenDecimals, - }, - }) - case TX_TYPES.DEPLOY_CONTRACT: - return h(ConfirmDeployContract, { - txData: this.gatherTxMeta(), - sendTransaction, - }) - default: - return h(Loading) - } -} - -PendingTx.contextTypes = { - t: PropTypes.func, -} diff --git a/ui/app/components/selected-account/selected-account.component.js b/ui/app/components/selected-account/selected-account.component.js index 3386a4196..6c202141e 100644 --- a/ui/app/components/selected-account/selected-account.component.js +++ b/ui/app/components/selected-account/selected-account.component.js @@ -1,17 +1,10 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import copyToClipboard from 'copy-to-clipboard' +import { addressSlicer } from '../../util' const Tooltip = require('../tooltip-v2.js') -const addressStripper = (address = '') => { - if (address.length < 4) { - return address - } - - return `${address.slice(0, 4)}...${address.slice(-4)}` -} - class SelectedAccount extends Component { state = { copied: false, @@ -48,7 +41,7 @@ class SelectedAccount extends Component { { selectedIdentity.name } </div> <div className="selected-account__address"> - { addressStripper(selectedAddress) } + { addressSlicer(selectedAddress) } </div> </div> </Tooltip> diff --git a/ui/app/components/send_/README.md b/ui/app/components/send/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/README.md +++ 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 index e69de29bb..e69de29bb 100644 --- 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 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 index b8407d147..9f4a96e61 100644 --- 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 @@ -2,7 +2,7 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import { checksumAddress } from '../../../util' import Identicon from '../../identicon' -import CurrencyDisplay from '../../send/currency-display' +import CurrencyDisplay from '../currency-display' export default class AccountListItem extends Component { @@ -17,6 +17,10 @@ export default class AccountListItem extends Component { icon: PropTypes.node, }; + static contextTypes = { + t: PropTypes.func, + }; + render () { const { account, @@ -67,8 +71,3 @@ export default class AccountListItem extends Component { </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 index 3151b1f1d..4b4519288 100644 --- 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 @@ -1,7 +1,7 @@ import { connect } from 'react-redux' import { getConversionRate, - getConvertedCurrency, + getCurrentCurrency, } from '../send.selectors.js' import AccountListItem from './account-list-item.component' @@ -10,6 +10,6 @@ export default connect(mapStateToProps)(AccountListItem) function mapStateToProps (state) { return { conversionRate: getConversionRate(state), - currentCurrency: getConvertedCurrency(state), + currentCurrency: getCurrentCurrency(state), } } diff --git a/ui/app/components/send_/account-list-item/account-list-item.scss b/ui/app/components/send/account-list-item/account-list-item.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/account-list-item/account-list-item.scss +++ b/ui/app/components/send/account-list-item/account-list-item.scss diff --git a/ui/app/components/send/account-list-item/index.js b/ui/app/components/send/account-list-item/index.js new file mode 100644 index 000000000..907485cf7 --- /dev/null +++ b/ui/app/components/send/account-list-item/index.js @@ -0,0 +1 @@ +export { default } from './account-list-item.container' diff --git a/ui/app/components/send_/account-list-item/tests/account-list-item-component.test.js b/ui/app/components/send/account-list-item/tests/account-list-item-component.test.js index bb7f3776c..ef152d2e7 100644 --- 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 @@ -4,7 +4,7 @@ import { shallow } from 'enzyme' import sinon from 'sinon' import proxyquire from 'proxyquire' import Identicon from '../../../identicon' -import CurrencyDisplay from '../../../send/currency-display' +import CurrencyDisplay from '../../currency-display' const utilsMethodStubs = { checksumAddress: sinon.stub().returns('mockCheckSumAddress'), 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 index 49da920e6..af0859117 100644 --- 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 @@ -12,7 +12,7 @@ proxyquire('../account-list-item.container.js', { }, '../send.selectors.js': { getConversionRate: (s) => `mockConversionRate:${s}`, - getConvertedCurrency: (s) => `mockCurrentCurrency:${s}`, + getCurrentCurrency: (s) => `mockCurrentCurrency:${s}`, }, }) diff --git a/ui/app/components/send/currency-display.js b/ui/app/components/send/currency-display/currency-display.js index e410bc070..2b8eaa41f 100644 --- a/ui/app/components/send/currency-display.js +++ b/ui/app/components/send/currency-display/currency-display.js @@ -1,10 +1,16 @@ const Component = require('react').Component const h = require('react-hyperscript') const inherits = require('util').inherits -const { conversionUtil, multiplyCurrencies } = require('../../conversion-util') +const { conversionUtil, multiplyCurrencies } = require('../../../conversion-util') +const { removeLeadingZeroes } = require('../send.utils') const currencyFormatter = require('currency-formatter') const currencies = require('currency-formatter/currencies') const ethUtil = require('ethereumjs-util') +const PropTypes = require('prop-types') + +CurrencyDisplay.contextTypes = { + t: PropTypes.func, +} module.exports = CurrencyDisplay @@ -74,6 +80,12 @@ CurrencyDisplay.prototype.getValueToRender = function ({ selectedToken, conversi CurrencyDisplay.prototype.getConvertedValueToRender = function (nonFormattedValue) { const { primaryCurrency, convertedCurrency, conversionRate } = this.props + if (conversionRate === 0 || conversionRate === null || conversionRate === undefined) { + if (nonFormattedValue !== 0) { + return null + } + } + let convertedValue = conversionUtil(nonFormattedValue, { fromNumericBase: 'dec', fromCurrency: primaryCurrency, @@ -81,20 +93,15 @@ CurrencyDisplay.prototype.getConvertedValueToRender = function (nonFormattedValu numberOfDecimals: 2, conversionRate, }) - convertedValue = Number(convertedValue).toFixed(2) + convertedValue = Number(convertedValue).toFixed(2) const upperCaseCurrencyCode = convertedCurrency.toUpperCase() - return currencies.find(currency => currency.code === upperCaseCurrencyCode) ? currencyFormatter.format(Number(convertedValue), { code: upperCaseCurrencyCode, }) - : convertedValue -} - -function removeLeadingZeroes (str) { - return str.replace(/^0*(?=\d)/, '') -} + : convertedValue + } CurrencyDisplay.prototype.handleChange = function (newVal) { this.setState({ valueToRender: removeLeadingZeroes(newVal) }) @@ -108,16 +115,28 @@ CurrencyDisplay.prototype.getInputWidth = function (valueToRender, readOnly) { return (valueLength + decimalPointDeficit + 0.75) + 'ch' } +CurrencyDisplay.prototype.onlyRenderConversions = function (convertedValueToRender) { + const { + convertedBalanceClassName = 'currency-display__converted-value', + convertedCurrency, + } = this.props + return h('div', { + className: convertedBalanceClassName, + }, convertedValueToRender == null + ? this.context.t('noConversionRateAvailable') + : `${convertedValueToRender} ${convertedCurrency.toUpperCase()}` +) + } + CurrencyDisplay.prototype.render = function () { const { className = 'currency-display', primaryBalanceClassName = 'currency-display__input', - convertedBalanceClassName = 'currency-display__converted-value', primaryCurrency, - convertedCurrency, readOnly = false, inError = false, onBlur, + step, } = this.props const { valueToRender } = this.state @@ -152,17 +171,14 @@ CurrencyDisplay.prototype.render = function () { width: this.getInputWidth(valueToRender, readOnly), }, min: 0, + step, }), h('span.currency-display__currency-symbol', primaryCurrency), ]), - ]), - - h('div', { - className: convertedBalanceClassName, - }, `${convertedValueToRender} ${convertedCurrency.toUpperCase()}`), + ]), this.onlyRenderConversions(convertedValueToRender), ]) diff --git a/ui/app/components/send/currency-display/index.js b/ui/app/components/send/currency-display/index.js new file mode 100644 index 000000000..5dc269c5a --- /dev/null +++ b/ui/app/components/send/currency-display/index.js @@ -0,0 +1 @@ +export { default } from './currency-display.js' diff --git a/ui/app/components/send/gas-fee-display-v2.js b/ui/app/components/send/gas-fee-display-v2.js deleted file mode 100644 index 1423aa84d..000000000 --- a/ui/app/components/send/gas-fee-display-v2.js +++ /dev/null @@ -1,53 +0,0 @@ -const Component = require('react').Component -const PropTypes = require('prop-types') -const h = require('react-hyperscript') -const inherits = require('util').inherits -const CurrencyDisplay = require('./currency-display') -const connect = require('react-redux').connect - -GasFeeDisplay.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(GasFeeDisplay) - - -inherits(GasFeeDisplay, Component) -function GasFeeDisplay () { - Component.call(this) -} - -GasFeeDisplay.prototype.render = function () { - const { - conversionRate, - gasTotal, - onClick, - primaryCurrency = 'ETH', - convertedCurrency, - gasLoadingError, - } = this.props - - return h('div.send-v2__gas-fee-display', [ - - gasTotal - ? h(CurrencyDisplay, { - primaryCurrency, - convertedCurrency, - value: gasTotal, - conversionRate, - convertedPrefix: '$', - readOnly: true, - }) - : gasLoadingError - ? h('div.currency-display.currency-display--message', this.context.t('setGasPrice')) - : h('div.currency-display', this.context.t('loading')), - - h('button.sliders-icon-container', { - onClick, - disabled: !gasTotal && !gasLoadingError, - }, [ - h('i.fa.fa-sliders.sliders-icon'), - ]), - - ]) -} diff --git a/ui/app/components/send/index.js b/ui/app/components/send/index.js new file mode 100644 index 000000000..b5114babc --- /dev/null +++ b/ui/app/components/send/index.js @@ -0,0 +1 @@ +export { default } from './send.container' diff --git a/ui/app/components/send/send-content/index.js b/ui/app/components/send/send-content/index.js new file mode 100644 index 000000000..891c17e6a --- /dev/null +++ b/ui/app/components/send/send-content/index.js @@ -0,0 +1 @@ +export { default } from './send-content.component' diff --git a/ui/app/components/send_/send-content/send-amount-row/README.md b/ui/app/components/send/send-content/send-amount-row/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/send-content/send-amount-row/README.md +++ 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 index bdf12b738..4d0d36ab4 100644 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.component.js +++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js @@ -13,6 +13,10 @@ export default class AmountMaxButton extends Component { tokenBalance: PropTypes.string, }; + static contextTypes = { + t: PropTypes.func, + }; + setMaxAmount () { const { balance, @@ -48,7 +52,3 @@ export default class AmountMaxButton extends Component { } } - -AmountMaxButton.contextTypes = { - t: PropTypes.func, -} diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.container.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js index 2d2ec42f7..2d2ec42f7 100644 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.container.js +++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.container.js diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js index 69fec1994..69fec1994 100644 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js +++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.selectors.js diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js index b490a7fd7..b490a7fd7 100644 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js +++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.utils.js diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/index.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/index.js new file mode 100644 index 000000000..ee8271494 --- /dev/null +++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/index.js @@ -0,0 +1 @@ +export { default } from './amount-max-button.container' diff --git a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js index 86a05ff21..86a05ff21 100644 --- 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 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 index 2cc00d6d6..2cc00d6d6 100644 --- 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 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 index 655fe1969..655fe1969 100644 --- 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 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 index 816df6a12..816df6a12 100644 --- 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 diff --git a/ui/app/components/send/send-content/send-amount-row/index.js b/ui/app/components/send/send-content/send-amount-row/index.js new file mode 100644 index 000000000..abc6852fe --- /dev/null +++ b/ui/app/components/send/send-content/send-amount-row/index.js @@ -0,0 +1 @@ +export { default } from './send-amount-row.container' diff --git a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.component.js b/ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js index 8da36d3b7..c548a5695 100644 --- a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.component.js +++ b/ui/app/components/send/send-content/send-amount-row/send-amount-row.component.js @@ -2,7 +2,7 @@ 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' +import CurrencyDisplay from '../../currency-display' export default class SendAmountRow extends Component { @@ -21,10 +21,15 @@ export default class SendAmountRow extends Component { selectedToken: PropTypes.object, setMaxModeTo: PropTypes.func, tokenBalance: PropTypes.string, + updateGasFeeError: PropTypes.func, updateSendAmount: PropTypes.func, updateSendAmountError: PropTypes.func, updateGas: PropTypes.func, - } + }; + + static contextTypes = { + t: PropTypes.func, + }; validateAmount (amount) { const { @@ -35,6 +40,7 @@ export default class SendAmountRow extends Component { primaryCurrency, selectedToken, tokenBalance, + updateGasFeeError, updateSendAmountError, } = this.props @@ -48,6 +54,19 @@ export default class SendAmountRow extends Component { selectedToken, tokenBalance, }) + + if (selectedToken) { + updateGasFeeError({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + }) + } } updateAmount (amount) { @@ -95,14 +114,10 @@ export default class SendAmountRow extends Component { primaryCurrency={primaryCurrency || 'ETH'} selectedToken={selectedToken} value={amount} + step="any" /> </SendRowWrapper> ) } } - -SendAmountRow.contextTypes = { - t: PropTypes.func, -} - diff --git a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js b/ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js index bbbf56971..3504d1b73 100644 --- a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.container.js +++ b/ui/app/components/send/send-content/send-amount-row/send-amount-row.container.js @@ -2,7 +2,7 @@ import { connect } from 'react-redux' import { getAmountConversionRate, getConversionRate, - getConvertedCurrency, + getCurrentCurrency, getGasTotal, getPrimaryCurrency, getSelectedToken, @@ -13,7 +13,7 @@ import { import { sendAmountIsInError, } from './send-amount-row.selectors' -import { getAmountErrorObject } from '../../send.utils' +import { getAmountErrorObject, getGasFeeErrorObject } from '../../send.utils' import { setMaxModeTo, updateSendAmount, @@ -31,7 +31,7 @@ function mapStateToProps (state) { amountConversionRate: getAmountConversionRate(state), balance: getSendFromBalance(state), conversionRate: getConversionRate(state), - convertedCurrency: getConvertedCurrency(state), + convertedCurrency: getCurrentCurrency(state), gasTotal: getGasTotal(state), inError: sendAmountIsInError(state), primaryCurrency: getPrimaryCurrency(state), @@ -44,6 +44,9 @@ function mapDispatchToProps (dispatch) { return { setMaxModeTo: bool => dispatch(setMaxModeTo(bool)), updateSendAmount: newAmount => dispatch(updateSendAmount(newAmount)), + updateGasFeeError: (amountDataObject) => { + dispatch(updateSendErrors(getGasFeeErrorObject(amountDataObject))) + }, updateSendAmountError: (amountDataObject) => { dispatch(updateSendErrors(getAmountErrorObject(amountDataObject))) }, diff --git a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.scss b/ui/app/components/send/send-content/send-amount-row/send-amount-row.scss index e69de29bb..e69de29bb 100644 --- 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 diff --git a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.selectors.js b/ui/app/components/send/send-content/send-amount-row/send-amount-row.selectors.js index fb08c7ed7..fb08c7ed7 100644 --- a/ui/app/components/send_/send-content/send-amount-row/send-amount-row.selectors.js +++ b/ui/app/components/send/send-content/send-amount-row/send-amount-row.selectors.js 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 index 579e18585..8425e076e 100644 --- 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 @@ -6,13 +6,14 @@ 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' +import CurrencyDisplay from '../../../currency-display' const propsMethodSpies = { setMaxModeTo: sinon.spy(), updateSendAmount: sinon.spy(), updateSendAmountError: sinon.spy(), updateGas: sinon.spy(), + updateGasFeeError: sinon.spy(), } sinon.spy(SendAmountRow.prototype, 'updateAmount') @@ -36,6 +37,7 @@ describe('SendAmountRow Component', function () { selectedToken={ { address: 'mockTokenAddress' } } setMaxModeTo={propsMethodSpies.setMaxModeTo} tokenBalance={'mockTokenBalance'} + updateGasFeeError={propsMethodSpies.updateGasFeeError} updateSendAmount={propsMethodSpies.updateSendAmount} updateSendAmountError={propsMethodSpies.updateSendAmountError} updateGas={propsMethodSpies.updateGas} @@ -47,6 +49,7 @@ describe('SendAmountRow Component', function () { propsMethodSpies.setMaxModeTo.resetHistory() propsMethodSpies.updateSendAmount.resetHistory() propsMethodSpies.updateSendAmountError.resetHistory() + propsMethodSpies.updateGasFeeError.resetHistory() SendAmountRow.prototype.validateAmount.resetHistory() SendAmountRow.prototype.updateAmount.resetHistory() }) @@ -72,6 +75,32 @@ describe('SendAmountRow Component', function () { ) }) + it('should call updateGasFeeError if selectedToken is truthy', () => { + assert.equal(propsMethodSpies.updateGasFeeError.callCount, 0) + instance.validateAmount('someAmount') + assert.equal(propsMethodSpies.updateGasFeeError.callCount, 1) + assert.deepEqual( + propsMethodSpies.updateGasFeeError.getCall(0).args, + [{ + amount: 'someAmount', + amountConversionRate: 'mockAmountConversionRate', + balance: 'mockBalance', + conversionRate: 7, + gasTotal: 'mockGasTotal', + primaryCurrency: 'mockPrimaryCurrency', + selectedToken: { address: 'mockTokenAddress' }, + tokenBalance: 'mockTokenBalance', + }] + ) + }) + + it('should call not updateGasFeeError if selectedToken is falsey', () => { + wrapper.setProps({ selectedToken: null }) + assert.equal(propsMethodSpies.updateGasFeeError.callCount, 0) + instance.validateAmount('someAmount') + assert.equal(propsMethodSpies.updateGasFeeError.callCount, 0) + }) + }) describe('updateAmount', () => { 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 index e4c913c69..52e351aee 100644 --- 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 @@ -24,7 +24,7 @@ proxyquire('../send-amount-row.container.js', { '../../send.selectors': { getAmountConversionRate: (s) => `mockAmountConversionRate:${s}`, getConversionRate: (s) => `mockConversionRate:${s}`, - getConvertedCurrency: (s) => `mockConvertedCurrency:${s}`, + getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`, getGasTotal: (s) => `mockGasTotal:${s}`, getPrimaryCurrency: (s) => `mockPrimaryCurrency:${s}`, getSelectedToken: (s) => `mockSelectedToken:${s}`, @@ -33,7 +33,10 @@ proxyquire('../send-amount-row.container.js', { getTokenBalance: (s) => `mockTokenBalance:${s}`, }, './send-amount-row.selectors': { sendAmountIsInError: (s) => `mockInError:${s}` }, - '../../send.utils': { getAmountErrorObject: (mockDataObject) => ({ ...mockDataObject, mockChange: true }) }, + '../../send.utils': { + getAmountErrorObject: (mockDataObject) => ({ ...mockDataObject, mockChange: true }), + getGasFeeErrorObject: (mockDataObject) => ({ ...mockDataObject, mockGasFeeErrorChange: true }), + }, '../../../../actions': actionSpies, '../../../../ducks/send.duck': duckActionSpies, }) @@ -66,6 +69,7 @@ describe('send-amount-row container', () => { beforeEach(() => { dispatchSpy = sinon.spy() mapDispatchToPropsObject = mapDispatchToProps(dispatchSpy) + duckActionSpies.updateSendErrors.resetHistory() }) describe('setMaxModeTo()', () => { @@ -92,6 +96,18 @@ describe('send-amount-row container', () => { }) }) + describe('updateGasFeeError()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.updateGasFeeError({ some: 'data' }) + assert(dispatchSpy.calledOnce) + assert(duckActionSpies.updateSendErrors.calledOnce) + assert.deepEqual( + duckActionSpies.updateSendErrors.getCall(0).args[0], + { some: 'data', mockGasFeeErrorChange: true } + ) + }) + }) + describe('updateSendAmountError()', () => { it('should dispatch an action', () => { mapDispatchToPropsObject.updateSendAmountError({ some: 'data' }) 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 index 4672cb8a7..4672cb8a7 100644 --- 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 diff --git a/ui/app/components/send_/send-content/send-content-README.md b/ui/app/components/send/send-content/send-content-README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/send-content/send-content-README.md +++ 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 index adc114c0e..7a0b1a18e 100644 --- a/ui/app/components/send_/send-content/send-content.component.js +++ b/ui/app/components/send/send-content/send-content.component.js @@ -4,6 +4,7 @@ import PageContainerContent from '../../page-container/page-container-content.co import SendAmountRow from './send-amount-row/' import SendFromRow from './send-from-row/' import SendGasRow from './send-gas-row/' +import SendHexDataRow from './send-hex-data-row' import SendToRow from './send-to-row/' export default class SendContent extends Component { @@ -20,6 +21,7 @@ export default class SendContent extends Component { <SendToRow updateGas={(updateData) => this.props.updateGas(updateData)} /> <SendAmountRow updateGas={(updateData) => this.props.updateGas(updateData)} /> <SendGasRow /> + <SendHexDataRow /> </div> </PageContainerContent> ) diff --git a/ui/app/components/send_/send-content/send-content.scss b/ui/app/components/send/send-content/send-content.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/send-content/send-content.scss +++ b/ui/app/components/send/send-content/send-content.scss diff --git a/ui/app/components/send/send-content/send-dropdown-list/index.js b/ui/app/components/send/send-content/send-dropdown-list/index.js new file mode 100644 index 000000000..04af6536c --- /dev/null +++ b/ui/app/components/send/send-content/send-dropdown-list/index.js @@ -0,0 +1 @@ +export { default } from './send-dropdown-list.component' diff --git a/ui/app/components/send_/send-content/send-dropdown-list/send-dropdown-list.component.js b/ui/app/components/send/send-content/send-dropdown-list/send-dropdown-list.component.js index 5c7174ecf..bedac1259 100644 --- 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 @@ -11,6 +11,10 @@ export default class SendDropdownList extends Component { activeAddress: PropTypes.string, }; + static contextTypes = { + t: PropTypes.func, + }; + getListItemIcon (accountAddress, activeAddress) { return accountAddress === activeAddress ? <i className={`fa fa-check fa-lg`} style={ { color: '#02c9b1' } }/> @@ -46,7 +50,3 @@ export default class SendDropdownList extends Component { } } - -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 index b92dd4dfe..b92dd4dfe 100644 --- 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 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 index e69de29bb..e69de29bb 100644 --- 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 diff --git a/ui/app/components/send_/send-content/send-from-row/from-dropdown/from-dropdown.component.js b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.component.js index 418766cd9..4f43a9d61 100644 --- a/ui/app/components/send_/send-content/send-from-row/from-dropdown/from-dropdown.component.js +++ b/ui/app/components/send/send-content/send-from-row/from-dropdown/from-dropdown.component.js @@ -14,6 +14,10 @@ export default class FromDropdown extends Component { selectedAccount: PropTypes.object, }; + static contextTypes = { + t: PropTypes.func, + }; + render () { const { accounts, @@ -40,7 +44,3 @@ export default class FromDropdown extends Component { } } - -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 index e69de29bb..e69de29bb 100644 --- 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 diff --git a/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js b/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js new file mode 100644 index 000000000..2314ef4e3 --- /dev/null +++ b/ui/app/components/send/send-content/send-from-row/from-dropdown/index.js @@ -0,0 +1 @@ +export { default } from './from-dropdown.component' diff --git a/ui/app/components/send_/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js b/ui/app/components/send/send-content/send-from-row/from-dropdown/tests/from-dropdown-component.test.js index 84fcb281e..84fcb281e 100644 --- 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 diff --git a/ui/app/components/send/send-content/send-from-row/index.js b/ui/app/components/send/send-content/send-from-row/index.js new file mode 100644 index 000000000..0a79726b2 --- /dev/null +++ b/ui/app/components/send/send-content/send-from-row/index.js @@ -0,0 +1 @@ +export { default } from './send-from-row.container' diff --git a/ui/app/components/send_/send-content/send-from-row/send-from-row-README.md b/ui/app/components/send/send-content/send-from-row/send-from-row-README.md index e69de29bb..e69de29bb 100644 --- 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 diff --git a/ui/app/components/send_/send-content/send-from-row/send-from-row.component.js b/ui/app/components/send/send-content/send-from-row/send-from-row.component.js index a580aef96..3e0e0de22 100644 --- a/ui/app/components/send_/send-content/send-from-row/send-from-row.component.js +++ b/ui/app/components/send/send-content/send-from-row/send-from-row.component.js @@ -17,6 +17,10 @@ export default class SendFromRow extends Component { setSendTokenBalance: PropTypes.func, }; + static contextTypes = { + t: PropTypes.func, + }; + async handleFromChange (newFrom) { const { updateSendFrom, @@ -57,7 +61,3 @@ export default class SendFromRow extends Component { } } - -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 index 33cb63b43..33cb63b43 100644 --- a/ui/app/components/send_/send-content/send-from-row/send-from-row.container.js +++ b/ui/app/components/send/send-content/send-from-row/send-from-row.container.js 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 index 03ef4806b..03ef4806b 100644 --- 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 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 index 9ba8d1739..9ba8d1739 100644 --- 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 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 index e080b2fe3..e080b2fe3 100644 --- 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 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 index ecb57bbc3..ecb57bbc3 100644 --- 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 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 index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/send-content/send-gas-row/README.md +++ b/ui/app/components/send/send-content/send-gas-row/README.md diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js new file mode 100644 index 000000000..bb9a94428 --- /dev/null +++ b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/gas-fee-display.component.js @@ -0,0 +1,61 @@ +import React, {Component} from 'react' +import PropTypes from 'prop-types' +import CurrencyDisplay from '../../../../send/currency-display' + + +export default class GasFeeDisplay extends Component { + + static propTypes = { + conversionRate: PropTypes.number, + primaryCurrency: PropTypes.string, + convertedCurrency: PropTypes.string, + gasLoadingError: PropTypes.bool, + gasTotal: PropTypes.string, + onClick: PropTypes.func, + }; + + static contextTypes = { + t: PropTypes.func, + }; + + render () { + const { + conversionRate, + gasTotal, + onClick, + primaryCurrency = 'ETH', + convertedCurrency, + gasLoadingError, + } = this.props + + return ( + <div className="send-v2__gas-fee-display"> + {gasTotal + ? <CurrencyDisplay + primaryCurrency={primaryCurrency} + convertedCurrency={convertedCurrency} + value={gasTotal} + conversionRate={conversionRate} + gasLoadingError={gasLoadingError} + convertedPrefix={'$'} + readOnly + /> + : gasLoadingError + ? <div className="currency-display.currency-display--message"> + {this.context.t('setGasPrice')} + </div> + : <div className="currency-display"> + {this.context.t('loading')} + </div> + } + <button + className="sliders-icon-container" + onClick={onClick} + disabled={!gasTotal && !gasLoadingError} + > + <i className="fa fa-sliders sliders-icon" /> + </button> + </div> + ) + } +} diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js new file mode 100644 index 000000000..dba0edb7b --- /dev/null +++ b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/index.js @@ -0,0 +1 @@ +export { default } from './gas-fee-display.component' diff --git a/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js new file mode 100644 index 000000000..7cbe8d0df --- /dev/null +++ b/ui/app/components/send/send-content/send-gas-row/gas-fee-display/test/gas-fee-display.component.test.js @@ -0,0 +1,55 @@ +import React from 'react' +import assert from 'assert' +import {shallow} from 'enzyme' +import GasFeeDisplay from '../gas-fee-display.component' +import CurrencyDisplay from '../../../../../send/currency-display' +import sinon from 'sinon' + + +const propsMethodSpies = { + showCustomizeGasModal: sinon.spy(), +} + +describe('SendGasRow Component', function () { + let wrapper + + beforeEach(() => { + wrapper = shallow(<GasFeeDisplay + conversionRate={20} + gasTotal={'mockGasTotal'} + onClick={propsMethodSpies.showCustomizeGasModal} + primaryCurrency={'mockPrimaryCurrency'} + convertedCurrency={'mockConvertedCurrency'} + />, {context: {t: str => str + '_t'}}) + }) + + afterEach(() => { + propsMethodSpies.showCustomizeGasModal.resetHistory() + }) + + describe('render', () => { + it('should render a CurrencyDisplay component', () => { + assert.equal(wrapper.find(CurrencyDisplay).length, 1) + }) + + it('should render the CurrencyDisplay with the correct props', () => { + const { + conversionRate, + convertedCurrency, + value, + } = wrapper.find(CurrencyDisplay).props() + assert.equal(conversionRate, 20) + assert.equal(convertedCurrency, 'mockConvertedCurrency') + assert.equal(value, 'mockGasTotal') + }) + + it('should render the Button with the correct props', () => { + const { + onClick, + } = wrapper.find('button').props() + assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 0) + onClick() + assert.equal(propsMethodSpies.showCustomizeGasModal.callCount, 1) + }) + }) +}) diff --git a/ui/app/components/send/send-content/send-gas-row/index.js b/ui/app/components/send/send-content/send-gas-row/index.js new file mode 100644 index 000000000..3c7ff1d5f --- /dev/null +++ b/ui/app/components/send/send-content/send-gas-row/index.js @@ -0,0 +1 @@ +export { default } from './send-gas-row.container' diff --git a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.component.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js index c80d8c0bb..91b58cfd0 100644 --- a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.component.js +++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js @@ -1,29 +1,39 @@ import React, { Component } from 'react' import PropTypes from 'prop-types' import SendRowWrapper from '../send-row-wrapper/' -import GasFeeDisplay from '../../../send/gas-fee-display-v2' +import GasFeeDisplay from './gas-fee-display/gas-fee-display.component' export default class SendGasRow extends Component { static propTypes = { conversionRate: PropTypes.number, convertedCurrency: PropTypes.string, + gasFeeError: PropTypes.bool, gasLoadingError: PropTypes.bool, gasTotal: PropTypes.string, showCustomizeGasModal: PropTypes.func, }; + static contextTypes = { + t: PropTypes.func, + }; + render () { const { conversionRate, convertedCurrency, gasLoadingError, gasTotal, + gasFeeError, showCustomizeGasModal, } = this.props return ( - <SendRowWrapper label={`${this.context.t('gasFee')}:`}> + <SendRowWrapper + label={`${this.context.t('gasFee')}:`} + showError={gasFeeError} + errorType={'gasFee'} + > <GasFeeDisplay conversionRate={conversionRate} convertedCurrency={convertedCurrency} @@ -36,7 +46,3 @@ export default class SendGasRow extends Component { } } - -SendGasRow.contextTypes = { - t: PropTypes.func, -} diff --git a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js index 20d3daa59..8f8e3e4dd 100644 --- a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.container.js +++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.container.js @@ -1,10 +1,10 @@ import { connect } from 'react-redux' import { getConversionRate, - getConvertedCurrency, + getCurrentCurrency, getGasTotal, } from '../../send.selectors.js' -import { sendGasIsInError } from './send-gas-row.selectors.js' +import { getGasLoadingError, gasFeeIsInError } from './send-gas-row.selectors.js' import { showModal } from '../../../../actions' import SendGasRow from './send-gas-row.component' @@ -13,9 +13,10 @@ export default connect(mapStateToProps, mapDispatchToProps)(SendGasRow) function mapStateToProps (state) { return { conversionRate: getConversionRate(state), - convertedCurrency: getConvertedCurrency(state), + convertedCurrency: getCurrentCurrency(state), gasTotal: getGasTotal(state), - gasLoadingError: sendGasIsInError(state), + gasFeeError: gasFeeIsInError(state), + gasLoadingError: getGasLoadingError(state), } } 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 index e69de29bb..e69de29bb 100644 --- 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 diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js new file mode 100644 index 000000000..96f6293c2 --- /dev/null +++ b/ui/app/components/send/send-content/send-gas-row/send-gas-row.selectors.js @@ -0,0 +1,14 @@ +const selectors = { + gasFeeIsInError, + getGasLoadingError, +} + +module.exports = selectors + +function getGasLoadingError (state) { + return state.send.errors.gasLoading +} + +function gasFeeIsInError (state) { + return Boolean(state.send.errors.gasFee) +} diff --git a/ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-component.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js index e4f05d708..54a92bd2d 100644 --- 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 @@ -5,7 +5,7 @@ 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' +import GasFeeDisplay from '../gas-fee-display/gas-fee-display.component' const propsMethodSpies = { showCustomizeGasModal: sinon.spy(), @@ -18,6 +18,7 @@ describe('SendGasRow Component', function () { wrapper = shallow(<SendGasRow conversionRate={20} convertedCurrency={'mockConvertedCurrency'} + gasFeeError={'mockGasFeeError'} gasLoadingError={false} gasTotal={'mockGasTotal'} showCustomizeGasModal={propsMethodSpies.showCustomizeGasModal} @@ -36,9 +37,13 @@ describe('SendGasRow Component', function () { it('should pass the correct props to SendRowWrapper', () => { const { label, + showError, + errorType, } = wrapper.find(SendRowWrapper).props() assert.equal(label, 'gasFee_t:') + assert.equal(showError, 'mockGasFeeError') + assert.equal(errorType, 'gasFee') }) it('should render a GasFeeDisplay as a child of the SendRowWrapper', () => { 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 index 9135524d1..2ce062505 100644 --- 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 @@ -19,10 +19,13 @@ proxyquire('../send-gas-row.container.js', { }, '../../send.selectors.js': { getConversionRate: (s) => `mockConversionRate:${s}`, - getConvertedCurrency: (s) => `mockConvertedCurrency:${s}`, + getCurrentCurrency: (s) => `mockConvertedCurrency:${s}`, getGasTotal: (s) => `mockGasTotal:${s}`, }, - './send-gas-row.selectors.js': { sendGasIsInError: (s) => `mockGasLoadingError:${s}` }, + './send-gas-row.selectors.js': { + getGasLoadingError: (s) => `mockGasLoadingError:${s}`, + gasFeeIsInError: (s) => `mockGasFeeError:${s}`, + }, '../../../../actions': actionSpies, }) @@ -35,6 +38,7 @@ describe('send-gas-row container', () => { conversionRate: 'mockConversionRate:mockState', convertedCurrency: 'mockConvertedCurrency:mockState', gasTotal: 'mockGasTotal:mockState', + gasFeeError: 'mockGasFeeError:mockState', gasLoadingError: 'mockGasLoadingError:mockState', }) }) diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js new file mode 100644 index 000000000..d46dd9d8b --- /dev/null +++ b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-selectors.test.js @@ -0,0 +1,49 @@ +import assert from 'assert' +import { + gasFeeIsInError, + getGasLoadingError, +} from '../send-gas-row.selectors.js' + +describe('send-gas-row selectors', () => { + + describe('getGasLoadingError()', () => { + it('should return send.errors.gasLoading', () => { + const state = { + send: { + errors: { + gasLoading: 'abc', + }, + }, + } + + assert.equal(getGasLoadingError(state), 'abc') + }) + }) + + describe('gasFeeIsInError()', () => { + it('should return true if send.errors.gasFee is truthy', () => { + const state = { + send: { + errors: { + gasFee: 'def', + }, + }, + } + + assert.equal(gasFeeIsInError(state), true) + }) + + it('should return false send.errors.gasFee is falsely', () => { + const state = { + send: { + errors: { + gasFee: null, + }, + }, + } + + assert.equal(gasFeeIsInError(state), false) + }) + }) + +}) diff --git a/ui/app/components/send/send-content/send-hex-data-row/index.js b/ui/app/components/send/send-content/send-hex-data-row/index.js new file mode 100644 index 000000000..08c341067 --- /dev/null +++ b/ui/app/components/send/send-content/send-hex-data-row/index.js @@ -0,0 +1 @@ +export { default } from './send-hex-data-row.container' diff --git a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js b/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js new file mode 100644 index 000000000..063930db3 --- /dev/null +++ b/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.component.js @@ -0,0 +1,40 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import SendRowWrapper from '../send-row-wrapper' + +export default class SendHexDataRow extends Component { + static propTypes = { + data: PropTypes.string, + inError: PropTypes.bool, + updateSendHexData: PropTypes.func.isRequired, + }; + + static contextTypes = { + t: PropTypes.func, + }; + + onInput = (event) => { + const {updateSendHexData} = this.props + event.target.value = event.target.value.replace(/\n/g, '') + updateSendHexData(event.target.value || null) + } + + render () { + const {inError} = this.props + const {t} = this.context + + return ( + <SendRowWrapper + label={`${t('hexData')}:`} + showError={inError} + errorType={'amount'} + > + <textarea + onInput={this.onInput} + placeholder="Optional" + className="send-v2__hex-data__input" + /> + </SendRowWrapper> + ) + } +} diff --git a/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.container.js b/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.container.js new file mode 100644 index 000000000..df554ca5f --- /dev/null +++ b/ui/app/components/send/send-content/send-hex-data-row/send-hex-data-row.container.js @@ -0,0 +1,21 @@ +import { connect } from 'react-redux' +import { + updateSendHexData, +} from '../../../../actions' +import SendHexDataRow from './send-hex-data-row.component' + +export default connect(mapStateToProps, mapDispatchToProps)(SendHexDataRow) + +function mapStateToProps (state) { + return { + data: state.metamask.send.data, + } +} + +function mapDispatchToProps (dispatch) { + return { + updateSendHexData (data) { + return dispatch(updateSendHexData(data)) + }, + } +} diff --git a/ui/app/components/send/send-content/send-row-wrapper/index.js b/ui/app/components/send/send-content/send-row-wrapper/index.js new file mode 100644 index 000000000..d17545dcc --- /dev/null +++ b/ui/app/components/send/send-content/send-row-wrapper/index.js @@ -0,0 +1 @@ +export { default } from './send-row-wrapper.component' diff --git a/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js new file mode 100644 index 000000000..c00617f83 --- /dev/null +++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/index.js @@ -0,0 +1 @@ +export { default } from './send-row-error-message.container' diff --git a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message-README.md index e69de29bb..e69de29bb 100644 --- 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 diff --git a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js index 0d314208b..61bc7bab7 100644 --- a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js +++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.component.js @@ -8,6 +8,10 @@ export default class SendRowErrorMessage extends Component { errorType: PropTypes.string, }; + static contextTypes = { + t: PropTypes.func, + }; + render () { const { errors, errorType } = this.props @@ -21,7 +25,3 @@ export default class SendRowErrorMessage extends Component { } } - -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 index 59622047f..59622047f 100644 --- a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js +++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-error-message/send-row-error-message.container.js 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 index e69de29bb..e69de29bb 100644 --- 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 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 index 2304a43d2..2304a43d2 100644 --- 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 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 index eecff165d..eecff165d 100644 --- 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 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 index e69de29bb..e69de29bb 100644 --- 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 diff --git a/ui/app/components/send_/send-content/send-row-wrapper/send-row-wrapper.component.js b/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js index f484bd8d9..b7528a15f 100644 --- a/ui/app/components/send_/send-content/send-row-wrapper/send-row-wrapper.component.js +++ b/ui/app/components/send/send-content/send-row-wrapper/send-row-wrapper.component.js @@ -11,6 +11,10 @@ export default class SendRowWrapper extends Component { showError: PropTypes.bool, }; + static contextTypes = { + t: PropTypes.func, + }; + render () { const { children, @@ -37,7 +41,3 @@ export default class SendRowWrapper extends Component { } } - -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 index e69de29bb..e69de29bb 100644 --- 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 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 index 30280e1d0..30280e1d0 100644 --- 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 diff --git a/ui/app/components/send/send-content/send-to-row/index.js b/ui/app/components/send/send-content/send-to-row/index.js new file mode 100644 index 000000000..121f15148 --- /dev/null +++ b/ui/app/components/send/send-content/send-to-row/index.js @@ -0,0 +1 @@ +export { default } from './send-to-row.container' diff --git a/ui/app/components/send_/send-content/send-to-row/send-to-row-README.md b/ui/app/components/send/send-content/send-to-row/send-to-row-README.md index e69de29bb..e69de29bb 100644 --- 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 diff --git a/ui/app/components/send_/send-content/send-to-row/send-to-row.component.js b/ui/app/components/send/send-content/send-to-row/send-to-row.component.js index 0a83186a5..892ad5d67 100644 --- a/ui/app/components/send_/send-content/send-to-row/send-to-row.component.js +++ b/ui/app/components/send/send-content/send-to-row/send-to-row.component.js @@ -19,9 +19,13 @@ export default class SendToRow extends Component { updateSendToError: PropTypes.func, }; - handleToChange (to, nickname = '') { + static contextTypes = { + t: PropTypes.func, + }; + + handleToChange (to, nickname = '', toError) { const { updateSendTo, updateSendToError, updateGas } = this.props - const toErrorObject = getToErrorObject(to) + const toErrorObject = getToErrorObject(to, toError) updateSendTo(to, nickname) updateSendToError(toErrorObject) if (toErrorObject.to === null) { @@ -53,7 +57,7 @@ export default class SendToRow extends Component { inError={inError} name={'address'} network={network} - onChange={(newTo, newNickname) => this.handleToChange(newTo, newNickname)} + onChange={({ toAddress, nickname, toError }) => this.handleToChange(toAddress, nickname, toError)} openDropdown={() => openToDropdown()} placeholder={this.context.t('recipientAddress')} to={to} @@ -63,8 +67,3 @@ export default class SendToRow extends Component { } } - -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 index 1c9c9d518..1c9c9d518 100644 --- a/ui/app/components/send_/send-content/send-to-row/send-to-row.container.js +++ b/ui/app/components/send/send-content/send-to-row/send-to-row.container.js diff --git a/ui/app/components/send_/send-content/send-to-row/send-to-row.selectors.js b/ui/app/components/send/send-content/send-to-row/send-to-row.selectors.js index 8919014be..8919014be 100644 --- a/ui/app/components/send_/send-content/send-to-row/send-to-row.selectors.js +++ b/ui/app/components/send/send-content/send-to-row/send-to-row.selectors.js diff --git a/ui/app/components/send_/send-content/send-to-row/send-to-row.utils.js b/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js index cea51ee20..6b90a9f09 100644 --- a/ui/app/components/send_/send-content/send-to-row/send-to-row.utils.js +++ b/ui/app/components/send/send-content/send-to-row/send-to-row.utils.js @@ -4,12 +4,10 @@ const { } = require('../../send.constants') const { isValidAddress } = require('../../../../util') -function getToErrorObject (to) { - let toError = null - +function getToErrorObject (to, toError = null) { if (!to) { toError = REQUIRED_ERROR - } else if (!isValidAddress(to)) { + } else if (!isValidAddress(to) && !toError) { toError = INVALID_RECIPIENT_ADDRESS_ERROR } 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 index 58fe51dcf..781371004 100644 --- 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 @@ -6,8 +6,8 @@ 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}`, + getToErrorObject: (to, toError) => ({ + to: to === false ? null : `mockToErrorObject:${to}${toError}`, }), }, }).default @@ -67,11 +67,11 @@ describe('SendToRow Component', function () { it('should call updateSendToError', () => { assert.equal(propsMethodSpies.updateSendToError.callCount, 0) - instance.handleToChange('mockTo2') + instance.handleToChange('mockTo2', '', 'mockToError') assert.equal(propsMethodSpies.updateSendToError.callCount, 1) assert.deepEqual( propsMethodSpies.updateSendToError.getCall(0).args, - [{ to: 'mockToErrorObject:mockTo2' }] + [{ to: 'mockToErrorObject:mockTo2mockToError' }] ) }) @@ -138,11 +138,11 @@ describe('SendToRow Component', function () { openDropdown() assert.equal(propsMethodSpies.openToDropdown.callCount, 1) assert.equal(SendToRow.prototype.handleToChange.callCount, 0) - onChange('mockNewTo', 'mockNewNickname') + onChange({ toAddress: 'mockNewTo', nickname: 'mockNewNickname', toError: 'mockToError' }) assert.equal(SendToRow.prototype.handleToChange.callCount, 1) assert.deepEqual( SendToRow.prototype.handleToChange.getCall(0).args, - ['mockNewTo', 'mockNewNickname'] + ['mockNewTo', 'mockNewNickname', 'mockToError'] ) }) }) diff --git a/ui/app/components/send_/send-content/send-to-row/tests/send-to-row-container.test.js b/ui/app/components/send/send-content/send-to-row/tests/send-to-row-container.test.js index 92355c00a..92355c00a 100644 --- 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 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 index 122ad3265..122ad3265 100644 --- 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 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 index 615c9581b..4d2447c32 100644 --- 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 @@ -40,6 +40,12 @@ describe('send-to-row utils', () => { to: null, }) }) + + it('should return the passed error if to is truthy but invalid if to is truthy and valid', () => { + assert.deepEqual(getToErrorObject('invalid #$ 345878', 'someExplicitError'), { + to: 'someExplicitError', + }) + }) }) }) diff --git a/ui/app/components/send_/send-content/tests/send-content-component.test.js b/ui/app/components/send/send-content/tests/send-content-component.test.js index d5bb6693c..d5bb6693c 100644 --- 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 diff --git a/ui/app/components/send_/send-footer/README.md b/ui/app/components/send/send-footer/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/send-footer/README.md +++ b/ui/app/components/send/send-footer/README.md diff --git a/ui/app/components/send/send-footer/index.js b/ui/app/components/send/send-footer/index.js new file mode 100644 index 000000000..58e91d622 --- /dev/null +++ b/ui/app/components/send/send-footer/index.js @@ -0,0 +1 @@ +export { default } from './send-footer.container' diff --git a/ui/app/components/send_/send-footer/send-footer.component.js b/ui/app/components/send/send-footer/send-footer.component.js index 6471ae1a3..518cff06e 100644 --- a/ui/app/components/send_/send-footer/send-footer.component.js +++ b/ui/app/components/send/send-footer/send-footer.component.js @@ -8,6 +8,7 @@ export default class SendFooter extends Component { static propTypes = { addToAddressBookIfNew: PropTypes.func, amount: PropTypes.string, + data: PropTypes.string, clearSend: PropTypes.func, disabled: PropTypes.bool, editingTransactionId: PropTypes.string, @@ -27,6 +28,10 @@ export default class SendFooter extends Component { update: PropTypes.func, }; + static contextTypes = { + t: PropTypes.func, + }; + onCancel () { this.props.clearSend() this.props.history.push(DEFAULT_ROUTE) @@ -37,6 +42,7 @@ export default class SendFooter extends Component { const { addToAddressBookIfNew, amount, + data, editingTransactionId, from: {address: from}, gasLimit: gas, @@ -48,6 +54,7 @@ export default class SendFooter extends Component { // updateTx, update, toAccounts, + history, } = this.props // Should not be needed because submit should be disabled if there are errors. @@ -60,9 +67,10 @@ export default class SendFooter extends Component { // TODO: add nickname functionality addToAddressBookIfNew(to, toAccounts) - editingTransactionId + const promise = editingTransactionId ? update({ amount, + data, editingTransactionId, from, gas, @@ -71,9 +79,10 @@ export default class SendFooter extends Component { to, unapprovedTxs, }) - : sign({ selectedToken, to, amount, from, gas, gasPrice }) + : sign({ data, selectedToken, to, amount, from, gas, gasPrice }) - this.props.history.push(CONFIRM_TRANSACTION_ROUTE) + Promise.resolve(promise) + .then(() => history.push(CONFIRM_TRANSACTION_ROUTE)) } formShouldBeDisabled () { @@ -93,7 +102,3 @@ export default class SendFooter extends Component { } } - -SendFooter.contextTypes = { - t: PropTypes.func, -} diff --git a/ui/app/components/send_/send-footer/send-footer.container.js b/ui/app/components/send/send-footer/send-footer.container.js index 260ff40bc..60de4d030 100644 --- a/ui/app/components/send_/send-footer/send-footer.container.js +++ b/ui/app/components/send/send-footer/send-footer.container.js @@ -18,6 +18,7 @@ import { getSendFromObject, getSendTo, getSendToAccounts, + getSendHexData, getTokenBalance, getUnapprovedTxs, } from '../send.selectors' @@ -35,6 +36,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(SendFooter) function mapStateToProps (state) { return { amount: getSendAmount(state), + data: getSendHexData(state), editingTransactionId: getSendEditingTransactionId(state), from: getSendFromObject(state), gasLimit: getGasLimit(state), @@ -52,9 +54,10 @@ function mapStateToProps (state) { function mapDispatchToProps (dispatch) { return { clearSend: () => dispatch(clearSend()), - sign: ({ selectedToken, to, amount, from, gas, gasPrice }) => { + sign: ({ selectedToken, to, amount, from, gas, gasPrice, data }) => { const txParams = constructTxParams({ amount, + data, from, gas, gasPrice, @@ -68,6 +71,7 @@ function mapDispatchToProps (dispatch) { }, update: ({ amount, + data, editingTransactionId, from, gas, @@ -78,6 +82,7 @@ function mapDispatchToProps (dispatch) { }) => { const editingTx = constructUpdatedTx({ amount, + data, editingTransactionId, from, gas, @@ -87,7 +92,7 @@ function mapDispatchToProps (dispatch) { unapprovedTxs, }) - dispatch(updateTransaction(editingTx)) + return dispatch(updateTransaction(editingTx)) }, addToAddressBookIfNew: (newAddress, toAccounts, nickname = '') => { const hexPrefixedAddress = ethUtil.addHexPrefix(newAddress) diff --git a/ui/app/components/send_/send-footer/send-footer.scss b/ui/app/components/send/send-footer/send-footer.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/send-footer/send-footer.scss +++ 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 index e20addfdc..e20addfdc 100644 --- a/ui/app/components/send_/send-footer/send-footer.selectors.js +++ b/ui/app/components/send/send-footer/send-footer.selectors.js diff --git a/ui/app/components/send_/send-footer/send-footer.utils.js b/ui/app/components/send/send-footer/send-footer.utils.js index 875e7d948..f82ff1e9b 100644 --- a/ui/app/components/send_/send-footer/send-footer.utils.js +++ b/ui/app/components/send/send-footer/send-footer.utils.js @@ -8,8 +8,9 @@ function addHexPrefixToObjectValues (obj) { }, {}) } -function constructTxParams ({ selectedToken, to, amount, from, gas, gasPrice }) { +function constructTxParams ({ selectedToken, data, to, amount, from, gas, gasPrice }) { const txParams = { + data, from, value: '0', gas, @@ -21,13 +22,12 @@ function constructTxParams ({ selectedToken, to, amount, from, gas, gasPrice }) txParams.to = to } - const hexPrefixedTxParams = addHexPrefixToObjectValues(txParams) - - return hexPrefixedTxParams + return addHexPrefixToObjectValues(txParams) } function constructUpdatedTx ({ amount, + data, editingTransactionId, from, gas, @@ -36,9 +36,21 @@ function constructUpdatedTx ({ to, unapprovedTxs, }) { + const unapprovedTx = unapprovedTxs[editingTransactionId] + const txParamsData = unapprovedTx.txParams.data ? unapprovedTx.txParams.data : data const editingTx = { - ...unapprovedTxs[editingTransactionId], - txParams: addHexPrefixToObjectValues({ from, gas, gasPrice }), + ...unapprovedTx, + txParams: Object.assign( + unapprovedTx.txParams, + addHexPrefixToObjectValues({ + data: txParamsData, + to, + from, + gas, + gasPrice, + value: amount, + }) + ), } if (selectedToken) { @@ -52,18 +64,10 @@ function constructUpdatedTx ({ 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 - } + if (typeof editingTx.txParams.data === 'undefined') { + delete editingTx.txParams.data } return editingTx 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 index e071fe54f..65e4bb654 100644 --- 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 @@ -129,6 +129,7 @@ describe('SendFooter Component', function () { assert.deepEqual( propsMethodSpies.update.getCall(0).args[0], { + data: undefined, amount: 'mockAmount', editingTransactionId: 'mockEditingTransactionId', from: 'mockAddress', @@ -152,6 +153,7 @@ describe('SendFooter Component', function () { assert.deepEqual( propsMethodSpies.sign.getCall(0).args[0], { + data: undefined, amount: 'mockAmount', from: 'mockAddress', gas: 'mockGasLimit', @@ -166,10 +168,13 @@ describe('SendFooter Component', function () { 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) + it('should call history.push', done => { + Promise.resolve(wrapper.instance().onSubmit(MOCK_EVENT)) + .then(() => { + assert.equal(historySpies.push.callCount, 1) + assert.equal(historySpies.push.getCall(0).args[0], CONFIRM_TRANSACTION_ROUTE) + done() + }) }) }) 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 index 39d6a7686..cf4c893ee 100644 --- 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 @@ -38,6 +38,7 @@ proxyquire('../send-footer.container.js', { getSendTo: (s) => `mockTo:${s}`, getSendToAccounts: (s) => `mockToAccounts:${s}`, getTokenBalance: (s) => `mockTokenBalance:${s}`, + getSendHexData: (s) => `mockHexData:${s}`, getUnapprovedTxs: (s) => `mockUnapprovedTxs:${s}`, }, './send-footer.selectors': { isSendFormInError: (s) => `mockInError:${s}` }, @@ -51,6 +52,7 @@ describe('send-footer container', () => { it('should map the correct properties to props', () => { assert.deepEqual(mapStateToProps('mockState'), { amount: 'mockAmount:mockState', + data: 'mockHexData:mockState', selectedToken: 'mockSelectedToken:mockState', editingTransactionId: 'mockEditingTransactionId:mockState', from: 'mockFromObject:mockState', @@ -100,6 +102,7 @@ describe('send-footer container', () => { assert.deepEqual( utilsStubs.constructTxParams.getCall(0).args[0], { + data: undefined, selectedToken: { address: '0xabc', }, @@ -129,6 +132,7 @@ describe('send-footer container', () => { assert.deepEqual( utilsStubs.constructTxParams.getCall(0).args[0], { + data: undefined, selectedToken: undefined, to: 'mockTo', amount: 'mockAmount', @@ -160,6 +164,7 @@ describe('send-footer container', () => { assert.deepEqual( utilsStubs.constructUpdatedTx.getCall(0).args[0], { + data: undefined, to: 'mockTo', amount: 'mockAmount', from: 'mockFrom', 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 index 8de032f57..8de032f57 100644 --- 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 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 index 2d3135995..28ff0c891 100644 --- 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 @@ -65,6 +65,28 @@ describe('send-footer utils', () => { }) describe('constructTxParams()', () => { + it('should return a new txParams object with data if there data is given', () => { + assert.deepEqual( + constructTxParams({ + data: 'someData', + selectedToken: false, + to: 'mockTo', + amount: 'mockAmount', + from: 'mockFrom', + gas: 'mockGas', + gasPrice: 'mockGasPrice', + }), + { + data: '0xsomeData', + to: '0xmockTo', + value: '0xmockAmount', + from: '0xmockFrom', + gas: '0xmockGas', + gasPrice: '0xmockGasPrice', + } + ) + }) + it('should return a new txParams object with value and to properties if there is no selectedToken', () => { assert.deepEqual( constructTxParams({ @@ -76,6 +98,7 @@ describe('send-footer utils', () => { gasPrice: 'mockGasPrice', }), { + data: undefined, to: '0xmockTo', value: '0xmockAmount', from: '0xmockFrom', @@ -96,6 +119,7 @@ describe('send-footer utils', () => { gasPrice: 'mockGasPrice', }), { + data: undefined, value: '0x0', from: '0xmockFrom', gas: '0xmockGas', diff --git a/ui/app/components/send_/send-header/README.md b/ui/app/components/send/send-header/README.md index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/send-header/README.md +++ b/ui/app/components/send/send-header/README.md diff --git a/ui/app/components/send/send-header/index.js b/ui/app/components/send/send-header/index.js new file mode 100644 index 000000000..0b17f0b7d --- /dev/null +++ b/ui/app/components/send/send-header/index.js @@ -0,0 +1 @@ +export { default } from './send-header.container' diff --git a/ui/app/components/send_/send-header/send-header.component.js b/ui/app/components/send/send-header/send-header.component.js index 5f6617fce..efc4bbf27 100644 --- a/ui/app/components/send_/send-header/send-header.component.js +++ b/ui/app/components/send/send-header/send-header.component.js @@ -12,6 +12,10 @@ export default class SendHeader extends Component { subtitleParams: PropTypes.array, }; + static contextTypes = { + t: PropTypes.func, + }; + onClose () { this.props.clearSend() this.props.history.push(DEFAULT_ROUTE) @@ -28,7 +32,3 @@ export default class SendHeader extends Component { } } - -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 index 4bcd0d1b6..4bcd0d1b6 100644 --- a/ui/app/components/send_/send-header/send-header.container.js +++ b/ui/app/components/send/send-header/send-header.container.js diff --git a/ui/app/components/send_/send-header/send-header.selectors.js b/ui/app/components/send/send-header/send-header.selectors.js index d7c9d3766..d7c9d3766 100644 --- a/ui/app/components/send_/send-header/send-header.selectors.js +++ b/ui/app/components/send/send-header/send-header.selectors.js 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 index 930bfa387..930bfa387 100644 --- 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 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 index 41a7e8a89..41a7e8a89 100644 --- 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 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 index e0c6a3ab3..e0c6a3ab3 100644 --- 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 diff --git a/ui/app/components/send_/send.component.js b/ui/app/components/send/send.component.js index 219b362f2..6f1b20c55 100644 --- a/ui/app/components/send_/send.component.js +++ b/ui/app/components/send/send.component.js @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import PersistentForm from '../../../lib/persistent-form' import { getAmountErrorObject, + getGasFeeErrorObject, getToAddressForGasUpdate, doesAmountErrorRequireUpdate, } from './send.utils' @@ -39,6 +40,10 @@ export default class SendTransactionScreen extends PersistentForm { updateSendTokenBalance: PropTypes.func, }; + static contextTypes = { + t: PropTypes.func, + }; + updateGas ({ to: updatedToAddress, amount: value } = {}) { const { amount, @@ -112,7 +117,19 @@ export default class SendTransactionScreen extends PersistentForm { selectedToken, tokenBalance, }) - updateSendErrors(amountErrorObject) + const gasFeeErrorObject = selectedToken + ? getGasFeeErrorObject({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + selectedToken, + tokenBalance, + }) + : { gasFee: null } + updateSendErrors(Object.assign(amountErrorObject, gasFeeErrorObject)) } if (!uninitialized) { @@ -143,6 +160,10 @@ export default class SendTransactionScreen extends PersistentForm { this.updateGas() } + componentWillUnmount () { + this.props.resetSendState() + } + render () { const { history } = this.props @@ -156,7 +177,3 @@ export default class SendTransactionScreen extends PersistentForm { } } - -SendTransactionScreen.contextTypes = { - t: PropTypes.func, -} diff --git a/ui/app/components/send_/send.constants.js b/ui/app/components/send/send.constants.js index df5dee371..8acdf0641 100644 --- a/ui/app/components/send_/send.constants.js +++ b/ui/app/components/send/send.constants.js @@ -36,6 +36,7 @@ const ONE_GWEI_IN_WEI_HEX = ethUtil.addHexPrefix(conversionUtil('0x1', { })) const SIMPLE_GAS_COST = '0x5208' // Hex for 21000, cost of a simple send. +const BASE_TOKEN_GAS_COST = '0x186a0' // Hex for 100000, a base estimate for token transfers. module.exports = { INSUFFICIENT_FUNDS_ERROR, @@ -52,4 +53,5 @@ module.exports = { REQUIRED_ERROR, SIMPLE_GAS_COST, TOKEN_TRANSFER_FUNCTION_SIGNATURE, + BASE_TOKEN_GAS_COST, } diff --git a/ui/app/components/send_/send.container.js b/ui/app/components/send/send.container.js index 185653c5f..44ebd2792 100644 --- a/ui/app/components/send_/send.container.js +++ b/ui/app/components/send/send.container.js @@ -28,6 +28,7 @@ import { setGasTotal, } from '../../actions' import { + resetSendState, updateSendErrors, } from '../../ducks/send.duck' import { @@ -87,5 +88,6 @@ function mapDispatchToProps (dispatch) { })) }, updateSendErrors: newError => dispatch(updateSendErrors(newError)), + resetSendState: () => dispatch(resetSendState()), } } diff --git a/ui/app/components/send_/send.scss b/ui/app/components/send/send.scss index e69de29bb..e69de29bb 100644 --- a/ui/app/components/send_/send.scss +++ 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 index 7e7cfe2e9..cf07eafe1 100644 --- a/ui/app/components/send_/send.selectors.js +++ b/ui/app/components/send/send.selectors.js @@ -14,7 +14,6 @@ const selectors = { getAmountConversionRate, getBlockGasLimit, getConversionRate, - getConvertedCurrency, getCurrentAccountWithSendEtherInfo, getCurrentCurrency, getCurrentNetwork, @@ -34,6 +33,7 @@ const selectors = { getSelectedTokenExchangeRate, getSelectedTokenToFiatRate, getSendAmount, + getSendHexData, getSendEditingTransactionId, getSendErrors, getSendFrom, @@ -98,10 +98,6 @@ 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) @@ -215,6 +211,10 @@ function getSendAmount (state) { return state.metamask.send.amount } +function getSendHexData (state) { + return state.metamask.send.data +} + function getSendEditingTransactionId (state) { return state.metamask.send.editingTransactionId } diff --git a/ui/app/components/send_/send.utils.js b/ui/app/components/send/send.utils.js index dfd459731..aa255c3d4 100644 --- a/ui/app/components/send_/send.utils.js +++ b/ui/app/components/send/send.utils.js @@ -10,6 +10,7 @@ const { calcTokenAmount, } = require('../../token-util') const { + BASE_TOKEN_GAS_COST, INSUFFICIENT_FUNDS_ERROR, INSUFFICIENT_TOKENS_ERROR, NEGATIVE_ETH_ERROR, @@ -29,12 +30,14 @@ module.exports = { estimateGasPriceFromRecentBlocks, generateTokenTransferData, getAmountErrorObject, + getGasFeeErrorObject, getToAddressForGasUpdate, isBalanceSufficient, isTokenBalanceSufficient, + removeLeadingZeroes, } -function calcGasTotal (gasLimit, gasPrice) { +function calcGasTotal (gasLimit = '0', gasPrice = '0') { return multiplyCurrencies(gasLimit, gasPrice, { toNumericBase: 'hex', multiplicandBase: 16, @@ -44,9 +47,9 @@ function calcGasTotal (gasLimit, gasPrice) { function isBalanceSufficient ({ amount = '0x0', - amountConversionRate = 0, - balance, - conversionRate, + amountConversionRate = 1, + balance = '0x0', + conversionRate = 1, gasTotal = '0x0', primaryCurrency, }) { @@ -108,9 +111,9 @@ function getAmountErrorObject ({ tokenBalance, }) { let insufficientFunds = false - if (gasTotal && conversionRate) { + if (gasTotal && conversionRate && !selectedToken) { insufficientFunds = !isBalanceSufficient({ - amount: selectedToken ? '0x0' : amount, + amount, amountConversionRate, balance, conversionRate, @@ -147,6 +150,34 @@ function getAmountErrorObject ({ return { amount: amountError } } +function getGasFeeErrorObject ({ + amount, + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, +}) { + let gasFeeError = null + + if (gasTotal && conversionRate) { + const insufficientFunds = !isBalanceSufficient({ + amount: '0x0', + amountConversionRate, + balance, + conversionRate, + gasTotal, + primaryCurrency, + }) + + if (insufficientFunds) { + gasFeeError = INSUFFICIENT_FUNDS_ERROR + } + } + + return { gasFee: gasFeeError } +} + function calcTokenBalance ({ selectedToken, usersToken }) { const { decimals } = selectedToken || {} return calcTokenAmount(usersToken.balance.toString(), decimals) + '' @@ -183,6 +214,8 @@ async function estimateGas ({ selectedAddress, selectedToken, blockGasLimit, to, if (!code || code === '0x') { return SIMPLE_GAS_COST } + } else if (selectedToken && !to) { + return BASE_TOKEN_GAS_COST } paramsForGasEstimate.to = selectedToken ? selectedToken.address : to @@ -273,3 +306,7 @@ function estimateGasPriceFromRecentBlocks (recentBlocks) { function getToAddressForGasUpdate (...addresses) { return [...addresses, ''].find(str => str !== undefined && str !== null).toLowerCase() } + +function removeLeadingZeroes (str) { + return str.replace(/^0*(?=\d)/, '') +} diff --git a/ui/app/components/send_/tests/send-component.test.js b/ui/app/components/send/tests/send-component.test.js index 4ba9b226d..6194ec508 100644 --- a/ui/app/components/send_/tests/send-component.test.js +++ b/ui/app/components/send/tests/send-component.test.js @@ -12,9 +12,11 @@ const propsMethodSpies = { updateAndSetGasTotal: sinon.spy(), updateSendErrors: sinon.spy(), updateSendTokenBalance: sinon.spy(), + resetSendState: sinon.spy(), } const utilsMethodStubs = { getAmountErrorObject: sinon.stub().returns({ amount: 'mockAmountError' }), + getGasFeeErrorObject: sinon.stub().returns({ gasFee: 'mockGasFeeError' }), doesAmountErrorRequireUpdate: sinon.stub().callsFake(obj => obj.balance !== obj.prevBalance), } @@ -50,6 +52,7 @@ describe('Send Component', function () { updateAndSetGasTotal={propsMethodSpies.updateAndSetGasTotal} updateSendErrors={propsMethodSpies.updateSendErrors} updateSendTokenBalance={propsMethodSpies.updateSendTokenBalance} + resetSendState={propsMethodSpies.resetSendState} />) }) @@ -58,6 +61,7 @@ describe('Send Component', function () { SendTransactionScreen.prototype.updateGas.resetHistory() utilsMethodStubs.doesAmountErrorRequireUpdate.resetHistory() utilsMethodStubs.getAmountErrorObject.resetHistory() + utilsMethodStubs.getGasFeeErrorObject.resetHistory() propsMethodSpies.updateAndSetGasTotal.resetHistory() propsMethodSpies.updateSendErrors.resetHistory() propsMethodSpies.updateSendTokenBalance.resetHistory() @@ -77,6 +81,15 @@ describe('Send Component', function () { }) }) + describe('componentWillUnmount', () => { + it('should call this.props.resetSendState', () => { + propsMethodSpies.resetSendState.resetHistory() + assert.equal(propsMethodSpies.resetSendState.callCount, 0) + wrapper.instance().componentWillUnmount() + assert.equal(propsMethodSpies.resetSendState.callCount, 1) + }) + }) + describe('componentDidUpdate', () => { it('should call doesAmountErrorRequireUpdate with the expected params', () => { utilsMethodStubs.getAmountErrorObject.resetHistory() @@ -133,8 +146,66 @@ describe('Send Component', function () { ) }) - it('should call updateSendErrors with the expected params', () => { + it('should call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true and selectedToken is truthy', () => { + utilsMethodStubs.getGasFeeErrorObject.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + }) + assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 1) + assert.deepEqual( + utilsMethodStubs.getGasFeeErrorObject.getCall(0).args[0], + { + amount: 'mockAmount', + amountConversionRate: 'mockAmountConversionRate', + balance: 'mockBalance', + conversionRate: 10, + gasTotal: 'mockGasTotal', + primaryCurrency: 'mockPrimaryCurrency', + selectedToken: 'mockSelectedToken', + tokenBalance: 'mockTokenBalance', + } + ) + }) + + it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns false', () => { + utilsMethodStubs.getGasFeeErrorObject.resetHistory() + wrapper.instance().componentDidUpdate({ + from: { address: 'mockAddress', balance: 'mockBalance' }, + }) + assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 0) + }) + + it('should not call getGasFeeErrorObject if doesAmountErrorRequireUpdate returns true but selectedToken is falsy', () => { + utilsMethodStubs.getGasFeeErrorObject.resetHistory() + wrapper.setProps({ selectedToken: null }) + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + }) + assert.equal(utilsMethodStubs.getGasFeeErrorObject.callCount, 0) + }) + + it('should call updateSendErrors with the expected params if selectedToken is falsy', () => { + propsMethodSpies.updateSendErrors.resetHistory() + wrapper.setProps({ selectedToken: null }) + wrapper.instance().componentDidUpdate({ + from: { + balance: 'balanceChanged', + }, + }) + assert.equal(propsMethodSpies.updateSendErrors.callCount, 1) + assert.deepEqual( + propsMethodSpies.updateSendErrors.getCall(0).args[0], + { amount: 'mockAmountError', gasFee: null } + ) + }) + + it('should call updateSendErrors with the expected params if selectedToken is truthy', () => { propsMethodSpies.updateSendErrors.resetHistory() + wrapper.setProps({ selectedToken: 'someToken' }) wrapper.instance().componentDidUpdate({ from: { balance: 'balanceChanged', @@ -143,7 +214,7 @@ describe('Send Component', function () { assert.equal(propsMethodSpies.updateSendErrors.callCount, 1) assert.deepEqual( propsMethodSpies.updateSendErrors.getCall(0).args[0], - { amount: 'mockAmountError'} + { amount: 'mockAmountError', gasFee: 'mockGasFeeError' } ) }) diff --git a/ui/app/components/send_/tests/send-container.test.js b/ui/app/components/send/tests/send-container.test.js index 91484f4d8..7a9120d24 100644 --- a/ui/app/components/send_/tests/send-container.test.js +++ b/ui/app/components/send/tests/send-container.test.js @@ -12,6 +12,7 @@ const actionSpies = { } const duckActionSpies = { updateSendErrors: sinon.spy(), + resetSendState: sinon.spy(), } proxyquire('../send.container.js', { @@ -152,6 +153,17 @@ describe('send container', () => { }) }) + describe('resetSendState()', () => { + it('should dispatch an action', () => { + mapDispatchToPropsObject.resetSendState() + assert(dispatchSpy.calledOnce) + assert.equal( + duckActionSpies.resetSendState.getCall(0).args.length, + 0 + ) + }) + }) + }) }) diff --git a/ui/app/components/send_/tests/send-selectors-test-data.js b/ui/app/components/send/tests/send-selectors-test-data.js index 8f9c19314..8f9c19314 100644 --- a/ui/app/components/send_/tests/send-selectors-test-data.js +++ b/ui/app/components/send/tests/send-selectors-test-data.js diff --git a/ui/app/components/send_/tests/send-selectors.test.js b/ui/app/components/send/tests/send-selectors.test.js index 152af8059..218da656b 100644 --- a/ui/app/components/send_/tests/send-selectors.test.js +++ b/ui/app/components/send/tests/send-selectors.test.js @@ -8,7 +8,6 @@ const { getBlockGasLimit, getAmountConversionRate, getConversionRate, - getConvertedCurrency, getCurrentAccountWithSendEtherInfo, getCurrentCurrency, getCurrentNetwork, @@ -154,15 +153,6 @@ describe('send selectors', () => { }) }) - 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( diff --git a/ui/app/components/send_/tests/send-utils.test.js b/ui/app/components/send/tests/send-utils.test.js index f3d5674b7..18dde495a 100644 --- a/ui/app/components/send_/tests/send-utils.test.js +++ b/ui/app/components/send/tests/send-utils.test.js @@ -2,6 +2,7 @@ import assert from 'assert' import sinon from 'sinon' import proxyquire from 'proxyquire' import { + BASE_TOKEN_GAS_COST, ONE_GWEI_IN_WEI_HEX, SIMPLE_GAS_COST, } from '../send.constants' @@ -16,7 +17,11 @@ const { } = require('../send.constants') const stubs = { - addCurrencies: sinon.stub().callsFake((a, b, obj) => a + b), + addCurrencies: sinon.stub().callsFake((a, b, obj) => { + if (String(a).match(/^0x.+/)) a = Number(String(a).slice(2)) + if (String(b).match(/^0x.+/)) b = Number(String(b).slice(2)) + return a + b + }), conversionUtil: sinon.stub().callsFake((val, obj) => parseInt(val, 16)), conversionGTE: sinon.stub().callsFake((obj1, obj2) => obj1.value >= obj2.value), multiplyCurrencies: sinon.stub().callsFake((a, b) => `${a}x${b}`), @@ -48,10 +53,12 @@ const { estimateGasPriceFromRecentBlocks, generateTokenTransferData, getAmountErrorObject, + getGasFeeErrorObject, getToAddressForGasUpdate, calcTokenBalance, isBalanceSufficient, isTokenBalanceSufficient, + removeLeadingZeroes, } = sendUtils describe('send utils', () => { @@ -142,6 +149,18 @@ describe('send utils', () => { primaryCurrency: 'ABC', expectedResult: { amount: INSUFFICIENT_FUNDS_ERROR }, }, + 'should not return insufficientFunds error if selectedToken is truthy': { + amount: '0x0', + amountConversionRate: 2, + balance: 1, + conversionRate: 3, + gasTotal: 17, + primaryCurrency: 'ABC', + selectedToken: { symbole: 'DEF', decimals: 0 }, + decimals: 0, + tokenBalance: 'sometokenbalance', + expectedResult: { amount: null }, + }, 'should return insufficientTokens error if token is selected and isTokenBalanceSufficient returns false': { amount: '0x10', amountConversionRate: 2, @@ -162,6 +181,32 @@ describe('send utils', () => { }) }) + describe('getGasFeeErrorObject()', () => { + const config = { + 'should return insufficientFunds error if isBalanceSufficient returns false': { + amountConversionRate: 2, + balance: 16, + conversionRate: 3, + gasTotal: 17, + primaryCurrency: 'ABC', + expectedResult: { gasFee: INSUFFICIENT_FUNDS_ERROR }, + }, + 'should return null error if isBalanceSufficient returns true': { + amountConversionRate: 2, + balance: 16, + conversionRate: 3, + gasTotal: 15, + primaryCurrency: 'ABC', + expectedResult: { gasFee: null }, + }, + } + Object.entries(config).map(([description, obj]) => { + it(description, () => { + assert.deepEqual(getGasFeeErrorObject(obj), obj.expectedResult) + }) + }) + }) + describe('calcTokenBalance()', () => { it('should return the calculated token blance', () => { assert.equal(calcTokenBalance({ @@ -221,6 +266,7 @@ describe('send utils', () => { describe('isTokenBalanceSufficient()', () => { it('should correctly call conversionUtil and return the result of calling conversionGTE', () => { stubs.conversionGTE.resetHistory() + stubs.conversionUtil.resetHistory() const result = isTokenBalanceSufficient({ amount: '0x10', tokenBalance: 123, @@ -336,6 +382,11 @@ describe('send utils', () => { assert.notEqual(result, SIMPLE_GAS_COST) }) + it(`should return ${BASE_TOKEN_GAS_COST} if passed a selectedToken but no to address`, async () => { + const result = await estimateGas(Object.assign({}, baseMockParams, { to: null, selectedToken: { address: '' } })) + assert.equal(result, BASE_TOKEN_GAS_COST) + }) + it(`should return the adjusted blockGasLimit if it fails with a 'Transaction execution error.'`, async () => { const result = await estimateGas(Object.assign({}, baseMockParams, { to: 'isContract willFailBecauseOf:Transaction execution error.', @@ -433,4 +484,29 @@ describe('send utils', () => { assert.equal(getToAddressForGasUpdate(undefined, 'B'), 'b') }) }) + + describe('removeLeadingZeroes()', () => { + it('should remove leading zeroes from int when user types', () => { + assert.equal(removeLeadingZeroes('0'), '0') + assert.equal(removeLeadingZeroes('1'), '1') + assert.equal(removeLeadingZeroes('00'), '0') + assert.equal(removeLeadingZeroes('01'), '1') + }) + + it('should remove leading zeroes from int when user copy/paste', () => { + assert.equal(removeLeadingZeroes('001'), '1') + }) + + it('should remove leading zeroes from float when user types', () => { + assert.equal(removeLeadingZeroes('0.'), '0.') + assert.equal(removeLeadingZeroes('0.0'), '0.0') + assert.equal(removeLeadingZeroes('0.00'), '0.00') + assert.equal(removeLeadingZeroes('0.001'), '0.001') + assert.equal(removeLeadingZeroes('0.10'), '0.10') + }) + + it('should remove leading zeroes from float when user copy/paste', () => { + assert.equal(removeLeadingZeroes('00.1'), '0.1') + }) + }) }) diff --git a/ui/app/components/send/to-autocomplete.component.js b/ui/app/components/send/to-autocomplete.component.js new file mode 100644 index 000000000..9e270db75 --- /dev/null +++ b/ui/app/components/send/to-autocomplete.component.js @@ -0,0 +1,141 @@ +import React, {Component} from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import AccountListItem from '../send/account-list-item/account-list-item.component' + + +export default class ToAutoComplete extends Component { + + static propTypes = { + dropdownOpen: PropTypes.bool, + openDropdown: PropTypes.func, + closeDropdown: PropTypes.func, + onChange: PropTypes.func, + to: PropTypes.string, + accounts: PropTypes.array, + inError: PropTypes.bool, + } + + static contextTypes = { + t: PropTypes.func, + } + + state = { + accountsToRender: [], + } + + getListItemIcon (listItemAddress, toAddress) { + return toAddress && listItemAddress === toAddress + ? <i className={'fa fa-check fa-lg'} + style={{ + color: '#02c9b1', + }} + /> + : null + } + + renderDropdown () { + const { + closeDropdown, + onChange, + to, + } = this.props + const {accountsToRender} = this.state + + if (!accountsToRender.length) { + return null + } + + return ( + <div> + <div className={'send-v2__from-dropdown__close-area'} onClick={closeDropdown} /> + <div className={'send-v2__from-dropdown__list'}> + {accountsToRender.map((account, i) => ( + <AccountListItem + key={i} + account={account} + className={'account-list-item__dropdown'} + handleClick={() => { + onChange(account.address) + closeDropdown() + }} + icon={this.getListItemIcon(account.address, to)} + displayBalance={false} + displayAddress={true} + /> + ))} + </div> + </div> + ) + } + + handleInputEvent (event = {}, cb) { + const { + to, + accounts, + closeDropdown, + openDropdown, + } = this.props + + const matchingAccounts = accounts.filter(({address}) => address.match(to || '')) + const matches = matchingAccounts.length + + if (!matches || matchingAccounts[0].address === to) { + this.setState({accountsToRender: []}) + event.target && event.target.select() + closeDropdown() + } else { + this.setState({accountsToRender: matchingAccounts}) + openDropdown() + } + cb && cb(event.target.value) + } + + componentDidUpdate (nextProps) { + if (this.props.to !== nextProps.to) { + this.handleInputEvent() + } + } + + render () { + const { + to, + dropdownOpen, + onChange, + inError, + } = this.props + + return ( + <div className={'send-v2__to-autocomplete'}> + <input + className={classnames('send-v2__to-autocomplete__input', { + 'send-v2__error-border': inError, + })} + placeholder={this.context.t('recipientAddress')} + value={to} + onChange={event => onChange(event.target.value)} + onFocus={event => this.handleInputEvent(event)} + style={{ + borderColor: inError ? 'red' : null, + }} + /> + { + to + ? null + : <i className={'fa fa-caret-down fa-lg send-v2__to-autocomplete__down-caret'} + onClick={() => this.handleInputEvent()} + style={{ + style: {color: '#dedede'}, + }} + /> + } + { + dropdownOpen + ? this.renderDropdown() + : null + } + </div> + ) + } + +} diff --git a/ui/app/components/send/to-autocomplete/index.js b/ui/app/components/send/to-autocomplete/index.js new file mode 100644 index 000000000..244d301d1 --- /dev/null +++ b/ui/app/components/send/to-autocomplete/index.js @@ -0,0 +1 @@ +export { default } from './to-autocomplete.js' diff --git a/ui/app/components/send/to-autocomplete.js b/ui/app/components/send/to-autocomplete/to-autocomplete.js index df74ef194..80cfa7a85 100644 --- a/ui/app/components/send/to-autocomplete.js +++ b/ui/app/components/send/to-autocomplete/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('../send_/account-list-item/account-list-item.component').default +const AccountListItem = require('../account-list-item/account-list-item.component').default const connect = require('react-redux').connect ToAutoComplete.contextTypes = { diff --git a/ui/app/components/send_/account-list-item/index.js b/ui/app/components/send_/account-list-item/index.js deleted file mode 100644 index 1fca540be..000000000 --- a/ui/app/components/send_/account-list-item/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './account-list-item.container'
\ No newline at end of file diff --git a/ui/app/components/send_/index.js b/ui/app/components/send_/index.js deleted file mode 100644 index 9a4dd5727..000000000 --- a/ui/app/components/send_/index.js +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 10b3c850e..000000000 --- a/ui/app/components/send_/send-content/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './send-content.component'
\ No newline at end of file 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 deleted file mode 100644 index 548b51f33..000000000 --- a/ui/app/components/send_/send-content/send-amount-row/amount-max-button/index.js +++ /dev/null @@ -1 +0,0 @@ -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/index.js b/ui/app/components/send_/send-content/send-amount-row/index.js deleted file mode 100644 index 94a7da56f..000000000 --- a/ui/app/components/send_/send-content/send-amount-row/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './send-amount-row.container'
\ No newline at end of file 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 deleted file mode 100644 index ee7736376..000000000 --- a/ui/app/components/send_/send-content/send-dropdown-list/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './send-dropdown-list.component'
\ No newline at end of file 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 deleted file mode 100644 index 6ab9a157a..000000000 --- a/ui/app/components/send_/send-content/send-from-row/from-dropdown/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './from-dropdown.component'
\ No newline at end of file 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 deleted file mode 100644 index 4a0916dba..000000000 --- a/ui/app/components/send_/send-content/send-from-row/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './send-from-row.container'
\ No newline at end of file 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 deleted file mode 100644 index 060ed7fd3..000000000 --- a/ui/app/components/send_/send-content/send-gas-row/index.js +++ /dev/null @@ -1 +0,0 @@ -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.selectors.js b/ui/app/components/send_/send-content/send-gas-row/send-gas-row.selectors.js deleted file mode 100644 index d069ae8c6..000000000 --- a/ui/app/components/send_/send-content/send-gas-row/send-gas-row.selectors.js +++ /dev/null @@ -1,9 +0,0 @@ -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-selectors.test.js b/ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-selectors.test.js deleted file mode 100644 index a5196334e..000000000 --- a/ui/app/components/send_/send-content/send-gas-row/tests/send-gas-row-selectors.test.js +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 5715f55c6..000000000 --- a/ui/app/components/send_/send-content/send-row-wrapper/index.js +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index bf49c55bd..000000000 --- a/ui/app/components/send_/send-content/send-row-wrapper/send-row-error-message/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './send-row-error-message.container'
\ No newline at end of file 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 deleted file mode 100644 index 4e7aa9747..000000000 --- a/ui/app/components/send_/send-content/send-to-row/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './send-to-row.container'
\ No newline at end of file diff --git a/ui/app/components/send_/send-footer/index.js b/ui/app/components/send_/send-footer/index.js deleted file mode 100644 index cd1727330..000000000 --- a/ui/app/components/send_/send-footer/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './send-footer.container'
\ No newline at end of file diff --git a/ui/app/components/send_/send-header/index.js b/ui/app/components/send_/send-header/index.js deleted file mode 100644 index b808eabbf..000000000 --- a/ui/app/components/send_/send-header/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './send-header.container'
\ No newline at end of file diff --git a/ui/app/components/sender-to-recipient.js b/ui/app/components/sender-to-recipient.js deleted file mode 100644 index 9cef8e401..000000000 --- a/ui/app/components/sender-to-recipient.js +++ /dev/null @@ -1,72 +0,0 @@ -const { Component } = require('react') -const h = require('react-hyperscript') -const connect = require('react-redux').connect -const PropTypes = require('prop-types') -const Identicon = require('./identicon') - -class SenderToRecipient extends Component { - renderRecipientIcon () { - const { recipientAddress } = this.props - return ( - recipientAddress - ? h(Identicon, { address: recipientAddress, diameter: 20 }) - : h('i.fa.fa-file-text-o') - ) - } - - renderRecipient () { - const { recipientName } = this.props - return ( - h('.sender-to-recipient__recipient', [ - this.renderRecipientIcon(), - h( - '.sender-to-recipient__name.sender-to-recipient__recipient-name', - recipientName || this.context.t('newContract') - ), - ]) - ) - } - - render () { - const { senderName, senderAddress } = this.props - - return ( - h('.sender-to-recipient__container', [ - h('.sender-to-recipient__sender', [ - h('.sender-to-recipient__sender-icon', [ - h(Identicon, { - address: senderAddress, - diameter: 20, - }), - ]), - h('.sender-to-recipient__name.sender-to-recipient__sender-name', senderName), - ]), - h('.sender-to-recipient__arrow-container', [ - h('.sender-to-recipient__arrow-circle', [ - h('img', { - height: 15, - width: 15, - src: './images/arrow-right.svg', - }), - ]), - ]), - this.renderRecipient(), - ]) - ) - } -} - -SenderToRecipient.propTypes = { - senderName: PropTypes.string, - senderAddress: PropTypes.string, - recipientName: PropTypes.string, - recipientAddress: PropTypes.string, - t: PropTypes.func, -} - -SenderToRecipient.contextTypes = { - t: PropTypes.func, -} - -module.exports = connect()(SenderToRecipient) - diff --git a/ui/app/components/sender-to-recipient/index.js b/ui/app/components/sender-to-recipient/index.js new file mode 100644 index 000000000..f515c4ac4 --- /dev/null +++ b/ui/app/components/sender-to-recipient/index.js @@ -0,0 +1 @@ +export { default } from './sender-to-recipient.component' diff --git a/ui/app/components/sender-to-recipient/index.scss b/ui/app/components/sender-to-recipient/index.scss new file mode 100644 index 000000000..a97393b8f --- /dev/null +++ b/ui/app/components/sender-to-recipient/index.scss @@ -0,0 +1,74 @@ +.sender-to-recipient { + &__container { + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + border-bottom: 1px solid $geyser; + position: relative; + flex: 0 0 auto; + height: 42px; + } + + &__tooltip-wrapper { + min-width: 0; + } + + &__tooltip-container { + max-width: 100%; + } + + &__sender, + &__recipient { + display: flex; + flex-direction: row; + align-items: center; + flex: 1; + padding: 0 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__sender { + padding-right: 30px; + cursor: pointer; + } + + &__recipient { + padding-left: 30px; + border-left: 1px solid $geyser; + + &--with-address { + cursor: pointer; + } + } + + &__arrow-container { + position: absolute; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + &__arrow-circle { + background: $white; + padding: 5px; + border: 1px solid $geyser; + border-radius: 20px; + height: 32px; + width: 32px; + display: flex; + justify-content: center; + align-items: center; + } + + &__name { + padding-left: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: .875rem; + } +} diff --git a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js new file mode 100644 index 000000000..cae173b56 --- /dev/null +++ b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js @@ -0,0 +1,117 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import Identicon from '../identicon' +import Tooltip from '../tooltip-v2' +import copyToClipboard from 'copy-to-clipboard' + +export default class SenderToRecipient extends Component { + static propTypes = { + senderName: PropTypes.string, + senderAddress: PropTypes.string, + recipientName: PropTypes.string, + recipientAddress: PropTypes.string, + t: PropTypes.func, + } + + static contextTypes = { + t: PropTypes.func, + } + + state = { + senderAddressCopied: false, + recipientAddressCopied: false, + } + + renderRecipientWithAddress () { + const { t } = this.context + const { recipientName, recipientAddress } = this.props + + return ( + <div + className="sender-to-recipient__recipient sender-to-recipient__recipient--with-address" + onClick={() => { + this.setState({ recipientAddressCopied: true }) + copyToClipboard(recipientAddress) + }} + > + <div className="sender-to-recipient__sender-icon"> + <Identicon + address={recipientAddress} + diameter={24} + /> + </div> + <Tooltip + position="bottom" + title={this.state.recipientAddressCopied ? t('copiedExclamation') : t('copyAddress')} + wrapperClassName="sender-to-recipient__tooltip-wrapper" + containerClassName="sender-to-recipient__tooltip-container" + onHidden={() => this.setState({ recipientAddressCopied: false })} + > + <div className="sender-to-recipient__name sender-to-recipient__recipient-name"> + { recipientName || this.context.t('newContract') } + </div> + </Tooltip> + </div> + ) + } + + renderRecipientWithoutAddress () { + return ( + <div className="sender-to-recipient__recipient"> + <i className="fa fa-file-text-o" /> + <div className="sender-to-recipient__name sender-to-recipient__recipient-name"> + { this.context.t('newContract') } + </div> + </div> + ) + } + + render () { + const { t } = this.context + const { senderName, senderAddress, recipientAddress } = this.props + + return ( + <div className="sender-to-recipient__container"> + <div + className="sender-to-recipient__sender" + onClick={() => { + this.setState({ senderAddressCopied: true }) + copyToClipboard(senderAddress) + }} + > + <div className="sender-to-recipient__sender-icon"> + <Identicon + address={senderAddress} + diameter={24} + /> + </div> + <Tooltip + position="bottom" + title={this.state.senderAddressCopied ? t('copiedExclamation') : t('copyAddress')} + wrapperClassName="sender-to-recipient__tooltip-wrapper" + containerClassName="sender-to-recipient__tooltip-container" + onHidden={() => this.setState({ senderAddressCopied: false })} + > + <div className="sender-to-recipient__name sender-to-recipient__sender-name"> + { senderName } + </div> + </Tooltip> + </div> + <div className="sender-to-recipient__arrow-container"> + <div className="sender-to-recipient__arrow-circle"> + <img + height={15} + width={15} + src="./images/arrow-right.svg" + /> + </div> + </div> + { + recipientAddress + ? this.renderRecipientWithAddress() + : this.renderRecipientWithoutAddress() + } + </div> + ) + } +} diff --git a/ui/app/components/shapeshift-form.js b/ui/app/components/shapeshift-form.js index 93d2023b5..2c4ba40bf 100644 --- a/ui/app/components/shapeshift-form.js +++ b/ui/app/components/shapeshift-form.js @@ -181,7 +181,7 @@ ShapeshiftForm.prototype.render = function () { return h('div.shapeshift-form-wrapper', [ showQrCode ? this.renderQrCode() - : h('div.shapeshift-form', [ + : h('div.modal-shapeshift-form', [ h('div.shapeshift-form__selectors', [ h('div.shapeshift-form__selector', [ diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js index 2a3e929fe..2e0102d1a 100644 --- a/ui/app/components/signature-request.js +++ b/ui/app/components/signature-request.js @@ -22,6 +22,8 @@ const { conversionRateSelector, } = require('../selectors.js') +import { clearConfirmTransaction } from '../ducks/confirm-transaction.duck' + const { DEFAULT_ROUTE } = require('../routes') function mapStateToProps (state) { @@ -39,6 +41,7 @@ function mapStateToProps (state) { function mapDispatchToProps (dispatch) { return { goHome: () => dispatch(actions.goHome()), + clearConfirmTransaction: () => dispatch(clearConfirmTransaction()), } } @@ -204,6 +207,9 @@ SignatureRequest.prototype.renderBody = function () { h('div.request-signature__rows', [ ...rows.map(({ name, value }) => { + if (typeof value === 'boolean') { + value = value.toString() + } return h('div.request-signature__row', [ h('div.request-signature__row-title', [`${name}:`]), h('div.request-signature__row-value', value), @@ -244,12 +250,18 @@ SignatureRequest.prototype.renderFooter = function () { return h('div.request-signature__footer', [ h('button.btn-default.btn--large.request-signature__footer__cancel-button', { onClick: event => { - cancel(event).then(() => this.props.history.push(DEFAULT_ROUTE)) + cancel(event).then(() => { + this.props.clearConfirmTransaction() + this.props.history.push(DEFAULT_ROUTE) + }) }, }, this.context.t('cancel')), h('button.btn-primary.btn--large', { onClick: event => { - sign(event).then(() => this.props.history.push(DEFAULT_ROUTE)) + sign(event).then(() => { + this.props.clearConfirmTransaction() + this.props.history.push(DEFAULT_ROUTE) + }) }, }, this.context.t('sign')), ]) diff --git a/ui/app/components/tabs/index.js b/ui/app/components/tabs/index.js new file mode 100644 index 000000000..3a8d18248 --- /dev/null +++ b/ui/app/components/tabs/index.js @@ -0,0 +1,3 @@ +import Tabs from './tabs.component' +import Tab from './tab' +export { Tabs, Tab } diff --git a/ui/app/components/tabs/index.scss b/ui/app/components/tabs/index.scss new file mode 100644 index 000000000..a3b42f8e3 --- /dev/null +++ b/ui/app/components/tabs/index.scss @@ -0,0 +1,11 @@ +@import './tab/index'; + +.tabs { + &__list { + display: flex; + justify-content: flex-start; + background-color: #f9fafa; + border-bottom: 1px solid $geyser; + padding: 0 16px; + } +} diff --git a/ui/app/components/tabs/tab/index.js b/ui/app/components/tabs/tab/index.js new file mode 100644 index 000000000..fbc309e8e --- /dev/null +++ b/ui/app/components/tabs/tab/index.js @@ -0,0 +1,2 @@ +import Tab from './tab.component' +module.exports = Tab diff --git a/ui/app/components/tabs/tab/index.scss b/ui/app/components/tabs/tab/index.scss new file mode 100644 index 000000000..1de6ffa0e --- /dev/null +++ b/ui/app/components/tabs/tab/index.scss @@ -0,0 +1,15 @@ +.tab { + color: #8C8E94; + font-size: .75rem; + text-transform: uppercase; + cursor: pointer; + padding: 8px 0; + margin: 0 8px; + min-width: 50px; + text-align: center; + + &--active { + color: $black; + border-bottom: 2px solid $curious-blue; + } +} diff --git a/ui/app/components/tabs/tab/tab.component.js b/ui/app/components/tabs/tab/tab.component.js new file mode 100644 index 000000000..a59da8904 --- /dev/null +++ b/ui/app/components/tabs/tab/tab.component.js @@ -0,0 +1,31 @@ +import React from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' + +const Tab = props => { + const { name, onClick, isActive, tabIndex } = props + + return ( + <li + className={classnames( + 'tab', + isActive && 'tab--active', + )} + onClick={event => { + event.preventDefault() + onClick(tabIndex) + }} + > + { name } + </li> + ) +} + +Tab.propTypes = { + name: PropTypes.string.isRequired, + onClick: PropTypes.func, + isActive: PropTypes.bool, + tabIndex: PropTypes.number, +} + +export default Tab diff --git a/ui/app/components/tabs/tabs.component.js b/ui/app/components/tabs/tabs.component.js new file mode 100644 index 000000000..d26dcff2f --- /dev/null +++ b/ui/app/components/tabs/tabs.component.js @@ -0,0 +1,62 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +export default class Tabs extends Component { + static propTypes = { + defaultActiveTabIndex: PropTypes.number, + children: PropTypes.node, + } + + constructor (props) { + super(props) + + this.state = { + activeTabIndex: props.defaultActiveTabIndex || 0, + } + } + + handleTabClick (tabIndex) { + const { activeTabIndex } = this.state + + if (tabIndex !== activeTabIndex) { + this.setState({ + activeTabIndex: tabIndex, + }) + } + } + + renderTabs () { + const numberOfTabs = React.Children.count(this.props.children) + + return React.Children.map(this.props.children, (child, index) => { + return child && React.cloneElement(child, { + onClick: index => this.handleTabClick(index), + tabIndex: index, + isActive: numberOfTabs > 1 && index === this.state.activeTabIndex, + key: index, + }) + }) + } + + renderActiveTabContent () { + const { children } = this.props + const { activeTabIndex } = this.state + + return children[activeTabIndex] + ? children[activeTabIndex].props.children + : children.props.children + } + + render () { + return ( + <div className="tabs"> + <ul className="tabs__list"> + { this.renderTabs() } + </ul> + <div className="tabs__content"> + { this.renderActiveTabContent() } + </div> + </div> + ) + } +} diff --git a/ui/app/components/token-balance.js b/ui/app/components/token-balance.js index 1900ccec7..99ca7335c 100644 --- a/ui/app/components/token-balance.js +++ b/ui/app/components/token-balance.js @@ -34,7 +34,7 @@ TokenBalance.prototype.render = function () { return isLoading ? h('span', '') : h('span.token-balance', [ - h('span.token-balance__amount', string), + h('span.hide-text-overflow.token-balance__amount', string), !balanceOnly && h('span.token-balance__symbol', symbol), ]) } @@ -98,6 +98,10 @@ TokenBalance.prototype.componentDidUpdate = function (nextProps) { } TokenBalance.prototype.updateBalance = function (tokens = []) { + if (!this.tracker.running) { + return + } + const [{ string, symbol }] = tokens this.setState({ @@ -110,5 +114,7 @@ TokenBalance.prototype.updateBalance = function (tokens = []) { TokenBalance.prototype.componentWillUnmount = function () { if (!this.tracker) return this.tracker.stop() + this.tracker.removeListener('update', this.balanceUpdater) + this.tracker.removeListener('error', this.showError) } diff --git a/ui/app/components/token-list.js b/ui/app/components/token-list.js index 4189cf801..42351cf89 100644 --- a/ui/app/components/token-list.js +++ b/ui/app/components/token-list.js @@ -158,12 +158,17 @@ TokenList.prototype.componentDidUpdate = function (nextProps) { } TokenList.prototype.updateBalances = function (tokens) { + if (!this.tracker.running) { + return + } this.setState({ tokens, isLoading: false }) } TokenList.prototype.componentWillUnmount = function () { if (!this.tracker) return this.tracker.stop() + this.tracker.removeListener('update', this.balanceUpdater) + this.tracker.removeListener('error', this.showError) } // function uniqueMergeTokens (tokensA, tokensB = []) { diff --git a/ui/app/components/tooltip-v2.js b/ui/app/components/tooltip-v2.js index 133a0f16a..05a5efc80 100644 --- a/ui/app/components/tooltip-v2.js +++ b/ui/app/components/tooltip-v2.js @@ -12,7 +12,7 @@ function Tooltip () { Tooltip.prototype.render = function () { const props = this.props - const { position, title, children, wrapperClassName } = props + const { position, title, children, wrapperClassName, containerClassName, onHidden } = props return h('div', { className: wrapperClassName, @@ -25,6 +25,8 @@ Tooltip.prototype.render = function () { hideOnClick: false, size: 'small', arrow: true, + className: containerClassName, + onHidden, }, children), ]) diff --git a/ui/app/components/tx-list-item.js b/ui/app/components/tx-list-item.js index 9a2fb5311..7513ba267 100644 --- a/ui/app/components/tx-list-item.js +++ b/ui/app/components/tx-list-item.js @@ -54,6 +54,8 @@ function TxListItem () { fiatTotal: null, isTokenTx: null, } + + this.unmounted = false } TxListItem.prototype.componentDidMount = async function () { @@ -67,9 +69,16 @@ TxListItem.prototype.componentDidMount = async function () { ? await this.getSendTokenTotal() : this.getSendEtherTotal() + if (this.unmounted) { + return + } this.setState({ total, fiatTotal, isTokenTx }) } +TxListItem.prototype.componentWillUnmount = function () { + this.unmounted = true +} + TxListItem.prototype.getAddressText = function () { const { address, @@ -204,14 +213,23 @@ TxListItem.prototype.showRetryButton = function () { if (!txParams) { return false } + let currentTxSharesEarliestNonce = false const currentNonce = txParams.nonce const currentNonceTxs = selectedAddressTxList.filter(tx => tx.txParams.nonce === currentNonce) const currentNonceSubmittedTxs = currentNonceTxs.filter(tx => tx.status === 'submitted') + const currentSubmittedTxs = selectedAddressTxList.filter(tx => tx.status === 'submitted') const lastSubmittedTxWithCurrentNonce = currentNonceSubmittedTxs[currentNonceSubmittedTxs.length - 1] const currentTxIsLatestWithNonce = lastSubmittedTxWithCurrentNonce && lastSubmittedTxWithCurrentNonce.id === transactionId + if (currentSubmittedTxs.length > 0) { + const earliestSubmitted = currentSubmittedTxs.reduce((tx1, tx2) => { + if (tx1.submittedTime < tx2.submittedTime) return tx1 + return tx2 + }) + currentTxSharesEarliestNonce = currentNonce === earliestSubmitted.txParams.nonce + } - return currentTxIsLatestWithNonce && Date.now() - transactionSubmittedTime > 30000 + return currentTxSharesEarliestNonce && currentTxIsLatestWithNonce && Date.now() - transactionSubmittedTime > 30000 } TxListItem.prototype.setSelectedToken = function (tokenAddress) { @@ -298,20 +316,16 @@ TxListItem.prototype.render = function () { ]), ]), - this.showRetryButton() && h('div.tx-list-item-retry-container', [ - - h('span.tx-list-item-retry-copy', 'Taking too long?'), - - h('span.tx-list-item-retry-link', { - onClick: (event) => { - event.stopPropagation() - if (isTokenTx) { - this.setSelectedToken(txParams.to) - } - this.resubmit() - }, - }, 'Increase the gas price on your transaction'), - + this.showRetryButton() && h('.tx-list-item-retry-container', { + onClick: (event) => { + event.stopPropagation() + if (isTokenTx) { + this.setSelectedToken(txParams.to) + } + this.resubmit() + }, + }, [ + h('span', 'Taking too long? Increase the gas price on your transaction'), ]), ]), // holding on icon from design diff --git a/ui/app/components/tx-view.js b/ui/app/components/tx-view.js index 014497fcd..654090da6 100644 --- a/ui/app/components/tx-view.js +++ b/ui/app/components/tx-view.js @@ -11,6 +11,7 @@ const { SEND_ROUTE } = require('../routes') const { checksumAddress: toChecksumAddress } = require('../util') const BalanceComponent = require('./balance-component') +const Tooltip = require('./tooltip') const TxList = require('./tx-list') const SelectedAccount = require('./selected-account') @@ -103,7 +104,8 @@ TxView.prototype.renderButtons = function () { } TxView.prototype.render = function () { - const { isMascara } = this.props + const { hideSidebar, isMascara, showSidebar, sidebarOpen } = this.props + const { t } = this.context return h('div.tx-view.flex-column', { style: {}, @@ -120,21 +122,30 @@ TxView.prototype.render = function () { }, }, [ - h('div.fa.fa-bars', { - style: { - fontSize: '1.3em', - cursor: 'pointer', - padding: '10px', - }, - onClick: () => this.props.sidebarOpen ? this.props.hideSidebar() : this.props.showSidebar(), - }), + h(Tooltip, { + title: t('menu'), + position: 'bottom', + }, [ + h('div.fa.fa-bars', { + style: { + fontSize: '1.3em', + cursor: 'pointer', + padding: '10px', + }, + onClick: () => sidebarOpen ? hideSidebar() : showSidebar(), + }), + ]), h(SelectedAccount), - !isMascara && h('div.open-in-browser', { - onClick: () => global.platform.openExtensionInBrowser(), - }, [h('img', { src: 'images/popout.svg' })]), - + !isMascara && h(Tooltip, { + title: t('openInTab'), + position: 'bottom', + }, [ + h('div.open-in-browser', { + onClick: () => global.platform.openExtensionInBrowser(), + }, [h('img', { src: 'images/popout.svg' })]), + ]), ]), this.renderHeroBalance(), diff --git a/ui/app/components/wallet-view.js b/ui/app/components/wallet-view.js index da142fad8..20c2be0f1 100644 --- a/ui/app/components/wallet-view.js +++ b/ui/app/components/wallet-view.js @@ -175,7 +175,7 @@ WalletView.prototype.render = function () { this.setState({ copyToClipboardPressed: false }) }, }, [ - `${checksummedAddress.slice(0, 4)}...${checksummedAddress.slice(-4)}`, + `${checksummedAddress.slice(0, 6)}...${checksummedAddress.slice(-4)}`, h('i.fa.fa-clipboard', { style: { marginLeft: '8px' } }), ]), ]), |