diff options
Diffstat (limited to 'ui')
103 files changed, 4335 insertions, 566 deletions
diff --git a/ui/app/actions.js b/ui/app/actions.js index ad890f565..1fb49c920 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -704,11 +704,10 @@ function signTypedMsg (msgData) { function signTx (txData) { return (dispatch) => { - dispatch(actions.showLoadingIndication()) global.ethQuery.sendTransaction(txData, (err, data) => { - dispatch(actions.hideLoadingIndication()) - if (err) return dispatch(actions.displayWarning(err.message)) - dispatch(actions.hideWarning()) + if (err) { + return dispatch(actions.displayWarning(err.message)) + } }) dispatch(actions.showConfTxPage({})) } @@ -910,29 +909,41 @@ function signTokenTx (tokenAddress, toAddress, amount, txData) { function updateTransaction (txData) { log.info('actions: updateTx: ' + JSON.stringify(txData)) - return (dispatch) => { + return dispatch => { log.debug(`actions calling background.updateTx`) - background.updateTransaction(txData, (err) => { - dispatch(actions.hideLoadingIndication()) - dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) - if (err) { - dispatch(actions.txError(err)) - dispatch(actions.goHome()) - return log.error(err.message) - } - dispatch(actions.showConfTxPage({ id: txData.id })) + dispatch(actions.showLoadingIndication()) + + return new Promise((resolve, reject) => { + background.updateTransaction(txData, (err) => { + dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) + if (err) { + dispatch(actions.txError(err)) + dispatch(actions.goHome()) + log.error(err.message) + return reject(err) + } + + resolve(txData) + }) }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { + dispatch(actions.showConfTxPage({ id: txData.id })) + dispatch(actions.hideLoadingIndication()) + return txData + }) } } function updateAndApproveTx (txData) { log.info('actions: updateAndApproveTx: ' + JSON.stringify(txData)) - return (dispatch) => { + return dispatch => { log.debug(`actions calling background.updateAndApproveTx`) + dispatch(actions.showLoadingIndication()) return new Promise((resolve, reject) => { background.updateAndApproveTransaction(txData, err => { - dispatch(actions.hideLoadingIndication()) dispatch(actions.updateTransactionParams(txData.id, txData.txParams)) dispatch(actions.clearSend()) @@ -943,10 +954,17 @@ function updateAndApproveTx (txData) { reject(err) } - dispatch(actions.completedTx(txData.id)) resolve(txData) }) }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { + dispatch(actions.clearSend()) + dispatch(actions.completedTx(txData.id)) + dispatch(actions.hideLoadingIndication()) + return txData + }) } } @@ -1038,13 +1056,25 @@ function cancelTypedMsg (msgData) { function cancelTx (txData) { return dispatch => { log.debug(`background.cancelTransaction`) + dispatch(actions.showLoadingIndication()) + return new Promise((resolve, reject) => { - background.cancelTransaction(txData.id, () => { + background.cancelTransaction(txData.id, err => { + if (err) { + return reject(err) + } + + resolve() + }) + }) + .then(() => updateMetamaskStateFromBackground()) + .then(newState => dispatch(actions.updateMetamaskState(newState))) + .then(() => { dispatch(actions.clearSend()) dispatch(actions.completedTx(txData.id)) - resolve(txData) + dispatch(actions.hideLoadingIndication()) + return txData }) - }) } } diff --git a/ui/app/app.js b/ui/app/app.js index 257239a61..74d360d3c 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -12,7 +12,7 @@ const log = require('loglevel') const InitializeScreen = require('../../mascara/src/app/first-time').default // accounts const SendTransactionScreen = require('./components/send_/send.container') -const ConfirmTxScreen = require('./conf-tx') +const ConfirmTransaction = require('./components/pages/confirm-transaction') // slideout menu const WalletView = require('./components/wallet-view') @@ -77,7 +77,10 @@ class App extends Component { h(Authenticated, { path: REVEAL_SEED_ROUTE, exact, component: RevealSeedConfirmation }), h(Authenticated, { path: SETTINGS_ROUTE, component: Settings }), h(Authenticated, { path: NOTICE_ROUTE, exact, component: NoticeScreen }), - h(Authenticated, { path: `${CONFIRM_TRANSACTION_ROUTE}/:id?`, component: ConfirmTxScreen }), + h(Authenticated, { + path: `${CONFIRM_TRANSACTION_ROUTE}/:id?`, + component: ConfirmTransaction, + }), h(Authenticated, { path: SEND_ROUTE, exact, component: SendTransactionScreen }), h(Authenticated, { path: ADD_TOKEN_ROUTE, exact, component: AddTokenPage }), h(Authenticated, { path: CONFIRM_ADD_TOKEN_ROUTE, exact, component: ConfirmAddTokenPage }), 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/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..631cf5803 --- /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, + fiatFee, + ethFee, + onHeaderClick, + fiatFeeColor, + 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: fiatFeeColor }} + > + { fiatFee } + </div> + <div className="confirm-detail-row__eth"> + { `\u2666 ${ethFee}` } + </div> + </div> + </div> + ) +} + +ConfirmDetailRow.propTypes = { + label: PropTypes.string, + fiatFee: PropTypes.string, + ethFee: PropTypes.string, + fiatFeeColor: 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..84d0d56ed --- /dev/null +++ b/ui/app/components/confirm-page-container/confirm-detail-row/index.scss @@ -0,0 +1,43 @@ +.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; + } + + &__fiat { + font-size: 1.5rem; + } + + &__eth { + color: $oslo-gray; + } + + &__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-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..93e4ae7bf --- /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, + valid: PropTypes.bool, + } + + render () { + const { + showEdit, + onEdit, + fromName, + fromAddress, + toName, + toAddress, + valid, + 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={!valid} + /> + </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/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/index.scss b/ui/app/components/index.scss index 351640f6e..32f0e90e4 100644 --- a/ui/app/components/index.scss +++ b/ui/app/components/index.scss @@ -4,6 +4,16 @@ @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/modals/customize-gas/customize-gas.component.js b/ui/app/components/modals/customize-gas/customize-gas.component.js new file mode 100644 index 000000000..d17c290b6 --- /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..160911c10 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%; diff --git a/ui/app/components/modals/modal.js b/ui/app/components/modals/modal.js index 85e85597a..973438b6b 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -24,6 +24,8 @@ 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', @@ -267,7 +269,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..e82d0e70c --- /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, 45%); + } + + &--ropsten { + background-color: lighten($crimson, 45%); + } + + &--kovan { + background-color: lighten($purple, 45%); + } + + &--rinkeby { + background-color: lighten($tulip-tree, 45%); + } + } + + &__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..d775b0362 --- /dev/null +++ b/ui/app/components/pages/confirm-approve/confirm-approve.component.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConfirmTransactionBase from '../confirm-transaction-base' + +export default class ConfirmApprove extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + tokenAddress: PropTypes.string, + toAddress: PropTypes.string, + tokenAmount: PropTypes.string, + tokenSymbol: PropTypes.string, + } + + render () { + const { toAddress, tokenAddress, tokenAmount, tokenSymbol } = this.props + + return ( + <ConfirmTransactionBase + toAddress={toAddress} + identiconAddress={tokenAddress} + title={`${tokenAmount} ${tokenSymbol}`} + warning={`By approving this action, you grant permission for this contract to spend up to ${tokenAmount} of your ${tokenSymbol}.`} + hideSubtitle + /> + ) + } +} 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..040e499ae --- /dev/null +++ b/ui/app/components/pages/confirm-approve/confirm-approve.container.js @@ -0,0 +1,28 @@ +import { connect } from 'react-redux' +import ConfirmApprove from './confirm-approve.component' + +const mapStateToProps = state => { + const { confirmTransaction } = state + const { + tokenData = {}, + txData: { txParams: { to: tokenAddress } = {} } = {}, + tokenProps: { tokenSymbol } = {}, + } = confirmTransaction + const { params = [] } = tokenData + + let toAddress = '' + let tokenAmount = '' + + if (params && params.length === 2) { + [{ value: toAddress }, { value: tokenAmount }] = params + } + + return { + toAddress, + tokenAddress, + 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..46ad9ccab --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/confirm-send-token.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 ConfirmSendToken extends Component { + static contextTypes = { + t: PropTypes.func, + } + + static propTypes = { + history: PropTypes.object, + tokenAddress: PropTypes.string, + toAddress: PropTypes.string, + numberOfTokens: PropTypes.number, + tokenSymbol: PropTypes.string, + editTransaction: PropTypes.func, + } + + handleEdit (confirmTransactionData) { + const { editTransaction, history } = this.props + editTransaction(confirmTransactionData) + history.push(SEND_ROUTE) + } + + render () { + const { toAddress, tokenAddress, tokenSymbol, numberOfTokens } = this.props + + return ( + <ConfirmTransactionBase + toAddress={toAddress} + identiconAddress={tokenAddress} + title={`${numberOfTokens} ${tokenSymbol}`} + onEdit={confirmTransactionData => this.handleEdit(confirmTransactionData)} + hideSubtitle + /> + ) + } +} 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..2d7efeed6 --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/confirm-send-token.container.js @@ -0,0 +1,72 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import ConfirmSendToken from './confirm-send-token.component' +import { calcTokenAmount } from '../../../token-util' +import { clearConfirmTransaction } from '../../../ducks/confirm-transaction.duck' +import { setSelectedToken, updateSend, showSendTokenPage } from '../../../actions' +import { conversionUtil } from '../../../conversion-util' + +const mapStateToProps = state => { + const { confirmTransaction } = state + const { + tokenData = {}, + tokenProps: { tokenSymbol, tokenDecimals } = {}, + txData: { txParams: { to: tokenAddress } = {} } = {}, + } = confirmTransaction + const { params = [] } = tokenData + + let toAddress = '' + let tokenAmount = '' + + if (params && params.length === 2) { + [{ value: toAddress }, { value: tokenAmount }] = params + } + + const numberOfTokens = tokenAmount && tokenDecimals + ? calcTokenAmount(tokenAmount, tokenDecimals) + : 0 + + return { + toAddress, + tokenAddress, + tokenSymbol, + numberOfTokens, + } +} + +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-send-token/index.scss b/ui/app/components/pages/confirm-send-token/index.scss new file mode 100644 index 000000000..0476749f6 --- /dev/null +++ b/ui/app/components/pages/confirm-send-token/index.scss @@ -0,0 +1,19 @@ +.confirm-send-token { + &__title { + padding: 4px 0; + display: flex; + align-items: center; + } + + &__identicon { + flex: 0 0 auto; + } + + &__title-text { + font-size: 2.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 8px; + } +} 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..842b34d2e --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -0,0 +1,320 @@ +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, + 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, + } + + 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, + hideDetails, + } = this.props + + if (hideDetails) { + return null + } + + return ( + detailsComponent || ( + <div className="confirm-page-container-content__details"> + <div className="confirm-page-container-content__gas-fee"> + <ConfirmDetailRow + label="Gas Fee" + fiatFee={formatCurrency(fiatTransactionFee, currentCurrency)} + ethFee={ethTransactionFee} + headerText="Edit" + headerTextClassName="confirm-detail-row__header-text--edit" + onHeaderClick={() => this.handleEditGas()} + /> + </div> + <div> + <ConfirmDetailRow + label="Total" + fiatFee={formatCurrency(fiatTransactionTotal, currentCurrency)} + ethFee={ethTransactionTotal} + headerText="Amount + Gas Fee" + headerTextClassName="confirm-detail-row__header-text--total" + fiatFeeColor="#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 } + </span> + </div> + <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 + + if (onSubmit) { + onSubmit(txData) + } else { + sendTransaction(txData) + .then(() => { + clearConfirmTransaction() + history.push(DEFAULT_ROUTE) + }) + } + } + + render () { + const { + isTxReprice, + fromName, + fromAddress, + toName, + toAddress, + methodData, + ethTransactionAmount, + fiatTransactionAmount, + valid: propsValid, + errorMessage, + errorKey: propsErrorKey, + currentCurrency, + action, + title, + subtitle, + hideSubtitle, + identiconAddress, + summaryComponent, + contentComponent, + onEdit, + nonce, + warning, + } = this.props + + 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} + 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} + valid={propsValid || valid} + 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..31108bbd0 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -0,0 +1,169 @@ +import { connect } from 'react-redux' +import { compose } from 'recompose' +import { withRouter } from 'react-router-dom' +import R from 'ramda' +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' + +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 + 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..25259b98c --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.component.js @@ -0,0 +1,77 @@ +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_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, +} from '../../../routes' +import { isConfirmDeployContract } from './confirm-transaction-switch.util' +import { TOKEN_METHOD_TRANSFER, TOKEN_METHOD_APPROVE } 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 } = txData + + + if (isConfirmDeployContract(txData)) { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_DEPLOY_CONTRACT_PATH}` + return <Redirect to={{ pathname }} /> + } + + if (fetchingMethodData) { + return <Loading /> + } + + if (name) { + const methodName = name.toLowerCase() + + switch (methodName.toLowerCase()) { + 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 }} /> + } + 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..622d2a37a --- /dev/null +++ b/ui/app/components/pages/confirm-transaction-switch/confirm-transaction-switch.constants.js @@ -0,0 +1,2 @@ +export const TOKEN_METHOD_TRANSFER = 'transfer' +export const TOKEN_METHOD_APPROVE = 'approve' 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..874a89fd2 --- /dev/null +++ b/ui/app/components/pages/confirm-transaction/confirm-transaction.component.js @@ -0,0 +1,150 @@ +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 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_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?${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/home.js b/ui/app/components/pages/home.js index d24d5cc55..38aa02dae 100644 --- a/ui/app/components/pages/home.js +++ b/ui/app/components/pages/home.js @@ -83,51 +83,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 +90,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'}) diff --git a/ui/app/components/pages/index.scss b/ui/app/components/pages/index.scss index b15c59863..8b333b6a8 100644 --- a/ui/app/components/pages/index.scss +++ b/ui/app/components/pages/index.scss @@ -3,3 +3,5 @@ @import './add-token/index'; @import './confirm-add-token/index'; + +@import './confirm-send-token/index'; 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..ff42e58de 100644 --- a/ui/app/components/send_/send-footer/send-footer.component.js +++ b/ui/app/components/send_/send-footer/send-footer.component.js @@ -48,6 +48,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,7 +61,7 @@ export default class SendFooter extends Component { // TODO: add nickname functionality addToAddressBookIfNew(to, toAccounts) - editingTransactionId + const promise = editingTransactionId ? update({ amount, editingTransactionId, @@ -73,7 +74,8 @@ export default class SendFooter extends Component { }) : sign({ selectedToken, to, amount, from, gas, gasPrice }) - this.props.history.push(CONFIRM_TRANSACTION_ROUTE) + Promise.resolve(promise) + .then(() => history.push(CONFIRM_TRANSACTION_ROUTE)) } formShouldBeDisabled () { diff --git a/ui/app/components/send_/send-footer/send-footer.container.js b/ui/app/components/send_/send-footer/send-footer.container.js index 260ff40bc..0af6fcfa1 100644 --- a/ui/app/components/send_/send-footer/send-footer.container.js +++ b/ui/app/components/send_/send-footer/send-footer.container.js @@ -87,7 +87,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/tests/send-footer-component.test.js b/ui/app/components/send_/send-footer/tests/send-footer-component.test.js index e071fe54f..4b2cd327d 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 @@ -166,10 +166,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.utils.js b/ui/app/components/send_/send.utils.js index c4537f335..aa255c3d4 100644 --- a/ui/app/components/send_/send.utils.js +++ b/ui/app/components/send_/send.utils.js @@ -37,7 +37,7 @@ module.exports = { removeLeadingZeroes, } -function calcGasTotal (gasLimit, gasPrice) { +function calcGasTotal (gasLimit = '0', gasPrice = '0') { return multiplyCurrencies(gasLimit, gasPrice, { toNumericBase: 'hex', multiplicandBase: 16, @@ -47,9 +47,9 @@ function calcGasTotal (gasLimit, gasPrice) { function isBalanceSufficient ({ amount = '0x0', - amountConversionRate = 0, - balance, - conversionRate, + amountConversionRate = 1, + balance = '0x0', + conversionRate = 1, gasTotal = '0x0', primaryCurrency, }) { 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/css/itcss/components/sender-to-recipient.scss b/ui/app/components/sender-to-recipient/index.scss index f16013cdf..a97393b8f 100644 --- a/ui/app/css/itcss/components/sender-to-recipient.scss +++ b/ui/app/components/sender-to-recipient/index.scss @@ -6,6 +6,16 @@ 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, @@ -14,7 +24,7 @@ flex-direction: row; align-items: center; flex: 1; - padding: 10px 20px; + padding: 0 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -22,11 +32,16 @@ &__sender { padding-right: 30px; + cursor: pointer; } &__recipient { - border-left: 1px solid $geyser; padding-left: 30px; + border-left: 1px solid $geyser; + + &--with-address { + cursor: pointer; + } } &__arrow-container { @@ -42,17 +57,18 @@ padding: 5px; border: 1px solid $geyser; border-radius: 20px; - height: 30px; - width: 30px; + height: 32px; + width: 32px; display: flex; justify-content: center; align-items: center; } &__name { - padding-left: 5px; + 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/signature-request.js b/ui/app/components/signature-request.js index bbb340fcf..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()), } } @@ -247,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/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-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/conf-tx.js b/ui/app/conf-tx.js index 461587cb1..4e8aaa07d 100644 --- a/ui/app/conf-tx.js +++ b/ui/app/conf-tx.js @@ -105,7 +105,7 @@ ConfirmTxScreen.prototype.componentDidUpdate = function (prevProps) { const unconfTxList = txHelper(unapprovedTxs, {}, {}, {}, network) - if (prevTx.status === 'dropped') { + if (prevTx && prevTx.status === 'dropped') { this.props.dispatch(actions.showModal({ name: 'TRANSACTION_CONFIRMED', onHide: () => history.push(DEFAULT_ROUTE), @@ -174,7 +174,6 @@ ConfirmTxScreen.prototype.render = function () { ]), */ - return currentTxView({ // Properties txData: txData, diff --git a/ui/app/constants/error-keys.js b/ui/app/constants/error-keys.js new file mode 100644 index 000000000..1b89be62e --- /dev/null +++ b/ui/app/constants/error-keys.js @@ -0,0 +1,3 @@ +export const INSUFFICIENT_FUNDS_ERROR_KEY = 'insufficientFunds' +export const GAS_LIMIT_TOO_LOW_ERROR_KEY = 'gasLimitTooLow' +export const TRANSACTION_ERROR = 'transactionError' diff --git a/ui/app/css/itcss/components/buttons.scss b/ui/app/css/itcss/components/buttons.scss index f93daec04..34565767f 100644 --- a/ui/app/css/itcss/components/buttons.scss +++ b/ui/app/css/itcss/components/buttons.scss @@ -4,7 +4,8 @@ .btn-default, .btn-primary, -.btn-secondary { +.btn-secondary, +.btn-confirm { height: 44px; background: $white; display: flex; @@ -13,13 +14,14 @@ box-sizing: border-box; border-radius: 4px; font-size: 14px; - font-weight: 500; + font-weight: 400; transition: border-color .3s ease; padding: 0 16px; min-width: 140px; width: 100%; text-transform: uppercase; outline: none; + font-family: Roboto; &--disabled, &[disabled] { @@ -71,6 +73,12 @@ } } +.btn-confirm { + color: $white; + border: 2px solid $curious-blue; + background-color: $curious-blue; +} + .btn--large { height: 54px; } @@ -119,19 +127,6 @@ } } -.btn-confirm { - background-color: $caribbean-green; // TODO: reusable color in colors.css - text-align: center; - padding: .8rem 1rem; - color: $white; - border: 2px solid $caribbean-green; - border-radius: 4px; - font-size: .85rem; - font-weight: 400; - transition: border-color .3s ease; - width: 100%; -} - // No longer used in flat design, remove when modal buttons done // div.wallet-btn { // border: 1px solid rgb(91, 93, 103); diff --git a/ui/app/css/itcss/components/index.scss b/ui/app/css/itcss/components/index.scss index 1d87b8004..5be040906 100644 --- a/ui/app/css/itcss/components/index.scss +++ b/ui/app/css/itcss/components/index.scss @@ -58,6 +58,4 @@ @import './welcome-screen.scss'; -@import './sender-to-recipient.scss'; - @import '../../../components/index'; diff --git a/ui/app/css/itcss/components/network.scss b/ui/app/css/itcss/components/network.scss index 6c8be0b6d..545a2a940 100644 --- a/ui/app/css/itcss/components/network.scss +++ b/ui/app/css/itcss/components/network.scss @@ -159,15 +159,3 @@ .network-caret { margin: 0 8px 2px; } - -.network-display { - &__container { - display: flex; - align-items: center; - justify-content: flex-start; - - @media screen and (min-width: 576px) { - display: none; - } - } -} diff --git a/ui/app/css/itcss/generic/index.scss b/ui/app/css/itcss/generic/index.scss index 3525d2003..d1c65afed 100644 --- a/ui/app/css/itcss/generic/index.scss +++ b/ui/app/css/itcss/generic/index.scss @@ -73,195 +73,6 @@ input.large-input { text-transform: uppercase; } -.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-clear, - .btn-cancel, - .btn-confirm { - font-size: 1rem; - } - } - - &__footer-button { - height: 55px; - font-size: 1rem; - text-transform: uppercase; - margin-right: 16px; - border-radius: 2px; - - &: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; - } -} - .input-label { padding-bottom: 10px; font-weight: 400; diff --git a/ui/app/css/itcss/settings/variables.scss b/ui/app/css/itcss/settings/variables.scss index 814d7a382..f90c8edc3 100644 --- a/ui/app/css/itcss/settings/variables.scss +++ b/ui/app/css/itcss/settings/variables.scss @@ -55,6 +55,7 @@ $dodger-blue: #3099f2; $zumthor: #edf7ff; $ecstasy: #f7861c; $linen: #fdf4f4; +$oslo-gray: #8C8E94; /* Z-Indicies diff --git a/ui/app/css/itcss/tools/utilities.scss b/ui/app/css/itcss/tools/utilities.scss index ee867640d..209614c6b 100644 --- a/ui/app/css/itcss/tools/utilities.scss +++ b/ui/app/css/itcss/tools/utilities.scss @@ -165,7 +165,7 @@ } .bold { - font-weight: 700; + font-weight: 500; } .text-transform-uppercase { diff --git a/ui/app/ducks/confirm-transaction.duck.js b/ui/app/ducks/confirm-transaction.duck.js new file mode 100644 index 000000000..1885e12d1 --- /dev/null +++ b/ui/app/ducks/confirm-transaction.duck.js @@ -0,0 +1,386 @@ +import { + conversionRateSelector, + currentCurrencySelector, + unconfirmedTransactionsHashSelector, +} from '../selectors/confirm-transaction' + +import { + getTokenData, + getMethodData, + getTransactionAmount, + getTransactionFee, + getHexGasTotal, + addFiat, + addEth, + increaseLastGasPrice, + hexGreaterThan, +} from '../helpers/confirm-transaction/util' + +import { getSymbolAndDecimals } from '../token-util' +import { conversionUtil } from '../conversion-util' + +// Actions +const createActionType = action => `metamask/confirm-transaction/${action}` + +const UPDATE_TX_DATA = createActionType('UPDATE_TX_DATA') +const CLEAR_TX_DATA = createActionType('CLEAR_TX_DATA') +const UPDATE_TOKEN_DATA = createActionType('UPDATE_TOKEN_DATA') +const CLEAR_TOKEN_DATA = createActionType('CLEAR_TOKEN_DATA') +const UPDATE_METHOD_DATA = createActionType('UPDATE_METHOD_DATA') +const CLEAR_METHOD_DATA = createActionType('CLEAR_METHOD_DATA') +const CLEAR_CONFIRM_TRANSACTION = createActionType('CLEAR_CONFIRM_TRANSACTION') +const UPDATE_TRANSACTION_AMOUNTS = createActionType('UPDATE_TRANSACTION_AMOUNTS') +const UPDATE_TRANSACTION_FEES = createActionType('UPDATE_TRANSACTION_FEES') +const UPDATE_TRANSACTION_TOTALS = createActionType('UPDATE_TRANSACTION_TOTALS') +const UPDATE_HEX_GAS_TOTAL = createActionType('UPDATE_HEX_GAS_TOTAL') +const UPDATE_TOKEN_PROPS = createActionType('UPDATE_TOKEN_PROPS') +const UPDATE_NONCE = createActionType('UPDATE_NONCE') +const FETCH_METHOD_DATA_START = createActionType('FETCH_METHOD_DATA_START') +const FETCH_METHOD_DATA_END = createActionType('FETCH_METHOD_DATA_END') + +// Initial state +const initState = { + txData: {}, + tokenData: {}, + methodData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + fiatTransactionAmount: '', + fiatTransactionFee: '', + fiatTransactionTotal: '', + ethTransactionAmount: '', + ethTransactionFee: '', + ethTransactionTotal: '', + hexGasTotal: '', + nonce: '', + fetchingMethodData: false, +} + +// Reducer +export default function reducer ({ confirmTransaction: confirmState = initState }, action = {}) { + switch (action.type) { + case UPDATE_TX_DATA: + return { + ...confirmState, + txData: { + ...action.payload, + }, + } + case CLEAR_TX_DATA: + return { + ...confirmState, + txData: {}, + } + case UPDATE_TOKEN_DATA: + return { + ...confirmState, + tokenData: { + ...action.payload, + }, + } + case CLEAR_TOKEN_DATA: + return { + ...confirmState, + tokenData: {}, + } + case UPDATE_METHOD_DATA: + return { + ...confirmState, + methodData: { + ...action.payload, + }, + } + case CLEAR_METHOD_DATA: + return { + ...confirmState, + methodData: {}, + } + case UPDATE_TRANSACTION_AMOUNTS: + const { fiatTransactionAmount, ethTransactionAmount } = action.payload + return { + ...confirmState, + fiatTransactionAmount: fiatTransactionAmount || confirmState.fiatTransactionAmount, + ethTransactionAmount: ethTransactionAmount || confirmState.ethTransactionAmount, + } + case UPDATE_TRANSACTION_FEES: + const { fiatTransactionFee, ethTransactionFee } = action.payload + return { + ...confirmState, + fiatTransactionFee: fiatTransactionFee || confirmState.fiatTransactionFee, + ethTransactionFee: ethTransactionFee || confirmState.ethTransactionFee, + } + case UPDATE_TRANSACTION_TOTALS: + const { fiatTransactionTotal, ethTransactionTotal } = action.payload + return { + ...confirmState, + fiatTransactionTotal: fiatTransactionTotal || confirmState.fiatTransactionTotal, + ethTransactionTotal: ethTransactionTotal || confirmState.ethTransactionTotal, + } + case UPDATE_HEX_GAS_TOTAL: + return { + ...confirmState, + hexGasTotal: action.payload, + } + case UPDATE_TOKEN_PROPS: + const { tokenSymbol = '', tokenDecimals = '' } = action.payload + return { + ...confirmState, + tokenProps: { + ...confirmState.tokenProps, + tokenSymbol, + tokenDecimals, + }, + } + case UPDATE_NONCE: + return { + ...confirmState, + nonce: action.payload, + } + case FETCH_METHOD_DATA_START: + return { + ...confirmState, + fetchingMethodData: true, + } + case FETCH_METHOD_DATA_END: + return { + ...confirmState, + fetchingMethodData: false, + } + case CLEAR_CONFIRM_TRANSACTION: + return initState + default: + return confirmState + } +} + +// Action Creators +export function updateTxData (txData) { + return { + type: UPDATE_TX_DATA, + payload: txData, + } +} + +export function clearTxData () { + return { + type: CLEAR_TX_DATA, + } +} + +export function updateTokenData (tokenData) { + return { + type: UPDATE_TOKEN_DATA, + payload: tokenData, + } +} + +export function clearTokenData () { + return { + type: CLEAR_TOKEN_DATA, + } +} + +export function updateMethodData (methodData) { + return { + type: UPDATE_METHOD_DATA, + payload: methodData, + } +} + +export function clearMethodData () { + return { + type: CLEAR_METHOD_DATA, + } +} + +export function updateTransactionAmounts (amounts) { + return { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: amounts, + } +} + +export function updateTransactionFees (fees) { + return { + type: UPDATE_TRANSACTION_FEES, + payload: fees, + } +} + +export function updateTransactionTotals (totals) { + return { + type: UPDATE_TRANSACTION_TOTALS, + payload: totals, + } +} + +export function updateHexGasTotal (hexGasTotal) { + return { + type: UPDATE_HEX_GAS_TOTAL, + payload: hexGasTotal, + } +} + +export function updateTokenProps (tokenProps) { + return { + type: UPDATE_TOKEN_PROPS, + payload: tokenProps, + } +} + +export function updateNonce (nonce) { + return { + type: UPDATE_NONCE, + payload: nonce, + } +} + +export function setFetchingMethodData (isFetching) { + return { + type: isFetching ? FETCH_METHOD_DATA_START : FETCH_METHOD_DATA_END, + } +} + +export function updateGasAndCalculate ({ gasLimit, gasPrice }) { + return (dispatch, getState) => { + const { confirmTransaction: { txData } } = getState() + const newTxData = { + ...txData, + txParams: { + ...txData.txParams, + gas: gasLimit, + gasPrice, + }, + } + + dispatch(updateTxDataAndCalculate(newTxData)) + } +} + +function increaseFromLastGasPrice (txData) { + const { lastGasPrice, txParams: { gasPrice: previousGasPrice } = {} } = txData + + // Set the minimum to a 10% increase from the lastGasPrice. + const minimumGasPrice = increaseLastGasPrice(lastGasPrice) + const gasPriceBelowMinimum = hexGreaterThan(minimumGasPrice, previousGasPrice) + const gasPrice = (!previousGasPrice || gasPriceBelowMinimum) ? minimumGasPrice : previousGasPrice + + return { + ...txData, + txParams: { + ...txData.txParams, + gasPrice, + }, + } +} + +export function updateTxDataAndCalculate (txData) { + return (dispatch, getState) => { + const state = getState() + const currentCurrency = currentCurrencySelector(state) + const conversionRate = conversionRateSelector(state) + + dispatch(updateTxData(txData)) + + const { txParams: { value, gas: gasLimit = '0x0', gasPrice = '0x0' } = {} } = txData + + const fiatTransactionAmount = getTransactionAmount({ + value, toCurrency: currentCurrency, conversionRate, numberOfDecimals: 2, + }) + const ethTransactionAmount = getTransactionAmount({ + value, toCurrency: 'ETH', conversionRate, numberOfDecimals: 6, + }) + + dispatch(updateTransactionAmounts({ fiatTransactionAmount, ethTransactionAmount })) + + const hexGasTotal = getHexGasTotal({ gasLimit, gasPrice }) + + dispatch(updateHexGasTotal(hexGasTotal)) + + const fiatTransactionFee = getTransactionFee({ + value: hexGasTotal, + toCurrency: currentCurrency, + numberOfDecimals: 2, + conversionRate, + }) + const ethTransactionFee = getTransactionFee({ + value: hexGasTotal, + toCurrency: 'ETH', + numberOfDecimals: 6, + conversionRate, + }) + + dispatch(updateTransactionFees({ fiatTransactionFee, ethTransactionFee })) + + const fiatTransactionTotal = addFiat(fiatTransactionFee, fiatTransactionAmount) + const ethTransactionTotal = addEth(ethTransactionFee, ethTransactionAmount) + + dispatch(updateTransactionTotals({ fiatTransactionTotal, ethTransactionTotal })) + } +} + +export function setTransactionToConfirm (transactionId) { + return async (dispatch, getState) => { + const state = getState() + const unconfirmedTransactionsHash = unconfirmedTransactionsHashSelector(state) + const transaction = unconfirmedTransactionsHash[transactionId] + + if (!transaction) { + console.error(`Transaction with id ${transactionId} not found`) + return + } + + if (transaction.txParams) { + const { lastGasPrice } = transaction + const txData = lastGasPrice ? increaseFromLastGasPrice(transaction) : transaction + dispatch(updateTxDataAndCalculate(txData)) + + const { txParams } = transaction + + if (txParams.data) { + const { tokens: existingTokens } = state + const { data, to: tokenAddress } = txParams + + try { + dispatch(setFetchingMethodData(true)) + const methodData = await getMethodData(data) + dispatch(updateMethodData(methodData)) + dispatch(setFetchingMethodData(false)) + } catch (error) { + dispatch(updateMethodData({})) + dispatch(setFetchingMethodData(false)) + } + + const tokenData = getTokenData(data) + dispatch(updateTokenData(tokenData)) + + try { + const tokenSymbolData = await getSymbolAndDecimals(tokenAddress, existingTokens) || {} + const { symbol: tokenSymbol = '', decimals: tokenDecimals = '' } = tokenSymbolData + dispatch(updateTokenProps({ tokenSymbol, tokenDecimals })) + } catch (error) { + dispatch(updateTokenProps({ tokenSymbol: '', tokenDecimals: '' })) + } + } + + if (txParams.nonce) { + const nonce = conversionUtil(txParams.nonce, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + }) + + dispatch(updateNonce(nonce)) + } + } else { + dispatch(updateTxData(transaction)) + } + } +} + +export function clearConfirmTransaction () { + return { + type: CLEAR_CONFIRM_TRANSACTION, + } +} diff --git a/ui/app/ducks/tests/confirm-transaction.duck.test.js b/ui/app/ducks/tests/confirm-transaction.duck.test.js new file mode 100644 index 000000000..111674e33 --- /dev/null +++ b/ui/app/ducks/tests/confirm-transaction.duck.test.js @@ -0,0 +1,675 @@ +import assert from 'assert' +import configureMockStore from 'redux-mock-store' +import thunk from 'redux-thunk' + +import ConfirmTransactionReducer, * as actions from '../confirm-transaction.duck.js' + +const initialState = { + txData: {}, + tokenData: {}, + methodData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + fiatTransactionAmount: '', + fiatTransactionFee: '', + fiatTransactionTotal: '', + ethTransactionAmount: '', + ethTransactionFee: '', + ethTransactionTotal: '', + hexGasTotal: '', + nonce: '', + fetchingMethodData: false, +} + +const UPDATE_TX_DATA = 'metamask/confirm-transaction/UPDATE_TX_DATA' +const CLEAR_TX_DATA = 'metamask/confirm-transaction/CLEAR_TX_DATA' +const UPDATE_TOKEN_DATA = 'metamask/confirm-transaction/UPDATE_TOKEN_DATA' +const CLEAR_TOKEN_DATA = 'metamask/confirm-transaction/CLEAR_TOKEN_DATA' +const UPDATE_METHOD_DATA = 'metamask/confirm-transaction/UPDATE_METHOD_DATA' +const CLEAR_METHOD_DATA = 'metamask/confirm-transaction/CLEAR_METHOD_DATA' +const UPDATE_TRANSACTION_AMOUNTS = 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS' +const UPDATE_TRANSACTION_FEES = 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES' +const UPDATE_TRANSACTION_TOTALS = 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS' +const UPDATE_HEX_GAS_TOTAL = 'metamask/confirm-transaction/UPDATE_HEX_GAS_TOTAL' +const UPDATE_TOKEN_PROPS = 'metamask/confirm-transaction/UPDATE_TOKEN_PROPS' +const UPDATE_NONCE = 'metamask/confirm-transaction/UPDATE_NONCE' +const FETCH_METHOD_DATA_START = 'metamask/confirm-transaction/FETCH_METHOD_DATA_START' +const FETCH_METHOD_DATA_END = 'metamask/confirm-transaction/FETCH_METHOD_DATA_END' +const CLEAR_CONFIRM_TRANSACTION = 'metamask/confirm-transaction/CLEAR_CONFIRM_TRANSACTION' + +describe('Confirm Transaction Duck', () => { + describe('State changes', () => { + const mockState = { + confirmTransaction: { + txData: { + id: 1, + }, + tokenData: { + name: 'abcToken', + }, + methodData: { + name: 'approve', + }, + tokenProps: { + tokenDecimals: '3', + tokenSymbol: 'ABC', + }, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '1.000021', + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '469.27', + hexGasTotal: '0x1319718a5000', + nonce: '0x0', + fetchingMethodData: false, + }, + } + + it('should initialize state', () => { + assert.deepEqual( + ConfirmTransactionReducer({}), + initialState + ) + }) + + it('should return state unchanged if it does not match a dispatched actions type', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: 'someOtherAction', + value: 'someValue', + }), + { ...mockState.confirmTransaction }, + ) + }) + + it('should set txData when receiving a UPDATE_TX_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TX_DATA, + payload: { + id: 2, + }, + }), + { + ...mockState.confirmTransaction, + txData: { + ...mockState.confirmTransaction.txData, + id: 2, + }, + } + ) + }) + + it('should clear txData when receiving a CLEAR_TX_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_TX_DATA, + }), + { + ...mockState.confirmTransaction, + txData: {}, + } + ) + }) + + it('should set tokenData when receiving a UPDATE_TOKEN_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TOKEN_DATA, + payload: { + name: 'defToken', + }, + }), + { + ...mockState.confirmTransaction, + tokenData: { + ...mockState.confirmTransaction.tokenData, + name: 'defToken', + }, + } + ) + }) + + it('should clear tokenData when receiving a CLEAR_TOKEN_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_TOKEN_DATA, + }), + { + ...mockState.confirmTransaction, + tokenData: {}, + } + ) + }) + + it('should set methodData when receiving a UPDATE_METHOD_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_METHOD_DATA, + payload: { + name: 'transferFrom', + }, + }), + { + ...mockState.confirmTransaction, + methodData: { + ...mockState.confirmTransaction.methodData, + name: 'transferFrom', + }, + } + ) + }) + + it('should clear methodData when receiving a CLEAR_METHOD_DATA action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_METHOD_DATA, + }), + { + ...mockState.confirmTransaction, + methodData: {}, + } + ) + }) + + it('should update transaction amounts when receiving an UPDATE_TRANSACTION_AMOUNTS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: { + fiatTransactionAmount: '123.45', + ethTransactionAmount: '.5', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionAmount: '123.45', + ethTransactionAmount: '.5', + } + ) + }) + + it('should update transaction fees when receiving an UPDATE_TRANSACTION_FEES action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_FEES, + payload: { + fiatTransactionFee: '123.45', + ethTransactionFee: '.5', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionFee: '123.45', + ethTransactionFee: '.5', + } + ) + }) + + it('should update transaction totals when receiving an UPDATE_TRANSACTION_TOTALS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TRANSACTION_TOTALS, + payload: { + fiatTransactionTotal: '123.45', + ethTransactionTotal: '.5', + }, + }), + { + ...mockState.confirmTransaction, + fiatTransactionTotal: '123.45', + ethTransactionTotal: '.5', + } + ) + }) + + it('should update hexGasTotal when receiving an UPDATE_HEX_GAS_TOTAL action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_HEX_GAS_TOTAL, + payload: '0x0', + }), + { + ...mockState.confirmTransaction, + hexGasTotal: '0x0', + } + ) + }) + + it('should update tokenProps when receiving an UPDATE_TOKEN_PROPS action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_TOKEN_PROPS, + payload: { + tokenSymbol: 'DEF', + tokenDecimals: '1', + }, + }), + { + ...mockState.confirmTransaction, + tokenProps: { + tokenSymbol: 'DEF', + tokenDecimals: '1', + }, + } + ) + }) + + it('should update nonce when receiving an UPDATE_NONCE action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: UPDATE_NONCE, + payload: '0x1', + }), + { + ...mockState.confirmTransaction, + nonce: '0x1', + } + ) + }) + + it('should set fetchingMethodData to true when receiving a FETCH_METHOD_DATA_START action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: FETCH_METHOD_DATA_START, + }), + { + ...mockState.confirmTransaction, + fetchingMethodData: true, + } + ) + }) + + it('should set fetchingMethodData to false when receiving a FETCH_METHOD_DATA_END action', () => { + assert.deepEqual( + ConfirmTransactionReducer({ confirmTransaction: { fetchingMethodData: true } }, { + type: FETCH_METHOD_DATA_END, + }), + { + fetchingMethodData: false, + } + ) + }) + + it('should clear confirmTransaction when receiving a FETCH_METHOD_DATA_END action', () => { + assert.deepEqual( + ConfirmTransactionReducer(mockState, { + type: CLEAR_CONFIRM_TRANSACTION, + }), + { + ...initialState, + } + ) + }) + }) + + describe('Single actions', () => { + it('should create an action to update txData', () => { + const txData = { test: 123 } + const expectedAction = { + type: UPDATE_TX_DATA, + payload: txData, + } + + assert.deepEqual( + actions.updateTxData(txData), + expectedAction + ) + }) + + it('should create an action to clear txData', () => { + const expectedAction = { + type: CLEAR_TX_DATA, + } + + assert.deepEqual( + actions.clearTxData(), + expectedAction + ) + }) + + it('should create an action to update tokenData', () => { + const tokenData = { test: 123 } + const expectedAction = { + type: UPDATE_TOKEN_DATA, + payload: tokenData, + } + + assert.deepEqual( + actions.updateTokenData(tokenData), + expectedAction + ) + }) + + it('should create an action to clear tokenData', () => { + const expectedAction = { + type: CLEAR_TOKEN_DATA, + } + + assert.deepEqual( + actions.clearTokenData(), + expectedAction + ) + }) + + it('should create an action to update methodData', () => { + const methodData = { test: 123 } + const expectedAction = { + type: UPDATE_METHOD_DATA, + payload: methodData, + } + + assert.deepEqual( + actions.updateMethodData(methodData), + expectedAction + ) + }) + + it('should create an action to clear methodData', () => { + const expectedAction = { + type: CLEAR_METHOD_DATA, + } + + assert.deepEqual( + actions.clearMethodData(), + expectedAction + ) + }) + + it('should create an action to update transaction amounts', () => { + const transactionAmounts = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_AMOUNTS, + payload: transactionAmounts, + } + + assert.deepEqual( + actions.updateTransactionAmounts(transactionAmounts), + expectedAction + ) + }) + + it('should create an action to update transaction fees', () => { + const transactionFees = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_FEES, + payload: transactionFees, + } + + assert.deepEqual( + actions.updateTransactionFees(transactionFees), + expectedAction + ) + }) + + it('should create an action to update transaction totals', () => { + const transactionTotals = { test: 123 } + const expectedAction = { + type: UPDATE_TRANSACTION_TOTALS, + payload: transactionTotals, + } + + assert.deepEqual( + actions.updateTransactionTotals(transactionTotals), + expectedAction + ) + }) + + it('should create an action to update hexGasTotal', () => { + const hexGasTotal = '0x0' + const expectedAction = { + type: UPDATE_HEX_GAS_TOTAL, + payload: hexGasTotal, + } + + assert.deepEqual( + actions.updateHexGasTotal(hexGasTotal), + expectedAction + ) + }) + + it('should create an action to update tokenProps', () => { + const tokenProps = { + tokenDecimals: '1', + tokenSymbol: 'abc', + } + const expectedAction = { + type: UPDATE_TOKEN_PROPS, + payload: tokenProps, + } + + assert.deepEqual( + actions.updateTokenProps(tokenProps), + expectedAction + ) + }) + + it('should create an action to update nonce', () => { + const nonce = '0x1' + const expectedAction = { + type: UPDATE_NONCE, + payload: nonce, + } + + assert.deepEqual( + actions.updateNonce(nonce), + expectedAction + ) + }) + + it('should create an action to set fetchingMethodData to true', () => { + const expectedAction = { + type: FETCH_METHOD_DATA_START, + } + + assert.deepEqual( + actions.setFetchingMethodData(true), + expectedAction + ) + }) + + it('should create an action to set fetchingMethodData to false', () => { + const expectedAction = { + type: FETCH_METHOD_DATA_END, + } + + assert.deepEqual( + actions.setFetchingMethodData(false), + expectedAction + ) + }) + + it('should create an action to clear confirmTransaction', () => { + const expectedAction = { + type: CLEAR_CONFIRM_TRANSACTION, + } + + assert.deepEqual( + actions.clearConfirmTransaction(), + expectedAction + ) + }) + }) + + describe('Thunk actions', done => { + it('updates txData and gas on an existing transaction in confirmTransaction', () => { + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + }, + confirmTransaction: { + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '1.000021', + fetchingMethodData: false, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '469.27', + hexGasTotal: '0x1319718a5000', + methodData: {}, + nonce: '', + tokenData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + txData: { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + }, + }, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_HEX_GAS_TOTAL', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.updateGasAndCalculate({ gasLimit: '0x2', gasPrice: '0x25' })) + + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + }) + + it('updates txData and updates gas values in confirmTransaction', () => { + const txData = { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x33450', + gasPrice: '0x2540be400', + to: '0x81b7e08f65bdf5648606c89998a9cc8164397647', + value: '0xde0b6b3a7640000', + }, + } + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + }, + confirmTransaction: { + ethTransactionAmount: '1', + ethTransactionFee: '0.000021', + ethTransactionTotal: '1.000021', + fetchingMethodData: false, + fiatTransactionAmount: '469.26', + fiatTransactionFee: '0.01', + fiatTransactionTotal: '469.27', + hexGasTotal: '0x1319718a5000', + methodData: {}, + nonce: '', + tokenData: {}, + tokenProps: { + tokenDecimals: '', + tokenSymbol: '', + }, + txData: { + ...txData, + txParams: { + ...txData.txParams, + }, + }, + }, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_HEX_GAS_TOTAL', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.updateTxDataAndCalculate(txData)) + + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + }) + + it('updates confirmTransaction transaction', done => { + const mockState = { + metamask: { + conversionRate: 468.58, + currentCurrency: 'usd', + network: '3', + unapprovedTxs: { + 2603411941761054: { + estimatedGas: '0x5208', + gasLimitSpecified: false, + gasPriceSpecified: false, + history: [], + id: 2603411941761054, + loadingDefaults: false, + metamaskNetworkId: '3', + origin: 'faucet.metamask.io', + simpleSend: true, + status: 'unapproved', + time: 1530838113716, + txParams: { + from: '0xc5ae6383e126f901dcb06131d97a88745bfa88d6', + gas: '0x33450', + gasPrice: '0x2540be400', + to: '0x81b7e08f65bdf5648606c89998a9cc8164397647', + value: '0xde0b6b3a7640000', + }, + }, + }, + }, + confirmTransaction: {}, + } + + const middlewares = [thunk] + const mockStore = configureMockStore(middlewares) + const store = mockStore(mockState) + const expectedActions = [ + 'metamask/confirm-transaction/UPDATE_TX_DATA', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_AMOUNTS', + 'metamask/confirm-transaction/UPDATE_HEX_GAS_TOTAL', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_FEES', + 'metamask/confirm-transaction/UPDATE_TRANSACTION_TOTALS', + ] + + store.dispatch(actions.setTransactionToConfirm(2603411941761054)) + .then(() => { + const storeActions = store.getActions() + assert.equal(storeActions.length, expectedActions.length) + storeActions.forEach((action, index) => assert.equal(action.type, expectedActions[index])) + done() + }) + }) + }) +}) diff --git a/ui/app/helpers/confirm-transaction/util.js b/ui/app/helpers/confirm-transaction/util.js new file mode 100644 index 000000000..ad247a348 --- /dev/null +++ b/ui/app/helpers/confirm-transaction/util.js @@ -0,0 +1,116 @@ +import currencyFormatter from 'currency-formatter' +import currencies from 'currency-formatter/currencies' +import abi from 'human-standard-token-abi' +import abiDecoder from 'abi-decoder' +import ethUtil from 'ethereumjs-util' + +abiDecoder.addABI(abi) + +import MethodRegistry from 'eth-method-registry' +const registry = new MethodRegistry({ provider: global.ethereumProvider }) + +import { + conversionUtil, + addCurrencies, + multiplyCurrencies, + conversionGreaterThan, +} from '../../conversion-util' + +export function getTokenData (data = {}) { + return abiDecoder.decodeMethod(data) +} + +export async function getMethodData (data = {}) { + const prefixedData = ethUtil.addHexPrefix(data) + const fourBytePrefix = prefixedData.slice(0, 10) + const sig = await registry.lookup(fourBytePrefix) + const parsedResult = registry.parse(sig) + + return { + name: parsedResult.name, + params: parsedResult.args, + } +} + +export function increaseLastGasPrice (lastGasPrice) { + return ethUtil.addHexPrefix(multiplyCurrencies(lastGasPrice, 1.1, { + multiplicandBase: 16, + multiplierBase: 10, + toNumericBase: 'hex', + })) +} + +export function hexGreaterThan (a, b) { + return conversionGreaterThan( + { value: a, fromNumericBase: 'hex' }, + { value: b, fromNumericBase: 'hex' }, + ) +} + +export function getHexGasTotal ({ gasLimit, gasPrice }) { + return ethUtil.addHexPrefix(multiplyCurrencies(gasLimit, gasPrice, { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 16, + })) +} + +export function addEth (...args) { + return args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'dec', + numberOfDecimals: 6, + }) + }) +} + +export function addFiat (...args) { + return args.reduce((acc, base) => { + return addCurrencies(acc, base, { + toNumericBase: 'dec', + numberOfDecimals: 2, + }) + }) +} + +export function getTransactionAmount ({ + value, + toCurrency, + conversionRate, + numberOfDecimals, +}) { + return conversionUtil(value, { + fromNumericBase: 'hex', + toNumericBase: 'dec', + fromCurrency: 'ETH', + toCurrency, + numberOfDecimals, + fromDenomination: 'WEI', + conversionRate, + }) +} + +export function getTransactionFee ({ + value, + toCurrency, + conversionRate, + numberOfDecimals, +}) { + return conversionUtil(value, { + fromNumericBase: 'BN', + toNumericBase: 'dec', + fromDenomination: 'WEI', + fromCurrency: 'ETH', + toCurrency, + numberOfDecimals, + conversionRate, + }) +} + +export function formatCurrency (value, currencyCode) { + const upperCaseCurrencyCode = currencyCode.toUpperCase() + + return currencies.find(currency => currency.code === upperCaseCurrencyCode) + ? currencyFormatter.format(Number(value), { code: upperCaseCurrencyCode }) + : value +} diff --git a/ui/app/helpers/confirm-transaction/util.test.js b/ui/app/helpers/confirm-transaction/util.test.js new file mode 100644 index 000000000..a9c8fae34 --- /dev/null +++ b/ui/app/helpers/confirm-transaction/util.test.js @@ -0,0 +1,137 @@ +import * as utils from './util' +import assert from 'assert' + +describe('Confirm Transaction utils', () => { + describe('increaseLastGasPrice', () => { + it('should increase the gasPrice by 10%', () => { + const increasedGasPrice = utils.increaseLastGasPrice('0xa') + assert.equal(increasedGasPrice, '0xb') + }) + + it('should prefix the result with 0x', () => { + const increasedGasPrice = utils.increaseLastGasPrice('a') + assert.equal(increasedGasPrice, '0xb') + }) + }) + + describe('hexGreaterThan', () => { + it('should return true if the first value is greater than the second value', () => { + assert.equal( + utils.hexGreaterThan('0xb', '0xa'), + true + ) + }) + + it('should return false if the first value is less than the second value', () => { + assert.equal( + utils.hexGreaterThan('0xa', '0xb'), + false + ) + }) + + it('should return false if the first value is equal to the second value', () => { + assert.equal( + utils.hexGreaterThan('0xa', '0xa'), + false + ) + }) + + it('should correctly compare prefixed and non-prefixed hex values', () => { + assert.equal( + utils.hexGreaterThan('0xb', 'a'), + true + ) + }) + }) + + describe('getHexGasTotal', () => { + it('should multiply the hex gasLimit and hex gasPrice values together', () => { + assert.equal( + utils.getHexGasTotal({ gasLimit: '0x5208', gasPrice: '0x3b9aca00' }), + '0x1319718a5000' + ) + }) + + it('should prefix the result with 0x', () => { + assert.equal( + utils.getHexGasTotal({ gasLimit: '5208', gasPrice: '3b9aca00' }), + '0x1319718a5000' + ) + }) + }) + + describe('addEth', () => { + it('should add two values together rounding to 6 decimal places', () => { + assert.equal( + utils.addEth('0.12345678', '0'), + '0.123457' + ) + }) + + it('should add any number of values together rounding to 6 decimal places', () => { + assert.equal( + utils.addEth('0.1', '0.02', '0.003', '0.0004', '0.00005', '0.000006', '0.0000007'), + '0.123457' + ) + }) + }) + + describe('addFiat', () => { + it('should add two values together rounding to 2 decimal places', () => { + assert.equal( + utils.addFiat('0.12345678', '0'), + '0.12' + ) + }) + + it('should add any number of values together rounding to 2 decimal places', () => { + assert.equal( + utils.addFiat('0.1', '0.02', '0.003', '0.0004', '0.00005', '0.000006', '0.0000007'), + '0.12' + ) + }) + }) + + describe('getTransactionAmount', () => { + it('should get the transaction amount in ETH', () => { + const ethTransactionAmount = utils.getTransactionAmount({ + value: '0xde0b6b3a7640000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, + }) + + assert.equal(ethTransactionAmount, '1') + }) + + it('should get the transaction amount in fiat', () => { + const fiatTransactionAmount = utils.getTransactionAmount({ + value: '0xde0b6b3a7640000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, + }) + + assert.equal(fiatTransactionAmount, '468.58') + }) + }) + + describe('getTransactionFee', () => { + it('should get the transaction fee in ETH', () => { + const ethTransactionFee = utils.getTransactionFee({ + value: '0x1319718a5000', toCurrency: 'ETH', conversionRate: 468.58, numberOfDecimals: 6, + }) + + assert.equal(ethTransactionFee, '0.000021') + }) + + it('should get the transaction fee in fiat', () => { + const fiatTransactionFee = utils.getTransactionFee({ + value: '0x1319718a5000', toCurrency: 'usd', conversionRate: 468.58, numberOfDecimals: 2, + }) + + assert.equal(fiatTransactionFee, '0.01') + }) + }) + + describe('formatCurrency', () => { + it('should format USD values', () => { + const value = utils.formatCurrency('123.45', 'usd') + assert.equal(value, '$123.45') + }) + }) +}) diff --git a/ui/app/reducers.js b/ui/app/reducers.js index 0b158a778..80e76d570 100644 --- a/ui/app/reducers.js +++ b/ui/app/reducers.js @@ -8,6 +8,7 @@ const reduceMetamask = require('./reducers/metamask') const reduceApp = require('./reducers/app') const reduceLocale = require('./reducers/locale') const reduceSend = require('./ducks/send.duck').default +import reduceConfirmTransaction from './ducks/confirm-transaction.duck' window.METAMASK_CACHED_LOG_STATE = null @@ -45,6 +46,8 @@ function rootReducer (state, action) { state.send = reduceSend(state, action) + state.confirmTransaction = reduceConfirmTransaction(state, action) + window.METAMASK_CACHED_LOG_STATE = state return state } diff --git a/ui/app/routes.js b/ui/app/routes.js index 0ff3f644d..7ac606b1a 100644 --- a/ui/app/routes.js +++ b/ui/app/routes.js @@ -10,8 +10,6 @@ const CONFIRM_ADD_TOKEN_ROUTE = '/confirm-add-token' const NEW_ACCOUNT_ROUTE = '/new-account' const IMPORT_ACCOUNT_ROUTE = '/new-account/import' const SEND_ROUTE = '/send' -const CONFIRM_TRANSACTION_ROUTE = '/confirm-transaction' -const SIGNATURE_REQUEST_ROUTE = '/confirm-transaction/signature-request' const NOTICE_ROUTE = '/notice' const WELCOME_ROUTE = '/welcome' const INITIALIZE_ROUTE = '/initialize' @@ -23,6 +21,14 @@ const INITIALIZE_NOTICE_ROUTE = '/initialize/notice' const INITIALIZE_BACKUP_PHRASE_ROUTE = '/initialize/backup-phrase' const INITIALIZE_CONFIRM_SEED_ROUTE = '/initialize/confirm-phrase' +const CONFIRM_TRANSACTION_ROUTE = '/confirm-transaction' +const CONFIRM_SEND_ETHER_PATH = '/send-ether' +const CONFIRM_SEND_TOKEN_PATH = '/send-token' +const CONFIRM_DEPLOY_CONTRACT_PATH = '/deploy-contract' +const CONFIRM_APPROVE_PATH = '/approve' +const CONFIRM_TOKEN_METHOD_PATH = '/token-method' +const SIGNATURE_REQUEST_PATH = '/signature-request' + module.exports = { DEFAULT_ROUTE, UNLOCK_ROUTE, @@ -36,9 +42,7 @@ module.exports = { NEW_ACCOUNT_ROUTE, IMPORT_ACCOUNT_ROUTE, SEND_ROUTE, - CONFIRM_TRANSACTION_ROUTE, NOTICE_ROUTE, - SIGNATURE_REQUEST_ROUTE, WELCOME_ROUTE, INITIALIZE_ROUTE, INITIALIZE_CREATE_PASSWORD_ROUTE, @@ -48,4 +52,11 @@ module.exports = { INITIALIZE_NOTICE_ROUTE, INITIALIZE_BACKUP_PHRASE_ROUTE, INITIALIZE_CONFIRM_SEED_ROUTE, + CONFIRM_TRANSACTION_ROUTE, + CONFIRM_SEND_ETHER_PATH, + CONFIRM_SEND_TOKEN_PATH, + CONFIRM_DEPLOY_CONTRACT_PATH, + CONFIRM_APPROVE_PATH, + CONFIRM_TOKEN_METHOD_PATH, + SIGNATURE_REQUEST_PATH, } diff --git a/ui/app/selectors.js b/ui/app/selectors.js index 3e2253550..d86462275 100644 --- a/ui/app/selectors.js +++ b/ui/app/selectors.js @@ -27,6 +27,7 @@ const selectors = { autoAddToBetaUI, getSendMaxModeState, getCurrentViewContext, + getTotalUnapprovedCount, } module.exports = selectors @@ -181,3 +182,15 @@ function getCurrentViewContext (state) { const { currentView = {} } = state.appState return currentView.context } + +function getTotalUnapprovedCount ({ metamask }) { + const { + unapprovedTxs = {}, + unapprovedMsgCount, + unapprovedPersonalMsgCount, + unapprovedTypedMessagesCount, + } = metamask + + return Object.keys(unapprovedTxs).length + unapprovedMsgCount + unapprovedPersonalMsgCount + + unapprovedTypedMessagesCount +} diff --git a/ui/app/selectors/confirm-transaction.js b/ui/app/selectors/confirm-transaction.js new file mode 100644 index 000000000..cde83804d --- /dev/null +++ b/ui/app/selectors/confirm-transaction.js @@ -0,0 +1,65 @@ +import { createSelector } from 'reselect' +import txHelper from '../../lib/tx-helper' + +const unapprovedTxsSelector = state => state.metamask.unapprovedTxs +const unapprovedMsgsSelector = state => state.metamask.unapprovedMsgs +const unapprovedPersonalMsgsSelector = state => state.metamask.unapprovedPersonalMsgs +const unapprovedTypedMessagesSelector = state => state.metamask.unapprovedTypedMessages +const networkSelector = state => state.metamask.network + +export const unconfirmedTransactionsListSelector = createSelector( + unapprovedTxsSelector, + unapprovedMsgsSelector, + unapprovedPersonalMsgsSelector, + unapprovedTypedMessagesSelector, + networkSelector, + ( + unapprovedTxs = {}, + unapprovedMsgs = {}, + unapprovedPersonalMsgs = {}, + unapprovedTypedMessages = {}, + network + ) => txHelper( + unapprovedTxs, + unapprovedMsgs, + unapprovedPersonalMsgs, + unapprovedTypedMessages, + network + ) || [] +) + +export const unconfirmedTransactionsHashSelector = createSelector( + unapprovedTxsSelector, + unapprovedMsgsSelector, + unapprovedPersonalMsgsSelector, + unapprovedTypedMessagesSelector, + networkSelector, + ( + unapprovedTxs = {}, + unapprovedMsgs = {}, + unapprovedPersonalMsgs = {}, + unapprovedTypedMessages = {}, + network + ) => { + const filteredUnapprovedTxs = Object.keys(unapprovedTxs).reduce((acc, address) => { + const { metamaskNetworkId } = unapprovedTxs[address] + const transactions = { ...acc } + + if (metamaskNetworkId === network) { + transactions[address] = unapprovedTxs[address] + } + + return transactions + }, {}) + + return { + ...filteredUnapprovedTxs, + ...unapprovedMsgs, + ...unapprovedPersonalMsgs, + ...unapprovedTypedMessages, + } + } +) + +export const currentCurrencySelector = state => state.metamask.currentCurrency +export const conversionRateSelector = state => state.metamask.conversionRate |